Skip to content

Refuse a peer's retired table generation, and keep newer or node-local tables when a peer drops one - #3119

Merged
kriszyp merged 17 commits into
mainfrom
kris/drop-table-conditional-lifecycle
Oct 9, 2026
Merged

kriszyp merged 17 commits into
mainfrom
kris/drop-table-conditional-lifecycle

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

harper#2962 gave each table generation a createdTime and 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:

  • A delayed forwarded drop_table dropped whatever generation held the name, including one created after the drop.
  • A marker recorded by another worker after the ingress check did not stop the create.
  • A joining node stamped a pre-stamp peer's unstamped definition with "now", so the stale copy spread as the newest generation.
  • A peer's drop removed a populated replicate: false table, breaking harper-pro#943's node-local contract.

❓ Your call: is the requirement warranted at this size? Yes, per the owner's ruling on harper-pro#956: the rule moves to the layer that owns the catalog, so every producer inherits it, instead of a fourteenth round of per-ingress patches. Reverting this PR restores harper#2962's unconditional behavior.

💡 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:

  • Conditional create. A create carrying a peer's generation (origin: 'cluster', or a supplied createdTime) throws TableGenerationDroppedError (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 at 0: any later drop retires it, nothing backfills it, and it is never confused with undefined (a table created on a pre-stamp build). Local creates are unchanged; they are stamped above the newest marker.
  • Conditional peer drop. dropTable({ peer: true, droppedTime }) never retires a replicate: false table, and with a time it retires only a generation created before it.
    • A cheap read skips the teardown; writeTombstone re-checks the row it locked, beside its table-id and storage-generation identity check.
    • A kept generation has its derived indexes and maintenance restored, and the call resolves false. The peer's time is recorded as a marker either way.
    • A peer drop that finds its class stale re-runs on the current class.
    • dropTable now resolves true when it dropped (including a tombstone left for a pending full-text retirement) and false otherwise.
  • Peer provenance outside the body. server.operation(body, { user, replicatedFrom }) sets the REPLICATED_FROM symbol, which a JSON request cannot carry. The bridge treats a drop as a peer's only through it. schema.dropTable discards a client's droppedTime and rejects a malformed peer time.
  • Node-local drops stay local. Dropping a replicate: false table writes no droppedTime and is not forwarded. A marker from an earlier, replicated generation of the name is kept and still sent. replicate is read from the catalog row first; the class flag alone covers runtime exclusions, and non-replicating system tables are always node-local.
  • Generation-safe dropTableMeta. Under the catalog lock, it removes a name's rows only when no primary row exists, live or still dropping. It still announces dropTable if its lock times out, and schema.dropTable no longer fails a completed drop over it.
  • Facts the replication layer reads:
    • 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.
    • Every recorded marker is announced to all threads and advances that thread's tableDropEpoch.
  • LMDB drops are marked in progress, as RocksDB drops already were. A schema reload on the dropping thread no longer completes the tombstone mid-drop, which used to make the drop throw "a replacement table became current". That race also made core's own LMDB lifecycle suites flaky on main. Every exit releases the mark, a failed table cleanup included, so the next load still completes a failed drop's tombstone.
  • A test-only switch, 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 no createdTime. They are separate fields.

This PR's path What #3118 changed there Result
A peer's create is refused at or before a drop marker Every RocksDB create writes its own create journal, and bare or suffixed store names follow name history The refusal (under the lock) runs before the journal and every store open, so a refused create leaves neither (test).
An unstamped peer definition is never stamped "now" A peer's generation attribute is replaced by this node's naming Core still stores the peer's missing createdTime as 0. The create journal carries no createdTime, its recovery writes none, and the createdBefore bound skips a finite 0.
A peer's drop retires only an older generation, atomically Tombstone identity is checked before the retirement journal is written. A dropper whose tombstone another thread completed retires only its own stores that the live catalog does not own The rule runs in 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.
A node-local table is never retired by a peer, and its own drop sends no marker On RocksDB, a drop with no droppedTime leaves an untimed /dropped/<table> row as name history A node-local drop has no time, so it now leaves that row. Every drop-time reader skips it: getTableDrops, pendingOrRecordedDropTime, isDroppedPeerGeneration, both create checks, and harper-pro#956's tableLifecycle.ts. Nothing reaches peers, and a peer's timed drop overwrites it (test).
The per-table createdBefore bound Nothing in the load path Written at load after reclamation, on primary rows only. A table created during a 5.2 rollback has no createdTime and gets its own bound at the next load.
The LMDB drop mark is released when cleanup() throws Nothing: #3118 changed only RocksDB retirement and journals Independent.
dropTable resolves true when it dropped retireRocksStores now also resolves false when another thread completed the tombstone This PR's two branches after it both resolved true. That is still right, because this drop wrote the tombstone, so they are now one call with that comment.
Recovery Interrupted-drop and journal recovery read journal phases under the catalog lock Recovery completes only tombstones. A peer's drop writes one only after the rule passed under the lock, so recovery has nothing to judge.

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

❓ Your call: the planning review returned Framing-Verdict: chosen-approach-sound (acd3af81cd7a) for moving the rule into core's catalog sections. Two amendments came from it and are adopted: node-local drops are suppressed at the source, and client lifecycle fields are scrubbed. The legacy per-table layout (v3 schema/ directory, where databasePath is not the database name) is not covered: it has no database catalog to hold a marker, so its drops still leave none. Giving it one means opening a second root for a database the loader assembles from per-table files, which is a migration.

  • Checks at each replication ingress (harper-pro#956 as it was): rejected. The forwarded RPC never passes those checks, and a check on a replication worker cannot be atomic with a catalog mutation another thread makes.
  • Cluster-wide incarnation IDs: rejected earlier (harper-pro#956 round 1). Component tables are created independently on every node, so one live table carries several IDs.
  • Do less (drop harper-pro's dropTableMeta call and add a tableReplicates guard): this leaves the delayed-RPC drop, the create race, the joining-node "now" stamp and the RPC drop of a node-local table.
  • A single database-level "first stamped load" epoch in place of the per-table createdBefore: rejected. After a rollback to a pre-stamp build, it would retire tables recreated during the rollback.

❓ Your call: where the pre-stamp heuristic lives. Core treats a generation with no stamp as created at 0, so a peer's timed drop retires it. The row-time judgment for tables a pre-stamp build created stays in harper-pro#956, which applies it to a schema-exchange marker before calling dropTable; core supplies only the createdBefore bound. A forwarded drop_table skips that judgment. It arrives while both nodes are connected, so it names the generation the origin dropped. Moving the judgment under the catalog lock later is additive.

❓ Your call: a kept, replaced or node-local peer drop answers with one 200 "was not dropped" message rather than a machine-readable outcome. harper-pro collects forwarded replies and never retries on them, so nothing depends on telling the three apart. Making the outcome machine-readable later changes the wire contract.

❓ Your call: the LMDB drop mark protects only reloads on the dropping thread. Another worker's reload could already complete an active LMDB drop before this PR. Announcing the drop generation before the stores drop, as the RocksDB drop does, would close that, and adding it later is additive.

🔧 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?

A table generation becomes live or stops being live only under the catalog lock that writes its primary row or its drop tombstone. That is where a peer's create is refused and a peer's drop is judged, so no caller can check the rule and then act on a catalog another thread has changed.

Creating a peer's generation, under the catalog lock

  1. Newest drop — read the name's drop marker, after any interrupted drop of it completed.
  2. Peer generation — origin: 'cluster' or a supplied createdTime; a definition with no stamp is taken as 0, never "now".
  3. Refuse — a generation the marker retires throws TableGenerationDroppedError (409, TABLE_GENERATION_DROPPED).
  4. Stamp — a peer's generation keeps its stamp; a local create is stamped above the newest drop of its name.
  • A stamped peer definition of a name that is live here is checked the same way, before its attributes merge into the live generation.
  • 0 and undefined mean different things. 0 is a peer generation whose node kept no stamp: any drop retires it and nothing backfills it. undefined is 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: false table?

A peer's drop

  1. Pre-check — a read of the primary row: a node-local table, or one created at or after the peer's drop time, is kept with no teardown.
  2. Under the lock — writeTombstone re-reads the row, confirms it is the same generation, and applies the same rule to the locked row.
  3. Kept — derived indexes and maintenance are restored, the peer's time is recorded as a marker, and the call resolves false.
  4. Replaced — a drop that reached a class another thread already replaced re-runs on the replacement.
  5. Dropped — the stores are retired, the tombstone becomes the marker, and the call resolves true.
  • Kept, still recorded. The peer's drop time becomes a marker here whether or not this generation goes, so the fact reaches nodes that still hold the older copy.
  • Result. dropTable resolves true when it dropped and false when it kept the table or found another generation. schema.dropTable answers false with 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_table from a client's?

Before After
Core read replicatedFrom from the operation body. A body carrying it with a finite droppedTime retired whatever generation held the name, a newer one included, and any client could write both fields. server.operation(body, { user, replicatedFrom }) sets the REPLICATED_FROM symbol, which a JSON body cannot carry. Only a drop with it is a peer's, judged by the rule above. A client's droppedTime is discarded, and a peer's malformed one is rejected.

Node-local tables stay node-local

What happens to a replicate: false table when a peer drops a table of the same name?

Before After
A peer's drop retired a populated replicate: false table. A node-local table's own drop left a marker and was forwarded to peers. A peer's drop never retires a node-local table; the marker is still recorded for relay. A node-local table's own drop writes no droppedTime and is not forwarded; on RocksDB it leaves untimed name history, which no peer reads. replicate is 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?

Fact Kept by core Read in harper-pro#956
isDroppedPeerGeneration(db, table, createdTime) one keyed read of the marker and of a live tombstone's droppedTime, with the rolling-upgrade exception judging a definition or structure before its records are applied
createdBefore (catalogCreatedBefore) written on each unstamped primary row at the first load on a stamping build; a rollback's tables get theirs at the next load the upgrade heuristic, only for drops older than the bound
tableDropEpoch() advanced on every thread when a marker is recorded: the recording thread at commit, the others by broadcast the per-record recheck of an accepted generation

Drop bookkeeping a live generation survives

What can the metadata cleanup and a schema reload no longer do to a live table?

  • dropTableMeta used 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.dropTable logs a cleanup failure instead of failing a drop that completed.
  • An LMDB drop is marked in progress from the moment its tombstone commits until it finishes, as a RocksDB drop already was. A schema reload on the dropping thread therefore leaves the tombstone to it instead of completing it underneath. Every exit releases the mark, including a failed table cleanup.

Every changed file:

❓ Your call: merge order. Core alone changes nothing for forwarded drops: harper-pro main never sent peer provenance (its operation connection has no node name), so forwarded drops already run as local-only drops there. The conditional peer drop takes effect when harper-pro#956 supplies the context. Merge this first, then #956 with its pointer bump.

❓ Your call: the forwarding decision for a node-local drop is read just before the drop, and the marker decision is made under the lock. A replicate flip by another thread during the drop's awaits makes them disagree. Either way the result is benign: the drop is forwarded as it was issued, or the marker reaches peers through the schema exchange. Carrying the locked decision out of dropTable would change its result type again, so it is left as is.

✅ Verification

On head 6ec39b226, rebased onto main at 87394e7c3 (which includes #3118):

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 independent
Complexity: 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

@kriszyp kriszyp added this to the v5.4 milestone Oct 8, 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 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.

Comment thread resources/databases.ts
kriszyp and others added 17 commits October 8, 2026 13:29
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
@kriszyp
kriszyp force-pushed the kris/drop-table-conditional-lifecycle branch from fb03fbb to 6ec39b2 Compare October 8, 2026 20:42
@kriszyp
kriszyp marked this pull request as ready for review October 9, 2026 04:25
@kriszyp
kriszyp merged commit bc61f1f into main Oct 9, 2026
62 of 64 checks passed
@kriszyp
kriszyp deleted the kris/drop-table-conditional-lifecycle branch October 9, 2026 04:25
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