Runtime jobs
Schedule typed one-off jobs with a payload at runtime through IJobScheduler, and cancel them by id.
Beyond compile-time recurring jobs, you can enqueue one-off work at runtime with a typed payload.
The job type still carries [ScheduledJob] so the generator emits its descriptor and a direct
invocation delegate — but it usually omits a recurring schedule.
Defining a runtime job
A runtime job implements IScheduledJob<TPayload>:
using Elarion.Abstractions.Scheduling;
[ScheduledJob("emails.send")]
public sealed class SendEmailJob(IEmailSender sender) : IScheduledJob<SendEmailPayload> {
public async ValueTask ExecuteAsync(
SendEmailPayload payload,
IScheduledJobContext context,
CancellationToken ct) {
await sender.SendAsync(payload.To, payload.Subject, payload.Body, ct);
}
}
public sealed record SendEmailPayload {
public required string To { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
}Scheduling and cancelling
Use the typed IJobScheduler API:
var handle = await scheduler.ScheduleAsync<SendEmailJob, SendEmailPayload>(
new SendEmailPayload {
To = "customer@example.com",
Subject = "Invoice",
Body = "Your invoice is ready."
},
timeProvider.GetUtcNow().AddMinutes(5),
ct);
await scheduler.CancelJobAsync(handle.JobId, ct);When a handler enqueues background work and needs to return an operation id, return handle.JobId.
That is the id callers pass back to CancelJobAsync.
Which node runs it — and what a restart costs
A runtime job lives entirely on the node that accepted the EnqueueAsync/ScheduleAsync call:
it is enqueued into that process's in-memory queue, no other node ever learns it exists, and it is
therefore trivially duplicate-free — but also lost if that node restarts, deploys, or crashes
before (or while) it runs. Two practical consequences:
ScheduleAsync(payload, dueTime)far in the future is fragile in deploy-heavy environments: a pod recycled before the due time silently discards the job.CancelJobAsync/CancelRunAsyncand the inspector only know about jobs held by the node answering the call — behind a load balancer, a cancel request landing on a different node returnsfalse.
Use runtime jobs for short-lived, loss-tolerable work anchored to the current process ("resize this image", "send this email now-ish"). For work that must reliably happen later, publish an outbox integration event — durable, cluster-executed, at-least-once — or wait for the durable one-shot store planned as ADR-0025 phase 2, which inverts the placement model (the receiving node records the job; whichever node claims it at due time executes it). See Execution semantics & positioning for the full decision matrix.
JobId vs. RunId
These two identifiers serve different purposes:
JobIdidentifies the logical runtime job.CancelJobAsync(jobId, ct)is the user-facing cancellation API: it cancels a queued job, a waiting retry attempt, or the active attempt without the caller needing to know the current state.RunIdidentifies one scheduler attempt, for diagnostics and snapshots. With deferred retry, each attempt gets a newRunIdbut keeps the sameJobId.
CancelRunAsync(runId, ct) targets one concrete attempt — use it for operator/diagnostic workflows
acting on a specific run shown in a snapshot. Job code can also request cancellation through
IScheduledJobContext.RequestCancellation().
Resilience for runtime jobs
Runtime jobs can opt into scheduler-deferred retry, which releases scheduler concurrency between
attempts and exposes WaitingRetry status — the right model when another handler needs to observe
progress. See Resilience for EnqueueAsync with a policy
reference and ScheduledJobResilienceMode.DeferredRetry.