Stop recovery adopting workflows this executor is already running - #493
Merged
Merged
Conversation
Two flaky tests (#477, #488) and a duplicate execution (#491) trace back to one defect: the launch-time recovery sweep adopting a workflow that this process started microseconds earlier. The sweep's query is bounded with `withEndTime(Instant.now())`, which filters `created_at <= endTime` at millisecond granularity. `launch()` returns inside the same millisecond that the first workflow can be started in, so an inclusive bound lets the sweep see it. CI caught it twice with the two events landing in the same logged millisecond: 20:35:25.577 DBOS Executor started 20:35:25.577 startWorkflow ... insertWorkflowStatus outcome-ownership-... 20:35:25.580 recoverWorkflow execute outcome-ownership-... 20:35:25.581 insertWorkflowStatus outcome-ownership-... <- row re-inserted 20:35:25.583 executeWorkflow task ... <- body runs again Both copies then run to completion and race to record the outcome. In WorkflowOutcomeOwnershipTest the re-insert also resurrects the row the test deleted, so the live run's error write matches after all and it reports its own failure instead of failing fast -- which is what #488 saw. In AsyncWorkflowTest the workflow body simply executes twice (#491). #477 is the same shape, an extra run counted, though its CI logs have since expired. Two changes: - The launch sweep's cutoff steps back a millisecond, so it can only see workflows that genuinely predate this process. Anything started from launch onwards is being run by a live execution and is not orphaned, however PENDING its row looks. - recoverWorkflow skips a workflow whose ID is active on this executor. ActiveWorkflowGuard already rejects a duplicate dispatch, but it is released just before the terminal outcome write, so a recovery landing in that window acquires the ID and runs the body again. recoverPendingWorkflows carries no time bound at all, so this is also what stands between an operator-triggered recovery and a duplicate execution. Separately, and unrelated to the above: DBOSExecutorTest.sleep (#490) asserted a 2s sleep returns within 2400ms, which measures the round trip around the sleep rather than the sleep, and goes red on whichever job is slowest. Widened to a bound only a repeated sleep could break -- the same correction #482 made to ChaosTest and the queue tests, which missed this one. The regression test drives recovery directly, since the millisecond collision that produced these failures is not something a test can schedule. It fails without the recoverWorkflow guard, with recovery re-inserting the deleted row.
devhawk
force-pushed
the
fix-flaky-tests
branch
from
September 12, 2026 00:46
87f14be to
58412e7
Compare
maxdml
reviewed
Sep 12, 2026
kraftp
approved these changes
Sep 12, 2026
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
One or more issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Moves launch recovery cutoff before executor startup and skips active in-process workflows; also relaxes the sleep timing test.
Changes:
- Prevents startup recovery from adopting newly created workflows.
- Adds an active-workflow recovery guard and regression test.
- Replaces the overly strict sleep timing bound.
File summaries
| File | Description |
|---|---|
| transact/src/test/java/dev/dbos/transact/execution/RecoveryServiceTest.java | Updated as part of this pull request. |
| transact/src/test/java/dev/dbos/transact/execution/DBOSExecutorTest.java | Updated as part of this pull request. |
| transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java | Updated as part of this pull request. |
Review details
Suppressed comments (2)
transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java:1220
- An active entry guarantees this workflow already inserted a row, so this branch should use the same fail-if-missing behavior as the adoption path. If recovery races deletion (as this test does),
retrieveWorkflowreturns a polling handle withfailIfMissing=false, sogetResult()waits forever for a row that recovery deliberately did not recreate instead of reportingDBOSNonExistentWorkflowException. Return a fail-if-missing handle here.
return retrieveWorkflow(workflowId);
transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java:1220
- This is only a sample check, not an ownership handoff. ActiveWorkflowGuard.release() removes the ID before the terminal outcome write, so recovery arriving in that interval sees no entry, re-inserts/executes the workflow, and can still duplicate its side effects. Since recoverPendingWorkflows has no cutoff, this path still violates the no-duplicate guarantee; recovery must coordinate with outcome ownership or the active entry must cover that window.
if (activeWorkflows.containsKey(workflowId)) {
logger.debug("recoverWorkflow skip active {}", workflowId);
return retrieveWorkflow(workflowId);
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The active-workflow check landed ahead of the queue branch, so recovering a queued workflow running on this executor returned before clearQueueAssignment. The row stayed PENDING instead of going back to ENQUEUED, and the concurrency slot the execution held was never freed -- StaticQueuesTest and DynamicQueuesTest testQueueConcurrencyUnderRecovery both failed on it across every CI job. Releasing the assignment is owed whether or not the workflow is live here, so it goes first; the active check now suppresses only the re-execution below it. The handle it returns is fail-if-missing. An active entry means this executor inserted the row, so a row deleted since is gone for good: getResult() should surface DBOSNonExistentWorkflowException rather than poll for one recovery deliberately did not put back. Also corrects the new test's rationale. It described recovery re-inserting the deleted row, which cannot happen -- executeWorkflowById reads the status and throws on null well before persistWorkflow. The test still fails without the guard, just by that throw rather than by observing a second execution.
devhawk
force-pushed
the
fix-flaky-tests
branch
from
September 14, 2026 20:54
63df92c to
e142567
Compare
3c829bf moved the active-workflow guard below the queue branch so that recovering a queued workflow running on this executor would still release its assignment. That reintroduces the duplicate execution the guard exists to prevent, in a worse form: clearQueueAssignment flips the live row back to ENQUEUED and returns before the guard is ever consulted, so a second runner is admitted by the next dequeue while the first is still going. When the first execution finishes, recordWorkflowOutput finds the row no longer PENDING, its result is discarded, and the workflow parks waiting for the duplicate's outcome. The premise behind that move -- that the assignment had to be released to free a leaked concurrency slot -- does not hold. An activeWorkflows entry means the execution is live here, so the slot is not leaked, it is in use, and it is released when the workflow completes. Recovery's job is to adopt orphans; a workflow this executor is still running is not one, and the correct handling is to leave its row entirely alone. Both testQueueConcurrencyUnderRecovery tests asserted the old behaviour (recovering wf1/wf2 sets them back to ENQUEUED while they are still blocked), which is the assertion that drove the reordering. They now expect the live rows to stay PENDING. Each also gained a closing counter assertion: only blockedWorkflow increments it, so the count still being 2 after all three workflows finish is a direct check that recovery started no second execution.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #488, fixes #491. Very likely fixes #477 (see caveat below). Fixes #490, which is unrelated to the rest.
One defect behind three reports
#477, #488 and #491 are the same bug: the launch-time recovery sweep adopting a workflow that this process started microseconds earlier, and running its body a second time.
start()bounds the sweep withwithEndTime(Instant.now()), read at the end of launch, and the filter iscreated_at <= endTimeat millisecond granularity. There is nothing between that read and the caller's firststartWorkflow, so the two land in the same millisecond and the just-inserted row is<=the cutoff. From the #488 job:Both copies run to completion and race to record the outcome. What each test noticed differed:
AsyncWorkflowTest.sameWorkflowId) — the body simply ran twice, soexecutionCountwas 2.WorkflowOutcomeOwnershipTest.failedRunFailsFastWhenItsRowIsDeleted) — recovery's re-insert resurrected the row the test had deleted, so the live run'sUPDATE ... WHERE status = 'PENDING'matched after all, it owned the outcome, and it reported its ownIllegalStateExceptioninstead of failing fast. That is why WorkflowOutcomeOwnershipTest.failedRunFailsFastWhenItsRowIsDeleted is flaky (reported its own error instead of DBOSNonExistentWorkflowException) #488 read as a visibility anomaly; it wasn't one.CancelResumeRaceTest) — a recovery duplicate of run 1 makes the resumed dispatch the third run, which is theexpected: <2> but was: <3>reported.ActiveWorkflowGuarddoes not stop this. It is released just before the terminal outcome write, so a recovery landing in that window acquires the ID and re-runs the body.The change
The sweep's cutoff moves to the top of
start(), before theisRunninggate. Everythingstart()goes on to do — the app-version hash over every registered workflow, the executor service, migrations, queue and scheduler start, listener callbacks — now lands between the read and the earliest a caller can start a workflow.The one-millisecond step-back stays, doing a different job: placement buys margin, the step-back makes the bound exclusive.
created_atis written fromSystem.currentTimeMillis()(the same clock this reads), so<= cutoff - 1msis exactly< cutoffon a millisecond-granular column. Without it astart()that finishes inside a millisecond — already-migrated database, no queues, no listeners — reopens the same hole.recoverWorkflowskips a workflow whose ID is active on this executor.recoverPendingWorkflows(), the Conductor/AdminServer entry point, carries no time bound at all, so this is the only thing standing between an operator-triggered recovery and a duplicate execution.What this does not fix
The launch path is now closed deterministically: the sweep can only see workflows created strictly before this process started, and none of those is running in it.
The operator-triggered path is narrowed, not closed. The active-ID check is a sample, not a lock, and during the
ActiveWorkflowGuardrelease window the ID is absent from the map — so a recovery landing exactly there still passes the check. Closing that means changing the release-before-write ordering, which is the invariantCancelResumeRaceTestexists to pin down, and is a design call rather than a flaky-test fix. Left alone deliberately.Separately: #490
DBOSExecutorTest.sleepasserted a 2s sleep returns within 2400ms. That measures the round trip around the sleep — launch, checkpoint, poll, resume, outcome write — not the sleep, so it reddens on whichever job is slowest. Widened to a bound only a repeated sleep could break, the same correction #482 made toChaosTestand the queue tests, which missed this one.Testing
RecoveryServiceTest.recoveryLeavesAWorkflowThisExecutorIsRunningAlonedrives recovery directly, since the millisecond collision that produced these failures is not something a test can schedule. Confirmed it fails without therecoverWorkflowguard — recovery re-inserts the deleted row — and passes with it.33 tests pass across
RecoveryServiceTest,WorkflowOutcomeOwnershipTest,AsyncWorkflowTest,CancelResumeRaceTestandDBOSExecutorTest;spotlessCheck,compileTestJavaandjavadocclean.I have left #477 for CI to confirm rather than closing it on reasoning: its original run logs have expired, so the link to this root cause is inferred from the test's shape (
setUp()launches,runCancelAndPark()starts a workflow immediately) rather than observed. Worth a few green runs before trusting it.🤖 Generated with Claude Code