Immediate.Jobs is a reflection-free background job scheduler for .NET 8+ built on Immediate.Handlers. A job is an [Handler] whose request can also be durably enqueued; a Roslyn source generator emits its typed scheduler, payload metadata, and dependency-injection registrations at compile time.
Immediate.Jobs provides at-least-once delivery. Every handler that performs externally visible work must be idempotent. The in-memory provider is single-node, non-durable, and intended only for development, tests, and non-critical work.
using Immediate.Handlers.Shared;
using Immediate.Jobs.Shared;
[Handler, Job(Name = "send-welcome-email", MaxAttempts = 5, Timeout = "00:02:00")]
public sealed partial class SendWelcomeEmail(IEmailSender sender)
{
public sealed record Payload(Guid UserId, string Template);
private ValueTask HandleAsync(Payload payload, CancellationToken cancellationToken) =>
new(sender.SendAsync(payload.UserId, payload.Template, cancellationToken));
}
public sealed class SignupService(SendWelcomeEmail.Scheduler welcomeEmail)
{
public ValueTask<JobHandle> EnqueueAsync(Guid userId, CancellationToken cancellationToken) =>
welcomeEmail.EnqueueAsync(new(userId, "v2"), cancellationToken);
}Scheduler is a nested generated type. The worker invokes the generated SendWelcomeEmail.Handler, so the same handler and its ordinary Immediate.Handlers behaviors work both inline and in the background. [Job] is class-only and is rejected unless the class is also marked [Handler].
Generated schedulers are scoped services. Resolve or inject them from a request or other DI scope so
enqueue-time context extractors can read the same scoped state as the caller. A singleton consumer,
such as an IHostedService, must create a scope for each unit of work rather than inject a generated
scheduler directly:
public sealed class ImportWorker(IServiceScopeFactory scopeFactory)
{
public async ValueTask EnqueueAsync(Guid importId, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var scheduler = scope.ServiceProvider.GetRequiredService<ImportJob.Scheduler>();
await scheduler.EnqueueAsync(new(importId), cancellationToken);
}
}EnqueueAsync, ScheduleAsync, ScheduleAtAsync, and TriggerNowAsync return a JobHandle. Its Id is an opaque
string invocation ID. Consumers must not parse it or depend on its format; storage integrations may
use another string ID scheme.
Applications that need Snowflake, ULID, or another identifier format can replace the singleton ID generator. The implementation is resolved from DI and must be thread-safe:
services.AddMyAppJobs(options => options.UseInMemory())
.UseIdGenerator<SnowflakeIdGenerator>();
sealed class SnowflakeIdGenerator(ISnowflakeService snowflakes) : IIdGenerator
{
public string CreateId(IdKind kind) =>
$"{(kind is IdKind.Job ? "job" : "batch")}_{snowflakes.GenerateSnowflakeId()}";
}Inject IJobBatchScheduler to create an atomic group. Generated schedulers inherit strongly typed
batch methods and return handles that can be connected into chains, fan-out branches, and fan-in
joins. Storage receives the entire batch in one transaction; disposing without committing writes
nothing.
await using var batch = batches.Begin();
var imported = import.AddToBatch(batch, new(importId));
var indexed = await index.ScheduleAfterAsync(imported, new(importId), cancellationToken: cancellationToken);
var notifyOwner = await notify.ScheduleAfterAsync(indexed, new(importId), cancellationToken: cancellationToken);
var updateMetrics = await metrics.ScheduleAfterAsync(indexed, new(importId), cancellationToken: cancellationToken);
await finalize.ScheduleAfterAsync(
[notifyOwner, updateMetrics],
new(importId),
cancellationToken: cancellationToken
);
BatchHandle committed = await batch.CommitAsync(cancellationToken);ContinuationTrigger.Success is the default; Failure runs after every parent settles when at
least one failed, and Complete runs regardless of outcome. A running batch member can also expand its workflow through its injected
JobDetails, with additions buffered until the attempt succeeds so retries do not duplicate work.
Use IJobBatchMonitor and IJobMonitor for status, member, graph, and job reads. See
Batches & Continuations for the complete API and semantics.
Define queues as strongly typed marker classes and assign jobs with UsesQueue<TQueue>:
[QueueDefinition(Name = "transactional-email", Priority = 100, Concurrency = 2)]
public sealed class TransactionalEmailQueue;
[Handler, Job, UsesQueue<TransactionalEmailQueue>]
public sealed partial class SendWelcomeEmail(IEmailSender sender)
{
// ...
}Larger priority values are acquired first. Queues at the same priority are considered in round-robin order, while jobs within a queue remain ordered by due time and creation time. Concurrency is a per-node limit; zero is unbounded. It composes with Job.MaxConcurrency and the global MaxParallelJobs limit.
When Name is omitted it is derived from the queue type (TransactionalEmailQueue becomes transactional-email-queue). Jobs without UsesQueue<TQueue> use the unbounded priority-zero default queue. Queue names are persisted with each invocation: keep an old definition until its nonterminal jobs drain, or migrate those rows before renaming/removing it.
Fair queues prevent one tenant's backlog from starving quieter tenants in the same queue. Supply a runtime group id when directly enqueueing or scheduling work, then opt into fair acquisition:
await welcomeEmail.EnqueueAsync(
new(userId, "v2"),
groupId: tenantId,
cancellationToken: cancellationToken
);
builder.Services.AddMyAppJobs(options =>
{
options.UseEntityFrameworkCore<AppDbContext>();
options.UseDistributed();
options.UseFairQueues();
});Fair queues are not FIFO and do not serialize a group. They rotate eligible work across groups and
deprioritize a group only when its non-expired in-flight share exceeds the configured threshold.
Null, empty, or whitespace group ids are ungrouped and retain the existing due-time order. Group ids
should identify reusable tenants, not individual jobs; use null for ordinary ungrouped work.
In-memory, EF Core, LinqToDB, and single-server storage support fair acquisition. Redis persists the
group id but rejects fair acquisition in this release. EF applications must migrate the nullable
GroupId column, (QueueName, State, GroupId) index, and immediate_fair_queue_groups table.
LinqToDB schema bootstrap creates them for new databases; existing databases need the equivalent
additive upgrade. See Fair queues for the algorithm, schema, tradeoffs, and
provider details.
Register the generated application job module:
builder.Services.AddMyAppHandlers();
builder.Services.AddMyAppJobs(options =>
{
options.UseEntityFrameworkCore<AppDbContext>(); // memory-primary single-server mode by default
options.MaxParallelJobs = 16;
options.PollingInterval = TimeSpan.FromSeconds(1);
}).AddHealthCheck();Both registration methods are generated per assembly and named after it, with ., -, and spaces
removed: an assembly named MyApp produces AddMyAppJobs, and Contoso.Billing.Api produces
AddContosoBillingApiJobs. Apply Immediate.Handlers'
[assembly: ImmediateAssemblyIdentifier("Billing")] to choose the infix yourself. The handlers
method comes from Immediate.Handlers and is declared in the assembly's root namespace, so a using
may be required; the jobs method is declared in Microsoft.Extensions.DependencyInjection.
AddMyAppJobs registers each job's scheduler, invoker, context extractors, queue definitions, and
job definition, but not the handlers themselves. The worker resolves SendWelcomeEmail.Handler
from DI when it executes an invocation, so an application that does not also call
AddMyAppHandlers() (and AddMyAppBehaviors(), if it declares behaviors) will enqueue jobs
successfully and then fail every attempt at execution time.
The EF Core package adds UseEntityFrameworkCore<TContext>(); the LinqToDB package adds
UseLinqToDB(dataOptions, schema). A durable provider implicitly selects single-server mode: memory
is the live authority and every transition is written through to the database for restart recovery.
Use options.UseSingleServer() to state that topology explicitly, options.UseDistributed() to
make the database authoritative for multi-node coordination, or options.UseInMemory() for a
non-durable development store.
Adding queue support introduces the required QueueName column on immediate_jobs, and invocation
IDs are stored as strings with a maximum length of 256. Execution correlation adds nullable
ExecutionTraceId, ExecutionSpanId, and ExecutionStartedAt columns. Applications using EF Core
storage must add a migration (or recreate a development database created from an earlier draft).
The model supplies "default" as the queue database default so existing rows are backfilled safely.
Code-defined schedules use five-field cron or six-field cron with seconds. See the Cronos usage guide for the supported syntax:
[Handler, Job(Cron = "0 */5 * * * *", TimeZone = "Europe/Vienna")]
public sealed partial class CleanupSessionsJob(AppDbContext db)
{
private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) =>
new(db.DeleteExpiredSessions(cancellationToken));
}Inject CleanupSessionsJob.Scheduler to trigger the code-defined job immediately:
await scheduler.TriggerNowAsync(cancellationToken);Schedulers for jobs with a compile-time Cron do not expose dynamic schedule mutation. To manage named schedules at runtime, define a separate payloadless job without Cron:
[Handler, Job(Name = "tenant-cleanup")]
public sealed partial class TenantCleanupJob(AppDbContext db)
{
private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) =>
new(db.DeleteExpiredSessions(cancellationToken));
}
await tenantCleanupScheduler.AddOrUpdateRecurringAsync("tenant-42-cleanup", "0 0 3 * * *", "UTC", cancellationToken);
await tenantCleanupScheduler.RemoveRecurringAsync("tenant-42-cleanup", cancellationToken);Code schedules are reconciled at startup: current definitions are re-asserted and persisted code-defined schedules that no longer exist are removed. Dynamic schedules are left unchanged and cannot replace a code-defined schedule with the same name. Storage uses a unique (schedule name, scheduled UTC occurrence) materialization key, so competing nodes produce one durable invocation for each occurrence.
Background dispatch calls the generated Immediate.Handlers handler, so jobs use the same behavior pipeline as inline requests. A job request does not need a jobs-specific base type or interface. Implement the optional IJobRequest capability only when the handler or a behavior needs execution metadata; the worker then populates its non-persisted JobDetails immediately before entering the pipeline.
public sealed record Payload(Guid UserId, string Template) : IJobRequest
{
public JobDetails? JobDetails { get; set; }
}[assembly: Behaviors(typeof(JobLoggingBehavior<,>))]
public sealed class JobLoggingBehavior<TRequest, TResponse>(ILogger<JobLoggingBehavior<TRequest, TResponse>> logger)
: Behavior<TRequest, TResponse>
where TRequest : IJobRequest
{
public override async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken)
{
var details = request.JobDetails ?? throw new InvalidOperationException("Job details are unavailable.");
logger.LogInformation("Starting {JobName} attempt {Attempt}", details.JobName, details.Attempt);
return await Next(request, cancellationToken);
}
}The IJobRequest constraint keeps this global behavior out of ordinary handlers and jobs that have not opted into execution metadata. Use Immediate.Handlers [Behaviors(...)] directly on a job to replace assembly behaviors, or put [Behaviors(...)] on a reusable custom attribute for a named job pipeline. Handler behaviors execute inside the retry boundary.
Use IJobContextExtractor<TContext> when a job needs request-scoped or ambient state that is not
part of its business payload. Capture runs while enqueueing in the caller's scope; restore runs in
the job's execution scope before the handler and its behaviors are resolved. The context value is
serialized with generated metadata, so it remains trimming- and Native AOT-safe.
// This is the durable, serializable value stored with the job.
public sealed record UsageContextSnapshot(Guid UserId, string TenantId);
// This is the scoped application service populated by the request and injected through DI.
public sealed class UsageContext
{
public UsageContextSnapshot? Value { get; set; }
}
public sealed class UsageContextExtractor(UsageContext usage)
: IJobContextExtractor<UsageContextSnapshot>
{
public string Key => "usage"; // stable across extractor type renames
public UsageContextSnapshot? Capture() => usage.Value;
public void Restore(UsageContextSnapshot context) => usage.Value = context;
}
[Handler, Job, UsesJobContext<UsageContextExtractor>]
public sealed partial class AuditUsageJob(UsageContext usage)
{
// usage.Value contains the enqueueing scope's snapshot when this job runs.
}Register the application-owned holder as scoped: builder.Services.AddScoped<UsageContext>().
The generated job registrations add UsageContextExtractor as scoped automatically. At enqueue,
the extractor reads the caller's UsageContext; at execution, it writes the deserialized snapshot
into the new job scope's UsageContext before the job and its behaviors are resolved. The snapshot
itself is persisted data passed to Restore, not a service resolved from DI.
For a family of jobs, put one or more extractor markers on a reusable attribute:
[UsesJobContext<UsageContextExtractor>]
[UsesJobContext<CorrelationContextExtractor>]
public sealed class WebJobAttribute : Attribute;
[Handler, Job, WebJob]
public sealed partial class SendInvoiceJob
{
// Captures and restores both contexts.
}Extractor keys identify persisted envelope slices and must be unique for a job. Return null from
Capture when there is no context to persist, as is typical when recurring work is
materialized outside a request.
Immediate.Jobsincludes the development-only in-memory provider, the memory-primary durable single-server topology, and the channel-backed worker pool.Immediate.Jobs.EntityFrameworkCoreis the provider-neutral EF Core adapter, validated with PostgreSQL, SQLite, and SQL Server.Immediate.Jobs.LinqToDBis the provider-neutral LinqToDB adapter, validated with PostgreSQL, SQLite, and SQL Server.Immediate.Jobs.Redisis the distributed queue + recurring adapter. It does not implement batches or continuations; those features require one of the graph-capable SQL providers.
All providers implement IJobStorage. Single-server mode restores unfinished jobs and recurring schedules into memory when the process starts. Distributed mode uses provider leases; if a process dies, its lease expires and another node can acquire the invocation. Redis always selects distributed mode because the single-server durable-replica topology requires all storage capabilities.
Pass either a StackExchange.Redis configuration string or an application-owned
IConnectionMultiplexer. UseRedis selects distributed mode automatically:
builder.Services.AddMyAppJobs(options =>
options.UseRedis("localhost:6379", redis =>
{
redis.Database = 1;
redis.KeyPrefix = "billing-jobs";
}));The prefix isolates applications and is also used as the Redis Cluster hash tag, keeping every
atomic Lua transition in one slot. The provider owns connections it creates from a configuration
string; it does not dispose an IConnectionMultiplexer supplied by the application. Terminal job
history is tracked in completion-time sorted indexes and removed by the normal
SucceededRetention / FailedRetention purge loop.
Register an IDbContextFactory<TContext>, select the database through its normal EF provider, and
include the jobs model in the application context:
builder.Services.AddDbContextFactory<AppDbContext>(db =>
db.UseNpgsql(connectionString)); // PostgreSQL
// db.UseSqlite(connectionString); // SQLite
// db.UseSqlServer(connectionString); // SQL Server
builder.Services.AddMyAppJobs(options =>
options.UseEntityFrameworkCore<AppDbContext>());
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddImmediateJobs(schema: "background"); // omit the schema for SQLite
}The application owns EF migrations. Add a migration after calling AddMyAppJobs, or use
EnsureCreatedAsync only for disposable development/test databases. The adapter does not reference
Npgsql, SQLite, or SQL Server provider packages.
Configure and reuse an immutable DataOptions, then pass it to both bootstrap and registration:
var dataOptions = new DataOptions().UsePostgreSQL(connectionString);
// new DataOptions().UseSQLite(connectionString);
// new DataOptions().UseSqlServer(connectionString);
await dataOptions.CreateImmediateJobsSchemaAsync(
schema: "background", // must be null for SQLite
cancellationToken);
builder.Services.AddMyAppJobs(options =>
options.UseLinqToDB(dataOptions, schema: "background"));CreateImmediateJobsSchemaAsync is an explicit, idempotent bootstrap helper for a fresh database.
It is never called by InitializeAsync and is not a production migration system. Applications own
the matching ADO.NET driver (Npgsql, Microsoft.Data.Sqlite, or Microsoft.Data.SqlClient); the
adapter package deliberately carries none of them. Named schemas are supported on PostgreSQL and
SQL Server. SQLite has no server schemas and is normally embedded/file-backed, including in the
storage conformance tests.
Queue-aware dispatch changes the provider acquisition seam to AcquireDueJobsAsync(JobAcquisitionRequest, ...). Custom providers must honor the request's queue order, queue capacities, and per-job capacities when upgrading.
Reference Immediate.Jobs.Dashboard, then map it after building the app:
var traceExplorer = new Uri("https://traces.example/");
var logExplorer = new Uri("https://logs.example/");
app.MapImmediateJobsDashboard("/jobs", options =>
{
_ = options.RequireAuthorization("operations");
_ = options.AddTelemetryLink(
"View latest trace",
JobTelemetryLinkKind.Trace,
context => context.Job.ExecutionTraceId is { } traceId
? new(traceExplorer, $"trace/{traceId}")
: null);
_ = options.AddTelemetryLink(
"View all retry logs",
JobTelemetryLinkKind.Logs,
context => new(logExplorer,
$"search?jobId={Uri.EscapeDataString(context.Job.Id)}"));
});The package serves an embedded SPA, JSON monitoring endpoints, and Server-Sent Events streams.
Without an authorization policy, dashboard access is allowed only in the Development environment.
The dashboard includes batch progress and a live dependency-graph viewer alongside filtered jobs,
recurring schedule actions, retry, cancellation, and atomic batch deletion. Job search and filters
are paged on the server in groups of 50; batch members show a link to their workflow.
Telemetry destinations are application-defined because Aspire, Jaeger, Grafana, Seq, Azure Monitor,
and other systems use different query URLs. Each execution attempt creates a distinct Activity
linked to the enqueue context; the persisted trace and span identify the latest attempt. A job-ID log
query covers every retry because the structured logging scope includes
{JobName, JobId, Attempt} on every attempt. Immediate.Jobs currently stores the attempt count and
latest failure rather than a separate row per attempt, so it cannot offer individual links for older
attempt spans. AddTelemetryLink may return null when a destination does not apply, and accepts
HTTP(S) or dashboard-relative URLs.
Immediate.Jobs.Testing provides JobTestHarness, a fake clock, advance-and-drain helpers, capture-only typed schedulers, enqueue assertions, and a single-job handler-pipeline runner. Delayed work, scheduled occurrences, timeout, and backoff tests do not need wall-clock sleeps.
Compile-time diagnostics:
| ID | Meaning |
|---|---|
IJOB0001 |
[Job] class is not also an Immediate [Handler] |
IJOB0002 |
Duplicate persisted job name |
IJOB0003 |
Duplicate persisted queue name |
IJOB0004 |
NodaTime is referenced without Immediate.Jobs.NodaTime |
IJOB0005 |
Invalid retry/concurrency/timeout configuration |
IJOB0006 |
A cron job declares a payload |
IJOB0007 |
Invalid cron expression or time zone |
IJOB0008 |
No usable job name; rename the class or set Name |
IJOB0020 |
AddToBatchAsync(JobDetails, ..., Detached) is contradictory |
IJOB003 |
Unsupported payload member/type |
IJOB004 |
Invalid private ValueTask HandleAsync(request, CancellationToken) signature |
IJOB010 |
Invalid queue name or concurrency configuration |
IJOB011 |
UsesQueue<T> targets a type without QueueDefinition |
IJOB013 |
UsesJobContext<T> targets an invalid extractor type |
IJOB014 |
Unsupported context member/type |
Runtime ImmediateJobException codes:
| ID | Meaning |
|---|---|
IJOB016 |
An empty atomic batch was committed |
IJOB017 |
Continuation handles belong to unrelated batches |
IJOB018 |
A continuation dependency cycle was detected |
IJOB019 |
A batch handle was used after commit or disposal |
Invalid Immediate.Jobs runtime operations and states throw ImmediateJobException. Invalid method
arguments, cron expressions, serialized data, and missing records retain their standard exception types.
Executions emit activities from ActivitySource Immediate.Jobs, metrics from Meter Immediate.Jobs, structured logs scoped by job name/id/attempt, and scheduler/storage health checks. The metrics include enqueue/success/failure/retry counters, duration histograms, queue depth, and active workers.
The Aspire sample runs the EF Core provider against an Aspire-managed PostgreSQL container and sends application logs, traces, metrics, and health status to the Aspire dashboard. It also exposes the Immediate.Jobs dashboard for job-specific history and operations.
The repository includes BenchmarkDotNet comparisons with TickerQ, Hangfire MemoryStorage, and Quartz.NET. In addition to enqueue, direct dispatch, and startup, the suite covers concurrent throughput, cron expressions, delegate invocation, job creation, serialization, and startup registration. These are microbenchmarks of deliberately different framework APIs—not end-to-end durability or worker-latency measurements—so run them on the deployment target before drawing conclusions.
The tables below are the historical ShortRun results from 21 July 2026: BenchmarkDotNet 0.15.8, .NET 8.0.22 Arm64 RyuJIT, Apple M3 Pro with 12 cores, macOS 26.5. Each result uses one launch, three warmup iterations, and three measurement iterations. Ratios use Immediate.Jobs as the baseline. The expanded TickerQ suite targets .NET 10 and does not yet have checked-in results.
| Framework | Mean | Ratio | Allocated | Allocation ratio |
|---|---|---|---|---|
| Immediate.Jobs | 3.796 μs | 1.00 | 5.07 KB | 1.00 |
| Hangfire | 16.122 μs | 4.25 | 14.49 KB | 2.86 |
| Quartz.NET | 17.288 μs | 4.56 | 6.34 KB | 1.25 |
| Framework | Mean | Ratio | Allocated |
|---|---|---|---|
| Immediate.Jobs | 0.9994 ns | 1.00 | 0 B |
| Hangfire | 28.0701 ns | 28.09 | 32 B |
| Quartz.NET | 0.0521 ns | 0.05 | 0 B |
The Immediate.Jobs and Quartz.NET dispatch operations are effectively below the benchmark's reliable measurement floor. Treat their sub-nanosecond values as "no measurable dispatch overhead" rather than literal timing precision.
| Framework | Mean | Ratio | Allocated | Allocation ratio |
|---|---|---|---|---|
| Immediate.Jobs | 393.60 ns | 1.00 | 649 B | 1.00 |
| Hangfire | 7,765.75 ns | 19.77 | 3,104 B | 4.78 |
| Quartz.NET | 11.67 ns | 0.03 | 136 B | 0.21 |
The complete generated reports are available for enqueue, direct dispatch, and scheduler construction.
Run the complete suite with:
dotnet run --project benchmarks/Immediate.Jobs.Benchmarks -c Release -- --filter '*'- Lease: 30 seconds, renewed while active.
- Attempts: 3 total, exponential backoff with jitter from 5 seconds.
- Successful history: 24 hours.
- Failed history: 7 days.
- Graceful worker drain: 30 seconds.
These values are configurable globally or, where applicable, on [Job].