Dependencies
Static edges that make a job wait on a parent before it runs: release modes, the awaiting-parent gate, fan-in, and reading a parent's output.
A Dependency makes one job wait on another. When you enqueue a job as a dependent, it does not start in Scheduled and become due right away. It starts in AwaitingParent and stays there until its parent reaches a terminal state. The mental model is a charge that has to settle before a receipt goes out: enqueue the charge, then enqueue the receipt as a dependent of it, and the receipt holds until the charge is done.
var chargeId = await client.EnqueueAsync(new ChargeCard(orderId), DateTimeOffset.UtcNow);
var receiptId = await client.EnqueueDependencyAsync(new SendReceipt(orderId), chargeId);The edge is static. It is declared when the dependent is enqueued and is never rewritten afterward. A dependent can read its parent's state and output to decide what to do, but BackWave never creates, skips, or reorders jobs based on that output. The graph you declare is the graph that runs.
Declaring a Dependency#
EnqueueDependencyAsync enqueues a job that waits on a single parent. Beyond the payload and parentId, it takes optional enqueuedAt, mode, queue, tags, and cancellationToken arguments.
The parentId is typically the id returned by an earlier enqueue. The call returns the new dependent's id, which you can in turn pass as the parentId of a further dependent to build a chain. If no job exists with the given parentId, the call throws rather than creating an orphaned dependent.
The enqueuedAt value sets the dependent's Due Time the usual way, but a Dependency adds a floor. A released dependent becomes due at its release time if that is later than its own Due Time, so it never runs ahead of the parent it waited on. If you omit queue, the dependent falls back to the Queue registered for its job type, and any tags you pass are merged on top of the job type's default Tags.
This client method takes exactly one parent. Waiting on several distinct parents (fan-in) is authored through Workflows, covered below.
How the latch works#
A dependent enters AwaitingParent with a counter, ParentsRemaining, set to the number of parents still pending. Each parent that reaches a terminal state decrements the counter by one. While parents are still pending, the dependent stays in AwaitingParent and nothing else happens. When the last parent resolves and the counter hits zero, the dependent moves to Scheduled and becomes due.
That decrement and release apply exactly once per parent-child edge. A handler body may run more than once under at-least-once execution, but the latch resolves each edge a single time regardless. This is the Effect-Once property described in Execution Guarantee, which owns the why. The full state machine, including how AwaitingParent fits among the entry states, lives on Job Lifecycle.
Reaction modes#
A Dependency carries a mode that decides what a parent's outcome means for the dependent. There are two.
| Mode | When the dependent runs | On a non-success parent |
|---|---|---|
OnSuccess (default) | Only if every parent Succeeded | The dependent is Cancelled |
OnAnyTerminal | Once every parent is terminal, whatever the outcome | The dependent still runs |
OnSuccess is the default and fits the common case: the receipt should only go out if the charge actually succeeded. OnAnyTerminal is for cleanup or notification work that needs to run whether the parent worked or not, such as releasing a hold or recording a final status.
When a parent fails or is cancelled#
Under OnSuccess, a parent that reaches any terminal state other than Succeeded cancels the waiting dependent. The dependent moves straight to the terminal Cancelled state, its counter is cleared, and the cancellation records that it was caused by a parent's failure. That cancellation then cascades to the dependent's own children, so a failure near the root of a chain stops the whole branch below it.
There is an enqueue-time short-circuit for the same reason. If you enqueue an OnSuccess dependent whose parent has already reached a non-success terminal state, the dependent is created directly as Cancelled. It never enters AwaitingParent, because there is nothing left to wait for.
This cancellation is permanent on the automatic path. A parent that was Cancelled, later requeued, and then Succeeds does not un-cancel a dependent that was already cancelled because of it. The only way to revive a cancelled branch is an explicit Workflow Retry, a Pro capability covered on the Workflows page.
Under OnAnyTerminal, none of this applies. A failed or cancelled parent simply decrements the counter like any other terminal outcome, and the dependent releases once all of its parents are terminal.
Fan-in: waiting on more than one parent#
A Dependency is an edge from a job to a parent set. The latch counts every parent in the set, and the dependent releases only when the whole set is terminal. A job can wait on up to 16 parents.
The EnqueueDependencyAsync client method declares one parent per call, which is enough for chains and for adding a single gate. To make one job wait on several distinct parents, you author the fan-in through a Workflow, which lets you name jobs and express the edges between them as a group. See Workflows for that authoring surface.
Reading a parent's output#
A job can hand a value to the jobs that depend on it. The producer sets it during its handler, and a released dependent pulls it on demand. BackWave never injects a parent's output into a dependent's arguments; the dependent asks for it when it wants it.
The producer calls SetOutput<T> inside its handler. The value is one opaque blob per job, last write wins within an Attempt, and it is persisted only on a successful outcome, committed together with the Succeeded transition. A failed or superseded Attempt discards whatever it set.
context.SetOutput(new ChargeResult(transactionId), ChargeJsonContext.Default.ChargeResult);The consumer reads it with GetDependencyOutputAsync<T>, naming the parent and passing the JSON type metadata for the shape the producer wrote.
var result = await context.GetDependencyOutputAsync(parentName, ChargeJsonContext.Default.ChargeResult);
if (result.HasOutput)
{
// use result.Output
}The call returns a DependencyOutput<T> with three fields:
| Field | Meaning |
|---|---|
AncestorState | The parent's already-decided terminal state, so the dependent can branch on whether it succeeded |
HasOutput | True only when the parent persisted a non-null output |
Output | The deserialized value, or default when HasOutput is false |
A few properties make this safe to rely on. Reads are scoped to a job's Dependency ancestors: you can read the output of a job you transitively depend on, and a non-ancestor on a parallel branch reads back as a clean absence rather than an error. Because the latch guarantees a parent is terminal before the dependent is released, and output is committed with the parent's success, a released OnSuccess dependent is guaranteed to see its ancestors' written output with no race. Absence is a normal result, not a failure: an OnAnyTerminal dependent routinely runs past a parent that failed or wrote nothing, so check HasOutput before using Output.
For a full producer-and-consumer walkthrough, see Read Another Job's Output.
Limits and defaults#
| Thing | Value |
|---|---|
| Default mode | OnSuccess |
| Maximum parents per job | 16 |
| Enqueue with an unknown parent | Rejected (throws) |
| Enqueue over the parent limit | Rejected |
| Maximum output size | 65,536 bytes |
| Output over the limit | Rejected, never truncated |
The output limit is a hard rejection rather than a truncation on purpose. A clipped serialized blob would not deserialize, so an oversized output is refused at write time instead of being silently cut. There is one output per job, last write wins within the winning Attempt.
Where to go next#
- Workflows: authoring fan-in to multiple parents, naming jobs, and Workflow Retry to revive a cancelled branch.
- Job Lifecycle: the state machine,
AwaitingParent, the entry states, and the cancellation cascade. - Execution Guarantee: why the latch decrement and release are Effect-Once.
- Chain Jobs with Dependencies: a how-to for
EnqueueDependencyAsyncwith full code. - Read Another Job's Output: the producer and consumer pairing in full.
- Job States & Transitions: the reference for
AwaitingParentandCancelled.