Repository navigation
Refuse a peer's retired table generation, and keep newer or node-local tables when a peer drops one - #3119
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the table drop lifecycle and replication mechanism by introducing lifecycle stamps and catalog-locked enforcement for table creation and deletion. It ensures that peer drops do not retire node-local tables, and introduces a REPLICATED_FROM symbol to safely track forwarded operations from peer nodes. Additionally, it implements boundPreStampCreations to bound pre-stamp table creations during catalog loading, and updates the thread communication to broadcast table drop markers. A review comment identifies a critical issue in boundPreStampCreations where snapshot.key is accessed on the primary attribute object, which does not inherently carry a key property, potentially causing attributesDbi.getSync(undefined) to fail.
A create that carries a peer's generation is refused under the catalog lock when a drop marker here retires it, and an unstamped one is kept at 0 instead of "now". A peer's drop never retires a replicate:false table, and with a time only a generation created before it, re-checked against the locked catalog row; the marker is recorded either way. Peer provenance comes from the operation context, never the JSON body. A node-local table's drop leaves no marker and is not forwarded, and dropTableMeta keeps a live or still-dropping primary row. Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…icate from the catalog first A stamped peer generation a drop retired could still merge its attributes into the live table through the existing-table branch; it is refused there too. A persisted replicate flag on the catalog row wins over this thread's class, which alone carries only runtime exclusions. Adds a test-only switch for a node that simulates a build before the stamps. Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…p a narrating comment Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…ld that stamps A table with no createdTime was created by a build that stored none, so before the first load on a build that stamps; that load writes the bound as createdBefore on its primary row. A drop recorded after the bound retires the table outright, so only an older drop leaves the replication layer's upgrade heuristic anything to decide. A table created during a rollback to such a build gets its own bound at the next load. Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…op epoch Each worker owns its own replication connections, so a marker learned on one must reach the others: to re-announce it to their peers, and to rejudge a peer generation their connections accepted before the marker existed. Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…on the loaded row Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…ncurrent bound and system-table exclusions A drop announcement queued before its handler registered is replayed outside manageThreads' catch, so the handler contains listener errors itself. A load that finds another thread already bounded an unstamped table keeps that bound on its snapshot, which its tableId repair may write back. A runtime-excluded system table stays node-local whatever its catalog row says. Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…cast reaches the others Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
… recorded marker Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…completed drop over its metadata cleanup Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…mment fixes Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
A schema reload on the dropping thread (one the drop's own schema signal can trigger) found the LMDB drop's tombstone and completed it mid-drop, so the drop then threw "a replacement table became current". The reload already skips a drop generation marked in progress; only the RocksDB path marked it. Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
…nnounce a drop whose cleanup cannot open its root Refs #1212 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT Dispatch-Task: hp-956-core-enforced-lifecycle
A cleanup failure after the tombstone left the mark held for the worker's life, so every later load on that thread skipped the tombstone instead of completing the drop. Dispatch-Task: hp-956-core-enforced-lifecycle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCffnMMpweL8Q9EceYqBbT
…n how they compose After rebasing over #3118: a node-local drop's untimed name history is read as no drop by every drop-time reader, a refused peer create leaves no create journal or store, and a kept peer drop journals nothing for reclamation. retireRocksStores now also resolves false when another thread completed the tombstone; this drop still retired its generation, so dropTable resolves true either way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMpTV1zehf49BFieVUy7d1 Dispatch-Task: harper-3119-rebase-main-3118
…inish three comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMpTV1zehf49BFieVUy7d1 Dispatch-Task: harper-3119-rebase-main-3118
fb03fbb to
6ec39b2
Compare
⊙ Problem
harper#2962 gave each table generation a
createdTimeand left a durable drop marker, but core's create and drop never consult them. Every caller had to check "a generation created before a drop of its name is dead" itself, and harper-pro#956 did so at each replication ingress. Thirteen review rounds kept finding paths where that check and the catalog mutation it guarded came apart:drop_tabledropped whatever generation held the name, including one created after the drop.replicate: falsetable, breaking harper-pro#943's node-local contract.💡 Solution
The rule is enforced where a generation becomes or stops being live, under the lock that publishes its primary catalog row or writes its drop tombstone:
origin: 'cluster', or a suppliedcreatedTime) throwsTableGenerationDroppedError(409,TABLE_GENERATION_DROPPED) when a drop marker here retires it. So does a stamped peer definition of a name that is live here, before its attributes merge into the newer generation. A peer that kept no stamp is stored at0: any later drop retires it, nothing backfills it, and it is never confused withundefined(a table created on a pre-stamp build). Local creates are unchanged; they are stamped above the newest marker.dropTable({ peer: true, droppedTime })never retires areplicate: falsetable, and with a time it retires only a generation created before it.writeTombstonere-checks the row it locked, beside its table-id and storage-generation identity check.false. The peer's time is recorded as a marker either way.dropTablenow resolvestruewhen it dropped (including a tombstone left for a pending full-text retirement) andfalseotherwise.server.operation(body, { user, replicatedFrom })sets theREPLICATED_FROMsymbol, which a JSON request cannot carry. The bridge treats a drop as a peer's only through it.schema.dropTablediscards a client'sdroppedTimeand rejects a malformed peer time.replicate: falsetable writes nodroppedTimeand is not forwarded. A marker from an earlier, replicated generation of the name is kept and still sent.replicateis read from the catalog row first; the class flag alone covers runtime exclusions, and non-replicating system tables are always node-local.dropTableMeta. Under the catalog lock, it removes a name's rows only when no primary row exists, live or still dropping. It still announcesdropTableif its lock times out, andschema.dropTableno longer fails a completed drop over it.isDroppedPeerGeneration: one keyed read of the marker and any live tombstone, with the recorded rolling-upgrade exception.createdBefore: an upper bound written on each unstamped table at the first load on a stamping build. A table created during a rollback gets its own bound at the next load.tableDropEpoch.main. Every exit releases the mark, a failed table cleanup included, so the next load still completes a failed drop's tombstone.HARPER_TEST_OMIT_TABLE_LIFECYCLE, makes a node store no stamps, markers or bounds; harper-pro#956's cluster test uses it to stand in for a pre-stamp build.How this composes with #3118
Rebased onto Preserve 5.2 rollback compatibility for first-time table creates (#3118), which touches the same create, drop-marker and retirement paths. Each path of this PR traced against it; none conflicts in behavior. Two terms differ: #3118's "unstamped" create is one whose stores have no name suffix (
T/@<generation>), while this PR's "unstamped" generation has nocreatedTime. They are separate fields.generationattribute is replaced by this node's namingcreatedTimeas0. The create journal carries nocreatedTime, its recovery writes none, and thecreatedBeforebound skips a finite0.writeTombstone, before any tombstone exists. A kept generation writes no tombstone and no retirement journal, so neither path runs for it. Reclamation's live-store guard (liveStoreNamesFor) also covers it, since its row is live. Test: it journals nothing and survives a reload, which runs reclamation.droppedTimeleaves an untimed/dropped/<table>row as name historygetTableDrops,pendingOrRecordedDropTime,isDroppedPeerGeneration, both create checks, and harper-pro#956'stableLifecycle.ts. Nothing reaches peers, and a peer's timed drop overwrites it (test).createdBeforeboundcreatedTimeand gets its own bound at the next load.cleanup()throwsdropTableresolvestruewhen it droppedretireRocksStoresnow also resolvesfalsewhen another thread completed the tombstonetrue. That is still right, because this drop wrote the tombstone, so they are now one call with that comment.The two lifecycle descriptions are now one note: untimed name history beside the timed markers, and store names and 5.2 rollback beside the stamps.
⚖️ Alternatives
dropTableMetacall and add atableReplicatesguard): this leaves the delayed-RPC drop, the create race, the joining-node "now" stamp and the RPC drop of a node-local table.createdBefore: rejected. After a rollback to a pre-stamp build, it would retire tables recreated during the rollback.🔧 Changes
Product and architecture tour
The rule runs where a generation goes live
Where is "a generation created before a drop of its name is dead" enforced?
Creating a peer's generation, under the catalog lock
origin: 'cluster'or a suppliedcreatedTime; a definition with no stamp is taken as0, never "now".TableGenerationDroppedError(409,TABLE_GENERATION_DROPPED).0andundefinedmean different things.0is a peer generation whose node kept no stamp: any drop retires it and nothing backfills it.undefinedis a table created on a build that stored no stamp.A peer's drop retires only an older, replicating generation
What does a peer's drop do to a generation created after it, or to a
replicate: falsetable?A peer's drop
writeTombstonere-reads the row, confirms it is the same generation, and applies the same rule to the locked row.false.true.dropTableresolvestruewhen it dropped andfalsewhen it kept the table or found another generation.schema.dropTableanswersfalsewith a 200 "was not dropped" message and does not forward it.A peer's drop is named outside the body
How does core tell a peer's forwarded
drop_tablefrom a client's?replicatedFromfrom the operation body. A body carrying it with a finitedroppedTimeretired whatever generation held the name, a newer one included, and any client could write both fields.server.operation(body, { user, replicatedFrom })sets theREPLICATED_FROMsymbol, which a JSON body cannot carry. Only a drop with it is a peer's, judged by the rule above. A client'sdroppedTimeis discarded, and a peer's malformed one is rejected.Node-local tables stay node-local
What happens to a
replicate: falsetable when a peer drops a table of the same name?replicate: falsetable. A node-local table's own drop left a marker and was forwarded to peers.droppedTimeand is not forwarded; on RocksDB it leaves untimed name history, which no peer reads.replicateis read from the catalog row first, so another thread's redeclaration counts, and non-replicating system tables are always node-local.A marker from an earlier, replicated generation of the name is kept and still sent after the name becomes node-local. Filtering markers by the name's current flag would leave an offline peer's replicated copy alive.
Facts the replication layer reads
What does core give the replication layer, which judges a peer's records without mutating the catalog?
isDroppedPeerGeneration(db, table, createdTime)droppedTime, with the rolling-upgrade exceptioncreatedBefore(catalogCreatedBefore)tableDropEpoch()Drop bookkeeping a live generation survives
What can the metadata cleanup and a schema reload no longer do to a live table?
dropTableMetaused to remove every catalog row of the name. Under the catalog lock it now removes them only when no primary row exists, live or still dropping, and it announces the drop even when it cannot take the lock.schema.dropTablelogs a cleanup failure instead of failing a drop that completed.Every changed file:
resources/databases.tsisDroppedPeerGeneration,isNodeLocalTableandreplicateIsFalse, which reads the catalog row first.withCatalogWrite: the RocksDB lock or the LMDB write transaction, one helper for both.createdBeforebound: written once per unstamped row, read, at each load.tableDropEpoch, andrecordTableDrop's lock-free fast path.dropTableMetakeeps live and dropping rows, and the test switch.resources/Table.tsdropTable's options and boolean result,keptFromPeer,recordPeerDrop.retireRocksStores, whosefalseno longer means only a pending retirement.dataLayer/schema.ts: provenance and thedroppedTimescrub, a peer's drop of a table already gone, a node-local drop is not forwarded, the "was not dropped" reply, a cleanup failure is logged.dataLayer/harperBridge/ResourceBridge.ts: a drop is a peer's only throughREPLICATED_FROM.server/serverHelpers/serverUtilities.ts:operation()takesreplicatedFromfrom its context.utility/hdbTerms.ts: theREPLICATED_FROMsymbol.utility/errors/hdbError.ts:TableGenerationDroppedError, a 409 with codeTABLE_GENERATION_DROPPED.resources/DESIGN.md: the lifecycle note gains where the rule is enforced, and is rewritten as one description with Preserve 5.2 rollback compatibility for first-time table creates #3118's: untimed name history and store names and 5.2 rollback;DESIGN.md: its index line.✅ Verification
On head
6ec39b226, rebased ontomainat87394e7c3(which includes #3118):dropTableLifecycle.test.jswith Preserve 5.2 rollback compatibility for first-time table creates #3118'sdropTableGeneration.test.js: 56 passing on RocksDB, 39 on LMDB (HARPER_STORAGE_ENGINE=lmdb; the rest skip on that engine).test:unit:resources: 4087 passing on RocksDB and 3090 on LMDB, none failing.test:unit:main: 6837 passing, none failing. These full runs were on19e28e894;6ec39b226changes only one test assertion and three comments, and the two lifecycle files above were re-run on it.integrationTests/upgrade/first-create-downgrade.test.tsagainst harper 5.2.15: 1 of 1 passing, not skipped. This and harper-pro#956's cluster suite are the kill-and-restart coverage. The lifecycle unit tests stand in for a crash by reloading in-process.check:design-docs, prettier and oxlint pass.closeMaintenance); a node-local table is never retired, and its own drop leaves no marker and is not forwarded.replicateread from the catalog first, with system-table exclusions kept;dropTableMetakeeps live and dropping rows; provenance comes fromserver.operation's context, a body's claim is a client's drop, and a client'sdroppedTimeis ignored.isDroppedPeerGenerationfor equal, 0 and undefined stamps and for a drop still completing;createdBeforewritten once and kept; a recorded marker advances the drop epoch.unitTests/dataLayer/schema.test.js: its bridge stub resolvestrue, matchingdropTable's new result.mainwith the sources swapped back anddistrebuilt, 11 of the first 24 lifecycle tests failed: every new behavior test, plus the tests updated for the new API. On thatmain, the LMDB adjacent-suite combination failed 6 of 6 runs from the drop race fixed here.coreat this head (6ec39b226), built in its own worktree andnode_modules:dropTableOfflinePeer.test.mjs: 8/8, scenarios 1, 1b, 2/2b, 2c, 3, 4, 5 and 6. Scenario 3's pre-stamp node runs withHARPER_TEST_OMIT_TABLE_LIFECYCLE. Before this work, scenarios 5 and 6 failed on harper-pro#956's previous head.replicateFalseFullCopy,excludeTablesReplication,protocolCapabilityRegistryandselectiveTableSubscription: 7/7. The replication unit suite: 1214 passing.Refs #1212
Signed: Claude Opus 5.5 (rebase onto #3118 and its composition trace by Claude Opus 5.5)
🤖 Generated by Anthropic Claude (Opus 5.5) in Claude Code; posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KMpTV1zehf49BFieVUy7d1
Related PRs: #3118 overlaps (merged and now this branch's base; the composition is traced above), #3124 overlaps (the v5.3 backport of #3118; a backport of this PR would carry the same composition), #2155 overlaps (the tombstone write in
dropTable), #2901 overlaps (the same DESIGN sections; its note that an offline peer re-creates a dropped table goes stale once this and harper-pro#956 land), #2962 overlaps (merged; the stamps and markers this enforces), 8 others independentComplexity: complicated
Review-Coverage: authored=claude; ran=cursor-composer,codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=11; full=2 @ 6ec39b2
Review-Attention: deep ~50m (critical: ResourceBridge.ts, schema.ts +2; decisions: peer-provenance-symbol, unstamped-peer-create-at-zero, core-retires-unstamped-on-peer-drop, kept-drop-reply-shape, test-env-flag-in-production, do-less-alternative) @ 6ec39b2