Repository navigation
Stamp table generations and keep a durable drop marker so a peer that missed a replicated drop_table cannot bring the table back - #2962
Merged
Conversation
…etire a generation created before a drop
A replicated drop_table is a one-shot operation RPC, and the dropping tombstone is
removed once the drop completes, so nothing durable said a table was dropped: a peer
offline for the drop kept its copy and brought the table back through the schema
handshake (harper#1212, cluster case; the single-node case is harper#2901).
Every generation now carries createdTime on its primary catalog row, taken from the
definition when a peer's propagated one supplies it. dropTable writes droppedTime
into the dropping tombstone it already persists before any destructive work, and
every completion path (RocksDB, LMDB, completeInterruptedDrop) promotes it to a
/dropped/<table> marker row before removing the tombstone, so neither fact depends
on a second write landing. Both stamps come from the record-version clock;
isDeadGeneration(createdTime, droppedTime) is strict, an equal stamp survives and a
missing one reads as 0. getTableDrops, recordTableDrop, onTableDropRecorded and
dropTable({ droppedTime }) are the replication layer's API for exchanging and
applying the markers.
Refs #1212.
Dispatch-Task: harperpro-1212-drop-table-cluster
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011c1WDgq57ETwb7spbF41hA
A recreate is stamped after the newest drop marker of its name and after any interrupted drop it completes, and a local drop is stamped after the generation it retires, so a peer clock running ahead cannot make a live generation read as dead. Marker writes are serialized (catalog lock on RocksDB, write transaction on LMDB) and announced only once committed; a drop joining one already in flight raises the tombstone's time instead of discarding the newer one; dropTableMeta promotes a tombstone it is about to remove; a throwing listener is contained. Dispatch-Task: harperpro-1212-drop-table-cluster Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011c1WDgq57ETwb7spbF41hA
…it, and invent no time for a pre-stamp one: review round 2 A drop a client sent with replicated:false leaves no drop marker; the bridge reads the flag, and the replication layer stamps replicatedFrom on a peer's forwarded drop so that one still does. A promotion re-reads the live tombstone inside its serialized section, so a drop that joined and raised the time cannot be promoted from an earlier copy on LMDB. A tombstone written before the stamps existed gets no synthesized marker: a time made up at completion could postdate a peer's live recreate. Dispatch-Task: harperpro-1212-drop-table-cluster Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011c1WDgq57ETwb7spbF41hA
…tone a replicating drop joins: review round 3 The bridge passes a forwarded drop's droppedTime to the table only when the replication layer stamped replicatedFrom on it, so every node retires the same generations whatever its clock, while a client's time stays untrusted. A drop that replicates and joins a tombstone a local-only drop left without a time stamps it, so the drop that replicated leaves its marker. Dispatch-Task: harperpro-1212-drop-table-cluster Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011c1WDgq57ETwb7spbF41hA
…g, and record a forwarded drop of a table already gone: review round 4 stampTableCreatedTime writes the stamp of a generation created on a build that kept none, once a peer's stamped definition and the table's own rows prove which generation it is; catalogCreatedTime reads the stamp a class loaded on another thread may not carry yet. pendingOrRecordedDropTime gives a forwarded drop the origin's time even while a full-text retirement is still completing. A peer's forwarded drop of a table already gone here records its time instead of a 404. Dispatch-Task: harperpro-1212-drop-table-cluster Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011c1WDgq57ETwb7spbF41hA
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces table lifecycle stamps (createdTime and droppedTime) and durable drop markers to prevent offline peers from resurrecting dropped tables during schema handshakes (harper#1212). The changes span across ResourceBridge.ts, schema.ts, Table.ts, and databases.ts, and are accompanied by a comprehensive test suite in dropTableLifecycle.test.js. The review feedback recommends replacing implicit type coercion checks in range comparisons (where droppedTime might be undefined) with explicit checks to avoid potential bugs and improve code readability.
kriszyp
added a commit
to HarperFast/harper-pro
that referenced
this pull request
Oct 1, 2026
The core pointer keeps this branch's target, HarperFast/harper#2962's head, which already contains the commit main moved to (Sync Core #954). Dispatch-Task: harperpro-1212-drop-table-cluster Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011c1WDgq57ETwb7spbF41hA
This was referenced Oct 4, 2026
This was referenced Oct 5, 2026
kriszyp
marked this pull request as ready for review
October 5, 2026 19:26
This was referenced Oct 5, 2026
This was referenced Oct 5, 2026
kriszyp
added a commit
that referenced
this pull request
Oct 8, 2026
The conflict hunks pulled in main's #2962 lifecycle machinery (timed drop markers, createdTime stamps, replication helpers), which is milestoned v5.4 and absent here; it is dropped. #3118 keys "this name has history" off the /dropped/<table> row that #2962 writes at every drop completion, so v5.3 gets only the untimed form #3118 itself introduced: both RocksDB completion paths (retireRocksStores, completeInterruptedDrop) record it before removing the tombstone, and the load parser skips /dropped/ rows. 5.4 reads the same row as its untimed marker. The design notes keep v5.3's section heading and drop the lifecycle-stamp paragraph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYAaU3uVxkbjHe19UNWRfK Dispatch-Task: cherry-resolve-kriszyp_harper_3118-6e6e74cf
kriszyp
added a commit
that referenced
this pull request
Oct 8, 2026
… creates (conflicts → v5.3) (#3124) * Keep first-time table stores readable after a minor rollback Keep legacy names until a table name has local history, journal unstamped creates independently, and preserve drop ownership during recovery. Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com> * Protect legacy replacement stores during generation reclamation Retirement journals can outlive a rollback that reuses bare store names. Preserve stores owned by the live catalog and their blobs, and verify the 5.2 recreate/re-upgrade path. Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com> * Retire stale drop stores before completing a rollback-visible drop Guard retirement of the dropper’s own physical handles against live catalog ownership, then preserve write settlement and blob cleanup. Document untimed name history and older-reader crash recovery limits. Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com> * Verify rollback data survives a further current-version restart Reopen the storage engine before the final rollback-recreate and index assertions, so retained handles cannot mask an erroneously reclaimed family. Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com> * Keep overlapping create recovery behind retired-primary blob cleanup Snapshot journal phases under the catalog lock and defer creating-row primary retirement to any pending retired journal. Verify both journal orderings unlink blobs before retirement completes. Co-Authored-By: OpenAI Codex GPT-6.1 <noreply@openai.com> * Resolve the v5.3 cherry-pick of #3118 without #2962's drop markers The conflict hunks pulled in main's #2962 lifecycle machinery (timed drop markers, createdTime stamps, replication helpers), which is milestoned v5.4 and absent here; it is dropped. #3118 keys "this name has history" off the /dropped/<table> row that #2962 writes at every drop completion, so v5.3 gets only the untimed form #3118 itself introduced: both RocksDB completion paths (retireRocksStores, completeInterruptedDrop) record it before removing the tombstone, and the load parser skips /dropped/ rows. 5.4 reads the same row as its untimed marker. The design notes keep v5.3's section heading and drop the lifecycle-stamp paragraph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYAaU3uVxkbjHe19UNWRfK Dispatch-Task: cherry-resolve-kriszyp_harper_3118-6e6e74cf * Drop v5.3-ignored localOnly drop arguments; state the name-history row contract v5.3's dropTable() takes no options, so the picked tests' localOnly argument only repeated the ordinary drop; the test now names what it checks. The recordTableNameHistory comment states the row-shape constraint instead of issue history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYAaU3uVxkbjHe19UNWRfK Dispatch-Task: cherry-resolve-kriszyp_harper_3118-6e6e74cf --------- Co-authored-by: Kris Zyp <kriszyp@gmail.com> Co-authored-by: OpenAI Codex GPT-6.1 <noreply@openai.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
A replicated
drop_tableis a one-shot operation RPC, and thedroppingtombstone is removed once the drop completes, so nothing durable records that a table was dropped. A peer that was offline for the drop keeps its copy and, on rejoin, brings the table back through harper-pro's schema handshake; its rows then leak by name into any same-name recreate. This is the cluster half of Dropped table can be resurrected after cluster restart — drop is not a durable, replayable txn-log event (#1212); the single-node half is Add a restart regression test: dropped tables stay dropped and same-name recreates start empty (#2901).💡 Solution
Every table generation gets a
createdTime, and every drop leaves a durabledroppedTimethat survives completion as a dropped row. A generation is dead when itscreatedTimeis strictly below the newest drop time of its name, so every node applies the same rule to an offline peer's old copy. Core supplies the primitives (getTableDrops,recordTableDrop,onTableDropRecorded); the companion PR, Retire a table generation a peer dropped while this node was offline instead of resurrecting it on rejoin, exchanges them and enforces the rule.User-visible change in core: a forwarded drop of a table this node already lacks answers success and records the origin's time, where it previously failed with a 404.
Refs #1212. Companion: Retire a table generation a peer dropped while this node was offline instead of resurrecting it on rejoin (harper-pro#956), which carries the closing reference.
⚖️ Alternatives
Planning review:
Framing-Verdict: better-alternative-exists, resolved by overrule.declareTable, andcreate_tableis not a replicated RPC, so one live table commonly has a different ID on each node. Retiring one node's ID would still admit a copy another node minted independently. Time ordering retires every copy created before the drop, whoever minted it. Its clock-skew exposure is the one every replicated record already has under last-writer-wins versions, narrowed by the causal floor.🔧 Changes
Product and architecture tour
One comparison decides whether a generation is live
A generation carries two stamps.
createdTimeis written at create and never changes.droppedTimeis the drop's time: it sits on the tombstone while a drop runs and on a dropped row once the drop completes.Lifecycle of one generation
A same-name recreate is stamped after the newest dropped row, so the drop it follows never retires it.
stateDiagram-v2 [*] --> Live: declareTable stamps createdTime Live --> Tombstone: dropTable stamps droppedTime Tombstone --> DroppedRow: completion promotes the time before removing the tombstone DroppedRow --> Live: same-name create stamped after the dropped row DroppedRow --> DroppedRow: a newer drop overwrites the timeBoth stamps come from one clock and order after what they follow
databases.ts:664
createdTime, from a build before this change, is older than any drop of its name.A drop writes its time first and promotes it before removing the tombstone
Every drop writes its tombstone with
droppedTimebefore any destructive work. Each completion path promotes that time to a dropped row before it removes the tombstone, so a crash between the two writes cannot lose the time. The tombstone write goes throughdropTableatTable.ts:2925.How a dropped name reaches a peer that was offline
A forwarded drop carries the origin's time, so every peer records the same one. The rejoin step is harper-pro's schema handshake, which this PR does not implement.
sequenceDiagram participant Origin participant Peer participant Offline Origin->>Origin: tombstone with droppedTime Origin->>Peer: forwarded drop with origin droppedTime Peer->>Peer: tombstone, then dropped row Note over Offline: misses the drop, keeps its old copy Offline->>Peer: rejoin with schema handshake Peer-->>Offline: dropped row for the name Offline->>Offline: isDeadGeneration retires the old copydropTableTable.ts:3028dropTableTable.ts:3159completeInterruptedDrop, at boot load or a same-name createdatabases.ts:5868dropTableMeta, for a tombstone still in placedatabases.ts:5888droppingis setA joining drop raises the tombstone's time and never lowers it
dropTableon a tombstone that already hasdroppingsetTable.ts:2911
Drop requests take one of four paths
ResourceBridge.dropTablemaps the operation's flags onto the table drop.replicated: falsefrom a client is node-local. A forwarded drop is recognized byreplicatedFromtogether with a finitedroppedTime, and the origin's time is used as it is.createdTimereplicated: falsereplicatedFromand a finitedroppedTimedroppedTimereplicatedFromonlyThe last row has no test case; the test list covers the other three. It is stamped by this node's clock rather than refused.
The already-gone path is in
dataLayer/schema.ts:231. It answers success even whenrecordTableDroprecords nothing.Marker writes are read-compare-write under the catalog lock
Every dropped-row write goes through one function. It compares the candidate time with the stored row, keeps the newer time, and re-reads the live tombstone inside the same critical section, so a copy read earlier cannot be promoted. A time only moves forward, and a row is overwritten only by a newer drop.
Read-compare-write of a dropped row
databases.ts:687
The dropped row a completed drop leaves behind
databases.ts:709
The row outlives a same-name recreate and is never collected. Removing it would stop it retiring a stale copy that a peer delivers later.
Where a generation's
createdTimecomes fromcreatedTimedeclareTable, no peer stampdatabases.ts:4309TableDefinition.createdTime, so every copy of a generation shares one stampstampTableCreatedTimewrites it once from a peer's stamped definitiondatabases.ts:789The backfill writes only an absent stamp, and the caller decides which peer definition supplies it. An unstamped generation reads as older than any drop of its name, so the next drop of that name retires it.
What this change leaves out
droppedTime)replicated: false)drop_databaseA tombstone from before this change is left alone until a stamped drop of that name happens. The alternative is to synthesize a time at completion; this PR does not, because that time could retire a peer's live recreate.
Changed files
DESIGN.md— root index entry for the drop note, retitled to cover lifecycle stamps.dataLayer/harperBridge/ResourceBridge.ts— maps the drop request's flags ontodropTable; see the routing table above.dataLayer/schema.ts— a forwarded drop of an already-gone table records its time and succeeds.resources/DESIGN.md— the drop-tombstone note now covers lifecycle stamps. It was tightened to fit the file's line budget; Add a restart regression test: dropped tables stay dropped and same-name recreates start empty edits the same section, so whichever lands second rebases a small conflict.resources/Table.ts—createdTimeon the table class (1673);dropTableoptions and the joining-drop branch; the tombstone's time (2925); promotion in both completion closures (3028, 3159).resources/databases.ts— the clock and comparison rule (664, 668); the marker write path (687); recording and reading (734, 744); the create-time stamp (4309); the stamp backfill (789); the pending drop time (812); promotion (822); the boot-scan skip of dropped rows (1480); interrupted-drop promotion (5868);dropTableMeta(5888).unitTests/resources/dropTableLifecycle.test.js— 17 cases; see Verification.✅ Verification
unitTests/resources/dropTableLifecycle.test.js(new, 17 cases) covers: create and supplied stamps survive a reload; a drop marker outlives a same-name recreate and a reload; a peer's drop time is applied, moves forward only, and is announced once readable; a recreate after a drop ahead of the local clock reads as newer; a generation stamped by a faster clock is retired; a pre-stamp interrupted drop completes without an invented time; a joining drop keeps the newer time; a node-local drop leaves no row; the operation flags map onto the drop; a replicating drop that joins a node-local one stamps the bare tombstone; a missing stamp is backfilled once; a forwarded drop of an already-gone table records its time;dropTableMetapromotes a tombstone it removes; the load completes an interrupted drop and promotes its tombstone. Passes on RocksDB and onHARPER_STORAGE_ENGINE=lmdb, alongsidedropTableGhost.test.jsanddropTableGeneration.test.js.npm run check:design-docspasses; prettier and oxlint are clean on the changed files.integrationTests/cluster/dropTableOfflinePeer.test.mjsruns against this core. Its six scenarios all pass: a connected drop with graceful and SIGKILL restarts; a peer offline for the drop, then both restarted; a drop and recreate while the peer is offline; a pre-stamp peer rejoining; a node-local drop.Signed: Claude Sonnet 5
Related PRs: unchecked (no related-PR snapshot in the review receipt)
Complexity: complicated
🤖 Generated with Claude Code
https://claude.ai/code/session_01CSitdq3dnTG5EWQeCwVoSi
Review-Coverage: authored=claude; ran=cursor-composer,gemini; adjudicated=domain; blocked=codex(out-of-budget),claude(fallback)(out-of-budget); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=5; full=1 @ 8f147ef
Review-Attention: deep ~120m (raised: unmeasured diff) @ 8f147ef