Workflows API

Pro

The complete BackWave Pro workflow surface: the typed builder, the composition shapes (parallel, conditional, saga compensation, child splice), typed output and workflow input, atomic enqueue and append, cancel, and the monitor projections, with every signature and default.


This page is the method-and-type reference for the BackWave Pro workflow API. It covers the startup calls that turn the surface on, the strongly-typed builder that composes a graph, the composition shapes it exposes, typed Job Output and the immutable Workflow Input seed, the enqueue and append entry points, the operator cancel, and the monitor reads that expose a Workflow's derived status. For what a Workflow is and how it sits over the Dependencies it groups, start with those concept pages; this page states the public contract of each call.

Workflows ship in a separate package. Once you reference it and register it, the authoring, read, and cancel methods appear as extension methods on the same BackWaveClient, BackWaveMonitor, and BackWaveOperator you already use for ordinary Jobs. The Pro-specific types, TypedWorkflowBuilder, WorkflowBranch, WorkflowStepRef, IWorkflow<TSeed>, IWorkflowStep / IWorkflowStep<TOut>, IWorkflowInput, IWorkflowGate<>, WorkflowView, WorkflowCancelResult, and InvalidWorkflowException, live in the BackWave.Pro namespace. The definition and enum types the methods pass and return, WorkflowDefinition, WorkflowStatus, WorkflowEdge, DependencyMode, and the rest, live in the base BackWave.Storage namespace, because a Workflow is composed from the same store primitives as any Job.

Registration#

AddBackWavePro is the whole boundary. Call it once at startup, after AddBackWave, and every Workflow method lights up on the existing client, monitor, and operator.

Program.cs
builder.Services.AddBackWavePro(builder.Configuration["BackWave:ProLicense"]);
AddBackWavePro.cs
public static IServiceCollection AddBackWavePro(
    this IServiceCollection services,
    string? license = null)

The license argument defaults to null, which is the correct value for the free tier (organizations under one million dollars in annual revenue). Above that, pass the purchased license string. The license is evaluated fully offline against a public key embedded in the package, with no network call. Evaluation always soft-fails: a missing, malformed, or out-of-term license logs a single startup warning and does nothing else. Features behave identically in every license state, so the call never changes what runs. See Licensing and Pricing for the model.

AddWorkflowGate#

A conditional (.If) workflow decides at run time on a registered gate type. Register each distinct gate once, after AddBackWavePro, so the worker that later claims the gate step can resolve its predicate. The gate's codec is supplied explicitly here; it is the one place a conditional needs a JsonTypeInfo.

AddWorkflowGate.cs
public static IServiceCollection AddWorkflowGate<TGate, TStep, TOut>(
    this IServiceCollection services,
    string wireName,
    JsonTypeInfo<WorkflowGate<TGate, TStep, TOut>> gateTypeInfo,
    string queue = "default")
 
// seed-aware overload:
public static IServiceCollection AddWorkflowGate<TGate, TStep, TOut, TInput>(
    this IServiceCollection services,
    string wireName,
    JsonTypeInfo<WorkflowGate<TGate, TStep, TOut, TInput>> gateTypeInfo,
    string queue = "default")

wireName is the gate step's Wire Name; queue overrides the default queue the gate runs on, which matters when no worker group serves "default". The gate step type (WorkflowGate<…>) is minted by the builder, never constructed by you, but it must be registered here and listed in a JsonSerializerContext like any other job. See Conditional branching below.

Marking a job as a workflow step#

A step is an ordinary [Job] payload record that additionally wears the IWorkflowStep marker. The marker is the compile-time gate: a plain [Job] payload can no longer be added to a workflow, and a mistyped or renamed step is a compile error rather than a runtime one. A step that also produces typed output implements IWorkflowStep<TOut> instead.

IWorkflowStep.cs
public interface IWorkflowStep;
public interface IWorkflowStep<TOut> : IWorkflowStep;
Steps.cs
[Job("price-order", Queue = "critical")]
public sealed record PriceOrder(string OrderRef, int Cents) : IWorkflowStep<OrderPrice>;
 
[Job("reserve-stock")]
public sealed record ReserveStock(string OrderRef, int ItemCount) : IWorkflowStep;

Steps must tolerate unknown JSON properties (the System.Text.Json default). A step record configured with JsonUnmappedMemberHandling.Disallow is rejected, because BackWave splices its own workflow-input and dependency metadata into a member's payload at enqueue, and a step whose payload declares a property in that reserved namespace is rejected for the same reason.

Starting a workflow#

Five extension methods on BackWaveClient begin a workflow. Workflow(...) opens a fresh builder, WorkflowAppend(id) opens a builder that grows an existing workflow, and StartWorkflow<TWorkflow, TInput>(...) enqueues a reusable IWorkflow<TSeed> definition in one call.

Start.cs
// No seed:
public static TypedWorkflowBuilder Workflow(
    this BackWaveClient client, string? name = null)
 
// Seed with an explicit codec:
public static TypedWorkflowBuilder Workflow<TInput>(
    this BackWaveClient client, TInput seed,
    JsonTypeInfo<TInput> seedTypeInfo, string? name = null)
 
// Seed, codec auto-resolved (TInput : IWorkflowInput listed in a JsonSerializerContext):
public static TypedWorkflowBuilder Workflow<TInput>(
    this BackWaveClient client, TInput seed, string? name = null)
    where TInput : IWorkflowInput
 
// Append into an existing workflow:
public static TypedWorkflowBuilder WorkflowAppend(
    this BackWaveClient client, Guid workflowId)
 
// Start a reusable IWorkflow<TSeed> definition (explicit codec, then auto):
public static ValueTask<Guid> StartWorkflow<TWorkflow, TInput>(
    this BackWaveClient client, TInput seed, JsonTypeInfo<TInput> seedTypeInfo,
    string? name = null, DbTransaction? transaction = null,
    CancellationToken cancellationToken = default)
    where TWorkflow : IWorkflow<TInput>, new()
 
public static ValueTask<Guid> StartWorkflow<TWorkflow, TInput>(
    this BackWaveClient client, TInput seed,
    string? name = null, DbTransaction? transaction = null,
    CancellationToken cancellationToken = default)
    where TWorkflow : IWorkflow<TInput>, new()
    where TInput : IWorkflowInput

The optional name is a human-readable label for the graph view; it is not unique, and the trivial "run B after A" case needs none. An append never takes a name, so it can never rename its target workflow.

The workflow builder#

TypedWorkflowBuilder composes a graph by chaining [Job] steps referenced by .NET type. It is pure: it touches no store and holds no I/O until EnqueueAsync, and the whole graph lowers to the same below-boundary member-and-edge dependency spine an ordinary set of dependencies would. The builder exposes the identity of the Workflow it is composing:

WorkflowId.cs
public Guid WorkflowId { get; }

This is a fresh Guid on a new Workflow, or the target id on an append.

Then#

Then<TStep> adds one step. In its linear form the step depends on the current frontier (the previously-added step, or nothing for the first step) and becomes the new frontier, which is the common case. Overloads add fan-in and a parameterless-step convenience.

Then.cs
// Linear: depends on the frontier, becomes the new frontier.
public TypedWorkflowBuilder Then<TStep>(
    TStep step,
    DependencyMode mode = DependencyMode.OnSuccess,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep
 
// Parameterless-step convenience.
public TypedWorkflowBuilder Then<TStep>(
    DependencyMode mode = DependencyMode.OnSuccess,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep, new()
 
// Fan-in by type list.
public TypedWorkflowBuilder Then<TStep>(
    TStep step, IReadOnlyList<Type> after,
    DependencyMode mode = DependencyMode.OnSuccess,
    IEnumerable<Guid>? afterExisting = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep
 
// Fan-in by WorkflowStepRef list (name-disambiguated).
public TypedWorkflowBuilder Then<TStep>(
    TStep step, IReadOnlyList<WorkflowStepRef> after,
    DependencyMode mode = DependencyMode.OnSuccess,
    IEnumerable<Guid>? afterExisting = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep

A fan-in step names its upstreams by type: after: [typeof(StepA), typeof(StepB)]. Each type is resolved to the one already-added node of that type. On an append, afterExisting names existing members by their Guid.

ParameterTypeDefaultMeaning
stepTSteprequiredThe step payload, a [Job] record wearing IWorkflowStep.
afterIReadOnlyList<Type> or IReadOnlyList<WorkflowStepRef>(none)The upstream steps this one fans in over, resolved by type. Omit to depend on the frontier.
modeDependencyModeOnSuccessWhen the parent edges release this step. See DependencyMode.
afterExistingIEnumerable<Guid>?nullAppend-only: existing member ids this new step depends on.
namestring?nullDisambiguates a repeated step type; also the Wire Name label in the graph view.
queuestring?nullThe Queue this step runs on. Defaults to the step type's registered Queue.
tagsJobTags?nullExtra Tags, unioned with the type's default Tags. Additive only.

Fan-in by type is ambiguous if the type repeats: after: [typeof(X)] throws InvalidWorkflowException when X appears more than once in the workflow. To fan in over a repeated type, disambiguate at add time with name: and reference the step through a WorkflowStepRef (below).

WorkflowStepRef#

WorkflowStepRef names a step by type plus an optional disambiguating name. A plain typeof(X) converts implicitly, so a name-less list reads exactly like the by-type overload.

WorkflowStepRef.cs
public readonly struct WorkflowStepRef
{
    public Type StepType { get; }
    public string? Name { get; }
    public WorkflowStepRef(Type stepType, string? name = null);
    public static implicit operator WorkflowStepRef(Type stepType) => new(stepType);
}

To fan in on one of several same-type steps, add each with a distinct name: and reference the one you want as new WorkflowStepRef(typeof(X), "the-name").

Parallel and WorkflowBranch#

Parallel(...) fans out one branch per argument from the current frontier, then makes the set of all branch tips the new frontier. There is no synthetic join node: the next Then(..., after: [...]) that names the branch tips is the join, and a plain .Then after a Parallel fans in over every tip. Leaving no follow-on keeps the branches as parallel leaves.

Parallel.cs
public TypedWorkflowBuilder Parallel(params WorkflowBranch[] branches)
public TypedWorkflowBuilder Parallel(params IWorkflowStep[] steps)   // fast path: each step is its own branch
WorkflowBranch.cs
public static WorkflowBranch Step<TStep>(
    TStep step, string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep
 
public static WorkflowBranch Do(Action<TypedWorkflowBuilder> build)

WorkflowBranch.Step(...) runs a single step; WorkflowBranch.Do(b => ...) hands you a sub-builder to chain several steps (and nest further Parallel) inside one branch. At least one branch is required, every branch must add at least one step, and branch types may not collide with each other or with the rest of the graph.

Conditional branching (If)#

.If<...>(then:, otherwise:) adds a runtime conditional that never reshapes the graph. Both arms are enqueued up front and a gate step is inserted after the current frontier. At run time the gate's handler pulls the already-decided output of the ancestor its predicate observes, evaluates the predicate, and cancels the arm that was not taken (its whole subtree); the taken arm proceeds. No node is created, skipped, or reordered.

If.cs
public TypedWorkflowBuilder If<TGate, TStep, TOut>(
    Action<TypedWorkflowBuilder> then,
    Action<TypedWorkflowBuilder>? otherwise = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TGate : IWorkflowGate<TStep, TOut>, new()
    where TStep : IWorkflowStep<TOut>
 
// seed-aware overload:
public TypedWorkflowBuilder If<TGate, TStep, TOut, TInput>(
    Action<TypedWorkflowBuilder> then,
    Action<TypedWorkflowBuilder>? otherwise = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TGate : IWorkflowGate<TStep, TOut, TInput>, new()
    where TStep : IWorkflowStep<TOut>
    where TInput : IWorkflowInput

The predicate is a named gate type, not a lambda, because it runs on whatever worker later claims the gate step and so must survive enqueue as payload bytes:

IWorkflowGate.cs
public interface IWorkflowGate<TStep, TOut> where TStep : IWorkflowStep<TOut>
{
    bool Enter(DependencyOutput<TOut> observed);
}
 
public interface IWorkflowGate<TStep, TOut, TInput>
    where TStep : IWorkflowStep<TOut> where TInput : IWorkflowInput
{
    bool Enter(DependencyOutput<TOut> observed, TInput input);
}

Enter returning true runs the then arm and cancels the alternate; false runs otherwise, or, if otherwise is omitted, cancels the primary arm and runs nothing. The seed-aware Enter(observed, input) is read-only: it receives only the observed output and the typed seed, never the builder or job context, so it can neither enqueue nor cancel. Register each gate once with AddWorkflowGate.

LargeOrderGate.cs
public sealed class LargeOrderGate : IWorkflowGate<PriceOrder, OrderPrice, CheckoutSeed>
{
    public bool Enter(DependencyOutput<OrderPrice> observed, CheckoutSeed input)
        => input.Expedite || (observed.HasOutput && observed.Output!.Cents >= input.ExpressThresholdCents);
}

.If leaves both arms' tips as the frontier. A join that converges past the gate MUST use mode: DependencyMode.OnAnyTerminal, so it releases once one arm succeeded and the other reached the terminal cancelled state. An OnSuccess join spanning both arms is rejected at Build(), because the gate always cancels one arm and such a join would always cascade-cancel. An arm step may also depend only on the gate or another step in the same arm.

WithCompensation#

WithCompensation(...) attaches a saga-style undo guarding the current frontier. The compensation is an ordinary step wired OnAnyTerminal to the protected work, so it always becomes reachable once that work is terminal, whatever the outcome. Its handler reads the protected step's already-decided state and decides: undo if the protected work failed, no-op if it succeeded. So the compensation always runs but usually no-ops; there is deliberately no OnFailure release mode.

WithCompensation.cs
public TypedWorkflowBuilder WithCompensation<TUndo>(
    TUndo undo, string? name = null, string? queue = null, JobTags? tags = null)
    where TUndo : IWorkflowStep
 
public TypedWorkflowBuilder WithCompensation<TUndo>(
    string? name = null, string? queue = null, JobTags? tags = null)
    where TUndo : IWorkflowStep, new()

The frontier is deliberately left on the protected work: a compensation is a side-branch, and whatever you chain after WithCompensation still depends on the protected work, not the undo. Calling it with an empty frontier throws. For a reverse-order saga, call WithCompensation again for earlier steps: each new compensation makes the previous one wait for it, so later-protected work undoes first. That reverse-order chain is scoped to the builder it is called on and does not span a Parallel/If branch or a ThenWorkflow child.

ThenWorkflow and IWorkflow#

ThenWorkflow<TChild, TSeed>(seed) splices a reusable IWorkflow<TSeed> definition into the current graph as an inline subgraph. The child's Build runs now, at construction; its steps are grafted onto the same flat graph rooted at the current frontier, and the child's leaf steps become the new frontier. There is no nested identity: one flat graph, one workflow row, one derived status, one retention unit.

ThenWorkflow.cs
public TypedWorkflowBuilder ThenWorkflow<TChild, TSeed>(TSeed seed)
    where TChild : IWorkflow<TSeed>, new()
 
public interface IWorkflow<in TSeed>
{
    void Build(TypedWorkflowBuilder builder, TSeed seed);
}
FulfilmentWorkflow.cs
public sealed class FulfilmentWorkflow : IWorkflow<FulfilmentSeed>
{
    public void Build(TypedWorkflowBuilder builder, FulfilmentSeed seed)
        => builder.Then(new PackParcel(seed.OrderRef)).Then(new PrintLabel(seed.OrderRef));
}

The TSeed here is build-time only: it shapes the child's graph and constructs its step payloads at build time. The spliced child shares the parent's Workflow Input, so a child step calling ctx.Input<T>() reads the parent's input, not this seed; read the seed's values into the child's step payloads at build time. Step-identity collisions span the combined graph, so a child step whose type (plus optional name) collides with a parent step throws. To run a child under its own independent identity with no join back, do not use ThenWorkflow; have a step call StartWorkflow<TChild, TSeed>(seed) instead.

Build and EnqueueAsync#

Build validates the graph and returns the prepared WorkflowDefinition; EnqueueAsync builds and writes it atomically, returning the new WorkflowId.

EnqueueAsync.cs
public WorkflowDefinition Build()
 
public async ValueTask<Guid> EnqueueAsync(
    DbTransaction? transaction = null,
    CancellationToken cancellationToken = default)

EnqueueAsync writes every member Job and the Workflow record in one all-or-nothing transaction. When you pass a transaction, the whole graph commits or rolls back together with your own writes on that same transaction, the Transactional Enqueue guarantee extended to a Workflow. If you pass a transaction and the storage adapter does not support it, the method throws NotSupportedException.

Enqueue.cs
var workflowId = await client.Workflow("order-fulfillment")
    .Then(new ValidateOrder(orderId))
    .Then(new ChargePayment(orderId))                                          // runs after validate
    .Then(new ReserveInventory(orderId), after: [typeof(ValidateOrder)])       // also after validate
    .Then(new PackShipment(orderId), after: [typeof(ChargePayment), typeof(ReserveInventory)]) // fan-in
    .Then(new NotifyBuyer(orderId))                                            // runs after pack
    .EnqueueAsync();

The store accepts or rejects a Workflow as a single unit, and a rejection inserts nothing. Any rejection surfaces as an InvalidOperationException whose message is Workflow enqueue failed: {reason}., where {reason} is one of the rejection reasons below. Build() and EnqueueAsync() throw InvalidOperationException if called on a sub-builder handed to a Parallel/If branch lambda or an IWorkflow child; those only accrete into the parent graph.

InvalidWorkflowException#

InvalidWorkflowException is the single exception the builder raises for an invalid graph: an empty or cyclic graph, a duplicate step identity, a fan-in over a type that is ambiguous or unresolved, a reserved-namespace payload, or one of the conditional guardrails above. Because the builder is pure and runs in your process, this is caught at build time rather than in production.

InvalidWorkflowException.cs
public sealed class InvalidWorkflowException(string message) : Exception(message);

Typed Job Output#

A step declares the output type it produces once, on its own contract, via IWorkflowStep<TOut>. It emits with ctx.SetOutput<TStep, TOut>(value); a downstream step pulls an ancestor's output with ctx.Output<TStep, TOut>(), type-checked against the producer's declaration. Pull, never push: nothing is injected into a reader's args.

JobOutput.cs
public static void SetOutput<TStep, TOut>(this JobContext context, TOut value)
    where TStep : IWorkflowStep<TOut>
 
public static ValueTask<DependencyOutput<TOut>> Output<TStep, TOut>(
    this JobContext context, CancellationToken cancellationToken = default)
    where TStep : IWorkflowStep<TOut>

DependencyOutput<TOut> exposes HasOutput (bool), Output (nullable TOut), and AncestorState (JobState). Absence is normal.

Read.cs
// in the producer's handler:
context.SetOutput<PriceOrder, OrderPrice>(new OrderPrice(job.Cents));
 
// in a downstream handler, reading an upstream typed output:
var charge = await context.Output<AuthorizeCharge, ChargeResult>(cancellationToken);
if (charge.AncestorState == JobState.Succeeded) { /* use charge.Output */ }

The output codec is auto-sourced from the app's JsonSerializerContext, so no serializer is passed at any call site (see Codec auto-wiring). A few rules keep it safe:

  • SetOutput<TStep, TOut> only works for the running step's own type. Writing under another step's type throws InvalidOperationException.
  • Reads are ambiguous over a repeated step type. An Output<TStep, TOut>() where TStep appears more than once among the reader's ancestors throws; there is no by-name output read. Structure the graph so at most one ancestor of the reader is that type.
  • Absence is normal, not an error. An ancestor that failed, was cancelled, emitted nothing, or a non-ancestor sibling on a parallel branch all resolve to a clean HasOutput == false.

The lower-level string-handle form, ctx.GetDependencyOutputAsync("wire-name", typeInfo, ct), still exists; the typed Output<TStep, TOut> is the workflow sugar over it.

Workflow Input#

Workflow Input is an immutable, set-once value supplied when a workflow is started, baked into every member's payload at enqueue, and read back inside a handler via ctx.Input<TInput>(). It shapes the graph at build time and supplies constant data to step payloads. It is never mutated and is not shared accumulating state; read an upstream step's result through Job Output, never through the seed.

IWorkflowInput.cs
public interface IWorkflowInput;
 
public static TInput Input<TInput>(this JobContext context)
    where TInput : IWorkflowInput
public static TInput Input<TInput>(this JobContext context, JsonTypeInfo<TInput> typeInfo)

Wearing IWorkflowInput is the explicit opt-in that makes ctx.Input<TInput>() resolvable and tells the [Job] generator to wire the seed's codec. A seed marked but not listed in any JsonSerializerContext is a BW0007 build error.

Seed.cs
public sealed record CheckoutSeed(string OrderRef, bool Expedite, int ExpressThresholdCents) : IWorkflowInput;
 
await client.Workflow(new CheckoutSeed(orderRef, expedite, ExpressThresholdCents: 100_000), name: $"checkout {orderRef}")
    // ...
    .EnqueueAsync();
 
// read back in any member's handler (no serializer arg):
var seed = ctx.Input<CheckoutSeed>();

Appended members carry no seed. WorkflowAppend does not re-bake the target workflow's seed into new members, so a handler for an appended step that calls ctx.Input<T>() throws at runtime. Give appended steps their constant data through their own payload. Reading a seed on a job started without one likewise throws InvalidOperationException.

Codec auto-wiring and BW0007#

Typed output codecs and seed codecs are auto-sourced from the app's own JsonSerializerContext; you pass no JsonTypeInfo at a workflow call site. The [Job] source generator scans every [JsonSerializable] listing in the compilation and emits the right codec reference for each workflow output type (a [Job] implementing IWorkflowStep<TOut>) and each seed type (marked IWorkflowInput). List the output DTO and the seed in a context and they are wired for you.

SampleOutputs.cs
[JsonSerializable(typeof(OrderPrice))]
[JsonSerializable(typeof(ChargeResult))]
public partial class SampleOutputJsonContext : JsonSerializerContext;

A workflow output type or seed type listed in no context is the BW0007 build error (severity Error):

BW0007
Workflow type '{0}' ({1}) is not listed in any JsonSerializerContext - add
[JsonSerializable(typeof({0}))] to a JsonSerializerContext so BackWave can wire its
serialization, or register it by hand with an explicit JsonTypeInfo

A one-click code fix adds the [JsonSerializable(...)] attribute to an existing context, or scaffolds a new one when none exists, and supports fix-all.

Gotchas:

  • Cross-assembly invisibility. Discovery is syntax over the current compilation only. A context, or an IWorkflowInput seed, declared in a referenced project is a compiled symbol, not source syntax, so a type whose only listing lives across an assembly boundary trips a BW0007 false-positive. List it in a context in the same assembly, or use the explicit-JsonTypeInfo escape hatch.
  • A type listed in more than one context binds to the first by ordinal context name, with no diagnostic (every listing serializes identically).
  • Array-typed listings work: [JsonSerializable(typeof(T[]))] resolves an IWorkflowStep<T[]> codec.
  • The explicit-JsonTypeInfo overloads (Workflow(seed, seedTypeInfo), ctx.Input(typeInfo), AddWorkflowGate(…, gateTypeInfo, …), ctx.GetDependencyOutputAsync(handle, typeInfo, ct)) remain as escape hatches.

Enqueue types#

WorkflowDefinition#

WorkflowDefinition is the prepared graph Build() emits and EnqueueAsync writes. It is a record in BackWave.Storage.

MemberTypeMeaning
WorkflowIdGuid (required)The Workflow's identity.
Namestring?The optional label, null when unnamed.
MembersIReadOnlyList<NewJob> (required)The member Jobs, each with its resolved parent ids.
RetentionWorkflowRetentionPolicyDefaults to UnitUntilDrained. See WorkflowRetentionPolicy.
RestartedFromGuid?Set on a restart to the original Workflow id, a lineage pointer. Ignored on an append.
IsAppendboolTrue when this enqueue appends into an existing Workflow. When true, WorkflowId must already exist, the Workflow row is left untouched, and existing members' Dependencies are never rewritten.

NewJob#

Each member of a WorkflowDefinition is a NewJob. The builder constructs these for you.

NewJob.cs
public sealed record NewJob(
    Guid JobId,
    string WireName,
    ReadOnlyMemory<byte> Payload,
    string Queue,
    DateTimeOffset DueTime)

It carries four init-only extras: Parents (IReadOnlyList<Guid>, default empty), Mode (DependencyMode, default OnSuccess), TraceContext (string?), and Tags (JobTags, default JobTags.Empty). A non-empty Parents makes the Job wait in AwaitingParent until every parent is terminal, then release per Mode. A member that carries a Workflow Input seed or any parent also carries reserved workflow-input or dependency metadata in its payload; only a seedless, parentless member is byte-for-byte identical to a standalone enqueue of the same step. Payload is opaque bytes to every adapter, so no below-boundary fact changes.

DependencyMode#

DependencyMode decides when a step's parent edges release it. It is the same release vocabulary documented on the Dependencies page.

DependencyMode.cs
public enum DependencyMode { OnSuccess, OnAnyTerminal }
ValueRelease rule
OnSuccessRelease only when every parent Succeeded. Any other terminal outcome on a parent cancels the dependent. This is the default.
OnAnyTerminalRelease once every parent is terminal, whatever the outcomes.

OnAnyTerminal is what converges past a conditional .If: the not-taken arm terminates cancelled, so a join over both arms must release on any terminal state, not only success.

WorkflowRetentionPolicy#

WorkflowRetentionPolicy governs when a drained Workflow's members become eligible for the normal per-Job retention window. It has a single value today, which is also the default.

WorkflowRetentionPolicy.cs
public enum WorkflowRetentionPolicy { UnitUntilDrained }

Under UnitUntilDrained, members are retained as a unit until the whole Workflow drains, meaning every member reaches a terminal state. Only then does each member's retention window begin, measured from the drain point. This keeps the graph coherent for the Workflow's whole life.

Rejection reasons#

The store validates a Workflow independently of the builder and can reject the enqueue as a unit. Anything other than acceptance inserts nothing. These reasons surface to your code through the InvalidOperationException thrown by EnqueueAsync.

ReasonCause
DuplicateWorkflowA Workflow with this id already exists on the creation path.
WorkflowNotFoundAn append targeted a Workflow id that does not exist.
DuplicateMemberA member Job id already exists in the store, or appears twice in the batch.
ContainmentViolationA member's gating parent is not a member of the same Workflow.
EmptyWorkflowThe Workflow has no members.
PayloadTooLargeA member's payload exceeds the store's payload size limit.
WireNameTooLongA member's wire name exceeds the store's length limit.
TooManyParentsA member exceeds the store's per-Job parent limit.

The numeric bounds behind the last three are the same store bounds that apply to any Job; see Limits and Defaults.

Appending to a Workflow#

WorkflowAppend returns a builder that adds new members to an existing Workflow. The existing members and their Dependencies are never rewritten; only new steps are added, and each new step depends on the frontier, on other new steps (via after: [typeof(...)]), or on existing members (via afterExisting: ids). The target Workflow must already exist or the enqueue is rejected as WorkflowNotFound.

Append.cs
await client.WorkflowAppend(workflowId)
    .Then(new Reconcile(orderRef), afterExisting: [loadJobId])
    .EnqueueAsync();

Appended members carry no Workflow Input; give them their constant data through their own payload.

Cancelling a Workflow#

CancelWorkflowAsync on BackWaveOperator cancels a Workflow as a group.

CancelWorkflowAsync.cs
public static async ValueTask<WorkflowCancelResult> CancelWorkflowAsync(
    this BackWaveOperator @operator,
    Guid workflowId,
    string actor,
    DateTimeOffset? now = null,
    CancellationToken cancellationToken = default)

The cancel is a one-time snapshot fan-out, not a standing rule. It cancels each member that is running or pending at the moment of the call. A member that starts after the call returns is unaffected, and members that have already finished are left untouched. Each per-member cancel is recorded against the actor you pass, for audit. A currently-leased member cancels cooperatively through its CancellationToken, driven by the heartbeat, exactly like a single-Job cancel; threads are never killed. See Execution Guarantee for that contract. Because an operator cancel produces no failed members, the Workflow's derived status reads as Cancelled, not Failed.

The now argument defaults to the operator's clock. When no Workflow has the given id, the method makes no cancel calls and returns WorkflowCancelResult.NotFound.

Cancel.cs
var result = await operator.CancelWorkflowAsync(workflowId, actor: "ops-oncall");

WorkflowCancelResult#

CancelWorkflowAsync returns a WorkflowCancelResult tallying what the snapshot did.

WorkflowCancelResult.cs
public sealed record WorkflowCancelResult(
    bool Found,
    int CancelledImmediately,
    int CancellationRequested)
{
    public static readonly WorkflowCancelResult NotFound = new(Found: false, 0, 0);
}
MemberMeaning
FoundTrue when a Workflow with the id existed. False means nothing was cancelled.
CancelledImmediatelyHow many still-pending members (Scheduled or AwaitingParent) were cancelled outright.
CancellationRequestedHow many running, leased members were asked to stop cooperatively and will cancel when their handler next checks.

Members that were already finished are counted in neither, and a member that terminates between the read and its own cancel is also counted in neither.

CancelResult#

Each per-member cancel resolves to a CancelResult, the same enum that governs a single-Job cancel. It is not returned by CancelWorkflowAsync directly; it feeds the two tallies above.

CancelResult.cs
public enum CancelResult { CancelledImmediately, CancellationRequested, NotCancellable }

A Scheduled or AwaitingParent member cancels immediately; a leased member has its cancellation flag set and cancels cooperatively on its next heartbeat; a member that is absent or already terminal comes back NotCancellable and moves neither tally.

Reading Workflows#

Two extension methods on BackWaveMonitor expose Workflows. A Workflow's status is always computed from its members' current states and never stored, so every read reflects where the members actually are.

ListWorkflowsAsync#

ListWorkflowsAsync returns every Workflow ordered by creation time, oldest first, each as a WorkflowSnapshot. The list is empty when no Workflows exist.

ListWorkflowsAsync.cs
public static ValueTask<IReadOnlyList<WorkflowSnapshot>> ListWorkflowsAsync(
    this BackWaveMonitor monitor,
    CancellationToken cancellationToken = default)

GetWorkflowAsync#

GetWorkflowAsync returns one Workflow's full graph as a WorkflowView, or null when no Workflow has that id. The members come back as Job snapshots that never include payload bytes.

GetWorkflowAsync.cs
public static async ValueTask<WorkflowView?> GetWorkflowAsync(
    this BackWaveMonitor monitor,
    Guid workflowId,
    CancellationToken cancellationToken = default)
ReadStatus.cs
var view = await monitor.GetWorkflowAsync(workflowId);
if (view is not null)
{
    Console.WriteLine($"{view.Name}: {view.Status} ({view.Members.Count} members)");
    foreach (var edge in view.Edges)
        Console.WriteLine($"  {edge.Parent} -> {edge.Child}");
}

WorkflowSnapshot#

WorkflowSnapshot is the per-Workflow row ListWorkflowsAsync returns.

MemberTypeMeaning
WorkflowIdGuid (required)The Workflow's identity.
Namestring?The optional label, null when unnamed.
CreatedAtDateTimeOffset (required)When the Workflow was created.
StatusWorkflowStatus (required)The derived status.
MemberCountint (required)How many member Jobs the Workflow has.
RestartedFromGuid?The Workflow this was restarted from, or null if created fresh.

WorkflowView#

WorkflowView is the full graph GetWorkflowAsync returns. Use it to render a Workflow's dependency graph and to drill from a member node into that Job's detail.

MemberTypeMeaning
WorkflowIdGuid (required)The Workflow's identity.
Namestring?The optional label, null when unnamed.
CreatedAtDateTimeOffset (required)When the Workflow was created.
StatusWorkflowStatus (required)The derived status.
MembersIReadOnlyList<JobSnapshot> (required)The member Jobs, never with payload bytes.
EdgesIReadOnlyList<WorkflowEdge> (required)The fixed dependency edges defining run order.
RestartedFromGuid?The Workflow this was restarted from, or null if created fresh.

WorkflowEdge#

A WorkflowEdge is one structural dependency edge: Child depends on Parent.

WorkflowEdge.cs
public sealed record WorkflowEdge(Guid Parent, Guid Child);

Unlike the live gating edges that resolve away as parents terminate, a Workflow's structural edges are immutable and recorded at enqueue, so the graph view stays complete for the Workflow's whole life.

WorkflowStatus#

WorkflowStatus is the derived status carried on every snapshot and view. It is always a projection of the member states, never stored, so it recomputes on every read and can legitimately move backward from Succeeded to Running when live work is appended to a drained Workflow.

WorkflowStatus.cs
public enum WorkflowStatus { Running, Failed, Cancelled, Succeeded }

The projection resolves by first-match-wins precedence, Running over Failed over Cancelled over Succeeded:

StatusWhen it applies
RunningAt least one member is still non-terminal.
FailedAll members are terminal and at least one is Dead-Lettered or Quarantined. Failure dominates: one failed member makes the whole Workflow Failed even when its siblings Succeeded.
CancelledAll members are terminal, none failed, and at least one is Cancelled. An operator cancel produces no failures, so it reads here.
SucceededEvery member Succeeded. The empty, no-member case also reads as Succeeded.

There is no partial state. A conditional (.If) workflow derives Cancelled even when every step that actually ran Succeeded, because the not-taken arm terminates cancelled and a cancelled member with no failed member makes the whole-workflow rollup Cancelled. This is expected and healthy for any conditional workflow; read per-step state from Members, not the whole-workflow rollup, to tell a healthy conditional run from a genuinely aborted one. The terminal member states that feed this projection come from the Job Lifecycle.

JobSnapshot#

Each member in a WorkflowView is a JobSnapshot, the read-only view of a Job that never carries payload bytes. It is the same snapshot type the monitor returns for any Job.

MemberTypeMeaning
JobIdGuid (required)The Job's identity.
WireNamestring (required)The registered wire name of the Job type.
Queuestring (required)The Queue the Job runs on.
StateJobState (required)The current lifecycle state.
Attemptint (required)The current attempt number.
DueTimeDateTimeOffset (required)When the Job becomes claimable.
LeaseOwnerstring?Set while leased, the "executing now" view.
LeaseExpiryDateTimeOffset?When the current lease expires.
CancelRequestedboolWhether a cooperative cancel has been requested.
TerminalAtDateTimeOffset?When the Job reached a terminal state.
TerminalCausestring?The recorded cause of a terminal outcome.
ScheduleIdstring?The Recurring Schedule that minted the Job, if any.
SequencelongThe paging cursor.
WorkflowIdGuid?The Workflow this Job belongs to, null if none.
TagsIReadOnlyList<JobTag>The Job's Tags, defaulting to empty.

Recovering a Workflow#

Recovery is a full redo, not a resume: BackWave records nothing inside a handler and cannot resume a graph part-way. To re-run a failed workflow, build the same graph again with the typed builder and EnqueueAsync it. That re-runs every step from the start, including steps that already Succeeded, so any non-idempotent step runs again and idempotency stays the handler's responsibility.

For a shape-preserving redo that carries a lineage pointer, Build() returns a WorkflowDefinition, and WorkflowDefinition.RestartAsNew() produces a fresh definition with a new Workflow id, fresh member ids remapped consistently, the same shape, and RestartedFrom set to the original. The monitor surfaces that lineage as RestartedFrom on both the snapshot and the view. Restart only ever creates new Jobs; the original's terminal Jobs are left as they are.

Where to go next#

  • Workflows: the concept behind this API, and where a Workflow sits over the Dependencies it groups.
  • Dependencies: the edges, release modes, and fan-in countdown that a Workflow composes.
  • Execution Guarantee: the at-least-once contract and cooperative cancellation every member inherits.
  • Job Lifecycle: the terminal states that drive a Workflow's derived status.
  • Build a Workflow: a guided walkthrough from builder to enqueue.
  • Testing Workflows: asserting on a Workflow's shape and derived status.
  • Limits and Defaults: the shared store bounds behind the enqueue rejection reasons.

Found a problem on this page? Report an issue