Skip to content

Lessons from building durable Workflows on Duroxide: sub-orchestration handles that survive select losers and continue-as-new, plus six related runtime changes #79

Open

Description

@waldemort-auto

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:

  1. a child's future is the only handle to the child;
  2. dropping that future cancels the child (CancelInstance is enqueued);
  3. 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 typed SelectAny over mixed outputs (enum-wrapped), polled in index order for determinism (same bias rule as Select2, src/combinators.rs:193-212). Allow select/join nesting (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 need utc_now to convert absolute deadlines.

P3 — Lossless continue-as-new

  1. 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) call enqueue_detached_from_pending and enqueue pending StartSubOrchestration actions (as linked children of the successor) before pushing the ContinueAsNew item.
  2. Spill instead of truncate at execution.rs:329-339: arrivals beyond MAX_CARRY_FORWARD_EVENTS are re-enqueued as WorkItem::QueueMessage for 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 / ExternalRaised for an instance with no history (orchestration.rs:676) while no StartOrchestration has 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 to max arrivals), plus try_dequeue_event (Add tryDequeueEvent(queueName) to orchestration context #8) and queue_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

  1. 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_orchestration futures 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.
  2. Default at parent terminal. Cancel outstanding linked children (proposed) or leave them running? Cancel matches held-future semantics and avoids leaks; _detached is the explicit opt-out.
  3. Spill vs fail at the carry cap. Spill preserves work and order; fail makes the bound visible. Either is acceptable; silent truncation is not.
  4. 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.
  5. Work-item schema in duroxide-pg. Confirm that SubOrchCompleted/SubOrchFailed gaining child_instance needs 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions