Skip to content

Keep a table being created invisible to catalog scans on other threads, so replication can never announce a partial attribute list - #2381

Merged
kriszyp merged 20 commits into
mainfrom
fix/catalog-scan-mid-create-partial-table
Sep 1, 2026
Merged

Keep a table being created invisible to catalog scans on other threads, so replication can never announce a partial attribute list#2381
kriszyp merged 20 commits into
mainfrom
fix/catalog-scan-mid-create-partial-table

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 28, 2026

Copy link
Copy Markdown
Member

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 row initStores needs 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, and initStores skips 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-handler removeStorageReclamationHandler, 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 failure replicate 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 Table map 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 individual putSync writes and the cross-thread update-attributes lock is taken only by writers, so on node 127.0.0.4 the http/1 thread — rescanning because main/0 had just signalled db6 — landed inside main/0's create_table db7.test {id, name} (10:43:40.316–.361), built db7.test with attributes: [id], and emitted updateTable; harper-pro forwarded that snapshot to its peers over the system connection. Node 127.0.0.2's replication thread had not yet loaded db7 (its own main/0 create 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 through ensureTabletable() as an authoritative definition, whose removal reconcile deleted the local name descriptor. create_table had already returned success; every later complete announcement hit the schemaDefined honor-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), and search_by_value on name failed.

This is not a regression of the 30db07ac..9a24b055 core range. The same test failed identically on harper-pro main on 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 pass origin: 'cluster' — nothing on harper-pro main does; 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's Table.attributes (describe, REST/GraphQL validation, MQTT on that worker).

For the human reviewer

  1. Write ordering rather than an atomic RocksDB write batch (catalog-ordering-vs-write-batch). The planning-mode review returned Framing-Verdict: better-alternative-exists: stage the new table's catalog rows (NEXT_TABLE_ID, attribute rows, primary row, reconcile removals) into one RocksTransaction and commitSync() it under the lock, as Table.ts already does for the replication cursor. I chose ordering because the guarantee the bug needs — no thread can ever build or announce a partial Table — 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, where exclusiveLock() 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.
  2. An interrupted create is an absent table, not a partial one (interrupted-create-semantics, skip-vs-salvage-partial-catalog). initStores refuses to load a table whose catalog has attribute rows but no primary row; the alternative is to derive the primary from an attribute row carrying isPrimaryKey, 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 — until create_table is 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-only create_table tables see the new failure shape; @table-declared tables self-heal on redeploy.
  3. Rollback scope of a create that throws (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-running makeTable(), consuming another NEXT_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-handler removeStorageReclamationHandler — a RocksDB column family shares its reclamation path with the whole database), the TTL cleanup timer and the expiresAt eviction 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, and openIndex() 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 with MDB_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: published flips immediately after the primary-row write, before the class is registered. The row is durable from there — on LMDB releaseLock()'s finally commits 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 through Table.cleanup() (reclamation handler, audit delete-removal callback, TTL timer, expiresAt interval). 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 the databases map itself rejects the assignment.
  4. Index registration timing (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 during add_attribute leaves its live index map unchanged. A reviewer may prefer a local list of opened handles for the rollback instead of the early registration.
  5. Evicted classes are now disposed, not just unlinked (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 bare delete, leaving its audit delete-removal callback, reclamation handler, and timers registered for the life of the process; it now calls the class's cleanup() 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 to dispose() is cheap now and expensive once harper-pro pins a core commit. A component that captured tables.X and 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 later scheduleCleanup() supersedes a pending pass, the replacement adopts its awaiters and settles them when its own scan completes, and runs immediately whenever it adopts any — runReclamationHandlers blocks 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: scanInterval 0, dispose, and a closed root store. No focused test drives a supersede while a reclamation run awaits the pass — that promise is reachable only through runReclamationHandlers(), 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.
  6. A create discards every row it finds for its table (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, which origin: 'cluster' otherwise exempts — clean-slate semantics, at the cost of an aborted attempt's lastIndexedKey/indexFormat, so a retry rebuilds an index from scratch rather than resuming. Cheap to soften later.
  7. The replication proof stops at the core event boundary (replication-proof-stops-at-the-core-event-boundary). The worker-thread test asserts no updateTable fires 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 of updateTable from peer forwarding.
  8. The rollback closes column families but does not drop them (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 a dropSync() under the same lock; left as is because the retry path is the one openRocksDatabase already documents for remnant families.
  9. Interrupted-catalog warn is latched per table per thread (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-run create_table, and a warn per rescan on every thread (a multi-KB line for a wide table) was the alternative. Trivially reversible.
  10. Pre-existing gaps, not fixed here — worth their own issues. (a) A worker still holding a class from a dropped same-name table that receives a direct 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) initStores runs the interrupted-drop reconcile from an unlocked catalog snapshot and completeInterruptedDrop never re-reads the tombstone under the lock, so a thread that read a dropping row 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 as Table.indices[attributeTableName] while that map is keyed by attribute name, so it is always undefined and 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.
  11. Rolling upgrades. The invariant holds only once every schema-creating node runs this code — an older peer still announces primary-first snapshots — so the receiver-side rule (Make cluster-origin table definitions additive-only so a peer's partial schema snapshot cannot destroy locally declared attributes #2258, and Apply peer table definitions additively so a partial DB_SCHEMA snapshot cannot destroy locally declared attributes harper-pro#750 which is still open) stays necessary and this is not a substitute for merging [Models] Derived caching table for @embed (low-latency writes + model-change backfill) #750. Stated in DESIGN.md.

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 the tag attribute-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), no updateTable was 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 on databases.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 that resetDatabases() reloads a table with all three attributes whose primary store and tag index both serve traffic. (e) fails on both engines with published set after setTable() instead of before it (the 'name' row must survive a failure after the publish point). (a) and (d) fail on origin/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.js green. npm run test:unit:main: 5112 passing, 1 failing — configValidator … does not warn when a relative rootPath resolves within the limit, which asserts path.resolve('relative/root', 'operations-server') stays under the 107-byte socket limit from process.cwd(); this worktree's cwd is 99 bytes, so it fails on origin/main from here too. (components … nonInteractiveSpawn git credential scoping also fails from a shell that exports GIT_CONFIG_GLOBAL; this run used env -u GIT_CONFIG_GLOBAL -u GIT_EDITOR and it passes.) npm run test:integration:all (run at bf65b069, 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.ts dies at import under this machine's Node v26.2 (ERR_IMPORT_ATTRIBUTE_MISSING on json/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.
  • End-to-end route: harper-pro integrationTests/cluster/replicationLoad.test.mjs in a harper-pro worktree (origin/main, own core submodule), npm run build between every switch. Unfixed core (ad9854be), plain: passed once — the race is timing-dependent (~1 in 3 CI runs). Unfixed core + a temporary, uncommitted 150 ms Atomics.wait after 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–7 defined locally … does not exist errors 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, zero defined locally errors, and three Skipping table test: its catalog has attribute rows but no primary key row warns 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

kriszyp and others added 15 commits August 28, 2026 10:23
…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
@kriszyp
kriszyp requested review from cb1kenobi and heskew August 28, 2026 18:02
@kriszyp kriszyp added this to the v5.3 milestone Aug 28, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread resources/databases.ts
@kriszyp
kriszyp marked this pull request as ready for review August 29, 2026 03:29
@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread resources/databases.ts
Kris Zyp and others added 3 commits September 1, 2026 12:04
…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>
Comment thread resources/databases.ts
Comment thread resources/Table.ts Outdated
Kris Zyp and others added 2 commits September 1, 2026 13:30
…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>

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed e6ec3b8 and found no blocking issues. No confirmed blocking defects remain on the changed lines. Previously raised publish-ordering and reclamation issues are addressed at this commit.


Generated by Barber AI

@kriszyp
kriszyp merged commit 237191b into main Sep 1, 2026
77 of 79 checks passed
@kriszyp
kriszyp deleted the fix/catalog-scan-mid-create-partial-table branch September 1, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants