Enqueueing and scheduling
Use generated schedulers for immediate, delayed, absolute and fair-group work.
Each generated JobName.Scheduler is registered as scoped. Inject it into an endpoint,
handler or scoped application service; do not capture it in a singleton.
public sealed class SignupService(SendWelcomeEmail.Scheduler welcomeEmail)
{
public ValueTask<JobHandle> EnqueueAsync(Guid userId, CancellationToken cancellationToken) =>
welcomeEmail.EnqueueAsync(new(userId, "v2"), cancellationToken);
} Scheduling forms
var payload = new SendWelcomeEmail.Payload(userId, "v2");
JobHandle now = await scheduler.EnqueueAsync(payload, cancellationToken);
JobHandle later = await scheduler.ScheduleAsync(payload, TimeSpan.FromMinutes(10), cancellationToken);
JobHandle at = await scheduler.ScheduleAsync(payload, shipAt, cancellationToken); Negative delays throw ArgumentOutOfRangeException. Absolute times are DateTimeOffset; the
storage layer compares them in UTC. Enqueue and schedule return after persistence, not execution.
Fair-group scheduling
When fair queues are enabled, pass a stable tenant or customer ID to the overloads with groupId:
await scheduler.EnqueueAsync(payload, groupId: tenantId, cancellationToken);
await scheduler.ScheduleAsync(payload, TimeSpan.FromMinutes(5), tenantId, cancellationToken);
await scheduler.ScheduleAsync(payload, shipAt, tenantId, cancellationToken); Whitespace is normalized to no group. Group IDs longer than 128 characters are rejected. A
non-empty group is still stored when UseFairQueues() was not called on the registration builder,
but it does not affect order and the worker logs one warning. Fair scheduling requires a provider
that supports it; Redis does not.
Because schedulers are scoped, a singleton worker creates a scope for each unit of work:
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);
}
} Custom identifiers
Immediate.Jobs creates job and batch IDs before writing them to storage. The default IIdGenerator returns a 32-character GUID string without separators. Replace it when your
platform uses Snowflake, ULID or another globally unique string format:
builder.Services.AddMyAppJobs()
.ConfigureStorage(storage => storage.UseInMemory())
.UseIdGenerator<SnowflakeIdGenerator>();
// ISnowflakeService is supplied and registered by your chosen Snowflake implementation.
public sealed class SnowflakeIdGenerator(ISnowflakeService snowflakes) : IIdGenerator
{
public string CreateId(IdKind kind)
{
var prefix = kind switch
{
IdKind.Job => "job",
IdKind.Batch => "batch",
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
return $"{prefix}_{snowflakes.GenerateSnowflakeId()}";
}
} UseIdGenerator<TGenerator>() registers TGenerator as a singleton, so its implementation and
dependencies must be thread-safe. With distributed storage, configure Snowflake worker/node IDs
so separate application instances cannot generate the same value. IdKind lets the generator
distinguish job runs, including recurring runs, from batches.
Treat the result as opaque even when your generator adds a readable prefix. Applications should
store and compare JobHandle or BatchHandle values, not parse business meaning from their
string properties.
Handles and cancellation
JobHandle.Value contains the generated invocation identifier. BatchHandle.Value does the same
for a committed batch. Both are immutable records and serialize to their string identifier. Use JobHandle.FromString(value) or BatchHandle.FromString(value) at a route, database or message
boundary that provides a raw string.
Both types derive from ContinuationHandle, so one continuation API accepts either a job or a
batch as its parent. Jobs added to an open batch return BatchJobHandle instead. That handle keeps
the in-memory batch association needed to build dependencies. Its JobHandle becomes available only
after the batch commits.
Use the same generated scheduler to cancel any non-terminal invocation:
JobHandle handle = await scheduler.EnqueueAsync(payload, cancellationToken);
await scheduler.CancelAsync(handle, cancellationToken); Cancellation immediately persists Cancelled, including for scheduled, pending, continuation-
waiting and active work. If a worker already owns the invocation, its in-process handler is not
forcibly interrupted. Storage rejects that worker’s later completion or failure, so it cannot
overwrite the cancelled record. Cancelling an unknown handle fails as not found. Cancelling a
finished invocation fails with ImmediateJobException.
The token passed to CancelAsync cancels the storage operation only. Likewise, cancellation tokens
on scheduling calls do not become future execution-cancellation tokens.