Skip to content

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
kriszyp merged 5 commits into
mainfrom
fix/drop-table-lifecycle-stamps
Oct 5, 2026
Merged

kriszyp merged 5 commits into
mainfrom
fix/drop-table-lifecycle-stamps

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

A replicated drop_table is a one-shot operation RPC, and the dropping tombstone 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).

❓ Your call: Is the cluster half warranted at this size, and does it merge with its companion? isDeadGeneration has no caller in core, so this PR alone changes no rejoin outcome. The rule takes effect when Retire a table generation a peer dropped while this node was offline instead of resurrecting it on rejoin (harper-pro#956) calls it.

💡 Solution

Every table generation gets a createdTime, and every drop leaves a durable droppedTime that survives completion as a dropped row. A generation is dead when its createdTime is 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.

  • Opaque incarnation ID per generation (framing round 1; overruled). Schema-defined tables are created by each node's own declareTable, and create_table is 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. createdTime is written at create and never changes. droppedTime is 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 time
Loading

Both stamps come from one clock and order after what they follow

databases.ts:664

tableLifecycleTime(after)
	now = getNextMonotonicTime()
	if after is finite and now <= after → after + LIFECYCLE_STEP   ⚠ a stamp orders after what it follows
	→ now

isDeadGeneration(createdTime, droppedTime)
	→ (createdTime is finite ? createdTime : 0) < droppedTime     // strict: an equal stamp survives
  • Floor. A stamp taken after a known fact is raised above it, so a peer whose clock runs ahead cannot make a live generation read as dead. The tests cover a recreate after a drop that is ahead of the local clock, and a drop of a generation stamped by a faster clock.
  • Missing stamp reads as 0. A generation without 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 droppedTime before 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 through dropTable at Table.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 copy
Loading
Completion path Where Dropped row written before the tombstone is removed
First completion closure in dropTable Table.ts:3028 Yes
Second completion closure in dropTable Table.ts:3159 Yes
completeInterruptedDrop, at boot load or a same-name create databases.ts:5868 Yes
dropTableMeta, for a tombstone still in place databases.ts:5888 Yes, when dropping is set

A joining drop raises the tombstone's time and never lowers it

dropTable on a tombstone that already has dropping set

Table.ts:2911

dropTable(options) on a tombstone with dropping set
	if options.localOnly → joinedTime = none
	else if options.droppedTime is finite → joinedTime = options.droppedTime
	else if tombstone.droppedTime is unset → joinedTime = tableLifecycleTime(createdTime)
	else → joinedTime = none
	if joinedTime is set and above tombstone.droppedTime (or the tombstone has none) → write the tombstone with joinedTime   ⚠ a time only moves forward
	→ true                                                                                                                     // joins the drop in flight

⚠️ Look hardest: this is the only branch where a tombstone's time rises after its drop began. A replicated drop that joins a bare tombstone stamps it, so the completion promotes a time. A local-only drop that joins leaves the time as it was.

Drop requests take one of four paths

ResourceBridge.dropTable maps the operation's flags onto the table drop. replicated: false from a client is node-local. A forwarded drop is recognized by replicatedFrom together with a finite droppedTime, and the origin's time is used as it is.

Request Flags Time the drop carries Dropped row
Client drop none This node's clock, floored above the generation's createdTime Written on completion
Client drop, node-local replicated: false None; the tombstone stays bare None
Forwarded drop replicatedFrom and a finite droppedTime The origin's droppedTime Written on completion
Forwarded drop without a finite time replicatedFrom only This node's clock, as for a client drop Written on completion

The last row has no test case; the test list covers the other three. It is stamped by this node's clock rather than refused.

Before After
A forwarded drop of a table this node already lacks fails with a 404 and records nothing. The drop records the origin's time and answers success, with a message that the table was already dropped.

The already-gone path is in dataLayer/schema.ts:231. It answers success even when recordTableDrop records nothing.

❓ Your call: recordTableDrop records nothing when the database is not loaded, and the path still answers success. Replication reaches it only after the schema load, so the case is believed unreachable. The alternative is to report a failure there. Changing it is one branch in dataLayer/schema.ts.

❓ Your call: replicatedFrom is the only proof that a drop came from a peer, and the operation schema allows unknown keys (validation/validationWrapper.ts:87). Any principal with the DROP_TABLE permission can therefore send replicatedFrom with a droppedTime of its choosing. That time retires every generation of the name stamped below it on this node, and on peers once harper-pro exchanges markers. Options: keep this trust, as the code does now, or accept a forwarded time only from the replication channel. This PR does not change it.

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

writeTableDropMarker(rootStore, dbis, table, droppedTime, tableId, exclusive, tombstoneKey)
	write():
		if tombstoneKey → live = dbis[tombstoneKey]   ⚠ re-read inside the section
			if live.dropping and live.droppedTime is above droppedTime → droppedTime = live.droppedTime
		if droppedTime is not finite → false          ⚠ no time, no dropped row
		if dbis['/dropped/' + table].droppedTime >= droppedTime → false
		dbis['/dropped/' + table] = { table, droppedTime, tableId? }
		→ true
	RocksDB: exclusive ? run write() under the catalog lock : run write() (the caller holds the lock)
	LMDB: transactionSync(write)
	if written → emit tableDropRecorded after commit   // listeners re-send schemas, which read this catalog

The dropped row a completed drop leaves behind

databases.ts:709

// key '/dropped/Orders' in the __dbis__ store; values illustrative
{ table: 'Orders', droppedTime: 1791300000.004, tableId: 7 }

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.

❓ Your call: Dropped rows are kept forever, one per dropped name per database. Growth is bounded by distinct names, and the boot scan and getTableDrops walk every row. Is that acceptable at expected name counts?

Where a generation's createdTime comes from

Generation created by createdTime Source
This node's declareTable, no peer stamp This node's clock, floored above the newest dropped row of the name databases.ts:4309
A peer's propagated definition The peer's value, kept as is TableDefinition.createdTime, so every copy of a generation shares one stamp
A build that stored no stamp Absent until stampTableCreatedTime writes it once from a peer's stamped definition databases.ts:789

The 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.

❓ Your call: Is retiring an unstamped generation on the next drop of its name the right default, or should such a generation survive until a stamped definition backfills it? The current behavior follows from the rule as written.

What this change leaves out

Case Behavior Why
Tombstone written before stamps existed (no droppedTime) No dropped row, no invented time A time made up at completion could postdate a peer's live recreate
Node-local drop (replicated: false) Tombstone stays bare, no dropped row Node-local tables keep the node-local contract
drop_database Out of scope It removes the root store and its catalog with it

A 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.

❓ Your call: Pre-stamp tombstones keep no marker. Say so if you would rather synthesize a time there, and accept the risk above.

Changed files

✅ 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; dropTableMeta promotes a tombstone it removes; the load completes an interrupted drop and promotes its tombstone. Passes on RocksDB and on HARPER_STORAGE_ENGINE=lmdb, alongside dropTableGhost.test.js and dropTableGeneration.test.js.
  • npm run check:design-docs passes; prettier and oxlint are clean on the changed files.
  • End to end: the companion's integrationTests/cluster/dropTableOfflinePeer.test.mjs runs 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

kriszyp and others added 5 commits October 1, 2026 07:43
…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
@kriszyp kriszyp added this to the v5.3 milestone Oct 1, 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 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.

Comment thread resources/Table.ts
Comment thread resources/databases.ts
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
@kriszyp kriszyp modified the milestones: v5.3, v5.4 Oct 5, 2026
@kriszyp
kriszyp marked this pull request as ready for review October 5, 2026 19:26
@kriszyp
kriszyp requested a review from cb1kenobi October 5, 2026 19:26
@kriszyp
kriszyp merged commit 6c6eb2d into main Oct 5, 2026
100 of 104 checks passed
@kriszyp
kriszyp deleted the fix/drop-table-lifecycle-stamps branch October 5, 2026 23:25
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>
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.

1 participant