Skip to content

fix(blob): idle watchdog on writeBlobWithStream source to unwedge stalled replication receives - #1444

Merged
kriszyp merged 9 commits into
mainfrom
fix/blob-receive-idle-watchdog
Jun 23, 2026
Merged

fix(blob): idle watchdog on writeBlobWithStream source to unwedge stalled replication receives#1444
kriszyp merged 9 commits into
mainfrom
fix/blob-receive-idle-watchdog

Conversation

@ldt1996

@ldt1996 ldt1996 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #1443.

Summary

writeBlobWithStream runs pipeline(stream, [compressedStream,] writeStream, finished). pipeline waits for the source to emit end or error. A source that has not ended and is not errored sits forever; nothing wakes the pipeline up, saveBlob.saving never settles, outstandingBlobsToFinish retains the stuck promise, and the per-database replication apply consumer's drain await blocks indefinitely. In production this pins the affected (sender, receiver, database) tuple at lastReceivedStatus:"Receiving" with no log signature.

Fix

Opt-in source-idle watchdog in writeBlobWithStream, armed via HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS (default 0 = off so behavior is unchanged for callers that do not set it; replication's blob-receive path sets 120000ms). Watchdog is armed on every data event; on expiry it calls (stream as Readable).destroy(new Error(...)) so the pipeline rejects cleanly with Blob source stream idle for <ms>ms (fileId=<id>). Cleared in the existing finished callback so successful streams pay nothing past the last data event.

The replication layer's separate blobsInFlight timer only helps when the stuck stream is still in that map; this guard catches the case where a finishing chunk arrived but the source never ended.

Test plan

  • npx mocha unitTests/resources/blob.test.js --grep "saveBlob with idle source stream" passes (3/3)
  • New describe block saveBlob with idle source stream (replication wedge regression) covers the three prod-observed states: destroyed-before-save, chunks-but-never-ends, mid-stream-then-destroy
  • Pre-existing Blob test suite failures (#1423) are unrelated to this change (verified by running on clean main)

Production observation

Akamai v4→v5 stage cluster (5.1.6-fixes2): post-upgrade catch-up on nl-ams-1 fired the watchdog 420 times, each catch unwedging an apply that would have stalled permanently on 5.1.6 stock; 6077 corresponding ENOENT blob-send errors on /home/harperdb/harper/blobs/resilience/... confirm the upstream missing-blob source on the sender.

Companion change

A complementary sender-side timeout lives in harper-pro (replication/replicationConnection.ts sendBlobs per-chunk Promise.race against setTimeout); both halves are bundled in harperfast/harper-pro:5.1.6-fixes2. That PR will be opened separately against harper-pro.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a source-stream idle watchdog for writeBlobWithStream to prevent replication apply consumers from wedging indefinitely when a stream goes silent without ending. It also adds corresponding regression tests. The review feedback highlights two important improvements: first, checking if the stream is paused to avoid false-positive timeouts during backpressure; second, explicitly removing the 'data' event listener upon stream completion to prevent memory leaks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread resources/blob.ts
Comment thread resources/blob.ts
@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Three new commits since the last review pass:

edbc0761b — merge main into branch: No logic change.

d28851785 — fix(test): eliminate race in Txn Expiration flaky test: Replaces the fragile trackedTxns.size === existingTxns count assertion with !trackedTxns.has(lastTxn) membership check. The old assertion races against background txn expirations from other suites removing entries during the 50ms window; the new form checks only the specific txn object opened by SlowResource.get(3), which is immune to concurrent removal of unrelated txns. Fix is correct.

Full diff re-verified: All prior findings (backpressure false-positive, listener leak, type narrowing) remain addressed. The watchdog logic, cleanup in finished(), input sanitization, and all 5 regression tests check out.

Ongoing observations (not blockers, same as prior pass):

  • The regression tests still use fixed await delay(N) sleeps; waitFor() per AGENTS.md would be more resilient under a loaded runner, but the headroom is sufficient.
  • HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS is read via process.env directly rather than through envGet/hdbTerms. Intentional escape-hatch design.
  • The watchdog only activates once harper-pro#459 sets blobStreamIdleTimeoutMs on the replication PassThrough.

@kriszyp

kriszyp commented Jun 22, 2026

Copy link
Copy Markdown
Member

The receiver-side watchdog is the load-bearing half here (it rescues independent of the sender), and the three regression tests nailing the destroyed/never-ended/torn-down PassThrough cases are great. 🙏

Same blocker as the sender PR (harper-pro#451): defaults to 0 (off) and nothing sets HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS — the comment says "replication's blob-receive path enables it," but that setter doesn't exist in harper or harper-pro. As written it ships inert.

Live stakes: a customer cluster preprod 5.1.7 is wedged on exactly this (harper-pro#453) — a stalled source blob stream leaves outstandingBlobsToFinish pinned, the apply consumer's drain await never resolves, the follower sticks at Receiving/ver=0, and replicated deploys are blocked with no self-heal. This watchdog enabled is what unwedges it.

Suggestion: give it a non-zero default in the replication receive path (mirror harper-pro REPLICATION_BLOBTIMEOUT ?? 120000), so 5.1.8 actually fixes the wedge rather than shipping a dormant switch. Glad to help wire the enablement + add a rolling-restart-during-copy integration case.

— Claude (Opus 4.8), from live a customer cluster investigation

The idle watchdog added in this PR defaulted to 0 (off), with nothing setting
HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS anywhere in harper or harper-pro — so it
shipped inert and the replication-receive wedge it targets could still happen
(observed live on JJill preprod 5.1.7, harper-pro#453: a stalled source blob
stream left saveBlob.saving unsettled, outstandingBlobsToFinish pinned, the
per-database apply consumer's drain await wedged, the follower stuck at
lastReceivedStatus="Receiving"/ver=0, COPY_COMPLETE never reached, deploys blocked).

Default it ON at 120000ms; HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS still overrides
(set 0 to disable). The only source that can stall this long is the
replication-fed PassThrough (storageInfo.source) — local Readable.from(...)
sources deliver immediately and finish — and the existing re-arm-on-pause guard
means legitimate write backpressure never trips it, so the practical blast
radius is the replication receive path this is meant to protect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jun 22, 2026

Copy link
Copy Markdown
Member

Pushed an enablement commit (eb6a309) per @kriszyp's call: getBlobStreamIdleTimeoutMs() now defaults to 120000ms instead of 0; HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS still overrides (set 0 to disable).

One thing to confirm since this lives in core blob.ts (not replication-only): it now applies to every writeBlobWithStream caller. I traced them — the only source that can sit idle this long is the replication-fed storageInfo.source PassThrough; the Readable.from(buffer/stream) callers deliver immediately and finish, and your re-arm-on-pause guard means legitimate write backpressure never trips it. So the practical blast radius is the replication receive path this targets. If you'd rather keep it strictly replication-scoped, the alternative is having harper-pro's replication init set the env rather than core defaulting it — happy to switch to that if you prefer. The three regression tests still pin the env explicitly, so they're unaffected.

Nice fix — the destroyed/never-ended/torn-down PassThrough coverage is exactly the a customer cluster (#453) shape. 🙏

— Claude (Opus 4.8)

Comment thread resources/blob.ts Outdated
kriszyp pushed a commit that referenced this pull request Jun 23, 2026
…ead writer

The lock is in-process and released on unlock() or DBHandle::close() (which
reaps even expired-owner locks), so a dead/crashed writer cannot leave it held.
The spin is pinned by a *live* in-progress write whose source stream stalled and
never reached unlock() — the writeBlobWithStream pipeline that #1444 watchdogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kriszyp and others added 2 commits June 23, 2026 08:17
…ault-on (#1458)

Scopes harper#1444 so the watchdog only fires for the owning caller (replication arms it via harper-pro#459); HTTP/cache writers stay off. Off by default; env var is a process-wide kill switch. Sanitizes the timeout value (no 1ms-collapse). Tests: 5/5. Gemini review addressed.
…1444 format check)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Jun 23, 2026
…blob can't spin a worker at 100% CPU (#1454) (#1456)

* fix(blob): bound the blob stream() incomplete-content wait so a truncated blob can't spin a worker at 100% CPU (#1454)

FileBackedBlob.stream() → readMore() busy-spun a worker thread indefinitely
when a blob file was present but truncated (its 8-byte header records a known
final size larger than the bytes on disk) and the write-lock still read as held.
In the bytesRead===0 branch, resumeIfWriterFinished() re-entered readMore()
synchronously with no backoff and no deadline, so a read at EOF returned 0 every
iteration and pegged the core. checkIfIsBeingWritten() caches its result, so a
writer that died or stalled without releasing its lock pinned the spin forever.
This is the prod-dyn/prod-gar GDI CPU storm; it is not addressed by #1423/#1424
(the spin branch is byte-identical 5.0.28 through 5.1.8).

Add a lazily-set no-progress deadline (getBlobReadTimeout(), default 20s) and a
20ms backoff before the resume re-entry, failing fast with a retryable
BlobReadError(503) once the deadline passes. The deadline is set only while no
bytes are readable, so a genuinely slow but progressing in-progress write -
which makes progress and resolves the pull each chunk, starting each new pull
with a fresh budget - is unaffected. Also track both readMore() 20ms backoff
timers via `timer` so cancel()/onError can clear a pending poll, avoiding a
read on a closed/reused fd when a stream is aborted mid-wait.

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

* docs(blob): correct #1454 stall wording — live stalled write, not a dead writer

The lock is in-process and released on unlock() or DBHandle::close() (which
reaps even expired-owner locks), so a dead/crashed writer cannot leave it held.
The spin is pinned by a *live* in-progress write whose source stream stalled and
never reached unlock() — the writeBlobWithStream pipeline that #1444 watchdogs.

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

---------

Co-authored-by: Kris Zyp <kris@harperdb.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ldt1996 and others added 2 commits June 23, 2026 18:16
The Slow txn will expire test asserted trackedTxns.size === existingTxns,
where existingTxns was sampled before SlowResource.get. trackedTxns is a
module-level singleton shared across the suite; leftover open txns from
earlier tests can expire (removed by the 20ms timer) during the following
50ms Promise.race window, dropping the count below existingTxns (the
0 == 5 / 1 == 6 AssertionError seen in CI). This branch's added blob tests
shift suite timing enough to make the latent flake deterministic.

Assert membership of the specific txn from SlowResource.get(3) instead,
which is immune to background txn churn. Identical to #1459.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp
kriszyp merged commit ce88577 into main Jun 23, 2026
46 of 47 checks passed
@kriszyp
kriszyp deleted the fix/blob-receive-idle-watchdog branch June 23, 2026 15:35
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.

Blob replication wedges when source stream stalls without finishing

3 participants