Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions resources/RecordEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,11 +879,29 @@ export function setAdditionalAuditRefs(refs: Array<{ version: number; nodeId: nu
}
export function removeEntry(store: any, entry: any, options?: any) {
if (!entry) return;
const removal = store.remove(entry.key, options);
if (entry.value && entry.metadataFlags & HAS_BLOBS) {
// if it used to have blobs, we need to delete the old blobs
deleteBlobsInObject(entry.value);
// Delete the old blobs only once the removal commits. Scheduling the unlink up front
// (as before) orphaned the blob when the removal didn't actually land — an expiration
// scan whose transaction is force-committed without this delete, or an aborted/version-
// conflicted removal — leaving a record that references a now-missing blob and wedges
// replication on ENOENT. The removal promise resolves when the write commits (false on
// a conditional-version miss; rejects on abort), so gate the unlink on it. See #1364.
const deleteOldBlobs = () => deleteBlobsInObject(entry.value);
if (removal && typeof removal.then === 'function') {
// Swallow rejections (aborted removal) — leave the blob in place in that case.
removal.then(
(committed: any) => {
if (committed !== false) deleteOldBlobs();
},
() => {}
);
} else {
// Synchronous (already-durable) removal: delete immediately.
deleteOldBlobs();
}
}
return store.remove(entry.key, options);
return removal;
}
export interface RecordObject {
getUpdatedTime(): number;
Expand Down
35 changes: 35 additions & 0 deletions unitTests/resources/blob.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ require('../testUtils');
const assert = require('assert');
const { setupTestDBPath } = require('../testUtils');
const { table, getDatabases } = require('#src/resources/databases');
const { removeEntry } = require('#src/resources/RecordEncoder');
const { Readable, PassThrough } = require('node:stream');
const { setAuditRetention } = require('#src/resources/auditStore');
const { setMainIsWorker } = require('#js/server/threads/manageThreads');
Expand Down Expand Up @@ -286,6 +287,40 @@ describe('Blob test', () => {
});
setDeletionDelay(500); // restore the default
});
it('blob unlink is gated on the removal committing (#1364)', async () => {
// removeEntry must only unlink the old blobs once the record removal commits. An
// expiration scan whose transaction is force-committed without the delete (or an
// aborted/version-conflicted removal) leaves the record in place; unlinking its blobs
// regardless would orphan the reference and wedge replication on ENOENT.
setDeletionDelay(0);
const realStore = BlobTest.primaryStore;
const blob = await createBlob(randomBytes(20000)); // > FILE_STORAGE_THRESHOLD → file-backed
await BlobTest.put({ id: 720, blob });
const filePath = getFilePathForBlob(blob);
assert(filePath, 'expected file-backed blob');
assert(existsSync(filePath), 'blob file should exist after put');

// Fetch a real entry (value + metadataFlags) the way the eviction scan does.
let entry;
for (const e of realStore.getRange({ start: 720, end: 721, versions: true })) {
if (e.key === 720) entry = e;
}
assert(entry && entry.value, 'expected a real entry carrying the blob');

// Removal that never commits (rejects): the blob must be preserved.
removeEntry({ remove: () => Promise.reject(new Error('aborted')) }, entry, undefined);
await delay(40);
assert(existsSync(filePath), 'blob must survive when the removal does not commit (#1364)');

// Removal that commits: the blob is unlinked.
removeEntry({ remove: () => Promise.resolve(true) }, entry, undefined);
await waitFor(() => !existsSync(filePath), {
message: 'blob should be unlinked once the removal commits',
});

await BlobTest.delete(720); // cleanup the real record (its blob file is already gone)
setDeletionDelay(500); // restore the default
});
it('slowly create a blob and save it before it is done', async () => {
let testString = 'this is a test string'.repeat(256);
let expectedResults = '';
Expand Down
Loading