fix(orchestrator): bound the readiness reconcile sweep so a hang cannot stop the loop (#296) - #301
Conversation
|
Warning Review limit reached
Next review available in: 58 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2b57adf19
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The abandoned pass is still running against the live control plane. | ||
| // Report where it lands, so an operator can tell a dependency that | ||
| // recovered late from one that never answered at all. | ||
| void sweep.then( |
There was a problem hiding this comment.
Drain the abandoned sweep before completing shutdown
When a periodic pass reaches this deadline and stop() runs before the original runOnce() settles, #readinessReconcileInFlight already refers to the settled deadline wrapper, so shutdown does not await the still-live #runOnceInFlight. Shutdown can consequently clear state and dispose the fleet while this sweep remains active; if the blocked dependency then recovers, the sweep can resume dispatching through disposed ports or mutate state after stop() has resolved. Track the underlying abandoned sweep and either drain it during shutdown or fence its continuation once stopping begins.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid, and fixed in e215355 — thank you, this was a real hole and I had already tripped over it without recognising it.
You are right that #readinessReconcileInFlight refers to the settled deadline wrapper, so shutdown was not awaiting the live #runOnceInFlight. I checked the partial fence before deciding: #isPassFatalFailure does treat #stopping as pass-fatal, but only once something in the sweep throws. That is exactly the case you describe — the blocked dependency recovers — so the sweep proceeds without error and never reaches that check, and can dispatch through disposed ports.
Corroboration that this is not theoretical: my fail-first tests leaked abandoned sweeps into subsequent tests and produced two unrelated failures in the full suite. I fixed that in the tests at the time; it was the same defect in the product, which I should have followed back then.
Took the drain option rather than the fence. #readinessReconcileAbandonedSweep is recorded when the deadline fires and awaited in stop(), right after #readinessReconcileInFlight. That restores exactly the pre-deadline shutdown contract — stop() outlives the sweep it started — rather than inventing a new one. It does mean a shutdown during a true hang waits as long as it did before this PR; that is the status quo for shutdown, and changing it is a separate decision from fixing the reconcile loop.
Fencing every continuation was the alternative I rejected: it means auditing every mutation reachable from runOnce() and is the same open-ended audit the issue explicitly defers as item 3.
New test: does not complete shutdown while a sweep abandoned by the deadline is still running. Fail-first was expected true to be false — stop() had already resolved with the sweep still hung.
There was a problem hiding this comment.
All reported issues were addressed across 4 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…orting its stall (#296) PR #301 review. Two findings, both real. P1 (Codex, cubic): the deadline bounds the *wait*, not the sweep, so `#readinessReconcileInFlight` can settle with its `runOnce()` still live. `stop()` awaited only the wrapper, then went on to release dispatch lifecycle leases and dispose ports underneath a sweep that was still running — and `#isPassFatalFailure` only fences a stopping sweep once something in it throws, so a sweep whose dependency recovers cleanly sails past that check and can dispatch through torn-down state. Track the abandoned sweep and drain it in `stop()`, restoring the pre-deadline contract that shutdown outlives the sweep it started. (The same leak showed up empirically in the fail-first tests, which had to drain it by hand to stop it bleeding into the next test.) P2 (cubic): clearing the in-flight timestamp when the wait ended discarded the only evidence that the underlying pass was still stuck, so every expiry restarted the stall clock and a permanently wedged Factory reported `retrying`. `stalled` is now derived from the earliest sweep still running, which after an expiry is the abandoned one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: f3e68b71-b0ba-4ace-8b08-35fb74bc03ad
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ot the newest (#296) PR #301 review, round two. Passes that coalesce onto one stuck `runOnce()` each abandon their own deadline wrapper, and keeping only the newest record advanced the stall age by two intervals every two intervals. At the tightest legal setting — `reconcileTimeoutMs === reconcileIntervalMs` — the age could then never reach three intervals, so `stalled` was never reported at all and a permanently wedged Factory went straight from `retrying` to `degraded`, skipping the early warning this PR exists to add. Retain every live abandoned sweep against its own start time, clear only the wrapper that settled, and age the stall from the earliest. `stop()` drains all of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: f3e68b71-b0ba-4ace-8b08-35fb74bc03ad
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…red wait (#296) PR #301 review, round three. Retaining every abandoned wait fixed the stall age but grew without bound in exactly the case this change exists for: a sweep that never settles produces another retained record on every expiry, forever. Key the record by the underlying sweep instead. Passes coalesce onto one in-flight `runOnce()`, so `#runOnceInFlight` is the identity of the work; it cannot have settled while a wait on it is still pending. One entry per stuck sweep, holding the FIRST wait's start time, keeps the honest stall age from round two and bounds retention at the same time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: f3e68b71-b0ba-4ace-8b08-35fb74bc03ad
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
… sweep (#296) PR #301 review, round four. `runOnce()` does not coalesce a mismatched `dryRun` — it waits BEHIND that sweep — so `#runOnceInFlight` at expiry can name an unrelated sweep the readiness pass is merely queued behind. Once that sweep settled, the record cleared and the readiness pass started its own, untracked, after `stop()` believed it had drained everything. The reasoning in the round three comment was wrong for that branch. Track the wait itself. It covers the queueing and whatever sweep it eventually runs, in every branch. Retention stays bounded without a collection: every live abandoned wait is a `runOnce()` with the same `dryRun`, so whatever they are queued behind, the first one out starts the sweep and the rest coalesce onto it — they settle together. The newest wait is therefore a sufficient drain target, and the earliest start is the honest age, so one promise and one timestamp carry both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: f3e68b71-b0ba-4ace-8b08-35fb74bc03ad
…rk it abandons (#296) A `runOnce()` that never settles stopped the readiness reconcile loop permanently and silently. `#scheduleReadinessReconcile` re-arms only from `sweep.finally(...)`, so a pending promise is never rescheduled, and both state-writing paths run on settle — success sets `lastCompletedAtMs`, failure increments `consecutiveFailures` — so a hang took neither. Production reported `state=healthy, consecutiveFailures=0, lastError=none` for 104 minutes while dispatching nothing. Only a process restart recovered it. Give the sweep a deadline. Expiry rejects, which routes it into the existing failure path that already increments the counter, records `lastError`, marks `degraded` past the threshold, and re-arms the loop. No parallel recovery machinery: the existing one was simply unreachable from a hang. The deadline bounds the wait, not the sweep — `runOnce()` owns a durable discovery lease and abandoning it mid-flight is not safe — so the abandoned work stays live and has to be owned. `stop()` drains it, so shutdown still outlives the sweep it started, and the in-flight age counts from when that work actually began rather than restarting on every expiry. The tracked value is the wait itself, never `#runOnceInFlight`: a mismatched-`dryRun` sweep is waited BEHIND rather than coalesced onto, so that handle can name unrelated work. Retention needs no collection — every live abandoned wait shares one `dryRun`, so they converge on one sweep and settle together. The deadline defaults to 90 minutes, NOT a small multiple of the 60s interval: #36 measured a real cold-mirror reconcile at 3,665,173 ms (61 minutes) because container disk is ephemeral and the Relayfile mirror rehydrates on every boot. A deadline under realistic worst-case hydration would convert a slow boot into a crash loop, which is worse than the bug. Rebased onto #299 and #300, which landed first and both touch this subsystem. The overlap with #300 was semantic, not textual: it had already added a derived `stalled` state over the same health block. Rather than ship two state machines, this drops its own derivation and its duplicate STALL_INTERVALS constant and keeps #300's, which is the better one — it is shared, guarded, and works out-of-process from a heartbeat. What #300 could not see is contributed as an input instead: after a deadline expiry the wait writes a settle timestamp while its sweep is still stuck, so timestamp order alone reports "nothing in flight". The daemon publishes `inFlightSinceMs`, and `readinessReconcileInFlightMs` prefers it, falling back to the order inference for heartbeats without it. One representation, fed by exact data. Against #299, the bounded deadline and the new broker-rebind recovery are shown to coexist: a sweep that recovers from a rebind completes instead of being killed, and an unreachable broker still fails the pass through the pre-existing error path without the deadline firing at all. Squashed from five commits: the original fix plus four rounds of review findings from Codex and cubic, each reproduced with a fail-first test before being fixed. PR #301 carries the round-by-round detail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: f3e68b71-b0ba-4ace-8b08-35fb74bc03ad
0cbd51b to
1e8d844
Compare
The deployed-diagnostics guide from #300 tells an operator that `lastStarted > lastCompleted` is the ONLY evidence a pass is in flight. After #296 that is outdated, and in one case actively wrong: once a wait ends on its deadline it records a failure while the sweep underneath it keeps running, so timestamp order reports nothing in flight during exactly the wedge this page exists to diagnose. Document `inFlightSinceMs` as the authoritative field, with the order comparison as the fallback for heartbeats that predate it. Also document how long a stall can last, since the page now describes a state that is bounded: the wait fails at 90 minutes and the loop re-arms, while the sweep keeps ageing because it holds a durable discovery lease. A `stalled` that never produces a rising `consecutiveFailures` means the loop is not running at all — a restart, not a wait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: f3e68b71-b0ba-4ace-8b08-35fb74bc03ad
Fixes #296.
The defect
#reconcileReadyIssues()had no bounded deadline. A singlerunOnce()that never settles stopped the readiness reconcile loop permanently and silently:#scheduleReadinessReconcilere-arms only fromsweep.finally(...), so a pending promise is never rescheduled.lastCompletedAtMsand clearslastError; failure setslastFailureAtMsand incrementsconsecutiveFailures. A hang takes neither, so the last successful pass's values stand forever.Production reported
state=healthy, consecutiveFailures=0, lastError=nonefor 104 minutes while dispatching nothing. Only a process restart recovered it.The fix
1. Bound the sweep.
#runOnceWithReadinessDeadline()racesrunOnce()againstreconcileTimeoutMs. On expiry it rejects, which routes into the existing failure path — the one that already incrementsconsecutiveFailures, recordslastError, marksdegradedpast the threshold, and re-arms the loop. No parallel recovery machinery was built; the existing one was simply unreachable from a hang.The sweep itself is not cancelled —
runOnce()owns a durable discovery lease, and abandoning it mid-flight is not safe. Expiry rejects the wait and leaves the pass to finish. That is sufficient: the rejection is what re-arms the timer. A later pass coalesces onto the still-runningrunOnce()(existing behavior) and fails on its own deadline too, so a persistent hang keeps counting up todegradedinstead of going quiet.2. Own the abandoned wait. Because the deadline bounds the wait and not the sweep, the abandoned work is still live. It is tracked and drained by
stop(), so shutdown still outlives the sweep it started, and the reported state keeps ageing from when that work actually began. Details in "Review rounds" below — this half of the design is entirely the product of the bot review.3. Derive the reported state.
stateis computed on read rather than last-write-wins, adding the missingstalledvalue for work in flight beyondREADINESS_RECONCILE_STALL_INTERVALS(3) × the interval.degradeddeliberately stays on top of the ladder: it is what existing monitors alert on, and a stall arriving on top of repeated failures must not silence that alarm.The must-not-fire control, and why the deadline is 90 minutes
DEFAULT_READINESS_RECONCILE_TIMEOUT_MS = 90 * 60_000. It is not a small multiple of the 60s interval, and the comment on the constant says so in as many words.#36 measured a real cold-mirror reconcile at 3,665,173 ms (61 minutes) in production, because container disk is ephemeral and the Relayfile mirror rehydrates on every boot. A deadline under realistic worst-case hydration converts a slow boot into a crash loop, which is strictly worse than the bug being fixed. 90 minutes leaves ~47% headroom over that measurement.
The two thresholds are decoupled on purpose: killing a pass must tolerate a 61-minute hydration, but reporting that nothing has dispatched for three intervals costs nothing. That is why
stalledfires at 3 minutes while the deadline sits at 90 — the safe deadline does not cost observability.Config also refuses
reconcileTimeoutMs < reconcileIntervalMs(schemasuperRefine), andstart()overrides — which bypass the schema — re-apply the same floor.Tests, fail-first
All in
describe('bounded readiness reconciliation'). Against the unmodified source:Each fails for the right mechanism, not just the right colour:
runOnce()that never settles leavesconsecutiveFailuresat 0 and the sweep counter frozen: the failure path is never reached and the loop is never rescheduled. It then asserts the loop reachesdegraded.state: 'healthy',consecutiveFailures: 0, alastStartedAtMsfrom a pass that never completed, and nolastCompletedAtMs.n * reconcileIntervalMs.must-not-fire (
does not kill a slow-but-completing sweep that runs for many intervals) passes before and after, as a control should. A pass that hydrates for 600ms against a 50ms interval — twelve intervals deep, far past the stall-report threshold — completes, dispatches, and records zeroreadinessReconcileErrors. It asserts completion from the sweep's own log line so the following 50ms pass cannot overwrite the evidence.I verified the control has teeth rather than assuming it: flipping its
reconcileTimeoutMsfrom4_000to200makes it fail withexpected 2 to be +0— the slow pass gets killed twice.Review rounds
Four findings from Codex and cubic, every one valid, every one reproduced with a fail-first test before being fixed. The five original commits were squashed into one to rebase onto a main that had moved; the round-by-round detail lives here and in the answered threads. They are worth reading as a set: the deadline was the easy half, and correctly owning the work it abandons was the hard half.
stop()awaited only the deadline wrapper, so shutdown released leases and disposed ports underneath a still-running sweep. The existing#stoppingfence in#isPassFatalFailuredoes not cover it — that check is only reached once the sweep throws, and the dangerous case is the one where the dependency recovers and the sweep continues cleanly.expected true to be false—stop()had already resolved with the sweep hungretrying.expected 'degraded' to be 'stalled'expected length 1, received 3runOnce()does not coalesce a mismatcheddryRun— it waits behind that sweep — so#runOnceInFlightat expiry could name an unrelated sweep, and the readiness pass then started its own work untracked afterstop()believed it had drained everything. This falsified reasoning I had given on the round-three thread.expected true to be false— shutdown completed with the readiness sweep still runningFinding 1 also had evidence I already held and under-read: my own fail-first tests leaked abandoned sweeps past teardown and caused two unrelated full-suite failures. I fixed that in the tests at the time instead of following it back to the product.
The settled design is two fields, not a collection. Every live abandoned wait is a
runOnce()with the samedryRun, so whatever they are queued behind, the first one out starts the sweep and the rest coalesce onto it — they converge and settle together. The newest wait is therefore a sufficient drain target and the earliest start is the honest age, which satisfies findings 2, 3 and 4 at once. The tracked value is always the wait itself, never a global handle.Verification
npm test— 1837 passed, 1 skipped, 0 failed (baseline at this branch point: 1828, plus 9 new).npm run build— clean.mainwas green, so I traced them to the leak described above, which turned out to be finding 1 in miniature.packagefailure on the first commit washolds the terminal Slack receipt lease for as long as the provider write runs— a fake-timer spin test running indispatch-ownermode, which never schedules a reconcile. Rather than calling it a flake on inference, I checked history: recent runs onmainshow the same profile, a different single test failing each time. Re-run passed, and every commit since has passedpackagefirst try.Rebased onto #299 and #300
Both landed first and both touch this subsystem. The textual conflicts were the small part; the overlap with #300 was semantic.
#300 had already added a derived
stalledstate over the same health block. Two state machines describing one subsystem is the failure mode to avoid, so this drops its own derivation and its duplicateREADINESS_RECONCILE_STALL_INTERVALS(mine 3, theirs 10 — same name, different value, different file) and keeps #300's. Theirs is the better one: shared module, guarded against throwing on the heartbeat path, and it works out-of-process from a heartbeat record, which is the whole point of #295.What #300 could not see is contributed as an input rather than a rival output. Its
readinessReconcileInFlightMsinfers "in flight" from timestamp order —lastStartedAtMsnewer than both settle timestamps. After a deadline expiry that inference is blind: the wait writeslastFailureAtMswhile itsrunOnce()is still stuck, so order says "nothing in flight" during exactly the wedge #295 exists to expose. The daemon now publishesinFlightSinceMs— the earliest sweep still running, which it knows and a reader cannot infer — andreadinessReconcileInFlightMsprefers it, falling back to the order inference for heartbeats that lack it. One representation, fed by exact data.Two consequences I accepted rather than fought, and one position I changed:
stallednow outranksdegraded. I had argued the opposite in this PR ("a stall must not silence an alarm keyed on degraded"), and feat(diagnostics): make a deployed Factory able to say why it is not dispatching (#295) #300's precedence is the merged contract. It is also the better call:stalledis the more specific diagnosis andconsecutiveFailuresships alongside, so nothing an alarm needs is lost. My comment asserting the old precedence was falsified by the merge and is gone rather than left to mislead.consecutiveFailures >= failureThreshold(the claim that actually matters — the failure path was reached and counted) instead of the literal stringdegraded.publishes an abandoned sweep to the deployed health projection as stalledis the guard for the seam. It asserts the discriminator directly: with only the three timestampsreadinessReconcileInFlightMsreturnsundefined— it has lost the stuck sweep — while the publishedinFlightSinceMsfinds it, andpublicHealthFromHeartbeatover the real heartbeat file (whatfactory diagnose --deployedreads) reportsstalled. The daemon's view and the operator's view reach the same conclusion.Against #299, the bounded deadline and the new broker-rebind recovery are shown to coexist, in
describe('against the #299 broker-rebind recovery'):readinessReconcileDeadlineExceededat 0. fix(fleet): re-read connection.json and reconnect when the broker rebinds (#291) #299's retry is one-shot and read-only by construction ("repeated failures cannot turn into a reconnect loop"), so it cannot outrun a deadline sized for fix(factory): replay existing Slack triage answers #36's 61 minutes.Full suite after the rebase: 1899 passed, 1 skipped, 0 failed — including #300's
public-healthanddiagnosesuites, unmodified.Explicitly not attempted
Item 3 of the issue — auditing every provider/roster/mount call inside
runOnce()for its own timeout — is left as follow-up. The deadline here is the backstop the issue describes it as, and bounding each individual dependency call carries the same crash-loop hazard as a too-small sweep deadline; it deserves its own change with its own worst-case measurements.Do not merge or deploy on my account.
🤖 Generated with Claude Code