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. Application assemblies reference
Elarion.Abstractions plus the generator analyzer to use [ScheduledJob],
IScheduledJob<TPayload>, generated descriptors, and scheduler contracts — without referencing 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]Register the generated descriptors and the chosen scheduler runtime in the host:
builder.Services.AddMyAppApplicationScheduledJobs(); // descriptors only
builder.Services.AddElarionScheduler(builder.Configuration);AddMyAppApplicationScheduledJobs() is descriptor registration only — it does 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.
In this section
Schedules
Overlap & misfire
Runtime jobs
Inspection
Execution semantics & positioning
Multi-node coordination
Feature flag providers
Wire a feature-flag backend into the host so [FeatureGate] handlers and variant services evaluate against the provider of your choice.
Schedules
Define when a job runs with FixedRate, FixedDelay, Cron, or an initial-delay one-off — using compact duration literals and config placeholders.