Skip to content

fix(blob): clean up replication-received blobs when a source apply is skipped (harper-pro#406) - #1341

Merged
kriszyp merged 1 commit into
mainfrom
kris/replicated-blob-skip-leak
Jun 17, 2026
Merged

fix(blob): clean up replication-received blobs when a source apply is skipped (harper-pro#406)#1341
kriszyp merged 1 commit into
mainfrom
kris/replicated-blob-skip-leak

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a replicated-blob orphan leak (harper-pro#406). On a replication/source apply, the record's blobs are saved out-of-band by receiveBlobs before the apply runs. If that apply then loses a version conflict (precedesExistingVersion <= 0write.skipped), the received blob was never unlinked — startPreCommitBlobsForRecord only tracked saveBeforeCommit blobs in write.savedBlobs, so the skip/abort cleanup had nothing to remove. On an active multi-node caching cluster these accumulate: an audit-aware cleanup_orphan_blobs sweep on one preprod node reclaimed 1059 true-orphan blobs (~84% of its blob files).

Changes

  • resources/blob.ts
    • startPreCommitBlobsForRecord(record, store, saveInRecord?, trackPersistedBlobs?): when trackPersistedBlobs is set, also track an already-saved (fileId-set) blob so the skip/abort cleanup can unlink it. Gated deliberately (see below).
    • cleanupUnusedBlobs(blobs, retainedFileIds?): never deletes a blob whose fileId the committed (winning) record still references.
    • new collectRetainedFileIds(record) helper.
  • resources/Table.ts: preCommitBlobsForRecordBefore forwards the flag; the _writeUpdate call site passes options?.isNotification.
  • resources/LMDBTransaction.ts (×2) and resources/DatabaseTransaction.ts (×3): the skip/abort cleanup sites pass collectRetainedFileIds(write.store.getEntry(write.key)?.value).

Where to look

  • The gating decision is load-bearing for data integrity. Already-saved blobs are tracked only on the source/replication apply path (isNotification). A received blob is owned by that one write (a unique fileId saved for it). A local write that carries an already-saved blob instead references another row's blob — and deleting that on abort/skip would corrupt the owning record. (Cross-model review caught exactly this in the first cut, where tracking was unconditional; this gating + the retained guard is the fix.) pack() already rejects reusing a blob across records, but the rejection throws after savedBlobs is populated, so the resulting abort would have triggered the bad delete — hence gating, not just relying on the guard.
  • Pre-existing consideration, not addressed here (worth a reviewer opinion): cross-node fileId collision. fileIds are per-origin-node sequences, so two records from different origins can share a fileId string and thus the same on-disk blob path. That's a latent collision independent of this change (the second receive already overwrites the first). This fix's skip-delete is same-origin-safe (a received blob's fileId is unique to its record) and the retained guard covers the same-key case, but a cross-key cross-node collision is not something this PR solves. Flagging in case you want a follow-up issue.

Tests

  • unitTests/resources/blob.test.js: 3 new tests — trackPersistedBlobs gating, the retainedFileIds guard (deletes non-retained, keeps retained), and collectRetainedFileIds. Existing "updating an unrelated attribute does not unlink a still-referenced blob" still passes.
  • Full unitTests/resources/** suite: 815 passing, 0 failing. Lint clean.

Review provenance / caveats

  • Cross-model review ran via Codex (twice). The first pass flagged a real data-loss blocker (unconditional tracking would delete a cross-row-shared blob on abort) — fixed by the isNotification gating above; Codex re-reviewed the revision and confirmed the blocker is resolved with no new findings.
  • Caveat: the Gemini (agy) leg timed out/produced no output both rounds, and the worktree-isolated reviewer subagent could not launch (harness WorktreeCreate hook error). I did the Harper-domain pass myself instead. So this change has had Codex + author domain review but not the Gemini or Opus-reviewer legs — worth a careful human data-integrity pass.

🤖 Generated by Claude (Opus 4.8). Fix for harper-pro#406.

… skipped

On a replication/source apply, the record's blobs are saved out-of-band by
receiveBlobs BEFORE the apply runs. If that apply then loses a version conflict
(precedesExistingVersion <= 0 -> write.skipped), the received blob was never
unlinked: startPreCommitBlobsForRecord only tracked saveBeforeCommit blobs in
write.savedBlobs, so the skip/abort cleanup had nothing to remove. On an active
multi-node caching cluster these orphans accumulate (observed: ~1000+ true
orphan blobs on one preprod node). See harper-pro#406.

- startPreCommitBlobsForRecord: new `trackPersistedBlobs` flag — also track an
  already-saved (fileId-set) blob, but ONLY when set. It's passed as
  options?.isNotification from _writeUpdate, so only source/replication applies
  track already-saved blobs. A local write carrying an already-saved blob
  references another row's blob (cross-row reuse, which pack() rejects, or a
  same-key unchanged-attribute update), and must NOT have it unlinked on
  abort/skip — gating to the source-apply path avoids that data-loss hazard.
- cleanupUnusedBlobs(blobs, retainedFileIds?): never deletes a blob whose fileId
  the committed (winning) record still references. The skip/abort cleanup sites
  in LMDBTransaction and DatabaseTransaction pass
  collectRetainedFileIds(write.store.getEntry(write.key)?.value) as that guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review June 17, 2026 05:48
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@kriszyp

kriszyp commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

CI note: every check is green except Integration Tests 4/6 (Windows, Node.js v24), which has been persistently slow/stuck on this PR — it hung ~19 min on the first run, and the fresh-runner re-run is again running long while all other integration shards (Bun, Node v22/v24/v26, and the other 5 Windows shards) and the full unit suite pass. This looks like Windows-runner flakiness on that one shard, not the change (the diff is core blob/transaction logic with no platform-specific behavior; the earlier v24 unit segfault was also a confirmed flake that passed on re-run). Flagging so it doesn't block review — re-trigger that shard if it doesn't settle.

— Claude (Opus 4.8)

@kriszyp
kriszyp merged commit 054b811 into main Jun 17, 2026
84 of 86 checks passed
@kriszyp
kriszyp deleted the kris/replicated-blob-skip-leak branch June 17, 2026 14:37
kriszyp added a commit that referenced this pull request Jun 18, 2026
…complete() (#1341 regression) (#1376)

startPreCommitBlobsForRecord with trackPersistedBlobs=true (added in #1341 for
orphan-blob cleanup) added already-saving blobs (fileId set) to blobsNeedingSaving,
so complete() awaited their in-flight save promises before the commit ran.

This creates a deadlock in backlog-recovery catch-up:

1. Many catch-up records arrive quickly; outstandingCommits exceeds
   MAX_OUTSTANDING_COMMITS (150), causing ws.pause() in replicationConnection.ts.
2. Partially-received blob streams (chunks still in kernel/WS buffer) can't
   receive more data while the WS is paused.
3. After blobTimeout (120s), those streams time out → hasBlobGap=true →
   complete() rejects → commit fails → onCommit() never fires →
   outstandingCommits never decrements → WS stays paused forever.

Fix: split the tracking into blobsNeedingSaving (awaited in complete()) and
blobsToTrackOnly (tracked for cleanup only, not awaited). Already-saving blobs
go into blobsToTrackOnly. Their durability is already tracked by
outstandingBlobsToFinish in replicationConnection.ts — blocking the commit on
them here is redundant and dangerous.

The orphan-blob cleanup from #1341 (harper-pro#406) is preserved: all blobs
(both lists) end up in write.savedBlobs via allTrackedBlobs.

Fixes backlog-recovery catch-up stall in CI (B stalls at ~315/800 for the
full convergence window). See harper-pro#414.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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