fix(orchestrator): bound the whole sweep, not one more call inside it - #374
Conversation
|
@coderabbitai review Requested for exact head |
|
Warning Review limit reachedNext included review available in 48 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe PR adds configurable aggregate budgets for discovery sweeps. It applies shared deadlines across sweep phases, bounds teardown, compensates delayed lease claims, prevents stale writes, and adds unit and end-to-end coverage. ChangesDiscovery sweep budgeting
Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: ⚪ Minimal · up to The PR bounds the full sweep and teardown without an identified call-site contract issue; no actionable merge-blocking risk remains beyond normal checks and review. Sequence Diagram(s)sequenceDiagram
participant Factory
participant SweepBudget
participant StateStore
participant DiscoverySession
Factory->>SweepBudget: start aggregate sweep budget
Factory->>StateStore: claim discovery lease
Factory->>DiscoverySession: prepare and execute discovery
DiscoverySession-->>Factory: return discovery results
Factory->>StateStore: checkpoint and commit results
SweepBudget-->>Factory: signal expiry
Factory->>StateStore: release or compensate lease
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. (2 skipped: 2 too large.) ✨ 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 |
Three unbounded calls have wedged this sweep in a single day, on three different transports. Each was real, each was bounded, and each time the wedge came back one layer down: the FACTORY_STATE Durable Object calls (factory-cloud#78), the relayfile change-feed tail reads (#368, shipped and verified in 0.1.75), then the retry of the now-bounded call. This does not bound a fourth. It makes the class of failure survivable. The property it establishes: NO SWEEP CAN BE IN FLIGHT FOR LONGER THAN ITS BUDGET, whatever it is waiting on. Elapsed time is charged against ONE timer for the whole pass, so it does not matter which await is slow, how many there are, or how many times the sweep retries one of them. The next unbounded call degrades a sweep instead of ending dispatch. WHY A PER-CALL BOUND CANNOT DO THIS. `relayfileOperationTimeoutMs` bounds one relayfile call and cannot see the retry loop around it or a call on another transport. `reconcileTimeoutMs` bounds the CALLER'S WAIT from outside `runOnce()`, so expiry leaves the sweep running and every later cycle coalesces onto the same wedged promise (factory.ts `runOnce()`, the `#runOnceInFlight` branch) — which is why the deployed daemon never recovers. The budget expires from INSIDE `#runOnceWithDiscoveryFence`, so the sweep unwinds, the lease goes back, `#runOnceInFlight` clears, and the next cycle claims a fresh lease. MECHANISM, PLAINLY. `budget.run()` is a race, not a cancellation — the same limitation #368 documented, stated for the same reason. CAN: abandon an in-flight await, from any transport, and unwind the sweep. CANNOT: stop the abandoned work. The socket stays open, the SDK's own retry loop keeps running, and a side effect already in flight still lands. PARTIAL: `budget.signal` aborts at expiry, so anything honouring an AbortSignal is really cancelled — nothing in the sweep consumes it yet (the relayfile client mints its own per-call signal and that file is owned by another lane this week); it is exported so wiring it is one line. `assertNotExpired()` is a between-await check and is worth nothing against a call that never returns, but it does make an abandoned pass unwind at its next loop iteration rather than run to completion beside its replacement. TEARDOWN IS BOUNDED SEPARATELY. On the path that matters the budget is spent by construction, so teardown cannot run under it or the lease would never be released — and releasing it is the half that makes the next cycle clean. An unbounded release would re-create this wedge one layer down. It gets a 30 s deadline; an abandoned release costs an orphaned lease for one expiry window, which a later sweep reclaims (`claim.reclaimedLease`). DEFAULT IS THE EXISTING ENVELOPE, DELIBERATELY. `sweepBudgetMs` defaults to `reconcileTimeoutMs` (90 min) and is clamped to it, so no sweep that survives today is killed by this. The value is a policy dial, the mechanism is the fix. Tightening it has a real cost: the checkpoint commits only at the end, so a budget below realistic cold-mirror hydration (#36 measured 61 min in production) makes a slow boot a loop that never progresses. TESTS (11), must-fire/must-not-fire for each: - must-fire, end to end: a sweep whose first post-claim call never returns is aborted at its budget naming the phase, the lease release is OBSERVED on the store, and the next cycle runs a fresh sweep and dispatches. Fail-first verified by mechanism: with only factory.ts reverted it fails after 4038 ms with "sweep never settled" — the pass never settles, exactly as production. - must-fire, primitive: three 40 ms calls under a 120 ms budget — the third is rejected because the SWEEP is out of time, not because it is slow; a bounded-but-always-failing call inside an unbounded retry loop ends at the budget (the L3 shape) after more than one attempt; the signal aborts; a spent budget refuses to start new work against the dependency it gave up on. - must-not-fire: a healthy sweep under a snug 30 s budget produces results IDENTICAL to an unbounded control (pulled, dispatched, skipped, spawns) — without this the trivial wrong fix, abort everything, passes; a caller's own failure still surfaces as itself and is never re-clothed as a budget expiry; with `sweepBudgetMs: 0` the same hung call stays pending, so every rejection above is attributable to the budget and not to the wrapper. WHAT THIS DOES NOT COVER. - It does not make anything faster or find the hanging call. A wedged dependency still costs one whole budget per cycle. - It does not cancel. See MECHANISM above. - The abandoned pass runs concurrently with the sweep that replaces it if it ever unsticks. It cannot commit a checkpoint (the store's epoch guard) but its in-flight side effects still land. - Two `stop()`/shutdown paths and the `#runOnceWithReadinessDeadline` abandoned-wait bookkeeping are unchanged; a budget expiry reaches them as an ordinary sweep failure. - The default changes no timing. Recovery inside 90 minutes needs either a tighter `sweepBudgetMs` or the L3 retry bound the other lane owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2 Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
2e31da9 to
b032e85
Compare
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e31da9a28
ℹ️ 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".
There was a problem hiding this comment.
Review completed against the latest diff
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…dget backstop The seven `bounded readiness reconciliation` cases assert on a sweep that the readiness deadline abandoned and that is STILL RUNNING. The aggregate budget makes that state unreachable at its default — it aborts the sweep at or before that deadline, so there is nothing left in flight to observe. That is the fix, not a regression. Each now passes `sweepBudgetMs: 0`, which selects the pre-#372 backstop those assertions are actually about: the #296/#301 abandoned-wait accounting, still the behaviour when the budget is disabled and still the shape a sweep degrades to if a teardown path cannot be abandoned. `0` as the disable value is the same control idiom #368 used for `operationTimeoutMs`. Adds the positive counterpart, which the redirected cases can no longer state: a live daemon whose first post-claim call never returns still completes `start()` and `stop()`, because the sweep is aborted rather than abandoned. Fail-first verified by mechanism against `origin/main`'s factory.ts: it fails after 4044 ms with `start never returned`. That is deliverable B demonstrated rather than argued — `#deferLiveEventDrain = false` sits in a `finally` around that unbounded `runOnce()` (main factory.ts:1983/2021/2039), so a wedged startup backfill also kills the live-event dispatch path, which is why a hung sweep meant zero dispatch instead of stale dispatch. Also documents a gap the shutdown test exposed and this PR does NOT close: `#startLiveSubscription` reads the event high-watermark before the backfill and outside any sweep, so that read is bounded only by the per-call relayfile deadline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
|
@coderabbitai review Requested for exact head |
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
…abandoned pass Five findings, all valid at 5c656f9, each with its own must-fire/must-not-fire. 1. THE DANGEROUS ONE. A fixed 90-minute `sweepBudgetMs` default is ABOVE any config that had already tightened `reconcileTimeoutMs`, so the cross-field check rejected it and `FactoryConfigSchema.parse` threw — Factory would not have started. It also silently capped a config that loosened the timeout above 90 minutes. The omitted budget is now derived from its SIBLING in a `.transform()`, never from a constant, and `resolvedSweepBudgetMs` is the one rule the schema and the orchestrator's `start()` clamp both use. 2. An abandoned `#performRunOnce` could write a stale tree listing into the REPLACEMENT sweep's checkpoint: `#rememberDiscoveryTree` reads the shared `#discoverySession` fresh, and by the time a late continuation resolves that is the next sweep's. `#isStaleDiscoveryContinuation()` compares the `discoveryEnumerationPass` epoch — an AsyncLocalStorage store, so it follows the async continuation and still carries the epoch that ISSUED the read — against the live one. It is the same fence the tree-read counters already used. Applied to the checkpoint write and to overload attribution, so a 429 that arrives after its sweep was abandoned cannot drive the replacement's ratchet. 3. The dispatch loop gets the same budget guard as the read loop, so a pass abandoned during enumeration cannot dispatch after its lease went back. 4. A lease claimed after the budget gave up on the claim was stranded: nobody would renew, commit or release it, so every later sweep deferred for a whole lease window. A compensating release is now attached to the abandoned claim. Fail-first verified by mechanism — with the compensation ablated the test fails with `stranded lease was never released`. 5. Unref'd deadline timers let Node exit before the budget fires. Under a one-shot `runOnce()` whose only pending work is a promise nothing else references, the command would return without reporting the wedge or releasing the lease. Both deadline timers are referenced now; they live for at most one budget and `dispose()` clears them from a `finally`. Also, on the review's reading of a comment: the pre-backfill watermark read is bounded neither by the sweep budget NOR by anything in the orchestrator — `#currentEventHighWatermark` (factory.ts:2192) awaits the mount directly under a bare try/catch. What bounds it in production is one layer lower, the deployed client's own `#bounded()` (relayfile-cloud-mount-client.ts:1057, #368). The comment now says which layer, because a `MountClient` without that deadline has no bound here at all. And the e2e must-fire no longer risks blaming a phase string for a timing stall: the budget has 400 ms of headroom over two in-memory calls, and `hungCalls` is asserted before the phase so a mis-timed run names the real cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Two findings at 2c2dd86, both this PR's own lesson recurring inside this PR. 1. THE BOUND BECAME THE WEDGE. `stop()` deliberately outlives the sweep it started (#301, the `#readinessReconcileAbandonedWait` drain), so a wedged sweep makes shutdown exactly as long as the sweep budget — 90 minutes at the default. Referencing the timer did not create that (before this PR the drain was unbounded, so shutdown was unbounded too), but it is the same trap the teardown deadline already answers one layer down, and an operator restarting a wedged container is the person who pays. NOT fixed by `unref()`. That is the trivially wrong version: it also lets Node exit before the budget fires, so a one-shot `runOnce()` returns having neither reported the wedge nor released the lease — the P2 that made the timer referenced in the first place. The two asks are in tension and only a shutdown-specific path satisfies both. `stop()` now arms a grace timer over the drain; after `STOP_TEARDOWN_TIMEOUT_MS` it calls `budget.expire()` on every in-flight sweep, routing them into the ordinary abort path — lease released, teardown bounded — instead of holding the process. The grace is what keeps an ordinary restart from discarding a sweep that was about to commit. A sweep that starts while `#stopping` is already set is expired immediately, so it cannot hand shutdown a fresh 90-minute budget. 2. The lease claim is now issued INSIDE the budget callback, so a spent budget rejects the phase without opening a lease it could only hand straight back. A lease taken after expiry makes every later sweep defer — the same "later cycles wait on a pass that is already over" failure this PR's own comparison names in `reconcileTimeoutMs`. Pairs, and their fail-first, verified by ablation: - must-fire: a live daemon whose PERIODIC sweep wedges under a 60 s budget still completes `stop()` inside 4 s. With the grace ablated it hangs to the vitest timeout — shutdown waiting out the budget, which is the defect. - must-not-fire: the budget timer appears in `process.getActiveResourcesInfo()` while a sweep runs and is gone the moment `dispose()` runs. This is what fails for the `unref()` version — that list contains only resources KEEPING THE EVENT LOOP ALIVE, so an unref'd timer never appears — and it also pins the other half: a settled sweep leaves nothing behind, which is what makes a referenced 90-minute timer affordable. - must-fire: a sweep aborted in the fleet-probe phase opens no lease and releases none. Scoped honestly in the test: every entry into `#claimDiscoverySweepUnderBudget` is preceded by a `budget.run` that throws first, so "already spent on entry" is a microtask race rather than a reachable state, and moving the claim inside the callback closes it by construction. The guarantee that does the work — `budget.run` never invokes its thunk once spent — is asserted directly on the primitive. - must-not-fire: a healthy sweep still claims exactly once and dispatches. The trivially wrong way to stop a spent budget claiming is to stop claiming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: b1177efc-90da-4ff1-bda0-ef5de1b475e2
|
@coderabbitai review Requested for exact head |
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Scope note before anyone merges this: it bounds the stall, it does not prevent itRecording this on the PR so the caveat travels with the change rather than living in a status thread. Measured against the deployed worker this morning,
The The container booted at 09:03:24Z, which is after Why that matters for this PR. The aggregate budget defaults to 90 minutes: What it does buy is recovery, not speed. On the deployed build the reconcile timeout rejects the caller's wait and leaves So: necessary, not sufficient. Please do not merge this believing it is the cure for the outage. The budget is also a config dial ( CI on this branch is currently red on |
Answers both open review threads on #374. `stop()` armed the sweep-budget grace timer only after awaiting `#heldAgentDeadlineSweepInFlight`, so an unrelated in-flight held-agent sweep silently extended a wedged discovery sweep's reprieve from `grace` to `held-agent sweep duration + grace` — unbounded if that sweep never returns, which is precisely the bound this change exists to provide. The grace is a timer; arming it costs nothing, so it now starts the clock at the moment shutdown starts, and the teardown it guards moved inside its `try` so the timer is still cleared on every path. Covered by a new must-fire that observes WHEN the lever arms rather than that it exists: a discovery sweep is wedged, a held-agent sweep is parked mid-release through the fleet, and the shutdown counter is read 3.2s into `stop()` — past the 2.5s grace, far short of the 60s budget. It fails `expected undefined to be 1` against the previous ordering. Also wraps the one test that arms the real 90-minute timer in `try/finally`. Without it a failing assertion left a *referenced* `Timeout` in the worker, so the failure would present as a hung suite instead of a named assertion. Proven with a temporary must-fire / must-not-fire pair: the old shape leaves the timer active after a throw, the new one does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 372b13bc-44a2-45a5-b5cc-aa228ccca39d
|
@coderabbitai review Requested for exact head |
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
This PR is an outage fix — but it is inert at its default valueFlagging this before the merge decision, because the framing matters. The outage. Factory's discovery sweep is wedged in production right now and has dispatched nothing for 39+ hours. Full measurements in #372, but in short: one sweep started Why this PR is the right mechanism. The budget expires from inside Why merging it alone would change nothing.
So the budget resolves to 90 minutes against a ~50-minute container lifetime and would never fire. Meanwhile Therefore: this is an outage fix only if it lands together with an explicit CI is currently red / |
… grace The new must-fire read the counter after a fixed 3.2s wait against a 2.5s grace — a few hundred milliseconds of headroom, which on a loaded worker is a new flake. This suite already carries two (#342, #373), and adding a third inside the PR whose subject is a wedge is the wrong trade. Polling costs the discrimination nothing: the held-agent sweep stays parked until the test releases it, so against the previous ordering `stop()` never reaches the arming call at all and the poll can only end in its own deadline. Re-measured both directions — pre-fix ordering: exit=1, "the shutdown lever never armed while an unrelated held-agent sweep was in flight"; with the fix: exit=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 372b13bc-44a2-45a5-b5cc-aa228ccca39d
|
@coderabbitai review Requested for exact head |
Corrections to my comment above, and a status changeFour things in my earlier comment need correcting. A lane verified my claims independently and disputed two of them; it was right, and I would rather correct them here than have anyone act on the originals. 1. CI is no longer red — this PR is green and merge-readyI said "CI is currently red / The earlier red at 2. My "set it near the 10-minute stall threshold" recommendation was risky — prefer ~15 minThis is the correction that matters most, because it is the one someone might have acted on.
Recommended instead: "liveSubscription": { "sweepBudgetMs": 900000 }
3.
|
Do not merge — factory-lead holds the gate.
Three unbounded calls have wedged this sweep in a single day, on three different
transports. Each was real, each was bounded, and each time the wedge came back
one layer down. This PR does not bound a fourth. It makes the class survivable.
A. The aggregate budget
The property: no sweep can be in flight for longer than its budget, whatever
it is waiting on. Elapsed time is charged against one timer for the whole
pass, so it does not matter which await is slow, how many there are, or how
many times the sweep retries one of them.
Why a per-call bound cannot do this
relayfileOperationTimeoutMs(#351/#368)reconcileTimeoutMs(#296)runOnce()runOnce(), the#runOnceInFlightbranch, mainfactory.ts:2986-2989)sweepBudgetMs(this PR)#runOnceWithDiscoveryFenceExpiring from inside the fence is the entire difference. The sweep unwinds,
the discovery lease goes back,
#runOnceInFlightclears, and the next cycleclaims a fresh lease and runs clean.
Mechanism, plainly — what it can and cannot interrupt
budget.run()is a race, not a cancellation. Same honest limitation #368documented for
withRelayfileCallDeadline, stated for the same reason.loop keeps running, and a side effect already in flight still lands.
budget.signalaborts at expiry, so anything that honours anAbortSignalis really cancelled. Nothing in the sweep consumes it yet: therelayfile client mints its own per-call signal and that file is owned by
factory-wedge-layer2-0825this week. It is exported so wiring it is one line,not a redesign.
assertNotExpired()is a between-await check and is worth nothing againsta call that never returns. What it buys is that an already-abandoned pass
unwinds at its next loop iteration if it ever regains control, instead of
running to completion beside the sweep that replaced it.
Teardown is bounded separately, on purpose
On the path that matters the budget is spent by construction, so teardown cannot
run under it — every step would reject and the lease would never be released,
and releasing it is the half that makes the next cycle clean. An unbounded
release would re-create this wedge one layer down, which is the pattern this
PR exists to end. It gets its own 30 s deadline. An abandoned release costs an
orphaned lease for one expiry window, which a later sweep reclaims as an orphan
(
claim.reclaimedLease).The default deliberately changes no timing
sweepBudgetMsdefaults toreconcileTimeoutMs(90 min) and is clamped to it,so no sweep that survives today is killed by this. The number is a policy
dial; the mechanism is the deliverable. Tightening it has a real cost: the
checkpoint commits only at the end of the pass, so a budget below realistic
cold-mirror hydration (#36 measured 61 min in production) turns a slow boot into
a loop that never makes progress — the trap
reconcileTimeoutMsalreadydocuments. Recovery inside 90 minutes therefore needs either a tighter
sweepBudgetMsat deploy time (your call, one config key) or the L3 retry boundthe other lane owns.
B. Is discovery hostage to the sweep?
The coupling you named is necessary, and it is not what cost us dispatch. A
different one is, and this PR removes it.
discoveryDeferred: 'sweep-in-flight'(mainfactory.ts:3071) — necessary, keep itIt fires only when
claimDiscoverySweepfinds another owner holding thedurable lease. What the lease protects is the discovery checkpoint: two
concurrent passes would both advance the same cursor via
#finalizeDiscoveryCheckpoint/completeDiscoverySweep, so one would commit awatermark covering trees the other listed and the uncovered trees would never be
re-read. That is a correctness invariant, not a convenience.
And it is cheap. A deferred pass returns immediately (main
:3062-3072) — itdoes not block, it settles successfully, and it costs one interval of freshness.
Note it never fired during this incident: within one process
runOnce()coalesces rather than defers (main
:2986-2989). Which answers your otherquestion —
Why
discoveryDeferredwent"sweep-in-flight"→Nonebetween 0.1.74 and 0.1.75discoveryDeferredis a latched marker on the last SETTLED pass. It iswritten only by
#recordReadinessSweepOutcome, which runs only on a pass thatsucceeded, and cleared only on the failure path (main
factory.ts:2310).exist and
reconcileTimeoutMsis 90 min. So an early boot-time deferral(previous incarnation's lease still live inside its 5-minute window) latched
and froze on the surface for the whole outage.
:2310clears the marker every time.So the change is a symptom of L2 working, not new behaviour. The operational
lesson is the one that matters:
discoveryDeferredis not evidence thatdiscovery was being deferred at the moment you read it. It can be arbitrarily
stale. The same latch applies to
lastError(see the note at the end).The coupling that actually cost all dispatch — main
factory.ts:1983/:2021/:2039/:2366Discovery is not only the sweep. The live subscription drain is a full,
independent dispatch path that never touches the discovery lease:
#enqueueLiveEvent(:2358) →#scheduleLiveEventDrain(:2366) →#handleLiveEventsWithYield→#handlePreparedLiveChange→#handleChange→triageIssue(:7837) →dispatch(:7847).Its gate at
:2366checks#liveEventDrainScheduled,#liveEventDrainActive,#deferLiveEventDrainand#started. There is no sweep gate. It dispatcheshappily while a sweep is in flight.
Except for one thing:
:2039is inside afinallyaround an unboundedrunOnce(). A wedgedstartup backfill therefore means
start()never returns,#deferLiveEventDrainstays
trueforever, and the live drain never starts. Both discovery pathsdie together. That is why a wedged sweep meant zero dispatch instead of stale
dispatch.
This PR fixes exactly that, with no extra change.
runOnce()now alwayssettles, so
:2039always runs, so the live drain always starts and keepsdispatching while later sweeps are degraded. A slow sweep now costs freshness —
the outcome you asked for in B — because the durable safety net degrades while
the event-driven path stays up.
This is not a reading, it is a test.
must-fire: a live daemon whose sweep is wedged still shuts downstarts a real daemon whose first post-claim call neverreturns. Against
origin/main'sfactory.tsit fails after 4044 ms withstart never returned—start()itself never comes back, which is:2039never running. With the budget,
start()returns andstop()completes.I have not removed the
:3071deferral, per your instruction, and I wouldnot: the lease is load-bearing for checkpoint correctness.
Proof
Fail-first verified by mechanism, not by assertion colour: with only
factory.tsreverted the end-to-end must-fire fails after 4038 ms withsweep never settled— the sweep never settles, which is the production defectin the production shape. (The must-not-fire passes with the fix reverted, as it
must: a healthy sweep is unaffected either way.)
must-fire (end to end). A sweep whose first post-claim call never returns is
aborted at its budget with the abandoned phase named; the lease handback is
observed on the store, not inferred; the next cycle runs a fresh sweep and
dispatches.
must-fire (primitive).
sweep is out of time, not because it is slow. This is the aggregate
property no per-call deadline has.
the budget after more than one attempt. That is the L3 shape.
the dependency it just gave up on.
must-not-fire.
unbounded control —
pulled,dispatched,skipped, and the spawn list.Without this the trivial wrong fix (abort everything immediately) passes.
expiry.
sweepBudgetMs: 0the same hung call stays pending, so everyrejection above is attributable to the budget and not to the wrapper.
What this does NOT cover
wedged dependency still costs one whole budget per cycle.
unsticks. It cannot commit a checkpoint (the store's epoch guard) but its
in-flight side effects still land.
start()'s own pre-backfill watermark read is outside this budget, andoutside the orchestrator's bounds entirely.
#startLiveSubscriptioncalls#currentEventHighWatermark()(factory.ts:2192) before the backfill; itawaits
mount.getEventHighWatermark()under a bare try/catch, NOT through#withRelayfileOperation, sorelayfileOperationTimeoutMsdoes not reach it.What bounds it in production is one layer lower — the deployed client's own
#bounded()(relayfile-cloud-mount-client.ts:1057, fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep #368). AMountClientwithout that deadline has none here at all, and
start()hangs forever.Found while writing the shutdown test; not fixed here because the file that
would fix it belongs to the L2 lane this week. (Thanks to the review for
catching that my first wording credited the orchestrator with a bound it does
not have.)
the budget — but a sweep whose teardown is ALSO abandoned still leaves an
orphaned lease for one expiry window.
#runOnceWithReadinessDeadlineabandoned-wait bookkeeping is unchanged;a budget expiry reaches it as an ordinary sweep failure. The seven
bounded readiness reconciliationtests that cover thataccounting now pass
sweepBudgetMs: 0— at the budget's default there is noabandoned-but-still-running sweep left for them to observe, which is the fix;
0selects the backstop underneath it, the same control idiom fix(mount): bound the relayfile change feed so a hung tail read cannot wedge the readiness sweep #368 used.Review fixes at this head
Every finding below was valid and is fixed, each with its own must-fire /
must-not-fire:
sweepBudgetMsdefault rejects any config that already tightenedreconcileTimeoutMs, and silently caps one that loosened it.transform(), never from a constant (resolvedSweepBudgetMs)#performRunOncecan write a stale tree into the REPLACEMENT sweep's checkpoint#isStaleDiscoveryContinuation()compares thediscoveryEnumerationPassALS epoch — which follows the async continuation, so it carries the epoch that ISSUED the read — against the live one; applied to the checkpoint write and to overload attributionrunOnce()returns without reporting the wedge or releasing the leasedispose()clears them from afinallyon every pathThe first one was the dangerous one: it would have taken Factory down on
deploy for any config that had tuned
reconcileTimeoutMs, because the schemathrows before the daemon starts.
A second round found two more, both this PR's own lesson recurring inside it:
stop()outlives the sweep it started (#301), so a wedged sweep made shutdown as long as the budget — 90 min at the defaultstop()arms a grace timer over the drain and then callsbudget.expire(), routing the sweep into its ORDINARY abort path. NOTunref(), which is in direct tension with the P2 above: it would also let Node exit before the budget firesbudget.runcould reject a spent budgetOn shutdown: before this PR that same drain was unbounded, so shutdown on a
wedged sweep never returned at all. A sweep that starts while
#stoppingisalready set is expired on creation, so it cannot hand shutdown a fresh budget.
The claim-ordering finding is scoped honestly in its test: "already spent on
entry" is not reachable by construction — every path into the helper is preceded
by a
budget.runthat throws first, and I verified by ablation that theintegration test still passes without the fix. What makes the microtask gap
worth closing is new in the same commit:
stop()can now spend a budgetasynchronously.
On the /evidence document — corrections
Three of the four readings do not survive the code, and none of them weakens the
case for this PR:
lastErroris stale, and does not describe the wedged pass.#readinessReconcileLastErroris cleared only on a successful pass (mainfactory.ts:2265). The wedged pass has not settled, so that breaker messagebelongs to the pass that failed at 07:51:59, not the one in flight since
07:52:59. Same latch as
discoveryDeferred.lastDurationMs: 4368is a failure's duration, not a success's. It iswritten on both paths (
:2304on failure).lastFailureAtMs07:51:59 is2 ms after
fleetControlPlane.lastFailureAtMs, so 4368 ms is how long thepass took to fail via the breaker. The last success was at 07:49:49. The
underlying instinct is still right — 07:47–07:50 shows sub-interval sweeps —
but this field is not the evidence for it.
the same event: the reconcile interval (
intervalMs: 60000) from the failureat 07:51:59.211, and the breaker's
resetTimeoutMs: 60000from07:51:59.209. They expire together by construction. Not a lead.
probe()wrapsroster()in
withTimeout(src/fleet/control-plane-circuit.ts:245-269), a genuinerace. So the 38-minute hang is almost certainly not in
roster().The unbounded thing on that path is the mutation after the probe
(
:180-183: "applies the local roster deadline without imposing a timeout onmutations") — a
spawn/resumeduring dispatch has no deadline at all. Thatis a live L4 candidate. I have not chased it, per the brief. Under this
PR it is a degraded sweep rather than the end of dispatch, which is the point.
Your framing stands where it counts: the failing transport is not the same one
twice, and an aggregate budget is agnostic to which one it is.