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 Processing, assigns a worker/lease and increments attempts. 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 records completion and any buffered mid-execution continuations. Failure stores the exception and either schedules a retry or leaves a terminal failure. Settling a graph node releases eligible children or cascade-cancels children whose trigger can no longer be satisfied.
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.
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.
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/migrate existing records before removing them.