Skip to content

Read blob extents lazily to avoid EMFILE on large blobs (issue #1967) - #2797

Merged
Akanksha Jain (jainakanksha-msft) merged 17 commits into
mainfrom
fix/1967-lazy-extent-file-handles
Sep 23, 2026
Merged

Akanksha Jain (jainakanksha-msft) merged 17 commits into
mainfrom
fix/1967-lazy-extent-file-handles

Conversation

@jainakanksha-msft

@jainakanksha-msft Akanksha Jain (jainakanksha-msft) commented Sep 22, 2026

Copy link
Copy Markdown
Member

What & why

Fixes EMFILE: too many open files when reading blobs that are spread across many extents (issue #1967).

FSExtentStore.readExtents previously opened a read stream — and therefore a file handle — for every extent up front before handing the array to multistream. For a blob composed of many extents (e.g. a block blob with many committed blocks, or a large append blob), this opened one file descriptor per extent simultaneously and could exceed the OS file-handle limit, failing the download with EMFILE.

As discussed on the original PR #2267, the maintainer's suggested fix was to open each extent only when it is about to be read and close it before opening the next. This PR does exactly that.

How

readExtents now:

  1. Resolves which extent sub-chunks to read (pure offset/count arithmetic — no file handles opened), keeping the existing RangeError validation unchanged.
  2. Hands multistream a lazy FactoryStream. multistream invokes the factory from its constructor, so the first extent begins opening as the merged stream is constructed; every subsequent extent is opened only after the previous stream has emitted close (its file descriptor is released). The result is at most one extent file descriptor open at a time, regardless of blob size.
  3. If the merged stream is destroyed (a programmatic teardown) while an extent is still opening, the freshly opened extent is destroyed instead of being installed, so its descriptor is not leaked.

Because the fix lives in the shared FSExtentStore, it also benefits queue and table reads, without any per-request stream bookkeeping or interface changes.

Scope / known follow-up

This PR eliminates the up-front N-descriptor spike and the normal-completion leak from #1967. It does not fully cover a raw client disconnect: the response pipe (serializer.ts) only unpipes the body on disconnect rather than destroying it, so the current extent's descriptor can still be held. That reduces the pre-existing disconnect leak from N descriptors to 1, and destroying the body stream on response close/abort is tracked as a focused follow-up in #2804.

Tests

Coverage lives in tests/blob/fsStore.test.ts, exercising FSExtentStore directly:

  • merges multiple extents into a single readable stream (full read), and a range that spans multiple extent boundaries;
  • asserts serial extent creation — only the first extent may begin opening before consumption, subsequent extents are deferred, and at most one extent stream is active at a time (the active-stream counter is decremented only on close/error, so it fails if the next extent opens before the previous descriptor is released);
  • asserts, deterministically via deferred signals, that an extent opened after the merged stream is destroyed mid-open is itself destroyed rather than leaked.

Each new assertion was verified to fail against the previous behaviour. Existing blockblob and appendblob suites also pass, and the full tsc build and eslint pass.

Notes

  • Supersedes Adding initial fix for bug issue #1967 #2267, which tracked/destroyed streams on res.on("close"); that approach didn't compile against the current interfaces, leaked its tracking Map, and didn't prevent a single large download from exhausting FDs up front. This PR addresses the root cause instead.
  • ChangeLog updated under "Upcoming Release".

Closes #1967

FSExtentStore.readExtents opened a read stream (and thus a file handle)
for every extent up front, so downloading a blob spread across many
extents could exceed the OS file-handle limit and fail with EMFILE.

It now hands multistream a lazy factory that opens each extent's read
stream only after the previous one has ended, keeping at most one extent
file handle open at a time regardless of blob size. This also covers
queue and table reads, which share FSExtentStore.

Adds a regression test that downloads a blob spanning many extents (full
blob and a cross-extent-boundary range) and asserts byte-exact content.

Supersedes #2267.
Copilot AI lite review requested due to automatic review settings September 22, 2026 10:25

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Add regression coverage that verifies extent streams are opened serially.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

This PR prevents EMFILE errors by lazily opening blob extent streams.

Changes:

  • Uses lazy multistream extent loading.
  • Adds full and ranged multi-extent download tests.
  • Updates the changelog.

The regression test does not yet verify bounded simultaneous stream usage.

File Summary
tests/​blob/​apis/​downloadmultiextent.test.ts Adds multi-extent download coverage; needs a deterministic lazy-loading assertion.
src/​common/​persistence/​FSExtentStore.ts Lazily opens extent streams to reduce file-descriptor usage.
ChangeLog.md Documents the upcoming fix.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/blob/apis/downloadmultiextent.test.ts Outdated

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Add regression coverage proving extent streams are opened sequentially and lazily.

Review effort: Lite
Findings: 1 Medium severity

Open (1)

Comment thread tests/blob/apis/downloadmultiextent.test.ts Outdated
Co-authored-by: jainakanksha-msft <181211853+jainakanksha-msft@users.noreply.github.com>
Copilot AI and others added 2 commits September 22, 2026 11:05
Co-authored-by: jainakanksha-msft <181211853+jainakanksha-msft@users.noreply.github.com>
Co-authored-by: jainakanksha-msft <181211853+jainakanksha-msft@users.noreply.github.com>

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Correct the pre-consumption stream-count assertion in the test.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread tests/blob/fsStore.test.ts Outdated
Copilot AI review requested due to automatic review settings September 22, 2026 11:07
Co-authored-by: jainakanksha-msft <181211853+jainakanksha-msft@users.noreply.github.com>
Co-authored-by: jainakanksha-msft <181211853+jainakanksha-msft@users.noreply.github.com>

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

No unresolved issues were identified, and the focused changes address the reported file-descriptor exhaustion.

Review effort: Lite
Findings: 1 High severity

Open (1)

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

No unresolved blocking issues were identified.

Review effort: Lite
Findings: 1 High severity

Open (1)

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Correct the test assertion that expects an extent to open before stream consumption.

Review effort: Lite
Findings: None

Resolved since last review (1)

An fs.ReadStream releases its file descriptor asynchronously, emitting
"close" after "end". multistream advances on "end", so the previous
implementation could open the next extent before the prior descriptor
was released, briefly holding two handles and still risking EMFILE near
the descriptor limit (issue #1967).

readExtents now waits for the previous extent stream's "close" before
opening the next, guaranteeing at most one extent file descriptor is
open at a time. The fsStore regression test now decrements its active
stream counter only on "close"/"error" (not "end"), so it fails if the
next extent opens before the previous one is closed.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

A client abort while the lazy factory is pending can leave a subsequently opened extent stream undisposed.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (1)

Comment thread src/common/persistence/FSExtentStore.ts
Comment thread tests/blob/fsStore.test.ts
Copilot AI review requested due to automatic review settings September 22, 2026 14:43
If the merged stream is destroyed (e.g. the client aborts) while the
factory is waiting for the previous extent's close or for readExtent to
resolve, multistream's _destroy only tears down its current stream. An
extent opened after that point was handed back but never consumed or
destroyed, leaking its file descriptor.

The factory now checks whether the merged stream has been destroyed once
readExtent resolves and, if so, destroys the freshly opened extent
instead of installing it. Adds an abort-while-opening regression test
that fails against the previous behaviour.

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The implementation addresses descriptor exhaustion with focused regression coverage and no unresolved correctness issues.

Review effort: Balanced
Findings: None

Resolved since last review (2)

readExtent returns NodeJS.ReadableStream, which does not declare
destroy(); cast to Readable in the abort guard so the project compiles
(tsc TS2339).

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

The abort test is timing-dependent, and the claimed first-read laziness does not match actual multistream behavior.

Review effort: Balanced
Findings: None

Previously missed (2)

In code that hasn't changed since last review

Medium severity Fixed delay races extent stream close event

tests/​blob/​fsStore.test.ts:185

This fixed delay does not synchronize with the extent stream's close event. If the event loop is stalled long enough for both timers to expire in one timers phase, this assertion can run before close callbacks and fail intermittently. Await a promise resolved by the opened stream's close event, and use a deferred signal to ensure opening has started before destroying the merged stream.

Low severity First extent opens before merged stream consumption

src/​common/​persistence/​FSExtentStore.ts:491

multistream@4.1.0 invokes the factory from its constructor, so constructing it here starts opening the first extent before the returned stream is consumed (the test's extentReadCalls <= 1 also permits this). This contradicts the PR description's claim that the first extent is not opened until consumption. Either update the description to clarify that only subsequent extents are deferred, or add an outer lazy readable if zero pre-consumption opens is required.

The abort-while-opening test relied on fixed setTimeout delays that did
not synchronize with the extent stream's close event and could assert
before the close callback ran. It now uses deferred signals: it waits
until the first extent has started opening, destroys the merged stream,
releases the open, then awaits the opened stream's close before
asserting no descriptor leaked.

Also reworded the merge test's first-read assertion: multistream invokes
the factory from its constructor, so the first extent may begin opening
before consumption; only subsequent extents are deferred.
Copilot AI review requested due to automatic review settings September 23, 2026 05:38
@jainakanksha-msft

Copy link
Copy Markdown
Member Author

Thanks — both "previously missed" findings are addressed in 26987b3:

  1. Abort test timing dependence (fsStore.test.ts): the test no longer uses fixed setTimeout delays. It uses deferred signals — it waits until the first extent has started opening, destroys the merged stream, releases the open, then awaits the opened stream's close event before asserting no descriptor leaked. Ran deterministically green across repeated runs.
  2. First-extent laziness (FSExtentStore.ts): correct — multistream@4.1.0 invokes the factory from its constructor, so the first extent begins opening at construction, not on first read. Only subsequent extents are deferred. The EMFILE guarantee (at most one extent descriptor open at a time) is unaffected, so rather than adding an outer lazy readable I updated the PR description and the test assertion wording to state this accurately.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Real client disconnects do not destroy the body stream, leaving extent descriptors open.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)

Comment thread src/common/persistence/FSExtentStore.ts Outdated
The abort guard releases an extent opened after the merged stream is
destroyed. Reworded the comment (and the test) to make clear this covers
a programmatic teardown; a raw HTTP client disconnect does not currently
destroy the merged stream (the response pipe only unpipes it), which is
tracked separately in issue #2804.

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Two moderate issues remain in error-to-close descriptor sequencing and its regression test.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Advance occurs before failed stream closes, allowing descriptor overlap

src/​common/​persistence/​FSExtentStore.ts:486

Resolving this gate on error can open the next extent before the failed fs.ReadStream has emitted close and released its descriptor. multistream 4.1.0 destroys the merged stream synchronously on a source error, but this promise is already resolved, so the next readExtent can still run during the error-to-close window and violate the one-descriptor guarantee. Wait only for close; the default fs.ReadStream auto-close path emits it after errors too.

Medium severity Test stops tracking descriptor activity on error instead of close

tests/​blob/​fsStore.test.ts:111

Deactivating on error makes this regression test stop counting the descriptor before it is actually closed, so it can miss the error-to-close overlap that the production code currently permits. Track activity until close only, matching the test's own stated invariant that descriptor release is what matters.

Address the error-to-close descriptor overlap: the close gate no longer
resolves on "error". fs.ReadStream auto-close emits "close" after an
error too, so waiting on "close" alone still covers the error path
without opening the next extent during the error-to-close window. The
merge test likewise tracks descriptor activity until "close" only.

Also simplify the abort regression test: it now gates the first open,
destroys the merged stream, releases the open, and awaits the opened
stream's close, then asserts that stream was destroyed - no manual
active-stream counters or extra signals.
Copilot AI review requested due to automatic review settings September 23, 2026 06:42
@jainakanksha-msft

Copy link
Copy Markdown
Member Author

Addressed the two "previously missed" findings from the latest review in 232ca08:

  1. Advance occurs before failed stream closes (FSExtentStore.ts) — the close gate no longer resolves on error. It waits for close only; fs.ReadStream auto-close emits close after an error as well, so the error path is still covered without opening the next extent during the error-to-close window. (If the current stream errors, multistream destroys the merged stream and the factory is not called again, so there is no hang.)
  2. Test stops tracking on error instead of close (fsStore.test.ts) — the merge test now decrements the active-descriptor counter only on close, matching the invariant that descriptor release is what matters.

Separately, I simplified the abort regression test per reviewer/maintainer feedback: it now gates the first open, destroys the merged stream, releases the open, awaits the opened stream's close, and asserts that stream was destroyed — no manual active-stream counters or extra deferred signals. Build, lint, and the fsStore suite (including the multi-extent merge and ranged reads) pass; the abort test is deterministic across repeated runs.

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

No blocking issues remain, and focused tests cover the changed behavior.

Review effort: Balanced
Findings: None

@jainakanksha-msft
Akanksha Jain (jainakanksha-msft) merged commit 8d60d3f into main Sep 23, 2026
46 checks passed
@jainakanksha-msft
Akanksha Jain (jainakanksha-msft) deleted the fix/1967-lazy-extent-file-handles branch September 23, 2026 07:05
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.

500 Errors - Error: EMFILE: too many open files

3 participants