HarperFast / HarperFast/harper

Live page_cache records reference deleted blob files: blob lifetime is supersession + 500ms, blind to readers and replication peers (was: #1302 retention half)

Open
#2,134 8 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
89
Forks
10
Avg merge
2d 6h
Merged PRs (30d)
200

Description

> **Status, 2026-08-11.** Root cause settled (maintainer analysis + field verification below):
> blob file lifetime is bound to **record supersession plus a fixed 500 ms timer**, with no
> accounting for outstanding consumers — and on a replicated cluster the dominant consumer that
> falls outside that window is **replication peers**, whose receivers advance past the missing
> blob and commit the record without its bytes (permanent divergence). Reference-aware
> reclamation + automatic backfill is being prototyped by the maintainer. The *user-facing* half
> is mitigated downstream: the prerender plugin (v0.40.0+) reads a cached body to completion
> before committing a response status, so an affected record becomes an ordinary origin proxy
> instead of a truncated `200` (verified: truncated-response counter 62-per-40min → zero). The
> data loss itself continues at the rates below.
>
> **Suggested priority: P1** — behind harper-pro#683, which is the supply line for most of this
> symptom here (detail in comments): its base copies re-stream the whole table, converting every
> concurrent supersession into a diverged record. Fixing #683 shrinks this issue's blast radius;
> the reverse is not true.

## Summary

A 4-node production prerender cluster steadily loses `page_cache` blob files while the records
referencing them stay live. Reads of those records fail with `BlobReadError: Blob file not found`
— the *current* version of a live record points at a path that no longer exists on disk. Present
on 5.1.26 and 5.2.1 alike.

## Root cause (maintainer analysis, field-confirmed)

`RecordEncoder.ts:796` unlinks the prior row's blobs on **every** write, and `deleteBlob`
(`resources/blob.ts:853`) is a bare `setTimeout(unlink, 500)` — no refcount, no read-snapshot
awareness, no replication awareness (the code's own TODO says as much). Two consumer classes fall
outside the 500 ms window:

1. **Replication peers (dominant here).** The origin unlinks a superseded blob while a receiver
is still fetching that fileId; the sender 404s; `isUnrecoverableSourceBlobError` classifies it
permanent; the receiver **advances its resume cursor past the blob and commits the record with
a dangling reference** — permanent until backfilled. Measured on this cluster: the advance-past
log line fired **374,889 times in 6 hours on one node** with a fast inbound base copy
(harper-pro#683 keeps re-triggering those copies), and 25% of sampled serve-path faults were
blobs **still present on a peer** — unambiguous divergence, repairable in principle, except the
repair operation cannot run on a multi-worker instance (harper-pro#684).
2. **In-flight HTTP readers (minority, transient).** `stream()` opens the file by path, lazily,
at stream time; a request whose record-read → file-open gap straddles a concurrent rewrite gets
ENOENT after the 200 is committed. Self-heals on retry.

Full check-by-check field data (existence matrix with positive controls, repeat-fault
distribution, per-node advance-past counts) is in the comments dated 2026-08-11.

## Production evidence

- Continuous fault stream on all four nodes; ~1,000 distinct cache keys fault per 6h cluster-wide
(a **floor** — see measurement caveats below), heaviest on the node with the most accumulated
copy divergence (601 keys/6h) even though its *current* advance-past rate is the lowest.
- Sampled events: 100% `store=page_cache`, 100% file-not-found (`BLOB_GONE_STATUS`), zero
retryable `BLOB_UNAVAILABLE`; blob ids span a 2.59M-id range (not clustered at the counter head).
- Measurement caveats: the pre-mitigation counter only saw blobs failing *mid-stream* (one failing
at `open()` produced no error at all), and Harper analytics under-records vs the logs (~34% low,
measured); treat all counts as floors.

## History: the #1302 hypothesis, tested and retired

This issue originally attributed the loss to the unmerged blob-retention half of #1302
(`kris/blob-invalidate-1302`). A controlled A/B on 5.2.1-equivalent code showed the hunk fixes its
own regression test but converts the loss into an orphan-sweeper deletion, and the maintainer
analysis above showed the invalidate path produces *record-without-reference* (a clean miss), not
*record-with-dangling-reference* (this symptom). That hypothesis is retired; details preserved in
the comment thread.

## Impact

With the downstream mitigation, no more corrupt bytes reach crawlers — the residual cost is lost
cache offload: every affected read becomes an origin fetch plus a re-render. Consumers without the
mitigation still serve truncated/empty bodies as `200`s with every success signal green. On the
replication path, records with dangling refs can also wedge links on 5.2.x (decode-side regression
— see the cross-linked family comment: fc578af re-throws before the cursor advances; deferred
guard harper-pro#432 never landed).

## Asks

1. **Reference-aware reclamation + automatic backfill** (the maintainer's proposed shape): hold a
superseded blob until open read snapshots and peers' resume cursors have passed it; backfill
automatically when a receiver advances past a blob instead of requiring a manual operation.
We can verify a prototype build against production traffic same-day.
2. Until then, harper-pro#684 (make `repair_blob_data` runnable on multi-worker instances) is the
only remediation for accumulated divergence, and it currently cannot run at all.

*(Withdrawn: the original ask for an awaitable blob-readiness API — the downstream read-to-
completion approach covers it, and it only narrows the race anyway.)*

Contributor guide

Open the contributing guide

Research direction

Start by reading RecordEncoder.ts around line 796 and resources/blob.ts around deleteBlob at line 853, then follow the replication receiver's handling of missing blobs and resume cursors. Review the linked field evidence and existing regression coverage before proposing a design. Done means superseded blobs remain available to active readers and peers, and missed blobs trigger automatic backfill without dangling references.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nodejs
Domain
backend, databases, distributed-systems
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.