fix(coordinator): reject Stop/StopByName on a dataflow with a pending… - #3114
Conversation
… restart initiate_restart() sends StopDataflow and registers a PendingRestart under the dataflow's UUID, but leaves the entry in running_dataflows until DataflowFinishedOnDaemon fires. Stop/StopByName only checked running_dataflows, not pending_restarts, so a concurrent Stop would fall through to stop_dataflow(), succeed, and get queued — then silently lose when the pending restart resolved first and spawned a new incarnation under a fresh UUID. The caller saw a clean 'stop succeeded' while the dataflow kept running under a different UUID. Reproduced live 3/3 with dora restart + dora stop fired concurrently on a real coordinator+daemon+node setup. Now rejects the stop with a clear error instead of silently losing.
|
😎 Merged successfully - details. |
|
🤖 Automated review by Claude — this is a fully automated review with no human in the loop. Treat it as advisory. Reviewed the diff — no correctness issues found. The pending-restart guard is correctly placed in both the One observation: this change ships without an automated test. The race is subtle and was verified only by manual repro, and the repo's TDD policy asks for a regression test at the tier that reproduces the bug. An integration test in Generated by Claude Code |
Reuses the shared stop-delay-node fixture (DORA_TEST_STOP_DELAY_MS) to make the race window deterministic instead of timing-dependent, so 'dora stop' reliably lands while a 'dora restart' is still pending on the same UUID. Asserts the stop is rejected with a clear error rather than silently succeeding while a new incarnation keeps running. Addresses review feedback on dora-rs#3114 asking for a regression test at the tier that reproduces the bug.
|
Added an e2e regression test addressing this: It reuses the shared The test starts a real coordinator + daemon, fires
Verified |
|
🤖 Automated review by Claude — fully automated, no human in the loop; please verify before acting. Re-reviewed the latest commit ( Generated by Claude Code |
|
Thanks for chasing this down — the race is real and worth fixing. My concern is with the chosen remedy rather than the diagnosis. 1. The guard also blocks
|
|
@phil-opp Addressed all three points:
Test rewritten accordingly: asserts |
|
Re-reviewed the revised diff (
No correctness issues found in the guard itself. One minor, non-blocking note: the Automated review; treat as advisory. Generated by Claude Code |
|
🤖 Automated review by Claude (fully automated — may contain mistakes) Re-checked after the latest commit ( Generated by Claude Code |
|
Thanks! Needs a main merge to fix the Audit check, then this is ready to land. |
|
/trunk merge |
…failure Daemons report their spawn results asynchronously, so on a multi-daemon dataflow one daemon can report a failure long after another has already started its nodes. The DataflowSpawnResult error arm only recorded the failure: it cached the spawn error and persisted a terminal Failed record, but never stopped the daemons that had already spawned, and never removed the dataflow from `running_dataflows`. Nothing else picked up the slack. The spawn-timeout watchdog skips the dataflow because its spawn result is no longer pending, and the terminal orphan-stop only runs when a daemon sends a status report, which each daemon does once at startup. So the nodes on the healthy daemon kept running unmanaged, and `dora list` reported the dataflow as Running against a Failed store record until someone ran `dora stop` by hand. The two sibling partial-failure paths already do the right thing: `run::spawn_dataflow` rolls back on a synchronous partial failure, and `check_spawn_timeouts` rolls back and tears down on a timeout. This gives the async path the same treatment by reusing the watchdog's logic: * Extract the watchdog's rollback plus in-memory teardown into `teardown_failed_spawn` (force-stop the started daemons, cancel a parked restart, final log line, close topic subscribers, synthesize per-node FailedToSpawn results, drain stop waiters, archive, cap). The watchdog keeps its own timeout wording and delegates the rest, so its behaviour is unchanged. * Move the DataflowSpawnResult handling into `handle_dataflow_spawn_result` so both orderings are covered and testable: a failure that makes the dataflow terminal rolls back every daemon no longer waiting on a spawn result, and a success reported after the teardown rolls that daemon back on its own. The pending-restart cancellation added in dora-rs#3114 moves into the shared helper instead of staying inline in the watchdog, so the async path can't leave a `PendingRestart` keyed to a dataflow that no longer exists, which would hang the parked restart caller and make every later Stop for that UUID fail. Closes dora-rs#3134
…failure (#3180) Daemons report their spawn results asynchronously, so on a multi-daemon dataflow one daemon can report a failure long after another has already started its nodes. The DataflowSpawnResult error arm only recorded the failure: it cached the spawn error and persisted a terminal Failed record, but never stopped the daemons that had already spawned, and never removed the dataflow from `running_dataflows`. Nothing else picked up the slack. The spawn-timeout watchdog skips the dataflow because its spawn result is no longer pending, and the terminal orphan-stop only runs when a daemon sends a status report, which each daemon does once at startup. So the nodes on the healthy daemon kept running unmanaged, and `dora list` reported the dataflow as Running against a Failed store record until someone ran `dora stop` by hand. The two sibling partial-failure paths already do the right thing: `run::spawn_dataflow` rolls back on a synchronous partial failure, and `check_spawn_timeouts` rolls back and tears down on a timeout. This gives the async path the same treatment by reusing the watchdog's logic: * Extract the watchdog's rollback plus in-memory teardown into `teardown_failed_spawn` (force-stop the started daemons, cancel a parked restart, final log line, close topic subscribers, synthesize per-node FailedToSpawn results, drain stop waiters, archive, cap). The watchdog keeps its own timeout wording and delegates the rest, so its behaviour is unchanged. * Move the DataflowSpawnResult handling into `handle_dataflow_spawn_result` so both orderings are covered and testable: a failure that makes the dataflow terminal rolls back every daemon no longer waiting on a spawn result, and a success reported after the teardown rolls that daemon back on its own. The pending-restart cancellation added in #3114 moves into the shared helper instead of staying inline in the watchdog, so the async path can't leave a `PendingRestart` keyed to a dataflow that no longer exists, which would hang the parked restart caller and make every later Stop for that UUID fail. Closes #3134 Co-authored-by: Philipp Oppermann <dev@phil-opp.com>
initiate_restart() sends StopDataflow and registers a PendingRestart under
the dataflow's UUID, but leaves the entry in running_dataflows until
DataflowFinishedOnDaemon fires. Stop/StopByName only checked
running_dataflows, not pending_restarts, so a concurrent Stop would fall
through to stop_dataflow(), succeed, and get queued — then silently lose
when the pending restart resolved first and spawned a new incarnation
under a fresh UUID. The caller saw a clean 'stop succeeded' while the
dataflow kept running under a different UUID.
Reproduced live 3/3 with dora restart + dora stop fired concurrently on
a real coordinator+daemon+node setup. Now rejects the stop with a clear
error instead of silently losing.
Why not a duplicate
SpawnedNodeResult{restart:true}leaks the node inrunning_nodes→ dataflow never finishes (stop-vs-restart race and respawn-failure both hit it) #2936 (open) — daemon-level node restart-loop leak in a single daemon'srunning_nodes, not this coordinator-level Restart control-request path
dataflow.start()on an already-stopping dataflow (barrier-completed-by-node-death start isn't gated onstop_sent) #3053 (open) — dataflow.start() firing on an already-stopping dataflow viathe startup barrier, a different trigger mechanism
mid-restart; already fixed by draining pending_restarts on disconnect.
Doesn't cover a concurrent Stop racing a healthy in-progress Restart.
node-lifecycle-e2e.rs, ws_control_tests.rs) exercises Restart racing Stop
on the same UUID.
this branch is based cleanly on origin/main and touches neither file.
Verification
pre-existing unrelated large_enum_variant warning tracked by fix(coordinator): box CachedResult::Cached to fix large_enum_variant on Windows #3001)
3/3 runs — 2/3 hit the new guard directly (clear rejection error), 1/3
the restart had already resolved before stop arrived so it correctly
reported the now-genuinely-finished old UUID (no silent loss in any run)