Scheduling
Source-generated scheduled jobs share one in-memory scheduler with typed invocation, explicit policies, and OpenTelemetry instrumentation.
Scheduled jobs are in-memory background work owned by one host-local scheduler. They are intended for recurring application tasks and delayed one-off jobs where persistence, dashboards, distributed locks, and cross-process catch-up are not required.
The scheduler uses source-generated descriptors and invocation delegates — there is no runtime
assembly scanning, Type activation, or reflection-based method invocation. Every run executes
through an async DI scope, receives a cancellation token, is tracked during shutdown, and emits
scheduler telemetry.
Implementation-neutral by design
The source-generation surface is implementation-neutral. [ScheduledJob],
IScheduledJob<TPayload>, and the scheduler contracts live in Elarion.Abstractions; application
assemblies reference Elarion (which bundles the generator), and the generated descriptors never
choose the default in-memory scheduler. A host then chooses a runtime by registering
AddElarionScheduler(...) or a custom IJobScheduler / IJobSchedulerInspector that consumes the
generated descriptors.
Enabling and registering
Enable scheduled job generation in the assembly that contains jobs:
using Elarion.Abstractions;
[assembly: UseElarion]Jobs are registered per module: the generator emits an
Add{Module}ScheduledJobs method for each [AppModule] and calls it from that module's generated
ConfigureDefaultServices, so the bootstrapper's AddElarion registers the descriptors of every
enabled module. A job whose namespace falls under no module is reported (ELSG010, warning) because
it would never be registered. The host adds only the chosen scheduler runtime:
builder.Services.AddElarion(builder.Configuration); // module descriptors, gated per module
builder.Services.AddElarionScheduler(builder.Configuration); // the in-memory runtimeThe generated Add{Module}ScheduledJobs methods are descriptor registration only — they do not start
a scheduler, register a hosted service, or choose InMemoryScheduler. That separation is intentional
so a different scheduler runtime can reuse the same generated descriptors.
AddElarionScheduler(IConfiguration) reads the Scheduler section:
{
"Scheduler": {
"Enabled": true,
"MaxConcurrentExecutions": 8,
"MaxRetainedCompletedJobs": 1024,
"MaxMisfireCatchUpRuns": 32
}
}A recurring job
A compile-time recurring job is an ordinary accessible method annotated with [ScheduledJob]. It
must be non-generic, return Task or ValueTask, and accept only optional IScheduledJobContext
and CancellationToken parameters.
using Elarion.Abstractions.Scheduling;
namespace MyApp.Application.Modules.Invoicing.Services;
public sealed class RecurringBillingJob(
IRecurringBillingProcessor processor,
TimeProvider timeProvider) {
[ScheduledJob(
"invoicing.recurringBilling",
FixedRate = "1d",
Enabled = "${Modules:Invoicing:Enabled}")]
public async ValueTask RunAsync(IScheduledJobContext context, CancellationToken ct) {
var today = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
await processor.ProcessAllAsync(today, ct);
}
}Operational characteristics
- The scheduler runtime is in-memory. Queued runtime jobs and due recurring state are lost when the process stops; recurring jobs re-derive their schedules at the next start. Cross-instance coordination of recurring jobs is an opt-in database backend (below).
- Fixed-rate and cron schedules skip missed in-process slots instead of replaying a burst (tunable with misfire policy).
- There is no global polling loop. The runtime waits until the nearest due item and wakes early when an earlier item is enqueued.
TimeProvideris used throughout, so tests can drive millisecond schedules deterministically with a fake clock. Production precision is bounded by OS timer resolution and host load.- Scheduling, enqueue, cancel, and execution all emit telemetry.
If you need durable queues or retry history that survives restarts, use dedicated job infrastructure. Elarion's scheduler runtime is deliberately in-process and lightweight; what it does offer for clusters is the recurring-job coordination below.
Running on more than one node
Every node runs the same source-generated job set, so without coordination each recurring job fires
once per node. Elarion.Scheduling.EntityFrameworkCore closes that with per-occurrence claim
rows over PostgreSQL (ADR-0025): right before executing a recurring occurrence, a
node claims it in the elarion_scheduler_claims table, and exactly one node wins — the others record
the occurrence as skipped (claimed-elsewhere) and continue. See
Multi-node coordination for the architecture, diagrams,
and practical trade-offs.
// In Program.cs, next to AddElarionScheduler (either order):
builder.Services.AddElarionScheduler(builder.Configuration);
builder.Services.AddElarionSchedulerEntityFrameworkCore<AppDbContext>();
// In OnModelCreating (or [GenerateElarionSchedulerClaims] on a [GenerateDbSets] context):
modelBuilder.UseElarionSchedulerClaims();How the claim dedupes depends on the schedule kind: cron occurrences are wall-clock deterministic
on every node, so they claim their exact (job, occurrence) slot; fixed-rate/fixed-delay due
times are anchored per node, so they dedupe by a one-interval window (at most one run per interval
cluster-wide); one-time startup schedules are never coordinated (startup work is per node by
design). If a claim cannot be made — say the database is briefly unreachable — the node fails
closed and skips the occurrence rather than risking a duplicate; the next occurrence retries.
A claim-retention purge worker keeps the table small (SchedulerClaimsOptions, default 7 days).
Runtime one-shot jobs (IJobScheduler) remain in-memory; durable one-shot scheduling is a planned
follow-up phase of ADR-0025.