Elarion

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.
  • TimeProvider is 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

On this page