Repository navigation
fix(aaas): bound the handoff drain, make forget win, and close the holes left in the A1 guards - #688
Merged
Merged
Conversation
added 6 commits
October 31, 2026 14:03
…les left in the A1 guards An independent re-review of the handoff fix showed that, with renewals now running through the drain, one call that never returns holds a tenant on its replica for ever, that a forget racing a handoff could lose and leave the tenant's image in the store, and that the guarded view and the delete paths still had holes; each is fixed behind a check that failed first. - router: `drainTimeoutMs` (default 120 s) ends a drain that cannot finish with the typed `DrainTimeoutError` and a `drain_timeout` event, and the tenant serves again. `close()` hands tenants off side by side (`closeConcurrency`, default 8). An abandon, a forget or a lost lease settles a handoff that is waiting. - forget: wins against a flushing handoff, keeps renewing the lease while it waits for an upload, refuses a second call, and calls during the wait fail with the typed draining error instead of a closed-database TypeError. An abandon during a forget does nothing. - renewals: a failed handoff no longer starts a second chain while a renewal is still under way. - `run()` refuses a callback that returns a promise, and takes the rejection of the one it left behind. - guarded view: `db.close()` is a no-op, the raw database is not reachable through a property descriptor, and the view is read-only. - epoch copies: the tenant root must be the tenant's own before anything is deleted recursively, and the open of a tenant that was ever forgotten deletes the copies of dead epochs. - durable store: a delete takes whole segments (`acme/` cannot take `acme2/`); the conformance suite requires it of every binding. - drill: a consumer writing through the ledger it holds while its replica hands off, over a slow object store; the SIGSTOP refusals and the per-link check are asserted. Refs #674
…t (A1) The RFC, the security design, the code map, the D-15 row, the capacity baseline and the run-all description now say what the hardened router does and what it still does not: a bounded drain, forget winning against a handoff, dead epochs swept at open, the tenant's own root only, the limits on forgetting one subject and on an abandoned tenure's local file. Three checks were added after a mutation showed the view's own gate hid the handle's: D4 and D5 pin that new calls are refused while a handoff drains and while an abandon is under way, P3 pins the RFC's shared-file row. The set trap of the guarded ledger went, because refusing defineProperty already makes an assignment fail. Refs #674
… of the independent review of the hardening An independent read-only review of this branch found one real defect and nine smaller points; each was checked before it was changed. - forget raised the floor and then deleted everything under the tenant's key, so an owner that was paused in between could wipe what a successor had already published; it now deletes only the epochs at or below the floor it raised (F7). - the root that is checked is the root that is deleted (a ".." behind a symlink could point the deletion elsewhere), and discarding the local ledger takes the same guard (P4). - the sweep of dead epochs runs beside the open, not in it, so a slow store cannot use up the lease before the first renewal (S1, S2). - closing the router waits, as long as a drain may take, for a forget that has left its table of handles (F8); a forget on a handle whose abandon is under way is refused (D5). - the view also refuses setPrototypeOf and preventExtensions, so it cannot be frozen into the ledger the router holds (V3); the redundant set trap is gone. - the DurableStore contract says every call must settle, and close() says what it bounds and what it leaves to the binding. - checks: option validation and the close concurrency are pinned (D6), the second forget in F6 can no longer deadlock a broken build into looking killed, the timing margins of H4, F4, D2 and D3 are wider, and the new conformance check is shown to fail on a store whose delete is a raw prefix. Refs #674
… the open, and what close() bounds The RFC, the security design, the code map and the run-all description follow the review of the hardening: a forget keeps a successor's epochs, the dead-epoch sweep is the router's and runs beside the open, closing the router waits for a forget under way, only the drain is bounded and a store binding must give every request a deadline. Refs #674
…xtensible The mutation that removed the preventExtensions trap survived because Object.freeze still failed on defineProperty, after the target had already been made non-extensible.
…bers CI failed on Node 22 and 24 (Linux) at P3: the restore deletes the shared ledger file and writes another, and Linux hands the freed inode number to the new file at once, so before and after were equal; macOS did not show it. Holding a descriptor to the old file keeps its inode from being reused, and shows what the RFC says: the older writer holds an unlinked file. D6 and R1 get more room on a slow runner.
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.
Refs #674
#684 made the graceful handoff lose no acknowledged write. An independent re-review of that fix (from the A2 session) found no new blocker, but left one liveness defect and a list of smaller ones, and said which of the original findings were only half fixed. I did not take the list on trust: for each item I wrote a check from its description, watched it fail on
mainwhere it could, then fixed it. Everything below reproduced, except where it says it is a decision.Base is
main; this is independent of #687 (the credential broker and governor follow-up). It changes nothing in the kernel (kernel-manifest-gate: zero diff), adds no dependency, and touches no WriteGate or M5 behavior.What was wrong, and what changed
router.close()wait for ever, and left the tenants queued behind it unflushed.abandon()did not settle the pending handoff eitherdrainTimeoutMs(default 120 s). Past ithandoff()rejects with the typedDrainTimeoutError, adrain_timeoutevent says so, and the tenant serves again.close()hands tenants off several at a time (closeConcurrency, default 8), so a stuck one holds only its own slot and is reported unflushed. An abandon, a forget or a lost lease now settles a waiting handoffforget()during a flushing handoff lost: the handoff released the lease,forgetTenantthen hit FENCED, and the final image stayed in the storeforgetTenantraised the floor and then deleted everything under the tenant's key. An owner paused between the two woke after a successor had taken over and published, and wiped the successor's epoch (found by the review of this branch; it was in the code before)forget()stopped lease renewal and then waited without bound for the upload, so an upload longer than the lease fenced it before it could deleteforgetTenanthas handed the lease backforget()waited, calls through the handle and the ledger threw an untypedTypeError: the database connection is not openrouter.close()did not wait for a forget under way (the handle had left its table), so a stop mid-forget left it half done and the lease unreleasedclose()waits for it, for as long as a drain may takerun()accepted a callback that returns a promise, which escapes the lease bracket and the drain and becomes an unhandled rejectionTypeErrornamingrunAsync; the promise it leaves behind is takenledger.db.close()closed the router's own connection, andObject.getOwnPropertyDescriptor(ledger, 'db').valuewas the raw databasecloseis a no-op in the guarded database, the descriptor trap returns the guarded one, and the view refusesdefineProperty,delete,setPrototypeOfandpreventExtensions, so nothing a consumer does to it reaches the ledger the router holdssweepMaskedCopieshad no caller)forgetTenantdeleted the root the caller named, recursively; a durable delete by a prefix that stops inside a name took its neighbours (acmetookacme2)<state root>/orgs/<org>/tenants/<tenant>for exactly that tenant, and uses the resolved path it checked, not the string it was given (a..behind a symlink could lead elsewhere);deletePrefixtakes whole segments, and the conformance suite requires it of every binding, with a broken store that shows the check can failhandle.run, SIGTERM landed outside any write, the SIGSTOP refusals and the per-link check were not assertedrunAsyncbracket the drain waits for, while its replica hands off over a slow object store, then is refused with the draining error; the refusals after the freeze and the number of links checked are assertedTwo original findings are decisions, not code, and the docs now say so: finding 5 (an abandoned or lost tenure keeps its local ledger only until the next open of that tenant on the same replica, whose restore replaces it with the durable copy; L2 pins it, a quarantine would be a retention feature) and finding 6 (forgetting one subject inside a live tenant is not propagated into snapshots already published; a restore may fall back to an older verified image; A3 needs a retention policy and a rule against falling back across a forget).
Public-contract check
I found nothing that needed the founder before coding, and say so here so you can overrule it:
booking.surfaceschema, event kinds and the error-code list are unchanged. The A2 surface already treats everyTenantStateErroras a tenant fault;DrainTimeoutErrorcomes only out ofhandoff()andclose(), never out of a request path.DrainTimeoutErrorclass, thedrain_timeoutrouter event, the optionsdrainTimeoutMsandcloseConcurrency.run()now throws for a callback that returns a promise (a misuse that used to escape the bracket);deletePrefixrejects a prefix that does not end with/(the S3 binding has to pass the same conformance check);close()reports unflushed tenants in the order they were opened, as before.main(A3 composes it).Evidence
Local Node v26.9.0, macOS arm64. SHA
22855e6e4ff3532b4be739cb9cbd6eb76f2e024afor the local runs below. CI on that head failed on Node 22 and 24 at one check only, P3 (statSync(file).inowas the same before and after the restore: Linux reuses an inode the moment a file is deleted, macOS did not show it);f904528replaces the inode comparison with a held descriptor and gives D6 and R1 more room. Nothing else of this PR changed in it.mainD1, D2, D3 (all three), R1, T1, V2, V3, F3, F4, F5, F6, D5, S1, P1, P2 and the new durable conformance check failed, each for the reason in the table. After the review of this branch, F7, F8, D5 (the forget on an abandoning handle), P4 and V3 (prototype, freeze) failed on the branch as it stood before the fix; F7 shows the successor's image gone from the store. D4, D6, S2, L2 and P3 pin behavior that already held. P1, P2 and P4 were run with sandboxed paths only.npx tsc --noEmitclean;node scripts/check-docs-i18n.mjs(94 pairs) andcheck-doc-readability.mjsOK.NPM_CONFIG_USERCONFIG=/dev/null NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ npm_config_registry=https://registry.npmjs.org/ bash scripts/run-all-tests.sh->ALL SUITES GREEN, exit 0, no ENOSPC. CI runs it again on Node 22 and 24.tenant-state-handoff-tests.ts12 -> 37 checks; §90 copies 27 (K2 now also shows the whole-segment check fails on a store whose delete is a raw prefix); the drill 1962 assertions over 2 SIGKILLs, 1 SIGSTOP and 1 graceful handoff.cd ts && npx tsx scripts/mutation-check.ts scripts/tenant-state-handoff-mutations.json, withmain's hardened runner): 48 of 48 killed, none by a hang or a broken edit: 47 ona3a3e1a, and J41 again on22855e6after V3 was strengthened (the only change since is that test). The first run found J41 alive; the cause was a check that did not look at the ledger after a refused freeze, not a redundant guardforgetTenantwipe above. Taken: the sweep moved out of the open; the checked root is the deleted root and discarding the ledger takes the guard; a forget is refused on a closing handle;close()waits for a forget; the view also refusessetPrototypeOfandpreventExtensions; a check that would deadlock instead of fail (F6); guards nothing pinned (the renewal flag, the option validation, the swallowed sweep failure, the conformance check); tighter timing margins (H4, F4, D2, D3, the drill's refusal check); P3's title that claimed more than it shows. Answered, not changed: bounding the flush and the forget (below).Not in this PR, and limits
drainTimeoutMsbelow the termination grace period of the deployment; the process stays alive while a drain waits, on purpose, because a graceful stop that gave up the moment nothing else was pending would exit without flushing.abandon()is a no-op during a forget; the contract that every call settles is written intoDurableStore, the RFC andclose(), and the S3 binding of A3 has to give every request a deadline. Worst case forclose()is the number of stuck tenants divided by eight, rounded up, times the drain deadline.sweepMaskedCopiesonce the previous owner's lease has run out. There is no scheduler for that.Review asks
drainTimeoutMs, and eight at a time forclose().run()throwing on a callback that returns a promise. A2 does not do it, but any other consumer that did would now get aTypeErrorinstead of an escaped write.