From 909c1b04740be60cca9cf04036c26ea1b988cf46 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 09:00:48 -0600 Subject: [PATCH 1/7] Add evict() regression tests for renewed records and conflicted blob commits Refs #2861 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- unitTests/resources/evictionBlobs.test.js | 125 ++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 unitTests/resources/evictionBlobs.test.js diff --git a/unitTests/resources/evictionBlobs.test.js b/unitTests/resources/evictionBlobs.test.js new file mode 100644 index 0000000000..4b2ca53358 --- /dev/null +++ b/unitTests/resources/evictionBlobs.test.js @@ -0,0 +1,125 @@ +require('../testUtils'); +const assert = require('assert'); +const { existsSync } = require('fs'); +const { randomBytes } = require('crypto'); +const { setTimeout: delay } = require('timers/promises'); +const { setupTestDBPath } = require('../testUtils'); +const { waitFor } = require('../waitFor.js'); +const { table } = require('#src/resources/databases'); +const { + createBlob, + getFileId, + getFilePathForBlob, + getBlobHoldStateForTesting, + setDeletionDelay, +} = require('#src/resources/blob'); +const { setMainIsWorker } = require('#js/server/threads/manageThreads'); + +// Long enough for a zero-delay reclamation queued by a wrong eviction to have unlinked its file, unless +// entries left by earlier tests hold the shared queue's tail back. +const RECLAMATION_SETTLE_MS = 200; +const REREFERENCED = 1; // hold-state slot offset, resources/blob.ts + +describe('evict() leaves a renewed record and its blobs intact', () => { + let UnauditedBlobs; + + before(function () { + setupTestDBPath(); + setMainIsWorker(true); + setDeletionDelay(0); + UnauditedBlobs = table({ + table: 'EvictUnauditedBlobs', + database: 'test', + audit: false, + attributes: [ + { name: 'id', isPrimaryKey: true }, + { name: 'tag', indexed: true }, + { name: 'blob', type: 'Blob' }, + ], + }); + }); + + after(function () { + setDeletionDelay(undefined); + }); + + async function putWithBlob(id, tag, blob) { + blob ??= await createBlob(randomBytes(20000)); // over the file-storage threshold + await UnauditedBlobs.put({ id, tag, blob }); + const filePath = getFilePathForBlob(blob); + assert.ok(filePath && existsSync(filePath), 'precondition: the blob is file-backed'); + return { entry: UnauditedBlobs.primaryStore.getEntry(id), filePath }; + } + + it('skips a version renewed after the caller read it, without a source or audit log', async function () { + const read = await putWithBlob('renewed', 'v1'); + const renewed = await putWithBlob('renewed', 'v2'); + await UnauditedBlobs.evict('renewed', read.entry.value, read.entry.version); + await delay(RECLAMATION_SETTLE_MS); + + assert.equal(UnauditedBlobs.primaryStore.getEntry('renewed')?.value.tag, 'v2', 'the renewed record must survive'); + assert.equal(UnauditedBlobs.indices.tag.getValuesCount('v2'), 1, "the renewed record's index entry must survive"); + assert.ok(existsSync(renewed.filePath), "the renewed record's blob file must survive"); + + // The same reclamation timing does unlink once the eviction targets the live version. + await UnauditedBlobs.evict('renewed', renewed.entry.value, renewed.entry.version); + assert.equal(UnauditedBlobs.primaryStore.getEntry('renewed'), undefined); + assert.equal(UnauditedBlobs.indices.tag.getValuesCount('v2'), 0); + await waitFor(() => !existsSync(renewed.filePath), { + message: 'an eviction of the live version reclaims its blob', + }); + }); + + it('keeps the blob file when its eviction loses a commit conflict', async function () { + if (process.env.HARPER_STORAGE_ENGINE === 'lmdb') return this.skip(); // LMDB writes have no commit conflict + const { Transaction } = require('@harperfast/rocksdb-js'); + const originalCommit = Transaction.prototype.commit; + const { entry, filePath } = await putWithBlob('contended', 'resident'); + // A competing write keeps the same blob. It has encoded (re-referencing the file) and is committing + // when the eviction reads and stages; the eviction's commit then lands after it and conflicts. + let eviction; + let evictionCommit; + Transaction.prototype.commit = function (...args) { + let competingCommitted; + const competing = new Promise((resolve) => (competingCommitted = resolve)); + Transaction.prototype.commit = function (...evictionArgs) { + Transaction.prototype.commit = originalCommit; + evictionCommit = competing.then(() => originalCommit.apply(this, evictionArgs)); + return evictionCommit; + }; + eviction = UnauditedBlobs.evict('contended', entry.value, entry.version); + const committing = originalCommit.apply(this, args); + committing.then(competingCommitted, competingCommitted); + return committing; + }; + try { + await UnauditedBlobs.put({ ...entry.value, tag: 'competing' }); + assert.ok(eviction, 'the eviction must run inside the competing commit'); + await eviction; + } finally { + Transaction.prototype.commit = originalCommit; + } + await delay(RECLAMATION_SETTLE_MS); + + // Queue-independent: queueing a reclamation clears the re-reference the competing write recorded. + const { table: holdState, slot } = getBlobHoldStateForTesting( + UnauditedBlobs.primaryStore.rootStore, + getFileId(entry.value.blob) + ); + assert.equal( + Atomics.load(holdState, slot + REREFERENCED), + 1, + "an abandoned eviction must not queue the competing write's blob for reclamation" + ); + await assert.rejects( + evictionCommit, + { code: 'ERR_BUSY' }, + 'the eviction commit must conflict with the competing write' + ); + assert.equal(UnauditedBlobs.primaryStore.getEntry('contended')?.value.tag, 'competing'); + assert.ok( + existsSync(filePath), + 'an abandoned eviction must not reclaim the blob the competing write still references' + ); + }); +}); From 66eaa296c1375d3cd6ae64ad0eac700c97d561b5 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 09:15:30 -0600 Subject: [PATCH 2/7] Version-gate RocksDB evict() on every table and reclaim its blobs only after commit evict() read the entry in its transaction and compared the caller's version only on audited or sourced tables; elsewhere it removed whatever version was live, deleting a renewed record, orphaning its new index keys and unlinking its blobs. Its RocksDB removal also went through removeEntry, which queues blob reclamation at staging, so an eviction abandoned on ERR_BUSY still reclaimed a blob the surviving record references. Refs #2861 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- resources/DESIGN.md | 2 +- resources/Table.ts | 46 ++++++++++++++--------- unitTests/resources/evictionBlobs.test.js | 33 +++++++++++++++- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/resources/DESIGN.md b/resources/DESIGN.md index 16e5beb982..98cd6b8e0a 100644 --- a/resources/DESIGN.md +++ b/resources/DESIGN.md @@ -392,7 +392,7 @@ The mitigations live in three places: When adding a new commit-handler early-return path, reset `write.skipped = false` per round and set it to `true` only when the round stages neither a record nor an audit entry. If this round stages an audit-only entry via `auditRecordToStore`, leave `skipped` unset: `save()` uses it to record `appendedTo` and the replication-confirmation target. An earlier attempt's committed or durable audit entry can still own a skipped retry's blobs; RocksDB cleanup checks `blobsAuditReferenced` and the appending handle's durability. Unsubmitted staging owns no files. `cleanupOrphans` is the periodic safety net; don't rely on it for transactional correctness. -**A removed record's blob files go only once its removal commits.** `removeEntry` (`RecordEncoder.ts`) decides from what `store.remove` returns: a promise gates the unlink on its resolution, anything else is taken as already durable. A RocksDB removal staged in a transaction (`{ transaction }`) returns nothing, so through `removeEntry` its blobs are queued for reclamation at staging time, and an abort leaves the record pointing at files reclaimed `storage.blobRetention` later. `getFromSource()`'s source-miss removal therefore stages `primaryStore.remove(id, { transaction })` itself and deletes the removed record's blobs from the fill's commit completion, keeping any file the record now stored at that key references (queueing a file for reclamation clears its re-reference signal); `_writeDelete`, RocksDB `evict()` and `updateRecord`'s superseded-blob deletion still go through the staging-time path ([#2861](https://github.com/HarperFast/harper/issues/2861)). +**A removed record's blob files go only once its removal commits.** `removeEntry` (`RecordEncoder.ts`) decides from what `store.remove` returns: a promise gates the unlink on its resolution, anything else is taken as already durable. A RocksDB removal staged in a transaction (`{ transaction }`) returns nothing, so through `removeEntry` its blobs are queued for reclamation at staging time, and an abort leaves the record pointing at files reclaimed `storage.blobRetention` later. `getFromSource()`'s source-miss removal and RocksDB `evict()` therefore stage the removal themselves and delete the removed record's blobs from their own commit completion, keeping any file the record now stored at that key references (queueing a file for reclamation clears its re-reference signal), so an `ERR_BUSY`-abandoned eviction reclaims nothing; `_writeDelete` and `updateRecord`'s superseded-blob deletion still go through the staging-time path ([#2861](https://github.com/HarperFast/harper/issues/2861), [#3367](https://github.com/HarperFast/harper/issues/3367)). `evict()` reads the entry in its own transaction and skips a version other than the one its caller read on every table, not only audited or sourced ones: the read path and the cleanup scan's blob bypass hand it a record read earlier (`evictionBlobs.test.js`). **Source-unavailable blobs must not abort the commit.** `startPreCommitBlobsForRecord().complete()` awaits each blob's `saving` promise; a rejection there propagates up and aborts the record's apply (the replication subscription loop catches and logs it as `error in subscription handler`). For a blob the replication source can no longer provide — evicted/expired at the origin, the receiver having flagged the rejection `sourceBlobUnavailable` (harper-pro#403) — that abort permanently wedged a replication copy stream on an expiration cache table whose TTL-evicted blobs are gone everywhere: every orphaned record's apply re-threw, the copy never advanced, and backpressure pinned at ~100%. `complete()` therefore tolerates a `sourceBlobUnavailable` rejection (`isSourceBlobUnavailable`): the record commits with a diverged blob reference, left for proactive backfill (harper-pro#388). Local/transient save faults stay unmarked and still reject, so the write aborts and a reconnect retries it — no silent loss. This is the apply/commit-side complement to the replication receiver's resume-cursor advance (harper-pro#403/#405), which handles the durability-watermark side of the same missing blob. diff --git a/resources/Table.ts b/resources/Table.ts index 703b8486de..4791ac173a 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -4073,6 +4073,7 @@ export function makeTable(options): TableResourceClass { static evict(id, existingRecord, existingVersion) { if (maintenanceClosed) return Promise.resolve(); let entry; + let removedRecordWithBlobs; // Raw on RocksDB: this method commits or aborts the handle itself, and its catch expects a conflict // to reject with ERR_BUSY (resources/DESIGN.md, over-time transactions). const lmdbTransaction = primaryStore.ifVersion @@ -4082,12 +4083,9 @@ export function makeTable(options): TableResourceClass { let options = { transaction }; let committed = false; try { - if (hasSourceGet || audit) { - if (!existingRecord) return; - entry = primaryStore.getEntry(id, options); - if (!entry || !existingRecord) return; - if (entry.version !== existingVersion) return; - } + if ((hasSourceGet || audit) && !existingRecord) return; + entry = primaryStore.getEntry(id, options); + if (!entry || entry.version !== existingVersion) return; if (hasSourceGet) { // if there is a resolution in-progress, abandon the eviction if (primaryStore.hasLock(id, entry.version)) return; @@ -4102,14 +4100,16 @@ export function makeTable(options): TableResourceClass { const indexCleanup = primaryStore.ifVersion(id, existingVersion, () => { updateIndices(id, existingRecord, null); }); - const removal = removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), existingVersion); + const removal = removeEntry(primaryStore, entry, existingVersion); lmdbCompletion = Promise.all([indexCleanup, removal]); } else { if (hasDerivedIndexRegistration(auditStore, tableId)) reserveLocalKey(primaryStore.rootStore, transaction as RocksTransaction); updateIndices(id, existingRecord, null, options); stageDerivedIndexEviction(transaction as RocksTransaction, id, existingVersion); - removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), options); + // not removeEntry: it queues blob reclamation at staging, and this commit can still conflict + primaryStore.removeSync(id, options); + if (entry.value && entry.metadataFlags & HAS_BLOBS) removedRecordWithBlobs = entry.value; } committed = true; // Eviction is best-effort cleanup, run fire-and-forget from the record-expiration sweep and the @@ -4133,16 +4133,26 @@ export function makeTable(options): TableResourceClass { // RocksDB: eviction writes went directly into the raw transaction via options; commit it directly. // The raw commit has no retry, so a concurrent-write conflict rejects here — swallow it // (abandon the eviction) and log anything unexpected, rather than letting it crash the process. + const onCommitFailure = (error) => { + // The commit failed, so the read-snapshot/transaction handle is still open — release it, as the + // batched-eviction path does on its own commit failures. committed===true skips the finally abort. + try { + (transaction as any).abort(); + } catch {} + if (error?.code === 'ERR_BUSY') logger.trace?.('Abandoned eviction of busy record', id); + else logger.warn?.('Error evicting record', id, error); + }; + const commit = commitTrackedRocksTransaction(transaction as RocksTransaction, primaryStore); + if (!removedRecordWithBlobs) return trackMaintenanceCommit(commit.catch(onCommitFailure)); return trackMaintenanceCommit( - commitTrackedRocksTransaction(transaction as RocksTransaction, primaryStore).catch((error) => { - // The commit failed, so the read-snapshot/transaction handle is still open — release it, as the - // batched-eviction path does on its own commit failures. committed===true skips the finally abort. + commit.then(() => { try { - (transaction as any).abort(); - } catch {} - if (error?.code === 'ERR_BUSY') logger.trace?.('Abandoned eviction of busy record', id); - else logger.warn?.('Error evicting record', id, error); - }) + // a write committed since the eviction may reference the same files again + deleteBlobsInObject(removedRecordWithBlobs, collectRetainedFileIds(primaryStore.getEntry(id)?.value)); + } catch (error) { + logger.warn?.('Error deleting blobs of evicted record', id, error); + } + }, onCommitFailure) ); } finally { if (!committed) { @@ -10160,8 +10170,8 @@ export function makeTable(options): TableResourceClass { count++; } if (action) { - // Blob-bearing records delete their blob files as a non-transactional side effect, so - // they stay on the per-record evict() path that preserves the existing blob/commit ordering. + // Blob-bearing records stay on the per-record evict() path, which reclaims their blob + // files only after its own commit; the batcher's removeEntry would queue them at staging. if (batcher && !(action === 'evict' && metadataFlags & HAS_BLOBS)) { await batcher.add(action, key, version); } else { diff --git a/unitTests/resources/evictionBlobs.test.js b/unitTests/resources/evictionBlobs.test.js index 4b2ca53358..fc518636ea 100644 --- a/unitTests/resources/evictionBlobs.test.js +++ b/unitTests/resources/evictionBlobs.test.js @@ -20,7 +20,8 @@ const { setMainIsWorker } = require('#js/server/threads/manageThreads'); const RECLAMATION_SETTLE_MS = 200; const REREFERENCED = 1; // hold-state slot offset, resources/blob.ts -describe('evict() leaves a renewed record and its blobs intact', () => { +describe('evict() leaves a renewed record and its blobs intact', function () { + this.timeout(10000); let UnauditedBlobs; before(function () { @@ -61,13 +62,13 @@ describe('evict() leaves a renewed record and its blobs intact', () => { assert.equal(UnauditedBlobs.indices.tag.getValuesCount('v2'), 1, "the renewed record's index entry must survive"); assert.ok(existsSync(renewed.filePath), "the renewed record's blob file must survive"); - // The same reclamation timing does unlink once the eviction targets the live version. await UnauditedBlobs.evict('renewed', renewed.entry.value, renewed.entry.version); assert.equal(UnauditedBlobs.primaryStore.getEntry('renewed'), undefined); assert.equal(UnauditedBlobs.indices.tag.getValuesCount('v2'), 0); await waitFor(() => !existsSync(renewed.filePath), { message: 'an eviction of the live version reclaims its blob', }); + await UnauditedBlobs.evict('renewed', renewed.entry.value, renewed.entry.version); // already gone }); it('keeps the blob file when its eviction loses a commit conflict', async function () { @@ -122,4 +123,32 @@ describe('evict() leaves a renewed record and its blobs intact', () => { 'an abandoned eviction must not reclaim the blob the competing write still references' ); }); + + it('keeps a blob file that a write committed after the eviction references again', async function () { + if (process.env.HARPER_STORAGE_ENGINE === 'lmdb') return this.skip(); // exercises the RocksDB post-commit step + const { Transaction } = require('@harperfast/rocksdb-js'); + const originalCommit = Transaction.prototype.commit; + const { entry, filePath } = await putWithBlob('refilled', 'resident'); + Transaction.prototype.commit = function (...args) { + Transaction.prototype.commit = originalCommit; + // lands between the eviction's commit and its blob cleanup + return originalCommit + .apply(this, args) + .then((result) => UnauditedBlobs.put({ ...entry.value, tag: 'refilled' }).then(() => result)); + }; + try { + await UnauditedBlobs.evict('refilled', entry.value, entry.version); + } finally { + Transaction.prototype.commit = originalCommit; + } + await delay(RECLAMATION_SETTLE_MS); + + const { table: holdState, slot } = getBlobHoldStateForTesting( + UnauditedBlobs.primaryStore.rootStore, + getFileId(entry.value.blob) + ); + assert.equal(UnauditedBlobs.primaryStore.getEntry('refilled')?.value.tag, 'refilled'); + assert.equal(Atomics.load(holdState, slot + REREFERENCED), 1, 'the refilled record keeps its blob file'); + assert.ok(existsSync(filePath)); + }); }); From da55d53304fc40a8be446d71ae35fe3f507359ed Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 09:31:21 -0600 Subject: [PATCH 3/7] Drop a narrating test comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- unitTests/resources/evictionBlobs.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unitTests/resources/evictionBlobs.test.js b/unitTests/resources/evictionBlobs.test.js index fc518636ea..610efd0007 100644 --- a/unitTests/resources/evictionBlobs.test.js +++ b/unitTests/resources/evictionBlobs.test.js @@ -68,7 +68,7 @@ describe('evict() leaves a renewed record and its blobs intact', function () { await waitFor(() => !existsSync(renewed.filePath), { message: 'an eviction of the live version reclaims its blob', }); - await UnauditedBlobs.evict('renewed', renewed.entry.value, renewed.entry.version); // already gone + await UnauditedBlobs.evict('renewed', renewed.entry.value, renewed.entry.version); }); it('keeps the blob file when its eviction loses a commit conflict', async function () { From 2df385106010fb50d48b68b23d6f22b38badfb76 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 09:42:31 -0600 Subject: [PATCH 4/7] Clean up eviction index keys from the entry the eviction read A resequenced write can reuse the version the caller read, so the caller's copy may not be the record being removed; the eviction batcher already indexes from the fresh entry. Refs #3384 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- resources/Table.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/Table.ts b/resources/Table.ts index 4791ac173a..2ed7f94ceb 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -4105,7 +4105,8 @@ export function makeTable(options): TableResourceClass { } else { if (hasDerivedIndexRegistration(auditStore, tableId)) reserveLocalKey(primaryStore.rootStore, transaction as RocksTransaction); - updateIndices(id, existingRecord, null, options); + // the entry read in this transaction, not the caller's copy: a resequenced write can reuse its version + updateIndices(id, entry.value, null, options); stageDerivedIndexEviction(transaction as RocksTransaction, id, existingVersion); // not removeEntry: it queues blob reclamation at staging, and this commit can still conflict primaryStore.removeSync(id, options); From 38cea56b98a529fbf4b345275c0af32045ff2933 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 09:54:09 -0600 Subject: [PATCH 5/7] Test eviction index cleanup under a resequenced, version-reusing write Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- unitTests/resources/evictionBlobs.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/unitTests/resources/evictionBlobs.test.js b/unitTests/resources/evictionBlobs.test.js index 610efd0007..fe99afc1d6 100644 --- a/unitTests/resources/evictionBlobs.test.js +++ b/unitTests/resources/evictionBlobs.test.js @@ -151,4 +151,19 @@ describe('evict() leaves a renewed record and its blobs intact', function () { assert.equal(Atomics.load(holdState, slot + REREFERENCED), 1, 'the refilled record keeps its blob file'); assert.ok(existsSync(filePath)); }); + + it('removes the index keys of the record it removes when a resequenced write reused the version', async function () { + if (process.env.HARPER_STORAGE_ENGINE === 'lmdb') return this.skip(); // only RocksDB resequences under a reused version + await UnauditedBlobs.put({ id: 'resequenced', tag: 'read' }); + const { version: baseVersion } = UnauditedBlobs.primaryStore.getEntry('resequenced'); + await UnauditedBlobs.patch('resequenced', { blob: null }, { timestamp: baseVersion + 100 }); + const read = UnauditedBlobs.primaryStore.getEntry('resequenced'); + await UnauditedBlobs.patch('resequenced', { tag: 'merged' }, { timestamp: baseVersion + 50 }); + const merged = UnauditedBlobs.primaryStore.getEntry('resequenced'); + assert.equal(merged.version, read.version, 'precondition: the resequenced write kept the version the caller read'); + assert.equal(merged.value.tag, 'merged'); + + await UnauditedBlobs.evict('resequenced', read.value, read.version); + assert.equal(UnauditedBlobs.indices.tag.getValuesCount('merged'), 0, 'no index entry may outlive its record'); + }); }); From ffbd49520740b07e5132b239ce8a095d7255e6cc Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 11:35:08 -0600 Subject: [PATCH 6/7] Index LMDB eviction cleanup from the entry it read too; assert index matches the stored record Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- resources/Table.ts | 2 +- unitTests/resources/evictionBlobs.test.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/resources/Table.ts b/resources/Table.ts index 2ed7f94ceb..e17674294f 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -4098,7 +4098,7 @@ export function makeTable(options): TableResourceClass { // Capture both promises so a real write failure on either resolves through evict()'s catch // below rather than escaping as an unhandled rejection from the fire-and-forget callers. const indexCleanup = primaryStore.ifVersion(id, existingVersion, () => { - updateIndices(id, existingRecord, null); + updateIndices(id, entry.value, null); }); const removal = removeEntry(primaryStore, entry, existingVersion); lmdbCompletion = Promise.all([indexCleanup, removal]); diff --git a/unitTests/resources/evictionBlobs.test.js b/unitTests/resources/evictionBlobs.test.js index fe99afc1d6..a5f59b7155 100644 --- a/unitTests/resources/evictionBlobs.test.js +++ b/unitTests/resources/evictionBlobs.test.js @@ -164,6 +164,11 @@ describe('evict() leaves a renewed record and its blobs intact', function () { assert.equal(merged.value.tag, 'merged'); await UnauditedBlobs.evict('resequenced', read.value, read.version); - assert.equal(UnauditedBlobs.indices.tag.getValuesCount('merged'), 0, 'no index entry may outlive its record'); + const present = UnauditedBlobs.primaryStore.getEntry('resequenced') ? 1 : 0; + assert.equal( + UnauditedBlobs.indices.tag.getValuesCount('merged'), + present, + 'the index must match the stored record' + ); }); }); From 2a20c57733fb4caf8434caad0bffb58cbe120e7a Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 21 Oct 2026 11:49:31 -0600 Subject: [PATCH 7/7] Use node: prefixes for builtins in the eviction blob tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01227hE2ocnUgq4RfTawEXQ8 Dispatch-Task: harper-evict-rocksdb-version-gate-blob-commit --- unitTests/resources/evictionBlobs.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/unitTests/resources/evictionBlobs.test.js b/unitTests/resources/evictionBlobs.test.js index a5f59b7155..534adac67d 100644 --- a/unitTests/resources/evictionBlobs.test.js +++ b/unitTests/resources/evictionBlobs.test.js @@ -1,8 +1,8 @@ require('../testUtils'); -const assert = require('assert'); -const { existsSync } = require('fs'); -const { randomBytes } = require('crypto'); -const { setTimeout: delay } = require('timers/promises'); +const assert = require('node:assert'); +const { existsSync } = require('node:fs'); +const { randomBytes } = require('node:crypto'); +const { setTimeout: delay } = require('node:timers/promises'); const { setupTestDBPath } = require('../testUtils'); const { waitFor } = require('../waitFor.js'); const { table } = require('#src/resources/databases');