Read Another Job’s Output

Emit a result from one job and read an ancestor’s output from another, with the size limit and absent-output rules.


Emit a result from one job and read an ancestor’s output from another, with the size limit and absent-output rules.

Job Output is the success-side twin of a failure message: one optional blob a Handler may emit when it runs cleanly, that a job downstream of it can later pull and deserialize. This guide covers both halves, producing on success and consuming from a descendant, plus the size limit and the rules for when the value is simply not there. It assumes you already know how to make one Job depend on another. If you do not, read Chain Jobs with Dependencies first, because output reads are scoped to ancestors you actually declared as parents.

The model is small. One Job emits a value. A descendant reads it on demand, lazily, and only if it asks for it. There is no push. Everything else in this guide is the shape of that value and what happens at the edges.

The model: emit on success, pull on demand#

A Job Output is one opaque value a Handler buffers during a successful run. It is not returned from the Handler. The Handler signature still returns Task, and returning normally still means success. You emit the value by calling a method on the Job Context, and BackWave commits it atomically with the success transition.

That atomic commit is the whole durability story. The output lands on the job row at the same instant the Job becomes Succeeded. There is no separate "save output" call, and no window where the Job is Succeeded but its output is still missing. A failed, Cancelled, or superseded Attempt persists no output at all, so a descendant never reads a half-written or stale blob.

Because the value commits with success, and because a dependent only runs after its parents have terminated, any ancestor a Job reads has, by construction, already committed whatever output it was going to commit. That happens-before guarantee is exactly why reads are scoped to ancestors. A parallel sibling has no such guarantee, so it is deliberately unreadable.

Two operations make up the whole feature:

  • Produce. context.SetOutput(value, typeInfo) inside the producer's Handler.
  • Consume. await context.GetDependencyOutputAsync<T>(handle, typeInfo) inside a descendant's Handler.

Reading output never reshapes the graph. It creates no job, skips none, reorders nothing. This is read-only branching on facts that are already decided, not a result-driven workflow.

Define the output type#

The value you emit is serialized through the same source-generated, reflection-free JSON path BackWave uses for payloads. The generator emits metadata for your Job payloads automatically, but the output type is a type you choose, so you supply its JsonTypeInfo yourself. Declare a JsonSerializerContext for it, the same pattern you already use for defining a Job.

Receipt.cs
public sealed record Receipt(string ChargeId, int AmountCents);
 
[JsonSerializable(typeof(Receipt))]
public partial class OutputJsonContext : JsonSerializerContext;

OutputJsonContext.Default.Receipt is the JsonTypeInfo<Receipt> you pass to both SetOutput and GetDependencyOutputAsync. The producer shape and the reader shape are the same because the same JsonTypeInfo encodes and decodes. If the two sides drift apart, the same compatibility concerns apply as for payloads, so the guidance in Evolve Job Payloads carries over to output types unchanged.

Emit output from the producer#

Call SetOutput inside the producing Handler at the point you have the result, passing the value and the JsonTypeInfo<T> that serializes it.

The value serializes immediately and buffers on the context. It is not written through at call time. It flushes onto the outcome write and persists only if the Attempt succeeds. Within one Attempt, last write wins: call SetOutput twice and the second value is the one that lands.

ChargeCardHandler.cs
[Job("charge-card")]
public sealed record ChargeCard(Guid OrderId);
 
public sealed class ChargeCardHandler : IJobHandler<ChargeCard>
{
    public async Task HandleAsync(ChargeCard job, JobContext context, CancellationToken cancellationToken)
    {
        var chargeId = await gateway.ChargeAsync(job.OrderId, cancellationToken);
 
        // Buffered now, committed atomically when this Attempt succeeds.
        context.SetOutput(new Receipt(chargeId, 4200), OutputJsonContext.Default.Receipt);
 
        // Returning normally => Succeeded => the output persists.
    }
}

If this Handler throws after the SetOutput call, nothing persists. Output is success-only: every non-success outcome, including a graceful failure or a Cancellation, writes no output. The same holds if the Attempt is superseded, for example when a Worker's Lease lapses and another node has already taken over. The buffered value is discarded with the rest of that Attempt's write, so there is never a split-brain output.

One reassurance. Output is functional data on the job row, not history. Turning the Job History Policy off does not erase it. It is deleted when the job is purged, though, so a read after the retention window returns nothing.

Read an ancestor's output from a descendant#

The reader must be enqueued with the producer as a gating parent, as a Workflow member or as a raw dependency. Inside the dependent's Handler, call GetDependencyOutputAsync, passing a handle to the ancestor and the same JsonTypeInfo<T> you wrote with. The handle, nameOrJobId, names which ancestor to read, in one of two forms:

  • A Workflow member's name. Resolved against the reader's transitive ancestor set, matched on the ancestor job's Wire Name, its declared job-type identity within the Workflow. This form is safe by construction: it only ever resolves a genuine ancestor.
  • A Guid-shaped string. A raw, non-Workflow dependency, read by its JobId. Pass the parent's JobId.ToString().

Resolution and IO happen only on the call, once per call. Constructing the context resolves nothing.

SendReceiptHandler.cs
public sealed class SendReceiptHandler : IJobHandler<SendReceipt>
{
    public async Task HandleAsync(SendReceipt job, JobContext context, CancellationToken cancellationToken)
    {
        var dep = await context.GetDependencyOutputAsync(
            "charge-card", OutputJsonContext.Default.Receipt, cancellationToken);
 
        if (dep.AncestorState == JobState.Succeeded && dep.HasOutput)
        {
            await email.SendAsync(dep.Output!.ChargeId, cancellationToken);
        }
        else
        {
            // The parent failed, was Cancelled, or emitted nothing. Handle the absence.
        }
    }
}

For a raw dependency, the only change is the handle:

SendReceiptHandler.cs
var dep = await context.GetDependencyOutputAsync(
    parentJobId.ToString(), OutputJsonContext.Default.Receipt, cancellationToken);

Branch on the result#

The read returns a DependencyOutput<T>, a small record that carries the answer and never throws on absence. It has three fields:

  • AncestorState is the ancestor's terminal state, one of Succeeded, Cancelled, DeadLettered, or Quarantined. It lets you ask "did the thing I depend on actually succeed?" without a second read.
  • HasOutput is true only when the ancestor persisted a non-null blob.
  • Output is the deserialized value, or default when HasOutput is false.

Always check HasOutput, or branch on AncestorState, before dereferencing Output. Absence is a normal answer, never an error. A parent can succeed having emitted nothing, in which case AncestorState is Succeeded and HasOutput is false. To reach the output of a parent that failed or was Cancelled, the dependency edge has to let this Job run anyway. That is what the OnAnyTerminal dependency mode is for: it releases the descendant on any terminal state rather than only on success, so the descendant can inspect a failed parent's state and output and decide what to do. The default OnSuccess mode only releases the descendant when the parent succeeded. See Chain Jobs with Dependencies for the two modes.

The size limit#

A serialized output is capped at MaxOutputBytes, a store bound that defaults to 65,536 bytes (64 KiB), the same size class as one job's payload. The cap is measured on the UTF-8 JSON bytes at write time, and the boundary is inclusive: a blob exactly at the limit is accepted, only > Max is rejected.

Over-limit output is rejected, never truncated. The outcome write throws JobOutputTooLargeException, which carries the JobId, the ActualBytes, and the MaxOutputBytes bound. The whole write is rejected as a unit, so the Job stays Leased with no partial state, and the Attempt fails. Rejecting rather than truncating is deliberate: a truncated blob would deserialize into a corrupted value in some descendant, and a loud failure now beats silent corruption later.

The fix is almost never to raise the bound. When a result is large, emit a reference instead of the data. Store the bytes wherever large bytes belong, a blob store, a table, an object key, and put the id in the output. The descendant reads the id and fetches the data itself. The exception message says as much.

Edge cases and gotchas#

A few behaviors to know before they bite.

  • A handle that resolves to no ancestor returns a clean absence. Name a sibling that is not an ancestor, or a name that matches nothing in scope, and you get HasOutput = false with no throw. The read fails closed, not loud.
  • An unresolved handle is distinguishable. When the handle resolves to no job at all, the result carries AncestorState of JobState.AwaitingParent as an "unresolved" sentinel, separate from the real terminal states. You can treat it as absence like any other.
  • The JobId form does not verify ancestry. When the handle parses as a Guid, BackWave reads that job directly, with no ancestor check. The name form walks the ancestor set and is safe by construction; the JobId form trusts that the id you passed is a real parent you depend on. Only read ancestors you actually declared as parents. Reading an unrelated job by JobId can race, since that job may not be terminal yet and may have no output, and you get back whatever state it happens to be in.
  • Duplicate Wire Names among ancestors are ambiguous. Name resolution expects a single matching ancestor. If two transitive ancestors share the same Wire Name, the name is ambiguous and the read throws. When a graph has repeated job types upstream, disambiguate by reading those parents by JobId instead.
  • A handler may read more than once. Under At-Least-Once execution, a descendant's Handler can run again. Each run re-pulls the value. A producer's output is stable once its Job is Succeeded, so re-reads are consistent. Keep your Handler idempotent for the usual reasons.
  • Reading needs a real execution context. Calling the read accessor on a context that was not built for Handler execution throws InvalidOperationException. In ordinary Handler code this never happens; it shows up only if you construct a context by hand outside the worker pipeline.

One operational note. The in-process accessor a Handler uses to read an ancestor's output is not gated by any permission. It is the pipeline consuming its own data. Only an operator viewing output in the dashboard sits behind the ViewSensitiveData permission, covered under the sensitive-data gate.

Where to go next#

  • Chain Jobs with Dependencies: declare the parent edges that make a Job a readable ancestor, and choose OnSuccess versus OnAnyTerminal.
  • Dependencies: the dependency latch and the happens-before guarantee that scopes output reads to ancestors.
  • Build a Workflow: the Workflow grouping that gives members the names the name-handle form resolves against.
  • React to Job Outcomes: the push-side counterpart, where a Transition Observer reacts when a Job reaches a state, in contrast to this pull-side read.
  • Evolve Job Payloads: the serialization compatibility rules that apply to output types as well as payloads.
  • Job States: the full set of values DependencyOutput.AncestorState can carry.