How it works
Follow a job from Roslyn discovery through generated code, durable acquisition and scoped execution.
Compile-time discovery
The incremental generator discovers classes with [Job] and a valid Immediate.Handlers [Handler]. Analyzers validate names, queues, cron and execution settings, the exact HandleAsync shape, context extractor contracts, and whether payload/context types can receive
source-generated JSON metadata.
For each job it emits IJ.<Namespace>.<Class>.g.cs containing:
- a scoped nested
Schedulerderiving fromJobScheduler<TPayload>; - an internal singleton
Invokerthat deserializes, restores context and enters the generated Immediate.Handlers pipeline; - a singleton
JobDefinitionfactory with stable name, queue and execution policy; - a generated
JsonSerializerContext/resolver for payload and context types; - registrations for the scheduler, invoker, extractors and definition.
At assembly level, IJ.ServiceCollectionExtensions.g.cs contains AddXxxJobs. It calls the
runtime registration once, registers queue definitions, and conditionally adds jobs selected by
tags. The assembly identifier and tags follow the same conventions as the other platform
generators.
Enqueue data flow
- Application code resolves the scoped generated scheduler.
- The scheduler captures the current trace link and opted-in context extractors.
- It serializes payload/context with generated metadata, generates an ID, and builds a record with stable job/queue names, due time and optional group/batch data.
- Storage persists that record (or a batch buffers it until atomic commit).
- The scheduler returns a
JobHandle; it does not wait for execution.
Worker data flow
The hosted service initializes storage and recurring definitions, then builds acquisition requests
from queue priority plus node, queue and job capacity. Storage atomically changes eligible due work
to Active, assigns a worker/lease, increments attempts and creates a retained execution record.
Distributed providers coordinate this in the shared backend; single-server mode acquires from
memory and mirrors ownership to its durable replica.
For each acquired record the worker creates a consumer activity and logging scope, starts lease
renewal and a linked timeout token, then creates a fresh async DI scope. The generated invoker
deserializes context and payload, restores known slices, assigns JobDetails and resolves the
generated handler. The call enters Immediate.Handlers behaviors and ends at the private job method.
Success atomically closes the execution and records completion plus any buffered mid-execution
continuations. Failure closes the attempt with its full exception and either schedules a retry or
leaves a terminal failure. Telemetry, renewal and terminal updates carry the acquired attempt and
worker ID so stale owners cannot mutate a reacquired or explicitly cancelled job. Settling a graph
node releases eligible children or marks unselected branches Skipped. Release and recursive
skipping are committed in the same transaction as the parent’s terminal transition.
Recurring materialization
Code-defined schedules are reconciled at startup. The recurring loop asks storage for due
schedules; the provider atomically materializes a uniquely keyed occurrence and advances its next
run. This keeps multiple distributed nodes from creating the same occurrence. Overlap policy is
evaluated against active occurrences of the same schedule; Skip persists a terminal skipped
occurrence so monitoring retains the scheduling decision.
Generated JSON, trimming and Native AOT
Schedulers and invokers call IJobSerializer overloads that receive generated JsonTypeInfo<T>. The generated resolver covers the payload graph and every opted-in context
type, so trimming does not need to preserve reflection-discovered constructors or properties.
Resolved payload metadata is cached per payload type for subsequent serialize/deserialize calls.
Unsupported shapes fail at compile time. The Native AOT sample publishes the same generated path;
custom serializers must honor the metadata overloads to retain this property.
The runtime itself does not scan assemblies or use a service locator to discover jobs. Durable job names, queue names, extractor keys and serialized contracts are nevertheless versioned data: deploy changes compatibly or drain/transform durable records before removing them.