Schedule Service (ac-service-schedule) – Technical Design Document¶
Introduction¶
Purpose:
This document describes the design, architecture, and operational model of the Schedule Service as it currently stands on develop, and records the design decisions taken.
Scope: The full service: the domain model, persistence, the HTTP API, the background scheduler, the HTTP and Kafka executors, the timezone model, concurrency guarantees, and the test strategy.
Source code: github.com/appcircleio/ac-service-schedule
System Overview¶
A DDD microservice that stores scheduled jobs and fires them according to a cron expression. It has a single bounded context, ScheduleContext. On its schedule, each job either issues an HTTP request or publishes a Kafka event. The minimum granularity is one minute (second-level scheduling is not supported).
Architecture¶
Technology Stack:
| Layer | Technology |
|---|---|
| Runtime | .NET 8 (ASP.NET Core) |
| Persistence | MongoDB |
| Distributed lock + cache | Redis (RedLock, StackExchange.Redis) |
| Messaging | Kafka (producer) |
| Identity | Keycloak (organization name lookup) |
| Cron parsing | Cronos (CronFormat.Standard) |
| Logging | NLog |
External Dependencies:
- MongoDB - job store.
- Redis - distributed lock (one instance per tick) and distributed cache.
- Kafka - event publishing for Kafka-type jobs.
- Keycloak - resolves the organization name on job creation (failure tolerated).
Layers:
- Domain (
ScheduleContext/ScheduledJobAgggreggate/) -ScheduledJobaggregate root, theScheduledJobHttpExecutor/ScheduledJobKafkaExecutorvalue objects,ScheduledJobDto,ScheduledJobStoreObject(the MongoDB document), andScheduledJobFactory. - Repository (
Data/ScheduledJobRepository,IScheduledJobRepository) - MongoDB CRUD plus the atomic claim;ScheduleMongoUnitOfWorkis the Unit-of-Work that fires domain events on commit. - Application (
Services/) -ScheduledJobService(CRUD plus cron / next-run calculation) andScheduledJobExecutorService(firing plus status notification). - Background (
BackgroundServices/SchedulerService) - aBackgroundServicethat polls periodically. - Infrastructure - the Kafka producer (
OutgoingMessageBrokerEventHandler). - Shared (
ScheduleContext/ScheduleTimeZone) - the single source of truth for timezone resolution.
Key Components¶
ScheduledJob (Domain)¶
- Purpose: Aggregate root representing a scheduled job and its executor(s).
- Responsibilities: Holds schedule, timezone, executor type, and executor details; exposes
IsHttpExecutor()/IsKafkaExecutor()guards and typed executor accessors.
ScheduledJobService (Application)¶
- Purpose: CRUD entry point used by the controller.
- Responsibilities: Validates executor type and timezone, computes the initial
NextExecutionfrom the cron expression and offset, resolves the organization name from Keycloak (tolerating failures), and persists through the repository.
SchedulerService (Background)¶
- Purpose: Periodically finds due jobs and fires them.
- Responsibilities: Polls MongoDB, acquires a per-job distributed lock, performs the atomic claim through the repository, and invokes the executor.
ScheduledJobExecutorService (Application)¶
- Purpose: Executes a due job and reports its status.
- Responsibilities: Runs the HTTP or Kafka executor and emits Started / Succeeded / Failed notifications (best-effort).
ScheduledJobRepository (Repository)¶
- Purpose: All MongoDB access.
- Responsibilities: CRUD plus
TryClaimNextExecutionAsync, the optimistic-concurrency claim that guarantees a tick fires once.
Detailed Design¶
Database Design¶
Collection: ScheduledJobs.
Fields: Id, OrganizationId, OrganizationName, Key, Name, ScheduleExpression, Timezone, ExecutorType (http / kafka), the embedded HttpExecutor / KafkaExecutor, IsActive, NextExecution, LastExecution, CreateDate.
Serialization: DateTimeOffset is stored as BsonType.DateTime; GUIDs use CSharpLegacy; a camelCase element-name convention is applied.
Indexes (created at startup by EnsureIndexesAsync):
idx_isActive_nextExecution- serves the scheduler poll query.idx_organizationId_key- unique.
API Surface (api/v1/schedules)¶
| Method | Route | Function |
|---|---|---|
| GET | /?organizationId= |
List by organization |
| GET | /{id} |
Get one |
| POST | / |
Create |
| PUT | /{id} |
Update |
| PATCH | /{id}/activate and /deactivate |
Activate / deactivate |
| DELETE | /{id} |
Delete |
ExecutorType must be http or kafka; otherwise a BusinessException (ScheduleServiceExecutorTypeInvalid) is thrown. An invalid timezone returns ScheduleServiceTimezoneInvalid.
Scheduler Algorithm¶
SchedulerService ticks every 45 seconds:
- Fetches jobs where
IsActive == true && NextExecution <= nowfrom MongoDB, capped at 100 per poll. - Acquires a RedLock per job, keyed
scheduled-job:{id}:{nextExecution:yyyyMMddHHmmss}(waitTime 0, expiry 2 minutes). Only one instance runs a given tick. - If the lock is acquired, the new
NextExecutionis computed from the cron expression, then the repository's atomic claim is invoked:TryClaimNextExecutionAsyncruns a conditionalUpdateOnethat advancesNextExecutionandLastExecutiononly ifid + the old NextExecutionstill matches. If it returnsfalse, another instance already handled the tick and this one exits. - The job is fired through
ScheduledJobExecutorService.ExecuteAsync; due jobs run concurrently viaTask.WhenAll.
Double guard: RedLock (fast, prevents most contention) plus the MongoDB conditional update (the definitive guarantee). Together they enforce the invariant that a tick fires at most once.
Missed occurrences: the next occurrence is computed forward from now. If the service was down for a stretch, missed occurrences are skipped (the job fires once and resumes on schedule) instead of accumulating and catching up one occurrence per poll.
Executors¶
- HTTP:
Method/Url/Body/Headers(JSON). AnIdempotency-Keyof{jobId}:{lastExecution:O}is set by the executor and cannot be overridden by the job's own headers. The timeout is applied with a linkedCancellationTokenSource.EnsureSuccessStatusCode()runs first, followed by the optionalExpectCodecheck. - Kafka: the event is published through
MessageBrokerEventBuilderFactory.OrganizationIdis taken from the job field first, then parsed from the message payload as a fallback; if both are empty the event is not published. A JSON message is sent as aJToken, otherwise as a raw string. Producer settings:Acks.Leader,MaxInFlight = 1, 30-second message timeout. - Status notifications (Started / Succeeded / Failed) are best-effort - a publish failure never fails the job (
SafeNotifyAsync).
Timezone Model¶
Timezones are entered and stored as fixed UTC offsets: 01:00, +03:30, -05:00. Both the create/update flow and the scheduler resolve them through a single ScheduleTimeZone helper, so next-run is computed identically everywhere. An invalid or empty value falls back to UTC. IANA names (for example Europe/Istanbul) are not supported.
Security Considerations¶
- Keycloak is consulted only at job creation to resolve the organization name; failures are tolerated and the name is left as
N/A. - The
Idempotency-Keyheader is owned by the executor and cannot be overridden by a job definition.
Integration and Interfaces¶
External interfaces: outbound HTTP requests to job-defined targets; Kafka event publishing to job-defined topics; Keycloak for organization name lookup.
Internal interfaces: the REST API under api/v1/schedules, consumed by other Appcircle components.
Concurrency and Distributed Execution¶
- Multiple instances can run simultaneously; the single-fire guarantee comes from RedLock plus the atomic MongoDB claim.
- DB I/O lives in the repository layer;
SchedulerServiceinvokes the claim through the repository. - Background work opens a fresh DI scope each tick via
IServiceScopeFactory, so no scoped service leaks across ticks.
Testing and Quality Assurance¶
Test project: test/Appcircle.ScheduleService.WebApi.Tests (xUnit).
- Unit:
ScheduleTimeZoneoffset parsing and resolution, cron next-run against UTC offsets, and theScheduledJobexecutor guards. - Integration (Testcontainers MongoDB,
Category=Integration, requires Docker): the claim wins a due tick exactly once under concurrency, and a stale claim is rejected.
Configuration¶
Required environment variables: ASPNETCORE_PORT, ASPNETCORE_REDIS_ENDPOINT, SCHEDULE_SERVICE_DB_CONNECTION_STRING, ASPNETCORE_KEYCLOAK_SERVER_URL / _CLIENT_ID / _SECRET_ID, ASPNETCORE_KAFKA_SERVER_URLS.
The NuGet package tier is selected by NUGET_ENVIRONMENT (Development = alpha, Preproduction = beta, Production = stable, unset = local ProjectReference).
Design Decisions¶
- Timezone format is a fixed UTC offset (timespan). IANA support was removed; the create/update flow and the scheduler resolve through one
ScheduleTimeZonehelper in the same way. CreateDateis set on create.- The by-key surface (upsert / set-active / delete by key) was removed. Only id-based and organization-based operations are supported.
- The optimistic-concurrency claim was moved into the repository (
TryClaimNextExecutionAsync);SchedulerServicecalls it, keeping DB I/O out of the background handler. - The single-fire guarantee is provided by RedLock plus the atomic MongoDB claim.
- Missed occurrences are skipped (no catch-up).
- A test project was added; integration tests run against MongoDB via Testcontainers / Docker.