Skip to content

Enqueueing and scheduling

Use generated schedulers for immediate, delayed, absolute and fair-group work.

3 min read

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.