Skip to content

cherry-pick: fix(blob): clean up orphaned blob files on aborted/superseded writesv5.0 (conflict)#479

Merged
kriszyp merged 6 commits into
v5.0from
cherry-pick/v5.0/pr-462
May 6, 2026
Merged

cherry-pick: fix(blob): clean up orphaned blob files on aborted/superseded writesv5.0 (conflict)#479
kriszyp merged 6 commits into
v5.0from
cherry-pick/v5.0/pr-462

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented May 6, 2026

Resolve cherry-pick conflicts

PR #462 (fix(blob): clean up orphaned blob files on aborted/superseded writes) conflicted when cherry-picking onto v5.0.

Conflict markers (<<<<<<< / ======= / >>>>>>>) have been committed to this branch. Please check out the branch, resolve all conflicts, push, and merge.

Source PR: #462
Commit(s): 88cddb1cdc910395acb359e6580e2f5c17acf28a 46166414647598c65fb5cd55a45b5706aec1fe36 dd03850a036f7791e8146d4849b27fd9c7fa26f9 a320330a41d734e4fbe7eb81bcbc6ee34dfbb164 7b6d90959bfe7699b56b70175cc8714d41109810 f85b5b75b504d677a8ba024ca51e79aad582fe84

kriszyp and others added 6 commits May 6, 2026 01:45
Blobs flagged with `saveBeforeCommit` (or `saveInRecord`) are written to
disk in the `beforeIntermediate` phase of a transaction write, before the
underlying record write commits. Several paths could leave the file on
disk with no record referencing it:

- Multi-write transaction where one `beforeIntermediate` rejected after
  others wrote files (only the failing blob was cleaned up by
  `deleteOnFailure`; succeeded peers were leaked).
- Commit-handler early-returns: out-of-order replicated update treated
  as duplicate, superseded by a newer put/delete, no-audit fullUpdate
  loses to existing version, cache-resolve version-changed.
- `DatabaseTransaction.commit` rejecting on `Promise.all(completions)`
  failure (e.g. blob save error) without aborting — leaked the
  underlying RocksDB transaction *and* the blob files.
- Mid-record decode failure in the replication ingest path.

Approach:

- `startPreCommitBlobsForRecord` now returns `{ blobs, complete }` so
  each `TransactionWrite` can attach `savedBlobs` for later cleanup.
- New `cleanupUnusedBlobs(blobs)` waits for any in-flight save to
  settle, then unlinks. Idempotent — clears the list after scheduling.
- Commit handlers in `Table.ts` set `write.skipped = true` (resetting
  to `false` on each invocation) on early-return paths that don't end
  up writing a record or audit reference. The transaction commit
  success paths walk writes and clean up `skipped` ones. Cleanup is
  deferred to post-commit because the same handler runs again on
  optimistic-lock retries and a retry can flip a previously-skipped
  write into a successful one (e.g. a concurrent delete makes our
  older replicated update suddenly win).
- `LMDBTransaction.abort` and `DatabaseTransaction.abort` walk all
  writes' `savedBlobs` and clean unconditionally.
- `DatabaseTransaction.commit` adds an explicit reject handler so a
  `Promise.all` failure aborts instead of leaking.

Tests: new `Blob test > multi-write transaction with one failing blob
cleans up succeeded blobs` and `cleanupUnusedBlobs is a no-op for
unsaved blobs`. The post-suite `cleanupOrphans` test acts as a safety
net across all blob tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…KNOWN_SIZE

When all blob content is read and the reader gets bytesRead=0, it re-reads
the header (still UNKNOWN_SIZE) and sets up an fs.watch watcher. If the
writer finishes and updates the header between that header read and the
watcher being created, no further writes occur and the watcher never fires,
causing a 60s timeout with 'size is supposed to be 281474976710655 bytes'.

Fix: after readSync at `position` returns 0, immediately re-read the header
from position 0. If the size is no longer UNKNOWN_SIZE, bypass the watcher
and retry directly. Apply the same check in the timeout callback as a second
line of defense. Also eliminates the shared module-level HEADER/headerView
buffers in favor of local DataView instances, avoiding any shared-state risk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…riterFinished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lect phase

When a join condition (e.g. relatedByName) becomes the primary iterator due
to a lower estimated count, the select phase uses filterMap.hasMappings=true
and calls targetTable.transformEntryForSelect with the outer table's readTxn.
That readTxn is a RocksTransaction scoped to the outer table's RocksDB column
family and cannot read from a different table's column family, so getEntry
returns null, producing [undefined] in the joined attribute.

Fix: compute targetReadTxn = targetTable._readTxnForContext(context) when the
target table differs from the current table, ensuring each joined table's
records are loaded with a transaction scoped to that table's own store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot requested a review from a team as a code owner May 6, 2026 01:45
@kriszyp kriszyp merged commit 294fcfa into v5.0 May 6, 2026
2 checks passed
@kriszyp kriszyp deleted the cherry-pick/v5.0/pr-462 branch May 6, 2026 02:10
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