Chain Jobs with Dependencies
Run one job after another with dependencies, choosing on-success versus on-any-terminal release.
Run one job after another with dependencies, choosing on-success versus on-any-terminal release.
A Dependency is a static edge from a child Job to a parent Job. You declare it once, when you enqueue the child, by pointing at an already-enqueued parent's id. The child then sits and waits until the parent reaches a terminal state, and at that moment BackWave either releases it to run or cancels it, depending on the release mode you chose. This guide covers the single-parent path on the base BackWave surface: how to declare the edge, the difference between the two release modes, what happens to the child when a parent fails, and how a child reads its parent's output. For one Job that waits on several parents at once (true fan-in), see the Workflows handoff at the end.
If you have not read Dependencies yet, skim it first. It defines the model this guide leans on: Dependency, Awaiting Parent, terminal state, on-success, and on-any-terminal.
The model: edges, terminal states, and release#
Three facts about Dependencies are worth fixing in your head before any code.
First, edges are declared once and never change. You point a new Job at an existing parent by id at enqueue time. There is no API to add or rewrite a Job's parents afterward. The parent must already be enqueued before you can depend on it.
Second, the parent's terminal state is the trigger, not mere completion. A Job is terminal when it reaches one of four states: Succeeded, Cancelled, Dead-Lettered, or Quarantined. The child waits until its parent is terminal, then BackWave decides what to do with it based on which terminal state that was.
Third, a waiting child is not claimable. While it waits, the child holds the Awaiting Parent state. Workers only ever claim Scheduled jobs that are due, so an Awaiting Parent job is invisible to claiming. When the parent settles, the child transitions out of Awaiting Parent in one of two directions: to Scheduled (released, now eligible at its due time) or straight to Cancelled (gated out). BackWave tracks the unresolved parent for you and acts the instant it settles.
There are exactly two release modes, and they differ in how each reads the parent's terminal state.
- on-success (the default): the child runs only if the parent Succeeded. Any other terminal outcome cancels the child.
- on-any-terminal: the child runs once the parent is terminal, whatever that terminal state is. Success, dead-letter, cancel, and quarantine all count equally as "the parent is done."
Declare a dependency#
You need two things: the parent's id, and a call to EnqueueDependencyAsync for the child.
The parent's id is just the return value of an ordinary enqueue. EnqueueAsync mints and returns the new Job's Guid client-side, and that Guid is what you depend on.
var chargeId = await client.EnqueueAsync(new ChargeCard(orderId), dueTime: DateTimeOffset.UtcNow);
var receiptId = await client.EnqueueDependencyAsync(
new SendReceipt(orderId),
parentId: chargeId);SendReceipt is now created in the Awaiting Parent state. It will not be claimed by any Worker until ChargeCard is terminal, and because the mode defaults to on-success, it runs only if ChargeCard Succeeded. If the charge ends any other way, the receipt is cancelled and never runs.
The arguments line up with the plain enqueue path, with a few specifics.
parentIdis the id of the Job this one waits on, typically the return value of an earlier enqueue. Exactly one parent here. Multiple parents are the Workflow surface, covered below.enqueuedAtdefaults to the current instant and sets the child's due time. A released child becomes due at the later of its own due time and the release instant, so a futureenqueuedAtplus a Dependency means the child runs atmax(enqueuedAt, release). Pass a future instant when you want both a delay and a dependency.modeisDependencyMode.OnSuccess(the default) orDependencyMode.OnAnyTerminal.queueis null by default, which uses the Queue declared on the Job type. Pass a name to override it for this enqueue.tagsmerge on top of the type's default Tags, never instead of them.
The call returns the child's Guid. That id is itself a valid parent, which is how you chain a third Job after the second.
Choose the release mode#
The mode you pass decides what a non-success parent does to the child. This is the heart of the feature, so here are both modes side by side.
on-success: run only if the parent succeeded#
The default. Use it for the common case, where the downstream Job only makes sense if the upstream one actually worked. A receipt only makes sense if the charge went through.
var receiptId = await client.EnqueueDependencyAsync(
new SendReceipt(orderId),
parentId: chargeId); // mode defaults to DependencyMode.OnSuccessIf ChargeCard ends Dead-Lettered, Cancelled, or Quarantined instead of Succeeded, the receipt is moved straight to Cancelled and never runs. The first non-success outcome on the parent decides it.
on-any-terminal: run once the parent settles, however it settled#
Use this for cleanup, compensation, and notification work that has to run no matter how the upstream Job ended. Releasing an inventory hold should happen whether or not the charge succeeded.
var releaseHoldId = await client.EnqueueDependencyAsync(
new ReleaseInventoryHold(orderId),
parentId: chargeId,
mode: DependencyMode.OnAnyTerminal); // runs whether the charge succeeded or notNow a Dead-Lettered or Cancelled charge still releases the hold. The child runs once the parent is terminal, full stop, and its Handler can inspect the parent's actual terminal state if it needs to branch (see reading the parent's output below).
What each terminal state does to the child#
| Parent terminal state | on-success child | on-any-terminal child |
|---|---|---|
| Succeeded | Released, runs | Released, runs |
| Dead-Lettered | Cancelled | Released, runs |
| Cancelled | Cancelled | Released, runs |
| Quarantined | Cancelled | Released, runs |
Under on-success, the cancellation cascades: when the child is cancelled by its parent, that child's own on-success children are cancelled too, recursively down the chain. A failed root takes its whole on-success subtree with it.
Cancellation under on-success is also permanent under every automatic path. A parent that later gets requeued and succeeds does not un-cancel a child that was already cancelled by its earlier failure. The edge fired once, at the moment the parent first settled, and the result stands. Reanimating a cancelled subtree is a BackWave Pro Workflow operation and is out of scope here.
Chain more than two jobs#
Because the child's id is a normal Guid, you chain by depending on it in turn. Each Job still has exactly one parent, and the chain can branch.
var chargeId = await client.EnqueueAsync(new ChargeCard(orderId), dueTime: DateTimeOffset.UtcNow);
var receiptId = await client.EnqueueDependencyAsync(new SendReceipt(orderId), parentId: chargeId);
var archiveId = await client.EnqueueDependencyAsync(new ArchiveOrder(orderId), parentId: receiptId);ArchiveOrder waits on SendReceipt, which waits on ChargeCard. Under the default on-success mode, all three run in order and any failure short-circuits the rest of the chain through the cascade.
Read the parent's output#
A child often needs a value the parent produced: a transaction id, a generated key, a computed total. Dependencies carry a success-side data channel for exactly this. The parent writes an output on success, and the child reads it.
Inside the parent's Handler, set the output. It is buffered on the Job Context and persisted only on a successful outcome, atomically with the Succeeded transition. Last write within an Attempt wins.
public Task HandleAsync(ChargeCard job, JobContext context, CancellationToken cancellationToken)
{
var transactionId = /* charge the card */;
context.SetOutput(new ChargeResult(transactionId), ChargeResultJsonContext.Default.ChargeResult);
return Task.CompletedTask;
}Inside the child's Handler, read it. For a Dependency created by EnqueueDependencyAsync, the handle is the parent's Guid rendered as a string.
public async Task HandleAsync(SendReceipt job, JobContext context, CancellationToken cancellationToken)
{
var parent = await context.GetDependencyOutputAsync(
job.ParentId.ToString(),
ChargeResultJsonContext.Default.ChargeResult,
cancellationToken);
if (parent.HasOutput)
{
var txn = parent.Output!.TransactionId;
// use the transaction id
}
}GetDependencyOutputAsync returns a DependencyOutput<T> with three members:
AncestorStateis the terminal state the parent reached.HasOutputis whether there is an output value to read.Outputis the value, or null whenHasOutputis false.
A few rules make this safe to use without guarding against exceptions.
Absence is normal, never a throw. A parent that failed, was cancelled, or simply emitted nothing yields HasOutput == false. This is the common case on the on-any-terminal path, where the child routinely runs past a non-success parent. Always branch on HasOutput before touching Output. Reading an output never creates, reorders, or skips any Job; it returns settled facts only.
You can only read a transitive ancestor's output. A child may read the output of the parents it waited on, and their parents, and so on up the chain. It cannot read a sibling or any other non-ancestor Job, because only an ancestor is guaranteed to have already written its output before the child runs. A non-ancestor is physically unresolvable.
Output has a size limit. A serialized output that exceeds the store's limit (65,536 bytes by default, configurable) is rejected at the producer's outcome write, failing that Attempt loudly rather than truncating. Keep outputs small. For a large result, write it to your own store and pass a reference.
Fan-in needs a Workflow#
Everything above gives each child exactly one parent. When you need one Job to wait on several parents at once, that is fan-in, and on the base surface there is no way to express it: EnqueueDependencyAsync takes a single parentId. True fan-in is the BackWave Pro Workflow surface, where you name nodes and a node can declare dependsOn over a list of other node names.
var wf = client.BuildWorkflow("fulfilment");
wf.AddJob("charge", new ChargeCard(orderId));
wf.AddJob("reserve", new ReserveStock(orderId));
wf.AddJob("ship", new ShipOrder(orderId), dependsOn: ["charge", "reserve"]); // waits on BOTH
var workflowId = await client.EnqueueWorkflowAsync(wf.Build());A Workflow also gets you named output handles instead of Guid strings, a graph view, cancel-all, transactional enqueue of the whole graph in one write, and the Restart and Retry operations that can reanimate a cancelled subtree. Reach for it whenever a single Job has more than one parent. The default ceiling is 16 parents per Job (configurable), and the single-parent path here can never trip it. See Build a Workflow for the full surface.
Edge cases and gotchas#
The parent may already be terminal at enqueue time. If you depend on a parent that has already finished, BackWave resolves the edge immediately and the child skips the Awaiting Parent stint. Under on-success, an already-Succeeded parent creates the child directly Scheduled, while an already-failed parent creates the child directly Cancelled. Under on-any-terminal, any already-terminal parent creates the child directly Scheduled. The call still returns a real Guid either way, so a successful return does not by itself mean the child will run. It may already be cancelled.
An unknown parent throws. If parentId does not name an existing Job, EnqueueDependencyAsync throws ArgumentException. The parent has to be enqueued first.
There is no transactional enqueue on this path. EnqueueDependencyAsync takes no transaction parameter, so you cannot commit a single dependent Job atomically with your own database writes. The parent's own EnqueueAsync does support a transaction, and the atomic-graph path is the Pro EnqueueWorkflowAsync, which writes every member of the graph in one transaction. See Transactional Enqueue with EF Core for why the single-dependency path stays non-transactional.
Other enqueue rejections still apply. The shared validation from plain enqueue is in force here too. An oversized serialized payload throws ArgumentException, and any other store rejection (a duplicate id, an over-long Wire Name) throws InvalidOperationException. The fixes are the same as on the plain enqueue path.
A released child still runs At-Least-Once. "Runs after the parent" does not mean "runs exactly once." Once released to Scheduled, a child runs under the normal At-Least-Once Execution contract, so its Handler can still run more than once on a lease lapse or crash. Idempotency remains the Handler author's job, exactly as it is for any other Job. See Retries & Error Handling for writing Handlers that survive re-execution.
Where to go next#
- Dependencies: the concept page behind this guide, with the full model of edges and release modes.
- Read another job's output: the deep dive on
SetOutput,GetDependencyOutputAsync, and the ancestor-scope rule. - Build a Workflow: fan-in, named handles, and atomic graph enqueue when one Job waits on many.
- Job Lifecycle: the Awaiting Parent state and the four terminal states a parent can reach.
- Configure Retries & Error Handling: how a parent becomes Dead-Lettered, the trigger for both release modes.
- Cancel a running job: the operator path that turns a parent Cancelled, and through it, an on-success child.