Contents
- Open Problems in the Loop Rework
- 306 (7ee7f43), #307 (09e08f0), and #311 (560a639).
- Summary
- 1. Loop history repeatedly persists full state
- 2. Clock failures disable loop rate limiting
- 3. Replay failures strand extension instances
- 4. Node-status write failures are discarded
- 5. Parent fallback stamps assume child generation 1
- 6. Composed child ids block orphan reclamation
- 7. Stamp validation fails open
- 323 introduced a shared typed lineage parser for execution stamps, composed
Open Problems in the Loop Rework
Findings from the review of the df.loop() child-sub-orchestration rework in
PR #228 (1320f65) that remain unresolved after the loop follow-up fixes in
306 (7ee7f43), #307 (09e08f0), and #311 (560a639).
As these get fixed, leave a placeholder in both the table and the description section.
0.2.5 has shipped, so further replay-breaking fixes require orchestration
versioning or another explicit drain boundary.
Summary
Ordered by severity.
| # | Short name | Applies to | Severity | Status |
|---|---|---|---|---|
| 1 | Loop history repeatedly persists full state | non-root and parallel-branch loops | π΄ Blocking | Open |
| 2 | Clock failures disable loop rate limiting | all loops | π΄ Blocking | Open |
| 3 | Replay failures strand extension instances | all replay-breaking changes | π΄ Blocking | Open |
| 4 | Node-status write failures are discarded | all nodes | π‘ Medium | Open |
| 5 | Parent fallback stamps assume child generation 1 | cancelled or failed loop children | π‘ Medium | Open |
| 6 | Composed child ids block orphan reclamation | loop and parallel children | π‘ Medium | Resolved in #323 |
| 7 | Stamp validation fails open | status write fence and inference | π‘ Medium | Open |
| 8 | Nested loops have no cumulative iteration budget | nested loops | π‘ Medium | Open |
| 9 | Signals inherit the sub-orchestration startup race | non-root loops | π‘ Medium | Open |
| 10 | The 0.2.5 drain guidance is not an executable runbook | 0.2.4 β 0.2.5 upgrade | π‘ Medium | Open |
| 11 | The status-write fence holds a row lock across round trips | schemas with status_details |
π’ Low | Open β measure before changing |
| 12 | Status capability and inference paths repeat parsing work | status writes and reads | π’ Low | Open β measure before changing |
1. Loop history repeatedly persists full state
Severity: π΄ Blocking β primarily affects non-root loops and loops inside JOIN/RACE branches.
The shared execute-subtree path removed the database reload that PR #228 did
on every child-loop generation, but it did not bound persisted input size.
Every continue_as_new still serializes the complete FunctionGraph, workflow
variables, and accumulated named-results map into the next input:
let graph_json = serde_json::to_string(graph)?;
let new_input = SubtreeInput {
graph: graph_json,
results: string_map_to_json(results)?,
vars: Some(string_map_to_json(&exec_ctx.vars)?),
// ...
};
The graph snapshot is also copied into every JOIN/RACE child input. A large
graph or named result is therefore persisted repeatedly across loop generations
and child histories. MAX_LOOP_ITERATIONS limits generations to 100,000, but
that is not a meaningful byte bound: a 1 MB carried result can still produce
storage measured in tens of gigabytes before the iteration guard trips.
Child engine records also remain until the root instance is retired. A nested
loop can create a child under each outer generation, so the retained child
population grows with execution shape even though each individual child uses
continue_as_new.
Suggested fix
Design a bounded persistence model before changing runtime behavior. The design must preserve all three named-result visibility rules: values created before a loop are readable inside it, values created in one iteration are readable in a later iteration, and values created inside the loop are returned to the parent. Candidate designs include bounded external state or a delta/checkpoint protocol. Set and test a numerical engine-storage bound as payload size and iteration count vary; merely removing fields from the input is not sufficient.
2. Clock failures disable loop rate limiting
Severity: π΄ Blocking β applies to all loops.
The one-second loop floor fails open. Both deterministic-clock reads discard
their errors in execute_loop_node:
let iter_started = ctx.utc_now().await.ok();
if let Some(started) = iter_started {
if let Ok(now) = ctx.utc_now().await {
// schedule the rate-limit timer
}
}
ctx.utc_now() is a fallible duroxide syscall, not an infallible local clock
read. If either call fails, the orchestration immediately executes
continue_as_new with no compensating timer. A persistent engine/store problem
therefore removes the guard intended to prevent an empty-bodied loop from
busy-spinning, amplifying load during the failure.
Suggested fix
Choose one fail-closed behavior: schedule a fixed durable delay through an API
that does not require a second clock read, or fail the loop with an explicit
diagnostic. Because changing the syscall sequence changes recorded history,
land the fix before v0.2.5 or version the orchestration.
3. Replay failures strand extension instances
Severity: π΄ Blocking β applies to replay-breaking upgrades and code changes.
When duroxide rejects replay as nondeterministic, the orchestration does not
re-enter execute(). Its normal update_instance_status(..., "failed")
activity therefore never runs, and the corresponding df.instances row can
remain pending or running indefinitely.
This is already acknowledged in CHANGELOG.md and
docs/upgrade-testing.md. The maintenance reconciliation in worker.rs only
removes expired rows that are already terminal and reclaims failed engine
records that have no df.instances row. It does not compare authoritative
engine failures with non-terminal extension rows. Consequences include:
df.status()anddf.await_instance()can wait forever.- The row never becomes eligible for terminal retention pruning.
- Operators must recognize and cancel stale rows manually after an upgrade.
Suggested fix
Add a reconciliation pass that identifies an authoritative duroxide terminal
failure after a short grace period and compare-and-sets the matching
df.instances row from pending/running to failed, preserving the engine
diagnostic. A normal completion or cancellation racing the sweep must win and
must never be overwritten.
4. Node-status write failures are discarded
Severity: π‘ Medium β applies to every executed node.
Callers schedule update-node-status and discard the returned Result with
let _ =. This includes ordinary running/terminal transitions, loop fallback
stamps, and subtree startup failures. The activity now has more failure points
than a plain update: pool acquisition, transaction start, row locking, update,
rollback, and commit.
A failed status activity does not fail the owning orchestration and is not
retried by caller policy. The workflow can report success while df.nodes
contains stale running or pending state, and read-side inference then treats
that stale state as authoritative input.
Suggested fix
Define bounded, idempotent retry for status writes. A legitimate supersession fence should remain a successful no-op, while transport, transaction, and row identity failures need distinct diagnostics. If retries are exhausted, prevent the owning orchestration from reporting success or persist a reconciliation marker that the worker can repair.
5. Parent fallback stamps assume child generation 1
Severity: π‘ Medium β affects cancelled or failed loop children that have already continued as new.
PR #228 moved ownership of a non-root loop nodeβs status into its child orchestration. The parent still needs a terminal fallback when the child never gets far enough to stamp itself, when the child future fails, or when a live loop loses a RACE. #307 implemented the latter two paths by constructing a child execution stamp with a hard-coded generation 1:
let child_stamp = format!("{}::1", subtree_instance_id(ctx, loop_node_id));
The same assumption appears in cancel_losing_loop_branch. It is valid only
before the child completes its first continue_as_new. If the loop has reached
generation 2 or later, its node can already carry a stamp such as
{child_instance_id}::2. The parentβs terminal ::1 write is then correctly
classified as older by incoming_stamp_is_superseded and fenced out. A fenced
write returns Ok, so the parent cannot distinguish it from an applied
terminal update; the completed parent instance can retain a physically
running loop node.
The existing losing-RACE test does not exercise this case. It races
df.sleep(1) against df.loop(df.sleep(30)), guaranteeing cancellation during
the first child generation, and explicitly asserts a trailing ::1 stamp.
There is no test in which a loop completes at least one continue_as_new before
losing the RACE.
fail_loop_child_future has the same stale-fallback problem. A normal child
failure usually stamps its own current generation before returning, but the
generation-1 fallback cannot repair a missing or failed child-owned stamp after
the child has advanced.
Suggested fix
Do not synthesize a child generation the parent cannot know. Define an explicit terminal parent-override operation, use authoritative child execution metadata if it can be obtained without violating orchestration determinism, or record cancellation separately from execution-lineage ownership. Any design must keep later stale child writes from reopening terminal state. Add a regression that lets the losing loop reach generation 2 or later, then asserts that both its physical and inferred node statuses are terminal.
6. Composed child ids block orphan reclamation
Severity: π‘ Medium β affects composed loop and parallel child ids.
Tracking: #312 β Reconciler misclassifies explicitly named sub-orchestrations as root orphans
Resolved: #323 β Fix reconciler child classification
PR #323 excludes both explicitly composed child ids and legacy Duroxide
sub:: ids before applying RECLAIM_BATCH. It also shares the typed lineage
parser with execution-stamp fencing and status inference.
7. Stamp validation fails open
Severity: π‘ Medium β affects node-status fencing and inferred status.
323 introduced a shared typed lineage parser for execution stamps, composed
instance ids, read-side status inference, and the write-side fence. The remaining problem is how parse failures are handled.
Malformed stamps disable protection. incoming_stamp_is_superseded returns
false when either stamp fails to parse, including malformed non-legacy data.
The current tests explicitly pin odd-token and nonnumeric stamps as βfence
disabled.β Read-side parse failures likewise produce None/false, causing
stale physical status to be shown rather than an observable error.
The unequal-depth rule is also only a convention: parent and descendant stamps at tied generations do not fence one another in either direction, leaving the outcome to write order.
Suggested fix
Preserve the inert path only for an absent status_details column or an
explicitly recognized legacy value; malformed current-format data should fail
closed with an observable diagnostic. Define and test the intended ordering
between parent and descendant stamps at equal generations.
8. Nested loops have no cumulative iteration budget
Severity: π‘ Medium β applies to nested loops.
MAX_LOOP_ITERATIONS is enforced per orchestration instance. A non-root inner
loop starts in its own child and receives its own counter, while an outer loop
can spawn a new inner child under every outer generation. Two loops that each
stay under the 100,000-iteration cap can therefore produce a combinatorial
amount of work and retained child state.
The one-second floor limits the rate of each individual loop but does not place a workflow-wide bound on nested work. The existing nested-loop E2E test proves correctness for a small 3 Γ 2 case; it does not establish a resource budget.
Suggested fix
Define whether the public limit is per loop, per nesting lineage, or per root workflow. If a cumulative budget is intended, carry a deterministic remaining budget into child inputs and decrement it at a clearly specified boundary. Document the semantics and test nested loops near the limit before changing the runtime input shape.
9. Signals inherit the sub-orchestration startup race
Severity: π‘ Medium β applies to df.wait_for_signal() inside non-root loops.
A non-root loop body executes in an execute-subtree child. It therefore
inherits the existing #154 limitation: a signal raised before the child
sub-orchestration reaches Running is not redelivered to that child. Moving
loops behind the child boundary widened the set of workflows exposed to the
race; neither #307 nor the #311 unification changes signal delivery.
Suggested fix
Fix #154 at the engine/sub-orchestration boundary or add a durable signal inbox
whose delivery is independent of child startup timing. Until then, document the
limitation specifically in the loop section of USER_GUIDE.md; upgrade/replay
notes are not sufficient guidance for authors of new workflows.
10. The 0.2.5 drain guidance is not an executable runbook
Severity: π‘ Medium β applies to the 0.2.4 β 0.2.5 upgrade.
The release artifacts now correctly warn that loops and parallel branches are replay-breaking, and the warning is attached to the open 0.2.4 β 0.2.5 upgrade path. The planned operational half is still missing.
docs/upgrade-testing.md says to βquiesce and drainβ or inspect/cancel stale
instances, but supplies no all-tenant query, role requirement, timeout/cancel
policy, zero-row check, worker restart verification, or post-upgrade health
check. This matters because tenant roles are RLS-scoped: a tenant can see zero
non-terminal rows while another tenant still has active work.
Suggested fix
Add a complete administrative runbook: stop new df.start() submissions;
connect as the documented role that bypasses tenant isolation; list every
non-terminal instance with a static schema-qualified query; wait to a declared
timeout; cancel or defer for unfinished work; verify zero rows; apply the
extension update; restart/verify the worker; and confirm scheduling and status
health. State explicitly that tenant-scoped execution is insufficient.
11. The status-write fence holds a row lock across round trips
Severity: π’ Low β measure before changing.
On schemas with status_details, every node transition performs
BEGIN β SELECT ... FOR UPDATE β application-side lineage comparison β UPDATE
β COMMIT. The lock is a single composite-primary-key row and there is no
nested lock acquisition, so this is not the previously documented duroxide
deadlock. It is nevertheless several round trips on every node transition,
holds a pooled connection and row lock across Rust-side work, and has no
explicit lock-timeout behavior.
The management pool is intentionally small as a DoS control and must not be enlarged as a shortcut.
Suggested fix
Measure status-write throughput and lock latency under concurrent stale/current writers. Then compare the current transaction with an atomic SQL predicate or a bounded CAS design. Preserve the exact sibling/ancestor semantics and surface a lock/CAS timeout as a retryable activity failure.
12. Status capability and inference paths repeat parsing work
Severity: π’ Low β measure before changing.
Two small costs remain in status-heavy paths:
status_details_presentcaches a positive schema probe permanently, but an absent column is re-probed throughinformation_schema.columnson every status write. Scenario B1 intentionally supports a new binary against an old schema, so this is the deployment where every node transition pays the metadata query.infer_statusesparses each nodeβsstatus_detailsJSON once while buildingscope_max, then up to twice again during the graph walk.
Suggested fix
For the schema probe, use a bounded negative-cache expiry or an invalidation mechanism that notices an in-place extension upgrade; a worker-lifetime negative cache could leave the fence disabled after the column appears. For inference, parse each stamp once into a temporary typed structure and reuse it in both passes. Benchmark first and keep output byte-for-byte equivalent over nested lineage fixtures.