Skip to content

Workflow building blocks

Batches compose real patterns through the fluent builder: sequential (RunJob<A>().ThenRunJob<B>()), parallel fan-out/fan-in (ThenInParallel(...)), approval gates, and compensation (OnFailure(...)). This page covers the three building blocks most people reach for first.

Pause a batch until a human approves or rejects from the dashboard:

b.AddBatch("rollout", batch => batch
.RunJob<DeployJob>()
.ThenWaitForApproval(
title: "Confirm rollout",
roles: new[] { "ops" },
timeout: TimeSpan.FromMinutes(30),
onTimeout: ApprovalTimeoutAction.Hold));

The gate holds until an authenticated caller with a matching role approves it. Roles are matched against ClaimTypes.Role by default — see Gotchas if you use Azure AD / Auth0 / SAML.

A few more rules worth knowing:

  • Set a timeout when the on-timeout action acts. If onTimeout is AutoApprove or Hold, you must also give a timeout — otherwise the gate waits indefinitely and the action never fires. The wizard and the REST validators reject that combination. onTimeout: Fail with no timeout is valid: the gate just waits until a human decides.
  • A pending gate is a snapshot of its definition at creation time. Editing a batch definition does not change a gate that is already waiting — its roles, timeout, and on-timeout action are fixed when the run reaches the gate. A stuck, undecidable gate is resolved by deciding it through the approvals API with proper authentication, or by restarting the host.

For “fetch a set of items, then process them on N workers”, implement IPartitionedJob<TItem>. The runtime owns the producer/consumer plumbing (a bounded channel plus N consumer tasks); you declare the source stream and the per-item work, with an optional commit hook:

public sealed class ReconcileInvoicesJob : IPartitionedJob<int>
{
// Stream the items to process. Yield lazily so the bounded channel applies backpressure.
public async IAsyncEnumerable<int> SourceAsync(JobContext context, CancellationToken ct)
{
context.Progress.SetTotal(100); // drives a live x/100 progress counter
for (var id = 1; id <= 100; id++)
yield return id;
}
// Runs on N concurrent workers — MUST be thread-safe.
public Task ProcessAsync(int id, JobContext context, CancellationToken ct) =>
ReconcileAsync(id, ct);
// Optional commit hook: runs exactly once after every item, single-threaded.
// Skipped on a fail-fast abort or cancellation; under ContinueOnError it commits the subset that succeeded.
public Task FinalizeAsync(JobContext context, CancellationToken ct) =>
SaveResultsAsync(ct);
}

Register it with a worker count and a per-item error policy:

b.AddPartitionedJob<ReconcileInvoicesJob, int>()
.Named("ReconcileInvoices")
.WithParallelism(4)
.WithItemErrorPolicy(ItemErrorPolicy.ContinueOnError);

The worker count can be overridden per run by passing the trigger parameter ukbatch.workers (an invalid value falls back to the configured parallelism with a warning, and the effective count is capped at 128).

Instead of registering each job explicitly, decorate it with [Job] and scan assemblies:

[Job(Name = "DailyReport", Schedule = "0 0 9 * * *", MaxRetries = 3, TimeoutSeconds = 600)]
public sealed class DailyReportJob : IJob { /* ... */ }
builder.AddUKBatchAspNetCore(b => b.ScanAssemblies(typeof(Program).Assembly));

[Job] carries optional Name, Schedule (cron), MaxRetries, TimeoutSeconds, and Tags.