Skip to content

Cancel and drain the old runner in ProjectionCoordinatorBase.StartAsync (#592) - #593

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/592-startasync-cancel-old-runner
Jul 30, 2026
Merged

Cancel and drain the old runner in ProjectionCoordinatorBase.StartAsync (#592)#593
jeremydmiller merged 1 commit into
mainfrom
fix/592-startasync-cancel-old-runner

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #592.

StartAsync disposed the previous CancellationTokenSource and built a fresh source + runner:

_cancellation?.SafeDispose();   // disposes — never cancels
_cancellation = new CancellationTokenSource();
_runner = Task.Run(() => executeAsync(_cancellation.Token), _cancellation.Token);

Disposing a CTS does not cancel it, and _runner was overwritten, so the previous executeAsync kept polling forever over a token that never trips and a task no drainRunner() would ever await. ResumeAsync() is StartAsync(default), so any resume not preceded by PauseAsync orphaned a live leadership loop for the rest of the process.

Two consequences, both observed:

  1. The orphan re-attains the leadership advisory lock right after StopAsync called ReleaseAllLocks — a stopped coordinator quietly holding leadership again.
  2. It strands the advisory-lock handle it wins after that lock's DisposeAsync has drained (AdvisoryLock strands a handle acquired after DisposeAsync — permanent 'idle in transaction' advisory-xact-lock session weasel#396), leaving a permanent idle in transaction session on pg_try_advisory_xact_lock that Marten's high-water gap detection reads as a live pre-gap reserver — Stopped host can leak the HotCold coordinator's advisory-xact-lock session ("idle in transaction"), which 9.16.1 gap detection treats as an eternal in-flight append — stalling every later daemon in the same process marten#5090.

Change

StartAsync and PauseAsync now share one stopRunnerAsync(): cancel, drain, dispose the spent source, clear the bookkeeping. A double StartAsync becomes equivalent to PauseAsync + StartAsync, which is what every caller already assumed, and no spent source is left behind to be cancelled or disposed a second time.

StartAsync becomes async Task (it awaits the drain). The signature is unchanged.

Tests

  • a_second_start_does_not_leave_an_orphaned_leadership_loop — bare ResumeAsync, then StopAsync, then assert the distributor sees no further lock attempts. Verified RED before the fix (the only failure of the 19).
  • pause_then_resume_still_restarts_the_leadership_loop — the drain must not break the legitimate pause/resume cycle.
  • repeated_start_stop_cycles_never_throw_on_a_spent_cancellation_source.

Local: ProjectionCoordinatorBaseTests 19/19, test-events and test-event-store green on net10.0.

🤖 Generated with Claude Code

StartAsync did `_cancellation?.SafeDispose()` and then built a fresh source +
runner. Disposing a CancellationTokenSource does not cancel it, and _runner was
overwritten, so the previous executeAsync kept polling forever against a token
that never trips, over a task no drainRunner would ever await.

ResumeAsync is StartAsync, so any resume not preceded by PauseAsync orphaned a
live leadership loop for the rest of the process. The orphan then re-attained
the leadership lock right after StopAsync released it, and stranded the advisory
lock handle it won after that lock had already been disposed (weasel#396) — a
permanent 'idle in transaction' session that Marten's high-water gap detection
reads as a live pre-gap reserver (marten#5090).

StartAsync and PauseAsync now share one cancel-then-drain path, so a double
StartAsync behaves like PauseAsync + StartAsync, and a spent source is never
left behind to be cancelled or disposed twice.

Regression test asserts nothing polls after StopAsync when a bare ResumeAsync
preceded it (verified RED before the fix), plus guards that pause/resume still
restarts the loop and that repeated start/stop cycles stay clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit cf59735 into main Jul 30, 2026
1 check passed
@jeremydmiller
jeremydmiller deleted the fix/592-startasync-cancel-old-runner branch July 30, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ProjectionCoordinatorBase.StartAsync disposes the old CancellationTokenSource without cancelling it, orphaning a live leadership loop

1 participant