Workflows
ProThe Pro grouping layer over dependencies: author a multi-job DAG as one named unit with atomic enqueue, derived status, and whole-graph cancel.
A Workflow is the BackWave Pro way to author, enqueue, watch, and cancel a multi-job dependency graph as a single named unit. Each node in the graph is a Job, each edge is a Dependency between two of those Jobs, and the whole thing is created in one atomic operation, carries one derived status, and cancels as a group. Underneath, a Workflow is still nothing more than static Dependency edges. It does not add a new execution capability. It adds a name, a sortable id, a graph view, and group-level lifecycle operations on top of the Dependencies you would otherwise wire by hand. A Workflow groups Dependencies. It is not a durable-execution engine, and the section near the end of this page draws that line precisely.
Workflows ship in the BackWave Pro package. Referencing the package and calling AddBackWavePro once at startup is the whole boundary: the Workflow methods light up as extensions on the same BackWaveClient, BackWaveMonitor, and BackWaveOperator you already use for ordinary Jobs.
services
.AddBackWave(bw => bw
.UseStore(new PostgresJobStore(connectionString))
.UseJobs(BackWaveJobs.Module)
.AddWorkerGroup(new WorkerGroupOptions { Name = "default" }))
.AddBackWavePro(builder.Configuration["BackWave:ProLicense"]);BackWave Pro is free for organizations under one million dollars in annual revenue, and the license argument defaults to null for that case. The licensing page covers the model in full; see Licensing and Pricing.
Authoring a Workflow#
You build a Workflow with a fluent builder. BuildWorkflow returns a WorkflowBuilder, each AddJob call adds one node, and Build validates the graph and produces a WorkflowDefinition ready to enqueue.
var workflow = client.BuildWorkflow("checkout");
workflow.AddJob("charge", new ChargeCard(orderId));
workflow.AddJob("receipt", new SendReceipt(orderId), dependsOn: ["charge"]);
var workflowId = await client.EnqueueWorkflowAsync(workflow.Build());The optional name passed to BuildWorkflow is a human-readable label for the graph view. It is not unique and the trivial "run B after A" case needs none.
AddJob<TJob> takes a node name, the Job payload, and several optional arguments:
| Argument | Meaning |
|---|---|
name | A handle for this node, unique within the Workflow. It is both the Dependency handle other nodes reference and the node's label in the graph view. |
job | The Job payload, a registered Job type, serialized through its registration. |
dependsOn | Names of other nodes in this same Workflow that must finish before this node becomes eligible. This is how you declare an edge. Omit it for a root node. |
mode | A DependencyMode deciding when a parent edge releases this node. Defaults to OnSuccess. |
queue | Overrides the Job type's registered Queue for this node. |
tags | Extra Tags, unioned with the Job type's default Tags. Defaults are never removed. |
after | Only meaningful on an append builder; see Growing a Workflow. |
The node name is the key idea. Because other nodes reference it in dependsOn, fan-in is just a node that lists several parent names. A node with two parents waits for both.
var workflow = client.BuildWorkflow("nightly-report");
workflow.AddJob("extract-orders", new ExtractOrders(day));
workflow.AddJob("extract-refunds", new ExtractRefunds(day));
workflow.AddJob("compile", new CompileReport(day),
dependsOn: ["extract-orders", "extract-refunds"]);
var workflowId = await client.EnqueueWorkflowAsync(workflow.Build());mode selects how each parent edge releases its child. There are two release modes, DependencyMode.OnSuccess (the default, where every parent must Succeed or the child is Cancelled) and DependencyMode.OnAnyTerminal (release once every parent is terminal, whatever the outcome). These are the same release modes documented on the Dependencies page, which owns the edge, countdown, and fan-in semantics in full.
Build runs the graph through client-side validation before anything touches the store. It checks that the graph is acyclic, that every dependsOn name resolves to a real node, and that the graph is not empty. A failure throws InvalidWorkflowException with a descriptive message naming the empty graph, the cycle, the unknown dependency, or the duplicate node name. This validation is pure and runs entirely in your process, so a malformed graph is caught at build time, never in production.
Enqueuing the whole graph atomically#
EnqueueWorkflowAsync writes the entire graph in one transaction. Every member Job and the Workflow record commit together or not at all. There is never a half-built Workflow or a dangling edge in the store: either the whole graph persists or nothing does. The method returns the Workflow's id.
Guid workflowId = await client.EnqueueWorkflowAsync(workflow.Build());You can co-commit the whole graph with your own database writes by passing a DbTransaction. The Workflow then commits or rolls back together with the business rows on that same transaction, the same Transactional Enqueue guarantee extended to a whole graph. If the storage adapter does not support transactional enqueue, supplying a transaction throws NotSupportedException.
await using var transaction = await db.Database.BeginTransactionAsync();
db.Orders.Add(order);
await db.SaveChangesAsync();
var workflowId = await client.EnqueueWorkflowAsync(
workflow.Build(),
transaction: transaction.GetDbTransaction());
await transaction.CommitAsync();The store rejects a Workflow as a single unit, and a rejection inserts nothing. Rejection reasons surface as an exception and cover a duplicate Workflow id, a missing append target, a duplicate member id, a member whose gating parent is not part of the same Workflow, an empty graph, an oversized payload, an over-length wire name, and too many parents on a node. The containment rule behind one of those reasons is worth stating directly: every gating parent of a member must belong to the same Workflow, and a Job belongs to at most one Workflow.
Workflow status#
A Workflow has one status, and it is always derived from the current states of its member Jobs. It is never stored on its own. Reading a Workflow recomputes the status from the member set, so it always reflects where the members actually are. The status is a WorkflowStatus with four values, resolved by first-match-wins precedence: Running, then Failed, then Cancelled, then Succeeded.
| Status | When it applies |
|---|---|
| Running | At least one member is still non-terminal. |
| Failed | All 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. |
| Cancelled | All members are terminal, none failed, and at least one is Cancelled. An operator cancel produces no failures, so it reads here rather than as Failed. |
| Succeeded | Every member Succeeded. The empty case also reads as Succeeded. |
Because status is a pure function of the member set, it can legitimately move backward from Succeeded to Running when you append new live work to a drained Workflow. The terminal member states that feed this projection (Succeeded, Dead-Lettered, Quarantined, Cancelled) come from the Job Lifecycle.
Two read methods on BackWaveMonitor expose Workflows. ListWorkflowsAsync returns every Workflow oldest first, each as a WorkflowSnapshot carrying the id, optional Name, creation time, derived status, member count, and a restart lineage pointer. GetWorkflowAsync returns one Workflow's full graph as a WorkflowView with the members as Job snapshots and the structural WorkflowEdge list, or null when no Workflow has that id.
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}");
}A WorkflowEdge is a fixed structural parent-to-child edge. Unlike the live gating edges that resolve away as parents finish, these stay in place for the Workflow's whole life, so the graph view is always complete.
Growing a Workflow#
A Workflow grows by appending, never by rewriting. BuildWorkflowAppend returns a builder whose added Jobs become new members of an existing Workflow. New nodes can name existing members as parents through AddJob's after argument, which takes the ids of those members. You can get a node's assigned id from the original builder with GetJobId, since each node's id is assigned at build time without a round-trip to the store.
var append = client.BuildWorkflowAppend(workflowId);
append.AddJob("notify", new NotifyOps(orderId), after: [chargeJobId]);
await client.EnqueueWorkflowAsync(append.Build());Appending only adds Jobs. The existing members and their Dependencies are never touched. The target Workflow must already exist or the enqueue is rejected. Appending live work to a Workflow that had already drained reopens its derived status to Running, which is the same projection rule described above.
Cancelling a Workflow#
CancelWorkflowAsync on BackWaveOperator cancels a Workflow as a group. It is a one-time snapshot, not a standing rule: it cancels each member that is running or pending at the moment of the call, and a member that starts after the call returns is unaffected. Already-terminal members are left untouched.
var result = await operator.CancelWorkflowAsync(workflowId, actor: "ops-oncall");Each per-member cancel is recorded against the actor you pass, for audit. A member that is currently leased cancels cooperatively through its CancellationToken, driven by the heartbeat, exactly like a single-Job cancel. Threads are never killed. Because a cancel produces no failed members, the Workflow's derived status reads as Cancelled rather than Failed. See Execution Guarantee for the cooperative cancellation contract.
The call returns a WorkflowCancelResult reporting whether the Workflow was found, how many pending members were cancelled outright, and how many running members were asked to stop cooperatively. There is no Workflow-level pause.
Recovering a failed Workflow#
The shipped recovery path is Workflow Restart. WorkflowDefinition.RestartAsNew re-instantiates the whole graph as a brand-new Workflow with a fresh id and fresh member ids, the same shape, and a RestartedFrom lineage pointer back to the original. You enqueue the returned definition the same way you enqueue any Workflow.
var restarted = originalDefinition.RestartAsNew();
var newWorkflowId = await client.EnqueueWorkflowAsync(restarted);Restart is a full redo, not a resume. It re-runs every step from the start, including the steps that already Succeeded, so any non-idempotent step runs again and idempotency stays the handler's responsibility. It only ever creates new Jobs, and the original's terminal Jobs are left as they are. The lineage shows up as RestartedFrom on the snapshot and view.
What a Workflow is not#
A Workflow groups static Dependency edges. It is not a durable-execution engine. Here is what that rules out.
- No replay. Nothing records or replays the steps inside a handler.
- No mid-handler resume. Recovery is graph-level, re-running whole nodes, never step-level.
- No signals, waits, conditionals, or timers, and no result-driven graphs. Edges are static and declared when each node is enqueued. Reading a parent's Job Output can branch your handler logic, but it can never change the shape of the graph.
- Handlers stay opaque and at-least-once. A Workflow inherits the same execution guarantee as any Job: a node may run more than once, and idempotency is the handler's job. Cancellation is cooperative.
- Growth is append-only. You can add new nodes, whose own edges are fixed at their enqueue, but you can never rewrite an existing node's Dependencies.
A useful framing: a Workflow is about as dynamic as a dependency graph in River or Hangfire, and deliberately not a Temporal-style workflow. The Execution Guarantee page covers the at-least-once, opaque-handler contract that every member carries.
Limits#
The numeric bounds that apply to a Workflow are the same store bounds that apply to any Job, not Workflow-specific limits.
| Limit or behavior | Value |
|---|---|
| Max parents (fan-in) per node | 16 |
| Max Job payload | 65,536 bytes |
| Max Job Output blob | 65,536 bytes, rejected rather than truncated when over |
| Max wire-name length | 128 characters |
| Minimum Workflow size | At least one node; an empty graph is rejected |
| Workflow name | Optional and non-unique |
| Workflow membership | At most one Workflow per Job |
| Atomicity | All members plus the Workflow record in one transaction; a partial write never persists |
| Cancel scope | A one-time snapshot of currently-running and pending members, not a standing rule |
| Workflow-level pause | None |
Members are retained as a unit until the whole Workflow drains, meaning every member reaches a terminal state, after which the normal per-Job retention window begins. The shared numeric bounds are listed in full under Limits and Defaults.
Where to go next#
- Dependencies: the layer below a Workflow, where edges, release modes, fan-in countdown, and Job Output are defined.
- Execution Guarantee: the at-least-once contract and cooperative cancellation that every member inherits.
- Job Lifecycle: the terminal member states (Succeeded, Dead-Lettered, Quarantined, Cancelled) 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.
- Workflows API: the full method and type reference.