Keep a table being created invisible to catalog scans on other threads, so replication can never announce a partial attribute list - #2381
Conversation
…scan cannot build, and replicate, a partial attribute list Every worker thread rebuilds its Table map by scanning the __dbis__ catalog (resetDatabases -> initStores) on any schema-change ITC signal, including one for an unrelated database. table() wrote a new table's primary row first and each attribute row afterwards as separate putSync writes on RocksDB, and the cross-thread update-attributes lock is taken only by writers, so a scan that landed inside a create_table saw the primary row with none or some of the attribute rows, built a Table whose attributes was that partial list, and emitted updateTable for it. harper-pro forwards that snapshot to peers as a DB_SCHEMA announcement; a peer whose replication thread had not yet loaded the table applied it as an authoritative definition and deleted its own just-declared attribute descriptors. create_table had already returned success, later complete announcements hit the schemaDefined honor-local guard, and search_by_value failed with "unknown attribute 'name'" (harper-pro replicationLoad.test.mjs, red on the sync-core PR harper-pro#775 and on harper-pro main on 2026-08-21 before any of that PR's core commits). The primary row is what makes a table loadable, so it now lands after every attribute row, still under the exclusive lock, and initStores skips (with a warn) a table whose catalog has attribute rows but no primary row. The catalog is either invisible or complete to every other thread, so no thread can build or announce a partial Table. LMDB already had this property because its exclusiveLock() is an environment-wide write transaction. An interrupted create now leaves a table that does not load at all instead of a half table; re-running create_table repairs it through the existing new-table reconcile. The additive-only cluster-origin rule (harper#2258, harper-pro#750) repairs the consumer of such a snapshot; this removes the snapshot at its source, so every consumer of the scanning thread's Table.attributes is covered. Refs harper-pro#775 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…rove the scan invariant from a second thread Planning-review findings. setTable() ran before the descriptor writes that can throw, so a create that failed after opening its stores left a Table only its own worker could see while every other worker (and the next start) saw no table. The class is now registered at the same point the primary row lands. The unit test now also spawns a worker thread that scans the catalog while the create is paused after its first attribute row and asserts the table is not loaded, then rescans after the create returns and asserts the complete attribute list. On origin/main the mid-create scan loads the table with [id, name]. Skipped on LMDB, where the scan blocks on the creator's write transaction instead of observing the catalog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…tion turns into a DB_SCHEMA announcement Cursor leg of the pre-push review, round 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…e same-name class on a mid-create scan Pre-push review, round 1 (graded leg). A create that threw after makeTable() used to leave a class only its own worker could see; deferring registration fixed that but left the stores it had opened and the callbacks makeTable() registered with no owner. table() now discards them on the failure path: Table.cleanup() (which also releases the storage-reclamation handler, through a per-handler removal, because a RocksDB column family shares its reclamation path with the whole database) and, on RocksDB, the primary and index column-family handles. An index is registered on the class as soon as it opens so a failure after that point still closes it. LMDB stores are shared handle slots inside the creator's write transaction and are left alone. initStores added a table to definedTables before deciding whether it could load it, so a worker still holding a dropped generation of the same name kept serving it through the recreate. A table whose catalog has no primary row is no longer counted as defined. The worker-thread test now seeds the other thread with a dropped first generation before the paused recreate, and a failure-injection test covers the rollback and the retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
The other thread now reports whether it could read the attribute row and the primary row directly from the catalog, so 'not loaded' cannot pass vacuously because the rows were not visible yet (domain leg, pre-push review round 1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
… class the rescan evicts Pre-push review, round 2 (Cursor leg). A create that throws before its primary row already rolled back its stores and callbacks; it now also removes the attribute rows it wrote, so only a crash can leave orphan rows behind. The rescan cleanup pass used to drop an evicted class (a table dropped on another worker) with a bare delete, leaving its audit delete-removal callback and storage-reclamation handler registered for the life of the process; it now calls the class's cleanup() first. The failure-injection test asserts the callbacks were released and the rows removed, and that the retried primary row carries schemaDefined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…cleanup() release timers and the audit slot Pre-push review, round 2 (graded leg). - The rollback gate was the deferred primary row, set only after NEXT_TABLE_ID and makeTable(); a failure in either leaked the primary column family. The store is tracked from the moment it opens, and the discard tolerates a class that never got built. - Rows found under the lock for a table with no primary row can only be aborted state, so a create reconciles them away regardless of origin; the cluster-origin exemption stays for existing tables. - Registering an index before its descriptor persists was correct only for an unpublished class (its map is private); an existing table keeps the original post-persistence assignment so a failed descriptor write leaves its live index map untouched. - makeTable() arms the TTL cleanup timer and the expiresAt eviction interval at construction; cleanup() now clears both, marks the class disposed so a run in flight does not reschedule, and the audit delete-removal handle releases the store slot it registered. The failure-injection test now also fails the NEXT_TABLE_ID write, before makeTable() runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…the retried primary store Pre-push review, round 2 (domain adjudication). The skip runs on every thread for every schema-change signal in the node, so an interrupted create used to emit a multi-KB warn per scan forever; it now names the attributes instead of serializing them, warns the first time per table and thread, and logs at debug after that until the table loads. The failure-injection test writes and reads a record through the retried table, so a retry that reopened the closed column-family handle would fail here rather than on first traffic. Comments trimmed to what DESIGN.md does not already say. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…eclamation registry safe under concurrency Pre-push review, round 3 (Gemini leg). - makeTable() registers its callbacks and arms its timers before it can throw from updatedAttributes()/setTTLExpiration(); it now releases them itself on the way out, since the caller never receives the class. - The primary-store handle is tracked before handleLocalTimeForGets wraps it, so a throw in the wrapper is covered by the rollback too. - The expiresAt eviction interval checks the disposed flag at entry. - removeStorageReclamationHandler replaces the handler list instead of splicing it, so runReclamationHandlers iterating across awaits skips none. - The incomplete-catalog debug line no longer serializes the attribute list. The failure-injection test adds a throw inside makeTable (negative expiration) and gives every attempt its own definition object, since table() annotates the attribute objects it is handed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…ate finds, and close an index handle whose setup throws
Pre-push review, round 3 (graded leg).
- The removal reconcile scanned the whole catalog under the schema lock for
every create and update; it now reads only the table's own key range, the
same bound dropTable already uses.
- Rows a create finds for its table are aborted state whatever their names, so
the reconcile removes them all before the attribute loop rewrites them; a
crashed attempt whose attribute later becomes the primary key can no longer
leave a duplicate descriptor behind.
- openIndex() closes a RocksDB handle it opened when format resolution or the
custom-index constructor throws, since no table owns it yet.
- cleanup() settles a scheduled cleanup pass a reclamation run is awaiting,
instead of leaving that run pending forever once the timer is cleared.
- The incomplete-catalog latch keys on database/table ('/' cannot appear in
either name) and is not touched on the healthy path while empty.
- The failure-injection test awaits the primary store read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
A failure before the paused recreate left the worker blocked in Atomics.wait and mocha hanging (pre-push review, round 3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
Pre-push review, round 3 (domain adjudication). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
scheduleCleanup() cleared the earlier pass's timer but left its promise pending, so a reclamation run awaiting it never continued (and the resolver set kept growing). Pre-push review, round 4 (Gemini leg). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
… retried index through the Table API A reclamation run that captured the handler list before the class was evicted could still call scheduleCleanup() afterwards; with no timer to arm, the promise it returned would never settle and the run would never continue. The failure-injection test now writes a record through the table and looks it up by the reopened index instead of writing to the primary store directly. Pre-push review, round 4 (graded leg). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
…e classes of a database that disappears from the scan Both left a reclamation run awaiting a promise nothing would resolve; the dropped-database eviction is the sibling of the table eviction already disposing its classes. Pre-push review, round 4 (domain adjudication). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QByBUAdrnc5PEdkw5anPem
There was a problem hiding this comment.
Code Review
This pull request ensures that a table remains invisible to catalog scans until its creation is complete by deferring the write of the primary key descriptor until after all attribute rows are written. It also implements robust cleanup mechanisms for timers, intervals, and storage reclamation handlers when a table is disposed or fails to create. The feedback suggests adding a defensive check for removedTables in resources/databases.ts to prevent a potential TypeError if the database entry is undefined.
|
Reviewed; no blockers found. |
cb1kenobi
left a comment
There was a problem hiding this comment.
Barbarian reviewed bf65b06 and found 1 blocking issue. The durable primary row is written before the table is marked published. If synchronous registration or event delivery throws, rollback can corrupt the catalog and leave a disposed registered class. Mark the table published immediately after the primary-row write.
…tration The durable primary row is what makes a table loadable to every other thread, so a failure past it must not roll the catalog back. `published` now flips immediately after that write: a throw from registering the class or persisting relationships used to run the rollback, which deletes the attribute rows while the primary row stays committed (on LMDB releaseLock()'s finally commits this create's transaction even while the error unwinds) — leaving every later scan loading exactly the primary-only schema this branch exists to prevent. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Keeping the catalog is the right call past the publish point, but the class that create built is then unreachable, and makeTable() registers it process-wide: the reclamation handler, the audit delete-removal callback, the TTL timer, the expiresAt interval. Release those when the registration is the step that threw. The stores are left open — the table is durable, and the scan that reloads it opens its own handles. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
…supersede A pass whose timer is cleared by a later scheduleCleanup() now hands its awaiters to the replacement, which settles them when its scan completes, so a storage-reclamation run is never told a table's storage was reclaimed before any scan ran. The replacement runs immediately when it adopts awaiters: a reclamation run blocks its whole path on that promise, and the replacement's own slot can be a full interval out. Also pin, with a regression test, that a create publishes its primary row with the relationship list already on it, so the persistence block that follows the publish point cannot leave a window where the table is loadable without its relationships. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…ff above it Co-Authored-By: Claude Opus <noreply@anthropic.com>
A table being created is now invisible to catalog scans on other worker threads until its catalog is complete:
table()writes the primary-key row (<table>/, the rowinitStoresneeds before it will load a table) after every attribute row, and treats that write — not the class registration that follows it — as the point the table is published, andinitStoresskips a table whose catalog has attribute rows but no primary row (and no longer counts it as defined, so a worker still holding a dropped same-name generation evicts it, disposing it, instead of serving it through the recreate). A create that throws from the moment its primary store opens is registered nowhere and releases what it had opened — the audit delete-removal callback, its storage-reclamation handler (through a new per-handlerremoveStorageReclamationHandler, because a RocksDB column family shares its reclamation path with the whole database), and on RocksDB the primary and index column-family handles. The invariant and its crash semantics are recorded in DESIGN.md. This closes the harper-pro cluster failurereplicate across many databases→{"error":"unknown attribute 'name'"}(red on the sync-core PR HarperFast/harper-pro#775) at its source.Root cause, from the failing job's server-log artifact. Every worker thread rebuilds its own
Tablemap by scanning the__dbis__catalog (resetDatabases()→initStores) on any schema-change ITC signal, including one for an unrelated database. On RocksDB the catalog rows are individualputSyncwrites and the cross-threadupdate-attributeslock is taken only by writers, so on node 127.0.0.4 thehttp/1thread — rescanning becausemain/0had just signalleddb6— landed insidemain/0'screate_table db7.test {id, name}(10:43:40.316–.361), builtdb7.testwithattributes: [id], and emittedupdateTable; harper-pro forwarded that snapshot to its peers over the system connection. Node 127.0.0.2's replication thread had not yet loadeddb7(its ownmain/0create signalled at .380, the thread processed it at .453), so at .397 it logged(Re)creating { table: 'test', schemaDefined: true, attributes: [ { name: 'id', type: 'ID', isPrimaryKey: true } ], database: 'db7' }and applied it throughensureTable→table()as an authoritative definition, whose removal reconcile deleted the localnamedescriptor.create_tablehad already returned success; every later complete announcement hit theschemaDefinedhonor-local guard (Schema for 'db7.test' is defined locally, but attribute 'name: String' from '127.0.0.4' does not match local attribute which does not exist, 6–7 times per node), andsearch_by_valueonnamefailed.This is not a regression of the
30db07ac..9a24b055core range. The same test failed identically on harper-promainon 2026-08-21 (runs 32506365300 and 32457825567) before any commit in that range existed, and the only commit that touches this surface, #2258, is inert for a caller that does not passorigin: 'cluster'— nothing on harper-promaindoes; that half of the fix is the open HarperFast/harper-pro#750. It is the pre-existing race that #2258 was written against (its message names this flake); harper-pro's CI hits it in roughly a third of runs, and the local oracle reproduces it deterministically once the create window is widened (see Verification). The receiver-side additive rule (#2258 + #750) contains the damage from a partial snapshot; this change stops the snapshot from being produced, which also covers every other consumer of the scanning thread'sTable.attributes(describe, REST/GraphQL validation, MQTT on that worker).For the human reviewer
catalog-ordering-vs-write-batch). The planning-mode review returnedFraming-Verdict: better-alternative-exists: stage the new table's catalog rows (NEXT_TABLE_ID, attribute rows, primary row, reconcile removals) into oneRocksTransactionandcommitSync()it under the lock, asTable.tsalready does for the replication cursor. I chose ordering because the guarantee the bug needs — no thread can ever build or announce a partialTable— is identical under both; the batch's extra benefit is confined to the catalog rows of a crashed create (a create that merely throws now removes its own rows), while the column families opened before a crash are orphaned either way. Cost side: the batch is RocksDB-only and has to be threaded through five catalog write sites inside a loop shared with the existing-table path, plus abort handling; the ordering change is one deferred put that also holds on LMDB, whereexclusiveLock()already is an environment-wide write transaction. Reversible: the batch can be layered on later without touching the loader rule. A "no" means rewriting the new-table catalog writes as a staged transaction before merge.interrupted-create-semantics,skip-vs-salvage-partial-catalog).initStoresrefuses to load a table whose catalog has attribute rows but no primary row; the alternative is to derive the primary from an attribute row carryingisPrimaryKey, which the loader still accepts for pre-5.x catalogs. So a create that crashes mid-way leaves orphan attribute rows and the column families opened before the crash (unchanged), and the table does not load — one warn per table per thread, then debug — untilcreate_tableis re-run, which writes the rows, reuses the families, and reconciles orphan rows away regardless of origin (rows found under the lock for a table with no primary row can only be aborted state). Previously it loaded as a table missing declared attributes. Salvaging would re-open the partial-announcement hole for any writer that persists a primary-key attribute row before the marker. Operators of API-onlycreate_tabletables see the new failure shape;@table-declared tables self-heal on redeploy.rollback-scope,publish-point). The class is published only with the primary row, so a create that throws after the primary store opens is rolled back and the retry rebuilds from scratch (re-runningmakeTable(), consuming anotherNEXT_TABLE_ID): the attribute rows it wrote are removed,Table.cleanup()releases the audit delete-removal callback and its store slot, the storage-reclamation handler (new per-handlerremoveStorageReclamationHandler— a RocksDB column family shares its reclamation path with the whole database), the TTL cleanup timer and theexpiresAteviction interval (with a disposed guard so a run in flight does not re-arm), and on RocksDB the primary and index column-family handles are closed;makeTable()releases its own registrations when it throws, andopenIndex()closes a RocksDB handle whose format resolution or custom-index constructor throws. On LMDB the stores are left alone: an LMDB store is a per-environment handle slot shared with every thread and still inside the creator's write transaction (closing it there fails the transaction withMDB_BAD_DBI, which the failure-injection test caught). The alternative — publishing early with a "loading" flag so the failing worker can repair in place — was rejected because nothing in the create loop looks the table up by name, and it would give the "no partial table anywhere" property back. Past the publish point nothing unwinds the catalog:publishedflips immediately after the primary-row write, before the class is registered. The row is durable from there — on LMDBreleaseLock()'sfinallycommits this create's write transaction even while an error unwinds — so a throw from the registration or from the relationships-persistence block must leave the rows alone; rolling back there deletes the attribute rows while the primary row stays committed, which is the primary-only schema this PR exists to prevent. A class the registration never accepted is unreachable, so it is released throughTable.cleanup()(reclamation handler, audit delete-removal callback, TTL timer,expiresAtinterval). Declined: its RocksDB primary and index handles are left open. Unlike the pre-publish rollback, where nothing else can be holding the table, the table is durable here and another thread may already be serving it, so closing its column families from the failing thread risks invalidating a handle in use; the price is a bounded native-handle leak on a path that only fires if thedatabasesmap itself rejects the assignment.index-registration-timing). An unpublished class registers a newly opened index before its descriptor persists so the rollback can close it; an existing table keeps the original post-persistence registration, so a failed descriptor write duringadd_attributeleaves its live index map unchanged. A reviewer may prefer a local list of opened handles for the rollback instead of the early registration.cleanup-on-class-eviction,dispose-via-existing-static-cleanup,evict-stale-generation-during-create-window). The rescan cleanup pass used to drop a class whose table (or database) was dropped elsewhere with a baredelete, leaving its audit delete-removal callback, reclamation handler, and timers registered for the life of the process; it now calls the class'scleanup()first. That method's meaning broadened from "remove the audit callback" to an irreversible dispose (timers cleared, any reclamation pass awaiting it settled) — it has no callers in core or harper-pro today, so the name was kept; renaming todispose()is cheap now and expensive once harper-pro pins a core commit. A component that capturedtables.Xand keeps using it across a drop/recreate now holds a disposed class instead of a stale-but-armed one pointing at dropped column families. And a worker whose rescan finds the recreate's incomplete catalog drops the generation it still holds a few ms early — "table not found" instead of "stale reads" on that worker for the create window — which is the behaviour the thread test pins. Settling the reclamation pass that awaits a scheduled cleanup is now the scan's job, not the scheduling's: when a laterscheduleCleanup()supersedes a pending pass, the replacement adopts its awaiters and settles them when its own scan completes, and runs immediately whenever it adopts any —runReclamationHandlersblocks its whole path on that promise, and the replacement's own slot can be a full interval out. Settling without a scan is left only where no scan can follow:scanInterval0, dispose, and a closed root store. No focused test drives a supersede while a reclamation run awaits the pass — that promise is reachable only throughrunReclamationHandlers(), which walks every path registered in the process, so a test for it would drive every other table's cleanup scan as a side effect; the ordering was verified by tracing the serialized scan chain.create-treats-every-existing-row-as-aborted), even for attribute names it redeclares — where to look hardest: this reconcile is now also active for cluster-origin creates, whichorigin: 'cluster'otherwise exempts — clean-slate semantics, at the cost of an aborted attempt'slastIndexedKey/indexFormat, so a retry rebuilds an index from scratch rather than resuming. Cheap to soften later.replication-proof-stops-at-the-core-event-boundary). The worker-thread test asserts noupdateTablefires mid-create, which is the boundary this repo owns; peer forwarding lives in harper-pro, and the executed harper-pro oracle runs below are the end-to-end evidence. Nothing here would catch a future decoupling ofupdateTablefrom peer forwarding.rollback-closes-but-does-not-drop). A retry reuses the families the failed attempt opened (the same remnant fallback an interrupted drop relies on), so a retry with different compression/codec options inherits the aborted attempt's family settings. Dropping them would be adropSync()under the same lock; left as is because the retry path is the oneopenRocksDatabasealready documents for remnant families.warn-latch-for-process-lifetime). First observation warns with the attribute names, later scans log at debug until the table loads; the state needs an operator to re-runcreate_table, and a warn per rescan on every thread (a multi-KB line for a wide table) was the alternative. Trivially reversible.table()call (not a rescan) takes the existing-table branch and mutates the stale class; it never revalidates the class against the durable primary descriptor under the lock. (b)initStoresruns the interrupted-drop reconcile from an unlocked catalog snapshot andcompleteInterruptedDropnever re-reads the tombstone under the lock, so a thread that read adroppingrow before another thread completed the drop and recreated the table can delete the new generation's column families and rows. (c)dropDatabase()deletes a database's table map without disposing the classes in it, so a create/drop-database cycle retains every table's reclamation handler, audit callback and timers for the life of the process — the rescan path is disposed here, the direct drop is not. (d) The de-index reconcile resolves its dbi asTable.indices[attributeTableName]while that map is keyed by attribute name, so it is alwaysundefinedand a de-indexed attribute's column family is never dropped (on the create path the map is also still empty there, so the key alone would not reclaim an aborted attempt's index family). All four predate this change; the rescan half of (a) is covered here.Verification
unitTests/resources/createTableCatalogOrder.test.js(new), run on RocksDB and on LMDB: (a) records the catalog write order for a create with a primary key and two attributes and asserts<table>/is the last row; (b) creates a table with a relationship attribute and asserts the row written at the publish point already carries the normalized relationship list, is the last catalog write, and is not rewritten by the relationship-persistence block that follows it (it fails — two primary-row writes — with the assignment of the relationships onto the deferred row disabled); (c) injects a throw on thetagattribute-row write (its index store is already open) and asserts the class is not registered and the primary row was never written, then retries and asserts the class, every attribute, the index, and the primary row; (d) on RocksDB, spawns a worker thread that first loads a dropped generation of the same name, then scans while the recreate is paused after its first attribute row — asserting the table is not loaded (neither generation), noupdateTablewas emitted, and, as a positive control, that the attribute row was readable and the primary row was not — then rescans after the create returns and asserts[id, name, tag]and the primary row (skipped on LMDB, where the scan blocks on the creator's write transaction instead of observing the catalog); and (e) injects a failure at the publish boundary — a throwing setter ondatabases.test[table], so the create fails at exactly the registration step — then asserts the unreachable class released its reclamation handler, that every catalog row survived, and thatresetDatabases()reloads a table with all three attributes whose primary store andtagindex both serve traffic. (e) fails on both engines withpublishedset aftersetTable()instead of before it (the 'name' row must survive a failure after the publish point). (a) and (d) fail onorigin/main(CatalogOrderTest/,CatalogOrderTest/name,CatalogOrderTest/tag;a scan during the create must not load the table, got attributes id,name) with a forced rebuild between.npm run test:unit:resources: green on RocksDB (1794 passing) and LMDB (1501 passing);unitTests/server/storageReclamation.test.jsgreen.npm run test:unit:main: 5112 passing, 1 failing —configValidator … does not warn when a relative rootPath resolves within the limit, which assertspath.resolve('relative/root', 'operations-server')stays under the 107-byte socket limit fromprocess.cwd(); this worktree's cwd is 99 bytes, so it fails onorigin/mainfrom here too. (components … nonInteractiveSpawn git credential scopingalso fails from a shell that exportsGIT_CONFIG_GLOBAL; this run usedenv -u GIT_CONFIG_GLOBAL -u GIT_EDITORand it passes.)npm run test:integration:all(run atbf65b069, not re-run for the publish-point commits, which only move a flag and add an error-path release): 1888 pass, 0 fail, 6 cancelled —integrationTests/server/ollama-backend.test.tsdies at import under this machine's Node v26.2 (ERR_IMPORT_ATTRIBUTE_MISSINGonjson/systemSchema.json), unrelated. The cleanup-scheduling change was re-checked against the eviction/TTL integration files it can reach —integrationTests/database/eviction-*.test.ts,ttl-*.test.ts,apiTests/ttl.test.ts: 40 pass, 0 fail.integrationTests/cluster/replicationLoad.test.mjsin a harper-pro worktree (origin/main, owncoresubmodule),npm run buildbetween every switch. Unfixed core (ad9854be), plain: passed once — the race is timing-dependent (~1 in 3 CI runs). Unfixed core + a temporary, uncommitted 150 msAtomics.waitafter the primary row put (widening the mid-create window): fails with{"error":"unknown attribute 'name'"}; node logs show two partial(Re)creating … attributes: [id]announcements per node and 6–7defined locally … does not existerrors per node — the CI artifact's signature. Fixed core (this branch's final head) + the same 150 ms wait moved to just before the deferred primary row put: passes 4/4; node logs show zero partial announcements, zerodefined locallyerrors, and threeSkipping table test: its catalog has attribute rows but no primary key rowwarns across two nodes — scans did land inside creates and skipped them. Fixed core, plain: 4/4, zero partial announcements.Refs HarperFast/harper-pro#775, HarperFast/harper-pro#750, #2258
Complexity: complicated
Review-Coverage: authored=claude; ran=codex; blocked=gemini(auth); declined=cursor-grok,cursor-composer,domain; rounds=10 @ e6ec3b8
Human-Review-Need: 3 @ e6ec3b8