Skip to content

Batches and continuations

Build atomic job graphs, chains, fan-out/fan-in and dynamically expanded workflows.

3 min read

Batches persist jobs and their dependency edges atomically. They require a graph-capable provider; Redis exposes queue and recurring capabilities only. Resolve the scoped IJobBatchScheduler from DI, normally through constructor injection alongside the generated job schedulers.

Atomic workflow graph

The constructor below makes every receiver explicit: batches is the runtime batch scheduler; the other parameters are nested scheduler types generated for their corresponding job classes.

public sealed class ImportWorkflow(
	IJobBatchScheduler batches,
	ImportData.Scheduler import,
	BuildIndex.Scheduler index,
	NotifyOwner.Scheduler notify,
	UpdateMetrics.Scheduler metrics,
	FinalizeImport.Scheduler finalize
)
{
	public async ValueTask<BatchHandle> StartAsync(
		Guid importId,
		CancellationToken cancellationToken
	)
	{
		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
		);

		return await batch.CommitAsync(cancellationToken);
	}
}

Within an open batch, AddToBatch and AddToBatchAt only buffer records. Continuations built from their handles remain in the same buffer. CommitAsync performs one atomic graph write and returns a BatchHandle; nothing becomes visible before commit.

Begin() returns the in-memory buffer shown above. Always dispose it: disposal without commit abandons the buffer. A batch can commit only once and cannot be modified after commit. As an alternative, batches.RunAsync(body, cancellationToken) creates the buffer, runs the body and commits only when the body completes successfully.

Failures before CommitAsync begins write nothing. Once commit begins, however, the batch is closed even when the call throws, and a transport failure can leave the durable outcome unknown: storage may have committed the graph before the caller lost the response. Do not retry the same JobBatch; an operation that rebuilds and commits another batch needs application-level idempotency or duplicate tracking.

Batch members can carry the same fair-queue group IDs as ordinary scheduled work:

var tenantId = "tenant-42";
var runAt = DateTimeOffset.UtcNow.AddMinutes(5);

var grouped = import.AddToBatchInGroup(batch, new(importId), tenantId);
var groupedAt = import.AddToBatchAt(batch, new(importId), runAt, tenantId);

AddToBatchInGroup also accepts an optional delay. Whitespace group IDs are normalized to no group, the 128-character limit still applies, and the configured provider must support fair acquisition for the group to affect dispatch order.

Chains, fan-out and fan-in

ScheduleAfterAsync(JobHandle, ...) creates a chain. Pass a ReadOnlySpan<JobHandle> to wait for all parents (fan-in), or create several children from one parent (fan-out). Duplicate parents and handles from unrelated open batches are rejected. ScheduleAfterAsync(BatchHandle, ...) waits for the entire prior batch. batches.Begin(previousBatch, trigger) creates a follow-up batch whose root members all depend on it.

ContinuationTriggerChild is released when…
Successall parents succeed; otherwise the child is cascade-cancelled.
Failureall parents are terminal and at least one failed; otherwise it is cancelled.
Completeall parents reach any terminal state.

For a continuation attached to a BatchHandle, the batch is one parent. It becomes Failed after all of its items finish when any item failed, so a Failure continuation is released in that case. Cancelled items alone make the batch Cancelled and do not satisfy Failure.

Expanding a running workflow

Implement IJobRequest to receive JobDetails, then use a generated scheduler:

[Handler, Job]
public sealed partial class ProcessOrder(SendEmail.Scheduler sendEmail)
{
	public sealed record Command(Guid OrderId) : IJobRequest
	{
		public JobDetails? JobDetails { get; set; }
	}

	private async ValueTask HandleAsync(Command command, CancellationToken cancellationToken)
	{
		var order = await LoadOrderData(command.OrderId, cancellationToken);

		_ = sendEmail.ScheduleAfter(
			command.JobDetails!,
			new(order.CustomerEmail, order.Summary),
			ContinuationOptions.BeforeContinuations
		);
	}
}

ScheduleAfter buffers work and persists it only if the current attempt succeeds. AddToBatchAsync adds concurrent work immediately to the running batch. ContinuationOptions controls how that new work relates to the current job’s existing continuations:

OptionBatch membershipEffect on existing continuations
DetachedNoneUnchanged; valid only with ScheduleAfter.
BesideContinuationsCurrent batchUnchanged; the new job forms a parallel branch.
BeforeContinuations (default)Current batchThey also wait for the new job, creating an additive dependency.

The BeforeContinuations splice keeps each existing dependency on the current job and adds a dependency on the new job. Existing continuations therefore wait for both jobs.

Monitor a graph through IJobBatchMonitor.GetStatusAsync, QueryMembersAsync and GetGraphAsync, or use the dashboard’s workflow view and batch cancel/delete operations.