Fan-out and child workflows
This guide builds an order-fulfilment workflow that reserves every line item
of an order in parallel, with a concurrency limit, and books shipping through
a separate Shipping workflow that runs as a durable child execution. You run
it end to end and then make shipping fail, to see each reservation compensated
individually.
Before you begin
Clone the ORBIT repository and run every command from its root:
git clone https://github.com/AjmalX0/Orbit.git
cd Orbit
You need orbit 0.1.0 or later, Python 3.9+ and Node.js 22.18+. The guide
reuses the order workers from Orders with compensation:
| File | Contents |
|---|---|
examples/fulfilment.orbit | Shipping contracts, the Shipping workflow and the Fulfilment workflow |
examples/contracts.orbit | LineItem, inventory.reserve, inventory.release (imported) |
examples/workers/typescript/order_workers.ts | inventory.reserve, inventory.release |
examples/workers/python/order_workers.py | shipping.quote, shipping.book |
The workflows
import "contracts.orbit"
task "shipping.quote" {
version "1.0.0"
input { order_id: String, items: Int }
output { carrier: String, cost: Float }
}
task "shipping.book" {
version "1.0.0"
input { order_id: String, carrier: String }
output { tracking: String }
}
workflow Shipping {
version "1.0.0"
input { order_id: String, items: Int }
output { carrier: String, tracking: String, cost: Float }
step quote { worker "shipping.quote" with { order_id: input.order_id, items: input.items } }
step book { worker "shipping.book" with { order_id: input.order_id, carrier: quote.carrier } timeout 30s }
return { carrier: quote.carrier, tracking: book.tracking, cost: quote.cost }
}
workflow Fulfilment {
version "1.0.0"
input {
order_id: String
items: List<LineItem>
}
output {
reservations: List<{ reservation_id: String }>
tracking: String
}
step reserve {
for item in input.items
max_parallel 4
worker "inventory.reserve"
using typescript
with { order_id: input.order_id + "-" + item.sku, items: [item] }
compensate {
worker "inventory.release"
with { reservation_id: reserve.reservation_id }
}
}
step ship {
workflow "Shipping"
with { order_id: input.order_id, items: len(input.items) }
timeout 1h
}
return { reservations: reserve, tracking: ship.tracking }
}
The fan-out step
reserve is a for step:
for item in input.itemsruns the task once per element.itemis in scope inwithand incompensate, so each element reserves one SKU.max_parallel 4allows at most four elements to run at the same time.- Each element is its own unit,
reserve[0],reserve[1], …, with its own attempts, timeouts and idempotency key (<execution>/reserve[i]). - The step’s output is the list of element outputs in input order. Its type is
List<{ reservation_id: String }>, which is whyreturncan usereservations: reservedirectly. - Inside
compensate,reserve.reservation_idrefers to the output of the element being compensated, not to the list.
An empty items list makes the step succeed immediately with [].
The child workflow step
ship is a workflow step. It starts Shipping as a separate execution:
- The compiler checks
withagainstShipping’sinputand typesship’s output asShipping’soutput, soship.trackingis aString. - The child execution has a deterministic id,
<parent id>.ship. The runtime can therefore never start it twice, even if it crashes right after starting it. timeout 1hlimits how long the parent waits for the child. Child steps take noretry,using,idempotency_keyorqueue_timeout; retries belong on the child’s own steps.Shippingmust be defined in, or imported into, the same compilation.orbit deployandorbit runregister it beforeFulfilment.
ship does not read reserve’s output and has no after clause, so the two
steps run concurrently. Add after reserve to ship if shipping must wait
for every reservation.
orbit check examples/fulfilment.orbit
ok Shipping@1.0.0 (2 steps)
ok Fulfilment@1.0.0 (2 steps)
Run it
Start the runtime and the two order workers, each in its own terminal:
orbit server
PYTHONPATH=sdks/python python3 examples/workers/python/order_workers.py
node examples/workers/typescript/order_workers.ts
Run an order with three line items:
orbit run examples/fulfilment.orbit --id ful-B-1 --input '{
"order_id": "B-1",
"items": [
{"sku": "BOOK-1", "quantity": 1},
{"sku": "PEN-2", "quantity": 3},
{"sku": "MUG-3", "quantity": 1}
]
}'
deployed Shipping@1.0.0 (sha256:e9480a2e5f6b)
deployed Fulfilment@1.0.0 (sha256:ecffc60c3bd3)
started execution ful-B-1
✓ reserve[0] succeeded 16ms
✓ reserve[1] succeeded 14ms
✓ reserve[2] succeeded 19ms
✓ reserve succeeded 27ms
✓ ship succeeded 96ms
COMPLETED ful-B-1 Fulfilment@1.0.0
elapsed: 112ms
{
"reservations": [
{
"reservation_id": "res-B-1-BOOK-1"
},
{
"reservation_id": "res-B-1-PEN-2"
},
{
"reservation_id": "res-B-1-MUG-3"
}
],
"tracking": "UPS-B-1"
}
orbit run deployed both workflows, callee first. orbit status shows one
row per element and one for the step as a whole. The ship unit is executed
by the runtime itself, which appears as the worker orbit-runtime:
orbit status ful-B-1
COMPLETED ful-B-1 Fulfilment@1.0.0
elapsed: 112ms
STEP STATUS ATTEMPT TIME DETAIL
reserve[0] SUCCEEDED 1 16ms worker orders-typescript
reserve[1] SUCCEEDED 1 14ms worker orders-typescript
reserve[2] SUCCEEDED 1 19ms worker orders-typescript
reserve SUCCEEDED 1 27ms
ship SUCCEEDED 1 96ms worker orbit-runtime
output: {"reservations":[{"reservation_id":"res-B-1-BOOK-1"},{"reservation_id":"res-B-1-PEN-2"},{"reservation_id":"res-B-1-MUG-3"}],"tracking":"UPS-B-1"}
The child is an execution of its own. orbit ps lists it next to its parent:
orbit ps
EXECUTION WORKFLOW VERSION STATUS AGE
ful-B-1.ship Shipping 1.0.0 COMPLETED 121ms
ful-B-1 Fulfilment 1.0.0 COMPLETED 135ms
orbit status ful-B-1.ship
COMPLETED ful-B-1.ship Shipping@1.0.0
elapsed: 33ms
STEP STATUS ATTEMPT TIME DETAIL
quote SUCCEEDED 1 23ms worker orders-python
book SUCCEEDED 1 4ms worker orders-python
output: {"carrier":"ups","tracking":"UPS-B-1","cost":7.5}
A failing child: per-element compensation
The Python worker’s shipping.book fails with the non-retryable error
NO_CARRIER for order ids that start with noship:
orbit run examples/fulfilment.orbit --id ful-noship-1 --input '{
"order_id": "noship-1",
"items": [
{"sku": "BOOK-1", "quantity": 1},
{"sku": "PEN-2", "quantity": 3}
]
}'
up to date Shipping@1.0.0 (sha256:e9480a2e5f6b)
up to date Fulfilment@1.0.0 (sha256:ecffc60c3bd3)
started execution ful-noship-1
✓ reserve[1] succeeded 5ms
✓ reserve[0] succeeded 9ms
✓ reserve succeeded 11ms
✗ ship failed child workflow `Shipping` failed: step `book` failed: no carrier serves this address
✓ compensate:reserve[0] succeeded 5ms
✓ compensate:reserve[1] succeeded 4ms
FAILED ful-noship-1 Fulfilment@1.0.0
elapsed: 163ms
compensation: completed
error: NO_CARRIER step `ship` failed: child workflow `Shipping` failed: step `book` failed: no carrier serves this address
The child failed first. Its history ends with the failure of book:
orbit logs ful-noship-1.ship
+ 0ms #1 WorkflowCreated
+ 0ms #2 WorkflowStarted
+ 2ms #3 StepScheduled step=quote attempt=1
+ 3ms #4 StepDispatched step=quote attempt=1 worker_id=orders-python
+ 10ms #5 StepSucceeded step=quote attempt=1
+ 11ms #6 StepScheduled step=book attempt=1
+ 13ms #7 StepDispatched step=book attempt=1 worker_id=orders-python
+ 18ms #8 StepAttemptFailed step=book attempt=1 error=NO_CARRIER no carrier serves this address
+ 18ms #9 StepFailed step=book error=NO_CARRIER no carrier serves this address
+ 18ms #10 WorkflowFailed error=NO_CARRIER step `book` failed: no carrier serves this address
The parent’s history shows the fan-out, the child’s error arriving as the
ship step’s error, and the compensation of each reserved element:
orbit logs ful-noship-1
+ 0ms #1 WorkflowCreated
+ 0ms #2 WorkflowStarted
+ 5ms #3 StepFanOut step=reserve
+ 5ms #4 StepScheduled step=ship attempt=1
+ 5ms #5 StepScheduled step=reserve[0] attempt=1
+ 5ms #6 StepScheduled step=reserve[1] attempt=1
+ 6ms #7 StepDispatched step=reserve[0] attempt=1 worker_id=orders-typescript
+ 9ms #8 StepDispatched step=reserve[1] attempt=1 worker_id=orders-typescript
+ 14ms #9 StepSucceeded step=reserve[1] attempt=1
+ 15ms #10 StepSucceeded step=reserve[0] attempt=1
+ 16ms #11 StepSucceeded step=reserve attempt=1
+ 51ms #12 StepDispatched step=ship attempt=1 worker_id=orbit-runtime
+ 149ms #13 StepAttemptFailed step=ship attempt=1 error=NO_CARRIER child workflow `Shipping` failed: step `book` failed: no carrier serves this address
+ 150ms #14 StepFailed step=ship error=NO_CARRIER child workflow `Shipping` failed: step `book` failed: no carrier serves this address
+ 150ms #15 CompensationStarted reason=step `ship` failed: child workflow `Shipping` failed: step `book` failed: no carrier serves this address
+ 150ms #16 StepScheduled step=compensate:reserve[0] attempt=1
+ 151ms #17 StepDispatched step=compensate:reserve[0] attempt=1 worker_id=orders-typescript
+ 156ms #18 StepSucceeded step=compensate:reserve[0] attempt=1
+ 156ms #19 StepScheduled step=compensate:reserve[1] attempt=1
+ 158ms #20 StepDispatched step=compensate:reserve[1] attempt=1 worker_id=orders-typescript
+ 163ms #21 StepSucceeded step=compensate:reserve[1] attempt=1
+ 163ms #22 CompensationCompleted
+ 163ms #23 WorkflowFailed error=NO_CARRIER step `ship` failed: child workflow `Shipping` failed: step `book` failed: no carrier serves this address
Points to note:
StepFanOut(#3) records the evaluated list once. Retries and recovery reuse it, so the set of elements cannot change mid-execution.reserveandshipwere scheduled together (#4 to #6), becauseshipdoes not depend onreserve.- The child’s error code propagates. The
shipstep fails with the child’s own code,NO_CARRIER. A child that is cancelled or times out fails the step withCHILD_CANCELLEDorCHILD_TIMED_OUT.orbit inspect ful-noship-1records the child’s id in the error details ("child_execution_id": "ful-noship-1.ship"). - Each element is compensated individually, newest first.
reserve[0]finished afterreserve[1](#10 after #9), so it was released first. Each compensation is its own unit with its own idempotency key, such asful-noship-1/compensate:reserve[0]. - A child compensates its own steps.
Shippingdeclares no compensations, and itsquotestep had nothing to undo. Compensations declared on the child’s steps run when the child itself fails, is cancelled or times out, including when the parent cancels it. A child that already completed is not rolled back when the parent fails later. To undo a completed child’s work, give theworkflowstep its owncompensateblock that calls a worker task, such as one that cancels the booking.
Variations
Limit concurrency to one element at a time to process items sequentially:
step reserve {
for item in input.items
max_parallel 1
worker "inventory.reserve"
with { order_id: input.order_id + "-" + item.sku, items: [item] }
}
Start one child per element by combining for with workflow. The child ids
are <parent>.<step>.<i>:
step ship_each {
for item in input.items
workflow "Shipping"
with { order_id: input.order_id + "-" + item.sku, items: item.quantity }
}
Use when to decide whether the whole fan-out runs. The loop variable is not
in scope in when, because the condition is evaluated once for the step:
step reserve {
for item in input.items
when len(input.items) <= 50
worker "inventory.reserve"
with { order_id: input.order_id + "-" + item.sku, items: [item] }
}