Repository navigation
Description
Summary
While building durable Workflows into PilotSwarm (microsoft/PilotSwarm#270) we ended up not using sub-orchestrations for the per-run controller, and instead built a "PostgreSQL is the truth, the queue is a hint" control plane with one detached helper orchestration per step. This issue explains why, with source citations at main = 36b789d (0.1.32 + #74; identical at v0.1.32 for every file cited), and proposes a set of Duroxide changes that would let an eternal controller orchestration keep children and state in the orchestration itself.
The controller is an event loop: poll commands, admit steps, elapse deadlines, collect results, route, and continue_as_new every N passes. It must therefore (a) drop futures regularly (every select loser is a drop) and (b) roll its history over regularly. At 0.1.32 both of those are fatal for a sub-orchestration:
- a child's future is the only handle to the child;
- dropping that future cancels the child (
CancelInstanceis enqueued); - the handle cannot cross continue-as-new: the child's completion is routed to the execution that scheduled it, and the successor drops it.
Seven further engine limits then forced the store-as-truth design. The result on the PilotSwarm side at the PR head: a 5.6k-line controller, a 7.8k-line store, 20 workflow_* tables, six helper orchestration kinds, and a 30 s backstop timer on every wait. Most of that is control plane an orchestration runtime should own.
What we hit (with evidence)
| # | Engine behaviour at 36b789d | Where | What PilotSwarm built instead | Cost |
|---|---|---|---|---|
| I1 | A sub-orchestration DurableFuture dropped before completion enqueues CancelInstance for the child. A select loser is a drop. |
src/lib.rs:683-693; src/runtime/execution.rs:127-137 ("parent dropped sub-orchestration future") |
No sub-orchestrations; detached helpers with stable ids (wfx-, wfs-, wfr-, wfa- + visit id) |
Parent/child link lost: no completion routing, no cancel cascade, no instance tree. Results travel through store rows plus wake hints. |
| I2 | A child's completion is stamped with the scheduling parent_execution_id; the parent's replay engine drops completions whose execution id is not current. At CAN, outstanding children are neither cancelled nor carried (unlike explicit cancellation, which does cancel them). |
execution.rs:201 (stamp); src/runtime/replay_engine.rs:442-460 (filter), :135 ("ignoring completion from previous execution"); execution.rs:300-371 (CAN) vs :374-392 (Cancelled) |
Helpers must outlive many timer wins and many CANs, so they cannot be children | A child that completes after the parent's CAN is orphaned: it runs to the end and its result is discarded. |
| I3 | A detached start (schedule_orchestration) or a schedule_sub_orchestration issued in the turn that ends in CAN is dropped: the ContinueAsNew branch never reads pending_actions. |
execution.rs:231,266 call enqueue_detached_from_pending; :300-371 does not. #78 |
Unwritten invariant: "every start is followed by an activity before any CAN" | One refactor away from silently losing a step. |
| I4 | Node select takes exactly two tasks and refuses nesting; core has only select2/select3. |
duroxide-node src/handlers.rs:531,538 (0.2.4); src/lib.rs:4137,4149 |
One queue multiplexes commands and wake hints; one timer at the nearest of all deadlines | Hint-draining loop; every deadline recomputed per pass. |
| I5 | CAN carries at most 100 unread queue arrivals; the excess is truncated with a warning. | src/runtime/limits.rs:14; execution.rs:329-339 |
Commands are store rows; the queue is a hint; 25-admission / 50-idle-wait CAN counters | An accepted command can still be dropped behind 100 hints. The whole store-as-truth layer exists for this. |
| I6 | A queue message or external event for an instance with no history is acked and dropped. | src/runtime/dispatchers/orchestration.rs:676 ("empty effective batch") |
Store first, then hint; 30 s backstop on every wait | Up to 30 s latency on a lost wake; extra turns. |
| I7 | The quiescence loop fails the turn after 100 subscription-bind iterations. | replay_engine.rs:1027-1049 |
Command reads capped at 32 per pass | An uncapped hint drain can still hit it. |
| I8 | A timer whose future is dropped is not cancelled at the provider; it still fires, costs a turn, lands in history. | ScheduleKind::Timer no-op src/lib.rs:464-470; execution.rs:168-181 |
Idle-wait counting to force CAN | History growth; wasted turns. |
| I9 | utc_now is a built-in activity (a full turn). |
src/lib.rs:2947 |
Read the clock only when a deadline is needed; the store clock decides "is it due" | Extra turns; store round-trips for time. |
I1 + I2 together are decisive. A long-lived parent has exactly three ways to stop awaiting a child: let the future lose a select (I1 cancels the child), let it go out of scope (same), or continue-as-new (I2 orphans it). There is no fourth way. The "Durable Supervisor" sketch in docs/proposals/durable-actors.md — a supervisor that carries children across continue_as_new — is exactly the pattern we needed, and it cannot run on the current runtime for these two reasons.
Proposals
Ordered by how much application code each removes. P1 is the architectural one; P2–P6 are small and independently shippable.
P1 — Re-awaitable sub-orchestration handles (survive select losers and CAN)
Goal. A parent can start a child, stop waiting for it (select loser, loop iteration, CAN), and wait for it again later — in any execution — without the child being cancelled and without losing its result. Cancellation becomes explicit.
// Existing: unchanged; cancel-on-drop preserved for compatibility.
let fut: DurableFuture<Result<String, String>> = ctx.schedule_sub_orchestration("Step", input);
// New: turn the future into a durable handle. Dropping the handle does nothing.
// SubOrchestrationHandle is serde-serializable: { child_instance: String }.
let h: SubOrchestrationHandle = fut.detach();
// Or start linked-but-not-awaited directly.
let h = ctx.start_sub_orchestration_linked("Step", input); // auto id
let h = ctx.start_sub_orchestration_linked_with_id("Step", id, input); // explicit id
// Wait (again) for a child, in this or any later execution.
// Dropping THIS future cancels only the wait, never the child.
let fut2 = ctx.await_sub_orchestration(&h);
// Explicit lifecycle.
ctx.cancel_sub_orchestration(&h); // CancelInstance; idempotent
let st: Option<SubOrchestrationTerminal> = ctx.sub_orchestration_result(&h); // replay-recorded; no turn
// Across CAN: handles are plain data in the CAN input, and the runtime also
// carries outstanding linked children automatically (below).
ctx.continue_as_new_typed(&State { children: vec![h], .. });History and routing:
| Piece | Today | Proposed |
|---|---|---|
| Child → parent completion | WorkItem::SubOrchCompleted { parent_instance, parent_execution_id (scheduling), parent_id, result }, filtered by parent_execution_id == current |
Add child_instance: String (serde default for old items). For linked children, route to the parent's latest execution — parent_execution_id_for_routing already exists at src/runtime/mod.rs:929 as the legacy fallback. In prep_completions, when parent_id is not in the current history, match by child_instance. |
| Parent-side wait | SubOrchestrationScheduled is the only correlation point |
New EventKind::SubOrchestrationAwaited { child_instance } emitted by await_sub_orchestration. SubOrchestrationCompleted/Failed carry source_event_id → the Awaited event (or the Scheduled event when the original future is the first await). Several awaits of one child in one execution are allowed; each resolves. |
| Dropping a linked child's wait | Cancels the child | New ScheduleKind::SubOrchestrationWait: drop is a no-op on the child; emits only a SubOrchestrationWaitCancelled breadcrumb. |
| Continue-as-new | Outstanding children ignored; later completions dropped | TurnResult::ContinueAsNew gains linked_children: Vec<String> and unconsumed_child_results: Vec<(child_instance, Result)> (results that arrived but no wait consumed — the analogue of unread queue arrivals). WorkItem::ContinueAsNew carries both; the dispatcher seeds SubOrchestrationLinked { child_instance } and any terminal result into the successor's history before external events. No 100-cap: bounded by the application's own child count. |
| Parent Completed/Failed with linked children outstanding | n/a | Default: cancel them (today's held-future semantics). Opt-out per start: start_sub_orchestration_linked_detached(...) leaves them running — today's schedule_orchestration, but with completion routing kept. |
Explicit CancelInstance of the parent |
Cancels children found in history | Unchanged; linked children found through SubOrchestrationLinked / SubOrchestrationScheduled. |
| Instance tree / status | From parent_instance |
Unchanged. |
Versioning: execution-pinned exactly like the 0.1.31 queue-race fix (RACE_CANCELLATION_FIX_SINCE, replay_engine.rs:20,560). New event kinds and work-item fields are additive; providers store events and work items as JSON, so no schema migration is expected (to be verified against duroxide-pg's work-item columns).
Tests (repository-native):
| # | Case | Expected |
|---|---|---|
| T1 | Child loses a select against a timer; parent later calls await_sub_orchestration |
Child keeps running; result delivered to the later await |
| T2 | Parent CANs with an outstanding linked child; child completes afterwards | Successor execution receives the result |
| T3 | Child completes during the CAN window (after the ContinueAsNew item, before the successor starts) — reuse the 0.1.32 "CAN admission window" harness |
Result carried, not dropped |
| T4 | detach() then cancel_sub_orchestration twice |
Child cancelled once; second cancel is a no-op |
| T5 | Parent completes with linked children outstanding | Children cancelled unless started _detached |
| T6 | Execution pinned to an older engine version | Old routing preserved |
P2 — N-way select, nesting, deadline timers
- Core:
ctx.select(Vec<DurableFuture<T>>) -> (usize, T)plus a typedSelectAnyover mixed outputs (enum-wrapped), polled in index order for determinism (same bias rule asSelect2,src/combinators.rs:193-212). Allowselect/joinnesting (box the inner). - Node: lift the "exactly 2 tasks" and "no nesting" rejections in
handlers.rs:497-543; the JS signature is already variadic. schedule_timer_until(deadline_ms)(Add an absolute-deadline timer variant (e.g.schedule_timer_until(deadline)) #34), so a controller does not needutc_nowto convert absolute deadlines.
P3 — Lossless continue-as-new
- Fix Detached start (schedule_orchestration) is dropped when the same turn ends in continue_as_new #78 and its sub-orchestration twin: in
TurnResult::ContinueAsNew(execution.rs:300-371) callenqueue_detached_from_pendingand enqueue pendingStartSubOrchestrationactions (as linked children of the successor) before pushing theContinueAsNewitem. - Spill instead of truncate at
execution.rs:329-339: arrivals beyondMAX_CARRY_FORWARD_EVENTSare re-enqueued asWorkItem::QueueMessagefor the instance (the dispatcher already defers items it cannot yet see across the CAN window), preserving order. The constant becomes a per-item payload bound, not a loss point. If spill is unwanted, fail the CAN with an Infrastructure error — never drop silently.
P4 — Messages to unstarted instances; bounded batch dequeue
- Do not ack
QueueMessage/ExternalRaisedfor an instance with no history (orchestration.rs:676) while noStartOrchestrationhas been seen, up to a provider-side TTL. Report the outcome to the sender:Delivered | Buffered | Expired. ctx.dequeue_events(name, max) -> DurableFuture<Vec<String>>(one subscription, up tomaxarrivals), plustry_dequeue_event(Add tryDequeueEvent(queueName) to orchestration context #8) andqueue_depth(name)(Expose event queue depth as orchestration-visible state #9). Together these make hint draining one bounded call and keep the quiescence bound (I7) out of reach.
P5 — Cancel dropped timers at the provider
ScheduleKind::Timer drop → WorkItem::CancelTimer { instance, execution_id, id } → the provider deletes the delayed orchestrator-queue row. If the row is already claimed, fall back to today's execution-id/duplicate filtering. Advertise as a provider capability; keep the no-op path for providers without it.
P6 — Small ergonomics
| Change | Today |
|---|---|
utc_now as a replay-recorded value in the turn (TimeRead event) |
A built-in activity costing a turn (src/lib.rs:2947) |
| Retry helpers accept an activity tag and a real total timeout | with_total_timeout aliases the per-attempt timeout (src/lib.rs:1512-1527) |
| Optional capability tag on orchestration work items | Orchestrations are not routed by version; a mixed fleet forces stop-deploy-start (see also #38) |
What the controller looks like after P1–P3
loop {
state = CAN input: cursors, parallels, ordinals, Vec<SubOrchestrationHandle>
waits = children.map(await_sub_orchestration)
+ [ dequeue_events(commands, 32), schedule_timer_until(nearest_deadline) ]
(i, out) = select(waits) // losers keep running
match out { child result => route, command => apply, timer => elapse }
if history is large { continue_as_new(state) } // children and unread commands are carried
}
Store writes become projections for the UI/API. No helper orchestrations, no wake hints, no backstop timer, no compare-and-set as the decision record.
Design choices to settle
- Routing policy for legacy children. Route all children to the parent's latest execution under the new policy, or only linked ones? Routing all is simpler and matches "a parent is one instance"; keeping the stamped execution for ordinary
schedule_sub_orchestrationfutures preserves today's determinism guarantees exactly. Our preference: route all under the new execution-pinned policy, since a completion that can only ever be dropped is not a useful guarantee. - Default at parent terminal. Cancel outstanding linked children (proposed) or leave them running? Cancel matches held-future semantics and avoids leaks;
_detachedis the explicit opt-out. - Spill vs fail at the carry cap. Spill preserves work and order; fail makes the bound visible. Either is acceptable; silent truncation is not.
- Where unconsumed child results live after CAN. Seeded into the successor's history (proposed, mirrors unread queue arrivals) vs. re-enqueued as work items. Seeding keeps them replay-visible on the first turn.
- Work-item schema in duroxide-pg. Confirm that
SubOrchCompleted/SubOrchFailedgainingchild_instanceneeds no migration.
Suggested sequencing
| Step | Size | Risk | Unblocks |
|---|---|---|---|
| P3 — #78 fix + spill | S | low; additive | removes the start/activity invariant now |
P2 — N-way select, nesting, timer_until |
S–M | low | a simpler wait loop |
| P5 — timer cancel | M | provider capability | history growth |
| P1 — linked child handles | L | medium: new event kinds, routing change, CAN carry; execution-pinned | the architecture change |
| P4 — buffered inbox, batch dequeue | M | provider TTL semantics | hint loop, lost-wake latency |
| P6 | S each | low | fewer turns |
Related: #78, #34, #8, #9, #38, #21. We are happy to draft P3 and the P1 proposal doc plus failing tests as PRs if the direction is agreed.
Activity