Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to close a database alias without tearing down the shared root store if other aliases still reference it. It updates closeDatabase to perform a logical release, adds closeDatabaseWithAliases to handle physical closure of all shared aliases (especially for backup restoration), and adds corresponding unit tests to prevent regression and SIGSEGV crashes under LMDB. The reviewer provided feedback on closeDatabaseWithAliases pointing out that a synchronous exception in runtime.close() would bypass Promise.resolve and reject the outer promise, suggesting the use of the new Promise executor pattern to safely catch both synchronous and asynchronous errors.
… store Dispatch-Task: harper-dbalias-lmdb-segfault Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbUogt8UDDeJB5YMCQ2bvU
…ases share closeDatabase() closed a root store while another loaded name (an alias of the same path) still referenced it. Under LMDB the second alias's close then ran mdb_dbi_close against the freed environment, which is the SIGSEGV on the Node 22 and 26 unit-test legs after #2683's alias suite landed; under RocksDB the shared root merely reported closed while the other name kept reading. closeDatabase is now the logical release of one name: it retires the name's table runtimes, closes the RocksDB column families the name opened, and tears the root store down only when no other name references it. LMDB table handles are environment-wide, so they close with the environment at the last name. closeDatabaseWithAliases is the physical close every name of a store, which the restore_backup ITC handler now uses. Dispatch-Task: harper-dbalias-lmdb-segfault Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbUogt8UDDeJB5YMCQ2bvU
Dispatch-Task: harper-dbalias-lmdb-segfault Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbUogt8UDDeJB5YMCQ2bvU
…ores Dispatch-Task: harper-dbalias-lmdb-segfault Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbUogt8UDDeJB5YMCQ2bvU
Dispatch-Task: harper-dbalias-lmdb-segfault Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbUogt8UDDeJB5YMCQ2bvU
main partitioned the root DESIGN.md into per-directory notes and removed docs/ (76178b7f8) while this PR was in review. Condense the design note accordingly: the invariant and mechanism move into resources/DESIGN.md, indexed from the root; the planning-round table and resolution stay in this PR's history rather than the repo. Dispatch-Task: pr-maint-17ed753da2b9a12a370fa539f41271a5 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion Use the shared unitTests/waitFor.js helper for the root-store close poll instead of a hand-rolled 1s loop that ends in a bare assert (a slow close on a loaded runner would fail without a diagnostic timeout message). Trim resources/DESIGN.md's new section to the invariant and mechanism; the incident narration (which legs, which flags reproduced it) belongs in the PR, per this file's own stated rule. Declined from this round, recorded in the PR body's For the human reviewer: the derived-index stop-failure catch is nearly dead weight given hnswDerivedIndex.ts's own settle-and-log (previously adjudicated, same tradeoff as the prior round); closing the alias currently owning an HNSW backend could strand a surviving alias without one (no in-tree caller reaches it); and closeDatabaseWithAliases's alias set is computed before its runtime-stop await, open to a race with a concurrent schema reload (the restore marker likely turns this into a timeout, not data loss, per the review's own dispute). closeLoadedDatabases not awaiting the same stop barrier is pre-existing and out of scope. Dispatch-Task: pr-maint-17ed753da2b9a12a370fa539f41271a5 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
0768515 to
840d14f
Compare
|
Closing as superseded by #2766, which fixes the LMDB segfault this PR targeted (closes the shared This PR also carried behavior #2766 deliberately leaves out:
Filed as follow-up rather than carried forward here:
No further action on this branch. 🤖 Generated with Claude Code |
Closing one name of a database that another configured name also resolves to (an alias of the same path) tore down the shared root store, and under LMDB the second name's close then ran
mdb_dbi_closeagainst the freed environment — the SIGSEGV (exit 139) in the "Unit tests: lmdb" step on the Node.js v22 and v26Unit Testlegs after #2683's alias suite landed (run 35646369634, run 35648838471).closeDatabase(name)is now the release of one name, and the root store closes with the last name that references it; a newcloseDatabaseWithAliases(name)is the physical close of every name of a store, which therestore_backupITC handler now uses so an aliased database can actually be restored online. Design note with the trace and alternatives:resources/DESIGN.md(moved there fromdocs/design/mid-review — see entry 7).This branch was rebased onto
mainafter a ~140-commit gap that included a RocksDB drop/reclamation rework and a repo-wide DESIGN.md restructuring; only one textual conflict (an import line inserverHandlers.js, merged), and one non-textual conflict handled below (entry 7). Two review findings are declined rather than fixed: entry 3 (alias discovery by identity, not path) and entry 9 (retiring the alias holding an HNSW backend can strand the survivor without one).For the human reviewer
closeDatabase, therestore_backupITC broadcast, runs exactly this two-name sequence on every worker of a deployment with a configured alias. The scope grew past a segfault fix on the planning review's verdict (better-alternative-exists, adopted): a name-only release with no physical close would have left an aliased online restore refused byverifyDatabaseClosedunder RocksDB, which base already does today by a different route. The fix as shipped covers both engines' bookkeeping; a "no" here means shipping a narrower close that keeps that restore gap.closeDatabasechanges meaning: logical release, not physical close. Chosen over keepingcloseDatabasephysical and adding a name-only variant, because every existing caller (the ITC handler, worker-exit teardown, tests) wants the last-name semantics and none wants a shared store torn down under another live name. harper-pro has no callers ofcloseDatabaseoutside core (grepped). Reversal is a rename.closeDatabaseWithAliasesreturnsfalseand the alias keeps its handles; restore then failsverifyDatabaseClosedwith a 409, not a purge under open handles (that gate polls the rocksdb-js registry by path, checked again on rebase againstdataLayer/rocksdbBackup.ts's current line numbers). Declined because names load per thread all at once from one configuration scan, and the only production close now closes every name together, so a half-loaded pair is not constructible in production; identity is also what keeps two databases at neighbouring paths from being confused. Reversal: local tocloseDatabaseWithAliases.close()already settles-and-logs before this point, so this is fail-open by construction at both layers, not a decision this PR alone could flip. Failing closed would need the runtime's stop contract changed too. A "no" here blocks restore on any derived-index backend that cannot prove it stopped in time.closeDatabasestays synchronous; only the physical close awaits the derived-index stop barrier.Table.cleanup()starts the runtime stop without awaiting it, so a flush in flight could land after the stores closed — for a restore, after the files were replaced.closeDatabaseWithAliasesawaits it (entry 4); worker-exit teardown (closeLoadedDatabases) cannot await and keeps the base behaviour, which never retired the runtime at all — an improvement, not a regression, and out of this PR's scope as pre-existing. A "no" means makingcloseDatabaseasync throughcloseLoadedDatabasesand its test callers.DbiWrap::closewould stop the crash for every embedder but needs a native registry and a dependency release, and would still leave harper closing a store another name reads from (the RocksDB probe showed the same broken state with no native fault). Harper created the sharing (lmdbDatabaseEnvs), so harper enforces the invariant; the native guard can be added later as defence in depth.docs/design/intoresources/DESIGN.mdmid-review. While this PR was open,mainpartitioned the rootDESIGN.mdinto per-directory notes and removeddocs/entirely (HarperFast/harper@76178b7f8). The rebase moved this PR's note into a newresources/DESIGN.mdsection, condensed to the invariant and mechanism per that file's own rule that review/process history belongs in the PR;npm run check:design-docspasses.mdb_dbi_closeis optional by LMDB's contract and invalidates the environment-wide slot for every wrapper of it, so the environment close at the last name releases them. Cost: nothing is leaked, but a future path that closes an environment late keeps those handles alive until then.table.cleanup()on that alias then settles and deletes the only registered backend, so writes through the surviving alias keep committing to the primary store but nothing updates the HNSW index. Declined because both in-tree production callers (closeDatabaseWithAliases,closeLoadedDatabases) close every alias together; only a direct caller of the exportedcloseDatabaseon a multi-alias HNSW table would hit it, and none exists today. A "no" here needs the surviving alias's backend re-registered insidecloseDatabase, which reaches into HNSW backend lifecycle this PR does not otherwise touch.MALLOC_PERTURB_in the resources suite. It turns the allocator-luck crash into a deterministic red assertion on glibc (dies withfree(): invalid pointeron base); on macOS and Windows the variable is ignored and the test passes without testing anything. Cost: two short process spawns per run.Changes
resources/databases.ts—closeDatabasecollects the name's root stores, marks each one still referenced by another loaded name as shared, retires every table's process-wide registrations throughTable.cleanup()(guarded, so a throw cannot stop the teardown), closes only RocksDB column-family handles per name, and runs the root-store teardown (audit-cleanup stop, storage-reclamation unregistration,dbisDband root close, env-cache entries) only for unshared roots;closeStorealso attaches a rejection handler to a promise-returning close.closeDatabaseWithAliasesfinds every loaded name sharing a root store, awaits their derived-index runtime stops, then closes them all.collectRootStores(tables plus the defined-database entry of a tableless name) andisRootStoreReferencedElsewherecompare by object identity.server/itc/serverHandlers.js— therestore_backupITC handler imports and awaitscloseDatabaseWithAliasesinstead ofcloseDatabase; unrelated to this PR, the rebase carried forwardmain's ownDROP_TABLEhandling in the same file, which this branch does not touch.resources/DESIGN.md— the new section states the invariant and mechanism (entry 7).DESIGN.md— one new index line links it in.Verification
Fails-on-base, with
origin/main'sdatabases.tsandserverHandlers.jsbuilt intodist: the two close-order tests and the physical-close test fail on both engines (cleanup count 0; rootclosed/closingafter the first close), and the child-process test fails under LMDB withfree(): invalid pointer. All pass with the fix, on Node 26.2.0 (the CI leg that failed).unitTests/resources/databaseAliasIdentity.test.js(both engines unless noted): closing either alias first retires the removed name'sTable.cleanup()once, leaves the shared rootopen, lets the surviving name read a record written through the other, and the last close leaves the rootclosedwith norefCount > 0entry in rocksdb-js's registry (RocksDB);closeDatabaseWithAliaseson one name removes both and closes the store;closeLoadedDatabaseswith two names releases the store (RocksDB, as that teardown is RocksDB-only by design); a child process (unitTests/resources/databaseAliasIdentity-close.js, which first reads through each name because that is what binds the handle the other name's close invalidates) closes two names in either order underMALLOC_PERTURB_=165and exits 0. The existing identity tests now create the store with a secondary index and a record;waitForCloseduses the sharedunitTests/waitFor.jshelper instead of a hand-rolled poll.Gates on
840d14f26(the rebased head), Node 26.2.0, afternpm ciagainstmain's bumped dependencies (@harperfast/rocksdb-js2.9.1→2.10.0,@harperfast/hnsw0.3.0→0.4.0):npm run test:unit:resources(RocksDB) 3034 passing, 0 failing;HARPER_STORAGE_ENGINE=lmdb npm run test:unit:resources2328 passing, 0 failing;npm run test:unit:main5851 passing, 5 failing — all 5 are box artifacts unrelated to this diff:gitCredentials.test.js:395fails on this machine's ownGIT_CONFIG_GLOBAL/GIT_EDITORenvironment regardless of branch, and 4 indeployStageActivate.test.js(a file this PR does not touch) passed 41/41 re-run alone once the box quieted.npm run lint:required,prettier --checkon the changed files,git diff --check, andnpm run check:design-docsall clean.End to end: not observable in the integration suite — the production trigger is the
restore_backupITC broadcast on a deployment with a configured alias, which no integration fixture configures; the unit test checks the same registry predicateverifyDatabaseClosedpolls. An aliased online-restore integration fixture is recorded as a follow-up.Refs #2683
Complexity: complicated
🤖 Generated with Claude Code
https://claude.ai/code/session_01DbUogt8UDDeJB5YMCQ2bvU
Origin — the dispatch brief this PR was written from
Close one database alias without tearing down the store its other aliases share
LIVE CONVERSATION about #2721.
You are answering a person, in a thread, one turn at a time. Every turn:
each of your previous turns is in it. Read the PR/issue and the code as needed.
they are talking to you, and a status template is not an answer.
Each turn arrives as ASK (answer it, change nothing) or PERFORM (do it, then say what you did) —
the person chose which when they sent it, and the run's own prompt tells you which one this is.
Never infer it from the wording: an unrequested commit in the middle of a discussion and a polite
description of work that was supposed to happen are the two failures this exists to prevent.
Never mark a PR ready and never merge from this conversation.
Dispatch: task
chat-pr-harper-2721-kriszyp· queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=6; full=2 @ 840d14f
Human-Review-Need: 3 (decisions: fail-open-on-unquiesced-stop, logical-vs-physical-close-split, lmdb-env-only-handle-release, per-thread-alias-discovery) @ 840d14f