Skip to content

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

Merged
kriszyp merged 2 commits into
mainfrom
kris/blob-spin-1454
Jun 23, 2026
Merged

fix(blob): bound the stream() incomplete-content wait so a truncated blob can't spin a worker at 100% CPU (#1454)#1456
kriszyp merged 2 commits into
mainfrom
kris/blob-spin-1454

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 23, 2026

Copy link
Copy Markdown
Member

Fixes #1454.

Summary

FileBackedBlob.stream()readMore() busy-spun a worker thread at ~100% CPU, indefinitely, when a blob file is present but truncated (its 8-byte header records a known final size larger than the bytes actually on disk) and the write-lock reads 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. checkIfIsBeingWritten() caches its result. The lock is in-process and released on unlock()/DBHandle::close(), so a dead writer can't leave it held — the pin is a live in-progress write whose source stream stalled and never reached unlock() (the writeBlobWithStream pipeline that #1443/#1444 watchdogs). This PR is the complementary reader-side bound.

This is the prod-dyn / prod-gar GDI CPU storm (harper-pro 5.0.28), confirmed by a 6-thread CPU profile (~73% in readMore: fs.read ~48% + Buffer.allocUnsafe(256KB) ~21%). The trigger is a wedged GDI blob-replication backlog (~11–12h behind) plus a 2h blob expiration: bodies evict/truncate before the backlog ships them.

Why this isn't already fixed by #1423/#1424

The spin branch is byte-for-byte identical 5.0.28 → 5.1.8. #1423's getBlobReadTimeout() timer lives in the else if (!resumeIfWriterFinished()) branch the spin never enters (resume returns true). #1424's descriptor cross-check is isFullRead-gated (HTTP serving streams a range → skipped) and only fires when header ≠ descriptor (the common truncation has header == descriptor).

The fix

  • A lazily-set no-progress deadline (incompleteDeadline = getBlobReadTimeout(), default 20s) + a 20ms backoff before the resume re-entry. Past the deadline, fail fast with a retryable BlobReadError(503).
  • The deadline is set only while no bytes are readable; a genuinely slow but progressing in-progress write (header written up front — the replication path) resolves the pull each chunk and starts each new pull with a fresh budget, so it is unaffected.
  • Both readMore() 20ms backoff timers are now assigned to timer so cancel()/onError can clear a pending poll (the cross-model review caught that the new one wasn't tracked → a read on a closed/reused fd if a stream is aborted mid-wait).

Where to look

Open items for the reviewer

The cross-model review surfaced pre-existing latent bugs in this file, in code this PR does not change — deliberately left out of this targeted patch and tracked separately in #1457: fd is not nulled after close (double-close on a reused fd), the start() open-retry timer is untracked, onError can fire twice if a slow async read completes after rejection, and slices read sequentially from byte 0.

Bound for a 5.1.x patch.

Generated with assistance from Claude (Opus 4.8). Cross-model reviewed (Codex + Gemini + Harper-domain).

…ated 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>

@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 no-progress deadline and a short backoff delay when reading file-backed blobs that are present but truncated while the writer lock is still held. This prevents the worker from busy-spinning at 100% CPU by avoiding synchronous re-entry of the read loop. A corresponding unit test has been added to verify that such reads fail promptly with a 503 status code. There are no review comments to address.

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.

@kriszyp
kriszyp requested review from heskew and removed request for kylebernhardy June 23, 2026 12:34
@kriszyp
kriszyp marked this pull request as ready for review June 23, 2026 12:34
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

…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>

@heskew heskew left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍 red ci seems unrelated

@kriszyp
kriszyp merged commit 6721253 into main Jun 23, 2026
81 of 82 checks passed
@kriszyp
kriszyp deleted the kris/blob-spin-1454 branch June 23, 2026 14:44
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 stream() busy-spins a worker at 100% CPU on a present-but-truncated blob whose writer lock stays held (survives #1423/#1424)

3 participants