Repository navigation
Preserve 5.2 rollback compatibility for first-time table creates - #3118
Conversation
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>
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>
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>
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>
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>
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request updates the table creation and drop lifecycle to support rollbacks to version 5.2, ensuring first-time creates on RocksDB use bare names when no prior drop history exists. It also refactors generation reclamation, adds a downgrade integration test, and updates unit tests. The review feedback suggests avoiding RocksDB-specific cleanup on LMDB in Table.ts while optimizing column array access, adding the missing table property to the GenerationRow type definition, and wrapping the test path resolution in a try/catch block to prevent potential test suite crashes.
| if (!removed) { | ||
| withUpdateAttributesLock(rootStore, `retire stale stores of '${databaseName}.${tableName}'`, () => { | ||
| const owned = liveStoreNamesFor(dbisDb, tableName); | ||
| const columns = new Set<string>((rootStore as any).columns); | ||
| for (const store of [primaryStore, ...Object.values(indices)]) { | ||
| if (store.name && columns.has(store.name) && !owned.has(store.name)) | ||
| dropColumnFamily(rootStore, store.name); | ||
| } | ||
| }); | ||
| } else await dbisDb.committed; |
There was a problem hiding this comment.
On LMDB storage engines, rootStore does not have a columns property, which means (rootStore as any).columns will be undefined. Passing undefined to new Set() is technically safe, but attempting to run this RocksDB-specific cleanup block on LMDB is unnecessary and could lead to unexpected behavior. Additionally, using optional chaining store?.name is safer in case any index store is not fully initialized or is nullish. Also, avoid copying the columns array (such as by creating a Set) because the columns getter in rocksdb-js already materializes a fresh array on each access; we can use .includes() directly to avoid redundant allocations.
if (!removed) {
withUpdateAttributesLock(rootStore, `retire stale stores of '${databaseName}.${tableName}'`, () => {
const columnsArray = (rootStore as any).columns;
if (!columnsArray) return;
const owned = liveStoreNamesFor(dbisDb, tableName);
for (const store of [primaryStore, ...Object.values(indices)]) {
if (store?.name && columnsArray.includes(store.name) && !owned.has(store.name))
dropColumnFamily(rootStore, store.name);
}
});
} else await dbisDb.committed;References
- Do not use
Array.from()or copy arrays returned by getters that already materialize a fresh array on each access (such ascolumnsin rocksdb-js), as the loop already iterates over a snapshot and copying it would be redundant.
| generation: string; | ||
| phase: 'creating' | 'retired'; | ||
| stores?: string[]; | ||
| creatingStores?: string[]; |
There was a problem hiding this comment.
The table property is used extensively on GenerationRow objects (e.g., value.table in reclaimGenerations and row.table in scheduleGenerationBlobSweep), but it is missing from the GenerationRow type definition. Adding it as an optional property ensures TypeScript type safety and completeness.
| creatingStores?: string[]; | |
| creatingStores?: string[]; | |
| table?: string; |
| const previousPath = process.env.HARPER_PREVIOUS_MINOR_PATH?.split(delimiter).find((path) => { | ||
| const { version } = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')); | ||
| return version.startsWith('5.2.'); | ||
| }); |
There was a problem hiding this comment.
If HARPER_PREVIOUS_MINOR_PATH contains paths that do not have a package.json file or contain invalid JSON, readFileSync or JSON.parse will throw an error, causing the entire test suite to crash during module loading. Wrapping this in a try/catch block ensures that any such errors are caught and the path is safely skipped.
const previousPath = process.env.HARPER_PREVIOUS_MINOR_PATH?.split(delimiter).find((path) => {
try {
const { version } = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8'));
return version.startsWith('5.2.');
} catch {
return false;
}
});…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
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
… 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>
…l tables when a peer drops one (#3119) * Enforce the table-generation rule in the catalog-locked create and drop 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 * Refuse a stale stamped peer definition of a live table, and read replicate 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 * Cover a forwarded drop's provenance through server.operation, and drop 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 * Bound when an unstamped table was created, at the first load on a build 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 * Judge a stamped peer definition of a live table under the catalog lock 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 * Announce a recorded drop marker on every thread, with a per-thread drop 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 * Never fail a database load over the createdBefore bound, and keep it 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 * Contain a replayed drop announcement's listener errors, and keep a concurrent 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 * Advance the recording thread's drop epoch at commit, before the broadcast 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 * Judge a peer's generation against a drop still completing, not only a 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 * Re-dispatch a peer drop that reached a stale class, and never fail a 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 * Announce a dropped table even when its metadata cleanup times out; comment 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 * Mark an LMDB drop in progress, as the RocksDB drop already is 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 * Mark an LMDB drop in progress as soon as its tombstone commits, and announce 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 * Release an LMDB drop's in-progress mark when table cleanup throws 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 * Describe the table lifecycle and 5.2 store naming as one rule, and pin 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 * Assert the announced marker carries the peer's exact drop time, and finish 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 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
⊙ Problem
A confirmed 5.3 → 5.2 rollback opens tables first created on 5.3 as empty because 5.2.15 does not read generation-suffixed store names. Writes during rollback then disappear on re-upgrade. Closes #3102.
💡 Solution
First-time RocksDB creates use legacy primary/index names. A create with local name history uses a fresh generation. Crash recovery keeps a separate journal identity, and local-only drops leave untimed name history without advertising a replicated drop.
⚖️ Alternatives
Planning review:
Framing-Verdict: chosen-approach-sound. Adopted its compatibility and lifecycle amendments: bare creating names live increatingStores, which shipped 5.3 recovery ignores; untimed local-drop markers preserve naming history; retirement checks its tombstone before touching stores.Patching 5.2 requires users to install a newer rollback target. Synchronous commit draining reverses #2604's nonblocking drop behavior. Conditional naming without create journaling leaves crash orphans unreclaimable. The existing journal supplies recovery without a second mechanism.
🔧 Changes
Product and architecture tour
Store names and compatibility
T/,T/strT/@uuid,T/str@uuidThe catalog lock protects the name-history decision and independent create journal. The native database lock spans the under-lock existing-primary recheck, store selection, publication and failed-create cleanup; another worker must reload the published table or wait for cleanup. Incoming primary-attribute generations are replaced with the local choice. Peer drop history can require stamping on a node that never held the table. Look hardest at publication ordering and the
creatingStoresfield: shipped 5.3 recovery interprets the oldstoresfield destructively even when a bare primary was published.The publication flag follows the primary write immediately, so failure to remove the journal cannot roll back a live table. Failure cleanup drops the recorded physical names and retains the journal when a physical drop fails. Recovery matches a published create by its primary name, including a dropping primary whose retirement belongs to its dropper. Unpublished creates are reclaimed by recorded names; old suffix-only journals remain supported.
Drops and durable name history
An untimed
/dropped/Tmarker records local name history without advertising a replicated drop; timed drop readers continue skipping it. The tombstone identity guard precedes store capture and retirement, with the existing post-drop guard retained for reentrant callbacks. A stale drop retires only its own primary/index handles whose names the live catalog does not own, waits for admitted writes and sweeps its incarnation's blobs. It cannot journal or drop replacement-only indexes.The existing lifecycle note records the naming, journal and rollback constraints; the design index points to that amended note.
Recovery ownership and proof
The live catalog wins when a 5.2 writer reuses a bare name while ignoring an old journal. The active dropper retains its primary handle for the final blob sweep, even when another handle already dropped the family. Reclamation still removes unowned predecessor indexes and rechecks ownership before a deferred blob sweep starts and when it finishes under the catalog lock. The legacy-writer blob regression proves the live blob survives and an unowned index is removed. The additional current-version restart proves rollback-created rows survive reopening after reclamation; the same test checks rows and secondary indexes through a real 5.2.15 round trip. The two journal-order crash cases require blob unlinking before retirement completes.
Creation and publication order
databases.ts:4388
Reclamation handoff
databases.ts:5672
✅ Verification
End-to-end route: integrationTests/upgrade/first-create-downgrade.test.ts uses a real 5.2.15 install and five boots (5.2 → current → confirmed 5.2 rollback → current → current restart). It checks primary rows, ordinary secondary-index searches and rollback writes in both an existing database and a newly created database. A stamped sibling recreate with a nonempty predecessor verifies the stated compatibility boundary. A table dropped immediately before rollback is recreated on 5.2, and its replacement survives re-upgrade despite the old retirement journal. It uses
HARPER_PREVIOUS_MINOR_PATH, including #3104's list form (that PR also updates the existing minor-upgrade suite to parse the list); no configured 5.2 install means a clean skip. CI provisioning of that install is supplied by #3104, not duplicated here.actual []versus rowsa,b. The live-retirement blob probe also fails before its fix because a live blob is unlinked.Release qualification: main is mergeable, but the automatic v5.3 cherry-pick reports conflicts on its generated branch, so release-line integration tests have not started. The executed local checks above cover this main-targeted PR; the backport still needs conflict resolution.
No companion documentation PR: this restores the existing rollback behavior for ordinary first creates and adds no public API/configuration. The existing stamped-table and recreate limits are explicit above.
🤖 Generated by OpenAI Codex; posted via @kriszyp.
Related PRs: #2155 independent (expiration sweeps), #2955 independent (cross-worker drop enforcement), #3084 independent (replication authorization), #3104 independent (CI version fixtures), #3114 independent (v5.3 backport of those fixtures), #3100 independent (subscription resume), #2604 overlaps (narrows create naming while retaining deferred reclamation), #2431 independent (TLS/deploy anchors), #1916 independent (eviction/removal anchors)
Complexity: complicated
Review-Coverage: authored=codex; ran=cursor-composer,claude,gemini; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=6; full=4 @ a4bbe27
Review-Attention: deep ~30m (critical: Table.ts, databases.ts; decisions: rollback-boundary, physical-naming, do-less-alternative) @ a4bbe27