# Chain Jobs with Dependencies

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](/docs/core-concepts/workflows) handoff at the end.

If you have not read [Dependencies](/docs/core-concepts/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](/docs/core-concepts/job-lifecycle) 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`](/docs/guides/define-and-enqueue-a-job) mints and returns the new Job's `Guid` client-side, and that `Guid` is what you depend on.

```csharp title="Program.cs"
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.

- `parentId` is 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.
- `enqueuedAt` defaults 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 future `enqueuedAt` plus a Dependency means the child runs at `max(enqueuedAt, release)`. Pass a future instant when you want both a delay and a dependency.
- `mode` is `DependencyMode.OnSuccess` (the default) or `DependencyMode.OnAnyTerminal`.
- `queue` is null by default, which uses the [Queue](/docs/core-concepts/queues) declared on the Job type. Pass a name to override it for this enqueue.
- `tags` merge on top of the type's default [Tags](/docs/core-concepts/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.

```csharp title="Program.cs"
var receiptId = await client.EnqueueDependencyAsync(
    new SendReceipt(orderId),
    parentId: chargeId);   // mode defaults to DependencyMode.OnSuccess
```

If `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.

```csharp title="Program.cs"
var releaseHoldId = await client.EnqueueDependencyAsync(
    new ReleaseInventoryHold(orderId),
    parentId: chargeId,
    mode: DependencyMode.OnAnyTerminal);   // runs whether the charge succeeded or not
```

Now 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](#read-the-parents-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.

```csharp title="Program.cs"
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](/docs/core-concepts/jobs-and-handlers) and persisted only on a successful outcome, atomically with the Succeeded transition. Last write within an Attempt wins.

```csharp title="ChargeCardHandler.cs"
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.

```csharp title="SendReceiptHandler.cs"
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:

- `AncestorState` is the terminal state the parent reached.
- `HasOutput` is whether there is an output value to read.
- `Output` is the value, or null when `HasOutput` is 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](/docs/core-concepts/workflows) surface, where you chain steps by their .NET type and a step can fan in over several upstreams at once.

```csharp title="Program.cs"
var workflowId = await client.Workflow("fulfilment")
    .Parallel(
        WorkflowBranch.Step(new ChargeCard(orderId)),
        WorkflowBranch.Step(new ReserveStock(orderId)))
    .Then(new ShipOrder(orderId))   // fans in over BOTH branch tips
    .EnqueueAsync();
```

A Workflow also gets you typed output flowing between steps instead of `Guid`-string handles, a graph view, cancel-all, transactional enqueue of the whole graph in one write, saga compensation and conditional gates, and a full-redo restart. 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](/docs/guides/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 a Pro Workflow, whose builder `.EnqueueAsync()` writes every member of the graph in one transaction. See [Transactional Enqueue with EF Core](/docs/guides/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](/docs/guides/define-and-enqueue-a-job#failure-modes).

**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](/docs/core-concepts/execution-guarantee) 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](/docs/guides/configure-retries-and-error-handling) for writing Handlers that survive re-execution.

## Where to go next

- [Dependencies](/docs/core-concepts/dependencies): the concept page behind this guide, with the full model of edges and release modes.
- [Read another job's output](/docs/guides/read-another-jobs-output): the deep dive on `SetOutput`, `GetDependencyOutputAsync`, and the ancestor-scope rule.
- [Build a Workflow](/docs/guides/build-a-workflow): parallel fan-out/fan-in, typed output passing, and atomic graph enqueue when one Job waits on many.
- [Job Lifecycle](/docs/core-concepts/job-lifecycle): the Awaiting Parent state and the four terminal states a parent can reach.
- [Configure Retries & Error Handling](/docs/guides/configure-retries-and-error-handling): how a parent becomes Dead-Lettered, the trigger for both release modes.
- [Cancel a running job](/docs/guides/cancel-a-running-job): the operator path that turns a parent Cancelled, and through it, an on-success child.
