Retain superseded blob files long enough for in-flight readers - #2145
Retain superseded blob files long enough for in-flight readers#2145kriszyp wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request replaces the fixed 500ms blob deletion delay with a robust, queue-based reclamation system. It introduces configurable retention delays, explicit retention holds, and cross-thread lock-backed hold checks, along with comprehensive unit tests. The reviewer identified three high-severity concurrency and lifecycle issues: first, the binary lock key for holding blobs does not safely count holders across multiple worker threads; second, cancelBlobReclamation only cancels reclamation on the local worker, allowing other workers to prematurely unlink re-referenced files; and third, rescheduling the reclamation queue fails to propagate the keepProcessAlive flag, which can cause short-lived processes to exit prematurely and leak files.
|
Blocker still open (re-verified against current HEAD, |
d747a82 to
36f13e7
Compare
2e0b2de to
8e7d3b2
Compare
a5780be to
5190820
Compare
| function isBlobHeld(storageInfo: StorageInfo | undefined, claim = false): boolean { | ||
| const store = storageInfo?.store; | ||
| const fileId = storageInfo?.fileId; | ||
| if (!store || !fileId) return false; | ||
| const state = blobHoldState(store, fileId); | ||
| if (!state) throw new Error(`Could not read hold state for blob ${fileId}`); | ||
| if (claim) return Atomics.compareExchange(state.table, state.slot + HOLDS, 0, RECLAIMING) !== 0; | ||
| return Atomics.load(state.table, state.slot + HOLDS) > 0; | ||
| } | ||
|
|
||
| /** | ||
| * Cancel a queued reclamation because a record version being written references the file again: the | ||
| * retain-on-update check covers only the write that supersedes, not a file already queued by an | ||
| * earlier one. Cancelling at encode rather than at commit means an aborted write leaves the file | ||
| * unreclaimed until the orphan sweeper runs — chosen deliberately over the alternative failure, | ||
| * which is unlinking a file the committed record still points at. | ||
| * | ||
| * The signal is recorded in shared memory as well as in this worker's queue, because the worker that | ||
| * queued the reclamation may not be this one. | ||
| */ | ||
| function cancelBlobReclamation(storageInfo: StorageInfo): void { |
There was a problem hiding this comment.
Hash collision on the shared hold slot still unlinks a re-referenced file early, despite the thread being marked resolved
File: resources/blob.ts:1038-1080 (isBlobHeld / cancelBlobReclamation), root cause in blobHoldSlot (line 938)
What: This is the same defect raised in the now-resolved thread on this line (comment id 3762003686) — re-checked against the current code, and it's still present verbatim, with no tag/disambiguation added. HOLD_TABLE_SLOTS is a fixed 4096-slot table shared by hash (blobHoldSlot), and fileId is a per-store sequential counter, so two unrelated fileIds routinely land on the same slot. While a reclaimer holds slot S at RECLAIMING for fileId A (the window between the compareExchange claim in isBlobHeld and releaseReclaimClaim in the unlink callback), a write referencing a colliding fileId B hits the early-return branch in cancelBlobReclamation (Atomics.load(...) < 0 → logger.warn + return). That return skips Atomics.store(..., REREFERENCED, 1) and the local pendingReclamation.delete(filePath) below it — so if B's own reclamation was queued (by this worker or another), nothing cancels it. B's queued unlink still fires on schedule even though a new record now references file B again.
Why it matters: This reproduces the exact bug class the PR sets out to fix (harper-pro#403/#388 — a peer treats a superseded-but-still-referenced blob as gone and permanently loses the bytes), just triggered by an unrelated hash collision instead of the original timing gap. It also contradicts the table's own documented invariant a few lines up ("a collision defers someone else's reclamation, it never unlinks early") — this path unlinks a referenced file early. The same misattribution makes holdBlobFile spuriously return null for a live, unrelated file whenever its hash collides with a fileId that's mid-reclaim; that null is documented (and consumed by the paired replication caller) as "already being reclaimed," which isn't true for the collision case.
Suggested fix: Disambiguate collisions — e.g. store a tag derived from the owning fileId alongside the counter and treat a slot as applicable only on a tag match, falling back to the safe default (not held / not reclaiming) on mismatch.
5190820 to
130d3f0
Compare
Blob file lifetime was bound to record supersession plus a fixed 500ms timer: RecordEncoder unlinks the prior row's blobs on every write, and deleteBlob armed setTimeout(unlink, 500). Nothing checked whether anything still needed the bytes. The file is opened lazily, by path, when a consumer calls stream()/bytes(), so a reader that resolved a record just before a concurrent write opens a file that is already gone — an ENOENT raised after the response headers are committed, which reaches the client as a truncated body with every signal reporting success (#2134). deleteBlob's own comment described the gap: "we need to determine when any read transaction are done with the file". Reclamation now defers on the three things that can still need a file, in descending order of precision: - Open read snapshots. A blob reference is fixed when the reader's snapshot is taken, not when the record is decoded, so a reader inside a transaction is entitled to the bytes while its snapshot lives. getOldestSnapshotTimestamp() reports the oldest unreleased snapshot; a queued file whose supersession is not yet behind it stays. Reads outside a transaction take no snapshot ("no transaction means read latest"), and LMDB exposes no watermark, so both fall through to the window below. - Explicit holds, counted with Atomics in the store's shared buffer — the same mechanism the blob file-id allocator uses. stream() holds for the life of a read, so a slow or backpressured response cannot lose its bytes mid-transfer, and harper-pro's replication send holds for the life of the send (harper-pro#403/#388). Concurrent holders on different workers each keep the file alive; a reclaimer claims the count from 0 to a RECLAIMING sentinel in one compareExchange, so a hold cannot slip in between "no holders" and the unlink. - storage.blobRetention, default 2s, as the time bound for everything above that cannot declare itself. A record version referencing a file again sets a shared flag the reclaiming worker consumes, so a re-reference on any worker cancels the reclamation. An age cap reclaims after 20 minutes regardless (logged, above the replication blob timeout) so a stuck holder cannot pin a file. cleanup_orphan_blobs skips files with a queued reclamation — a file inside its window is unreferenced by any live record, which is exactly what made the sweeper delete the ones being retained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130d3f0 to
c66e09e
Compare
Blob files are unlinked on record supersession plus a fixed 500ms timer, with nothing checking whether anything still needs the bytes. The file is opened lazily, by path, when a consumer calls
stream()/bytes()— not when the record is decoded — so a reader that resolved a record just before a concurrent write opens a file that is already gone. On the HTTP path that ENOENT is raised after the response headers are committed, so it reaches the client as a truncated body while every signal reports success.deleteBlob's own comment described the gap: "we need to determine when any read transaction are done with the file, and then delete it, this is a hack to just give it some time for that."storage.blobRetention), an order of magnitude above a value that was too short for a slow or backpressured response. The cost is only the churn produced within the window; 0 reclaims as soon as the queue drains.holdBlobFile(blob)retains a file for a consumer whose need for the bytes outlives the record version that referenced it — primarily replication, where a peer that has not yet fetched a superseded blob gets a clean 404 from the sender, classifies it as unrecoverable at source, and advances its resume cursor past a record whose bytes it will never have (Blob replication wedges permanently when source blob is gone (ENOENT) on an expiration cache table — held resume cursor never recovers harper-pro#403/Tolerate missing role on user #388). Holds are counted in the store's shared buffer withAtomics— the same mechanism the blob file-id allocator in this file already uses — because the consumer and the write that supersedes it routinely run on different workers. Concurrent holders on different workers each keep the file alive; a reclaimer takes the count from 0 to aRECLAIMINGsentinel in onecompareExchange, so a hold cannot be acquired between "no holders" and the unlink, and a hold arriving after the claim returnsnullinstead of falsely reporting success.retainedFileIdscheck covers only the write that supersedes, not a file already queued by an earlier one.getOldestSnapshotTimestamp()reports the oldest unreleased snapshot, and a queued file whose supersession is not yet behind it stays. Reads outside a transaction take no snapshot ("no transaction means read latest",DatabaseTransaction.getReadTxn), and LMDB exposes no watermark — both fall through to the window.stream()retains the file for the life of the read and releases on completion, cancel, or a failed open, so a slow or backpressured response cannot have its bytes reclaimed mid-read.Pending reclamations are keyed by file path rather than fileId, which is a per-store counter two databases can collide on. The shared hold state is one fixed 4096-slot table per store rather than a buffer per fileId:
getUserSharedBufferdocuments no eviction andcancel()only detaches a listener, so a key per blob would grow without bound on a churning table. Slots are shared by hash, which can only ever over-retain — a collision defers someone else's reclamation, it never unlinks early — and the reclaim claim is handed back after the unlink so a shared slot cannot stay poisoned.Reported in #2134 as progressive
page_cacheblob loss on a 4-node cluster. That report attributes the loss to the unmerged retain-on-invalidate branch from #1302; that diagnosis does not hold — an invalidated record no longer references the blob at all, which the reporter's owncleanupOrphansrun demonstrates by finding the retained file as an orphan — so this addresses the lifetime rule underneath instead.Paired with HarperFast/harper-pro#687, the first caller of the hold API. Docs: HarperFast/documentation#622.
Where to look
resources/blob.ts—runReclamationis the whole policy in one function; read it first, thendeleteBlob,holdBlobFile, andisBlobHeld.The first review of this change found a blocker worth knowing about while reading: the original version tracked reader references and holds in module-level
Maps, which are per-worker-thread, so neither signal reached the thread doing the unlinking — it passed its unit tests while protecting nothing in production. The reference tracking is gone (a time window needs no cross-thread signal) and holds moved to the store lock.a hold taken on another thread defers reclamationis the regression test for that.Verification
Route: extended unit tests in
unitTests/resources/blob.test.js, plus the repo's full unit gates.npx mocha unitTests/resources/blob.test.js— 70 passing on RocksDB, 69 + 1 skipped on LMDB (the snapshot case skips itself where no watermark exists). Each new case verified to fail without its fix, including forcing the stream hold to null and disabling the snapshot check.git checkout origin/main -- resources/blob.ts utility/hdbTerms.ts,rm tsconfig.build.tsbuildinfo, rebuild — an incremental build silently keeps the newdistand invalidates this check): all three new tests fail, withsuperseded blob must be retained while a reader still references it,holdBlobFile is not a function, anda blob held by another thread's lock must not be reclaimed. The reader test exercises the shipped default rather than a compressed knob, which is what makes it meaningful — driving it throughsetDeletionDelaywould pass on base, since on base that knob is the delay.npm run test:unit:main— 4355 passing, 0 failing.npm run test:unit:resources— 1512 passing, 3 failing; all three (randomAccessFields×2,replayStructures) reproduce identically on a cleanorigin/maintree with the same dependencies, so they are pre-existing here, not regressions.HARPER_RUN_STRESS_TESTS=1 npm run test:integrationover the blob replication suites:Receive-side blob save rejection containmentandReceive-side blob resume-cursor clamp on transient save failureboth pass against this core.Authoritative-table blob byte-integrity after receive-side save failurefails identically on a clean baseline (localECONNREFUSEDduring a restart-phase deploy).test:integration:all— CI covers it.Open concerns from review
For the human reviewer
Seven judgment calls, ranked by what a "no" costs. Each is answerable from the entry alone.
A hold is a lease on a binary lock.Resolved. Holds are an atomic count in the store's shared buffer, so concurrent holders on different workers are independent and a hold means what its name says. What remains for review is the table: 4096 slots per store, hashed, collisions over-retain only.Resolved. It returnsholdBlobFilehas no failure signal.nullwhen reclamation has already claimed the file, so the caller is told rather than misled.Human-Review-Need: 4 (decisions: retention-default-2s, binary-lock-vs-refcount, cancel-at-encode-not-commit, age-cap-deletes-held-bytes, ship-hold-api-before-caller, worker-local-queue, retention-config-shape) @ d747a82