Child sagas (spawn_sagas / join_sagas)
Warden can fan out work to child saga instances and wait for them with an engine-native join barrier. Children stay ordinary linear sagas (reason/commit/loops). The parent does not run parallel forward steps inside one FSM.
v1 supports wait_all only. There is no cooperative CANCEL, no join timeout auto-abort, and no wait_any / quorum.
Authoring
steps:
- id: plan
use: plan-work
version: "1.0.0"
with: {}
# catalog reason step produces output.items: [{ "id": "a", ... }, ...]
- id: dispatch
kind: spawn_sagas
spawn:
saga_name: "child-work"
saga_version: "1.0.0"
items_from: "$.steps.plan.output.data.items"
item_var: "item"
result_from: "$.steps.finalize.output.data" # REQUIRED
max_children: 8 # optional; engine hard cap is 16
input:
payload:
from: "$.item"
shared_flag:
from: "$.input.shared_flag"
- id: await_children
kind: join_sagas
join:
spawn_step_id: "dispatch"
allow_zero_success: true # default true
- id: reduce
use: reduce-children
version: "1.0.0"
with: {}
# catalog reason step reads $.steps.await_children.output.data.children
Rules:
- Each item in
items_frommust be an object with a non-empty stringid(used for idempotent child starts). - Empty
items_fromfails spawn withSPAWN_EMPTY_ITEMS. - More than
max_children(default/hard max 16) fails withTOO_MANY_CHILDREN. result_fromis required and must be a JSONPath into each child saga context.- Resolve context for
spawn.inputis the parent context plus$.itemand$.{item_var}. - Children inherit the parent namespace. Deploy the child saga definition before the parent; the child must be active at parent deploy time.
- Spawning an inactive or missing child definition fails the spawn step (
SPAWN_CHILD_DEFINITION_INACTIVE/SPAWN_CHILD_DEFINITION_NOT_FOUND) instead of raising an unhandled exception. - Child start hydrate / asset freeze failures (missing schema, inactive step, invalid embed) fail the spawn step with
SPAWN_CHILD_HYDRATE_FAILED. Child creates run in a nested transaction (savepoint): a mid-loop failure rolls back any children already written in that spawn attempt so the parent does not commit orphans next to a failed spawn. - Spawn/join are not allowed inside loop bodies.
- Each
join.spawn_step_idmust reference aspawn_sagasstep; at most one join per spawn.
Runtime model
spawn_sagasruns in the engine (no worker command). It starts one child saga per item withstart_idempotency_key = sha256(parent_trace:spawn_step_id:item_id)and writessaga_childrenlink rows.parent_trace_idis set on each child instance.join_sagasparksIN_PROGRESSuntil every linked child reaches a terminal status (COMPLETED,FAILED, orCOMPENSATED).- On child terminalization, the engine wakes the parent join (same transaction family as saga completion/failure/compensation).
- Join output is written to
steps.<join_id>.output.data:
{
"summary": { "total": 2, "succeeded": 1, "failed": 1 },
"children": [
{
"item_id": "a",
"child_trace_id": "...",
"status": "COMPLETED",
"output": { "...": "..." },
"error": null
},
{
"item_id": "b",
"child_trace_id": "...",
"status": "COMPENSATED",
"output": null,
"error": { "code": "...", "message": "..." }
}
]
}
summary.succeededcountsCOMPLETED.summary.failedcountsFAILED+COMPENSATED.- Child row
statuspreserves the raw terminal status. - On
COMPLETED,outputisresult_from(ornullif missing). On failure terminals,outputisnullanderroris populated. - If
allow_zero_success: falseand no child completed, the join step fails withALL_CHILDREN_FAILED.
Isolation note
Hermeticity for mutating work (sandboxes, installs, tests) comes from each child saga’s own environment, not from extra Warden worker processes. Prefer one sandbox (or equivalent) per child for side-effecting arms.
Compensation
When the parent compensates:
join_sagasis skipped (no side effects).spawn_sagasblocks until all of its children are terminal, then continues LIFO. v1 does not cancel in-flight children; it waits for them to finish on their own.
Recovery
Parked join_sagas steps have no worker-commands row, so outbox claim reap does not treat them as stuck workers.
Operator warden saga retry-step on:
join_sagas— callstry_complete_join(missed-wake recovery); never dispatchesDO_STEP.spawn_sagas— re-enters the idempotent spawn path.
Future step-timeout reapers must exclude these kinds (or treat join as a join wake, never worker timeout).
Observability
List children of a parent:
warden list sagas --parent-trace-id <parent_trace_id>
# GET /v1/sagas?parent_trace_id=...
Saga instance JSON includes parent_trace_id when set.