Skip to content

feat(sdk): make consumption-paced decrypt prefetch the default (DSPX-4654) - #1024

Closed
dmihalcik-virtru wants to merge 1 commit into
DSPX-4653-source-bufferingfrom
DSPX-4654-paced-prefetch
Closed

dmihalcik-virtru wants to merge 1 commit into
DSPX-4653-source-bufferingfrom
DSPX-4654-paced-prefetch

Conversation

@dmihalcik-virtru

Copy link
Copy Markdown
Member

Item 4 of spec/DSPX-4648-web-sdk-large-files.md, sub-task DSPX-4654. Final PR in the stack.

Stacked on #1023 (DSPX-4653). Review only the last commit; the base is the rest of the stack.

What

Bounded segment scheduling was opt-in, selected only when a caller set segmentBatchSize or maxConcurrentSegmentBatches. Everyone else got updateChunkQueue, which fired 500-segment batches three at a time without waiting for the consumer — 1.5 GiB of payload in flight at the 1 MiB segment size, and 24 GiB once chooseSegmentSize (PR A) picks 16 MiB. Neither number was chosen; they fell out of a segment count that nothing tied to memory.

Every decrypt now goes through createBoundedSegmentScheduler, with its parameters derived from a byte budget rather than a segment count.

Why a byte budget

derivePrefetchWindow divides DEFAULT_PREFETCH_BYTE_BUDGET (128 MiB) by the segment size, so peak memory stays roughly flat as the segment size grows:

segment size batch × concurrency window
≤ 87 KiB 500 × 3 ≤ 128 MiB (batch cap binds)
1 MiB 42 × 3 126 MiB
4 MiB 10 × 3 120 MiB
16 MiB 2 × 3 96 MiB
64 MiB 1 × 2 128 MiB
256 MiB 1 × 1 256 MiB (budget blown)

Two deliberate asymmetries:

  • Concurrency yields before batch size. Three batches of one segment beats one batch of three, because the reads overlap.
  • MAX_SEGMENT_BATCH_SIZE = 500 (the legacy value) caps the batch below what the budget would allow. At a 1 KiB segment size the budget permits 43,690 segments per batch — inside 128 MiB, but the consumer would wait on one absurd ranged read for the first byte.

The one case that exceeds the budget is a segment larger than the budget itself. There is no smaller unit to schedule, and refusing to decrypt a validly-formed TDF would be worse than spending the memory.

The budget is charged against encryptedSegmentSizeDefault, not the plaintext size — ciphertext segments are what the window actually holds.

Explicit knobs still win, and each falls back to its derived value rather than to the other, so setting one does not silently rescale the other.

Behaviour change (intentional)

The legacy path swallowed batch failures with .catch(() => undefined), leaving the consumer awaiting a mailbox that would never settle — a hang, not a clean failure. Failures now surface through the scheduler's onError, which rejects the affected chunks and stops scheduling. Arguably a bug fix, but it is observable, so it is called out here and in the spec. No existing test depended on the silent behaviour.

updateChunkQueue and the LEGACY_* / DEFAULT_BOUND_* constants are deleted rather than bypassed, so there is no second path left to rot.

What this does not do

It does not bound decrypt memory on its own. readStream still materializes one Chunk (hash string + mailbox promise) per segment before the first byte is read — at 16 MiB segments a 50 TiB file is 3.28 M of them. That is bounded by PR A's manifest cap but not by this budget; fixing it means streaming the segment list rather than segments.map(...), which is a separate change. Recorded as outstanding in the spec.

Also deferred: adaptive sizing from navigator.deviceMemory (a fixed number is predictable across browsers and testable), and progressHandler-driven backpressure.

How to test

cd lib && npm test

New coverage:

  • scale-limits.spec.ts — derivePrefetchWindow: window stays inside the budget at every rung of the ladder; concurrency yields before batch size; the batch cap binds at small segments; the legacy 1 MiB window is preserved; single-oversized-segment fallback; caller-supplied budgets; input validation.
  • tdf.spec.ts — getBoundedSegmentSchedulerOptions: derived defaults with nothing configured, explicit settings winning without rescaling the other, non-positive settings rejected. Plus 'surfaces a failing batch instead of stalling, and stops scheduling', which pins the error-behaviour change.

Verified green locally across all three tiers: mocha 486 passing / 6 pending, karma TOTAL: 486 SUCCESS, wtr 253 passed, npm test exit 0, npm run lint exit 0.

Risk

Touches the decrypt hot path for every caller, not just large files. The scheduler itself is unchanged and was already covered; the change is which path reaches it and with what parameters. The error-surfacing change is the one to watch — a caller that previously saw a hang on a failed segment fetch will now see a rejection.

@dmihalcik-virtru
dmihalcik-virtru requested a review from a team as a code owner September 9, 2026 14:22
@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dmihalcik-virtru
dmihalcik-virtru added this pull request to stack #1033 September 10, 2026 20:52
@dmihalcik-virtru
dmihalcik-virtru removed this pull request from stack #1033 September 22, 2026 14:24
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4653-source-buffering branch from d6a1ecd to a8ce43f Compare September 22, 2026 15:53
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4654-paced-prefetch branch 2 times, most recently from 1ab0150 to 01383d5 Compare September 23, 2026 18:15
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4653-source-buffering branch from a8ce43f to 2f8c03d Compare September 23, 2026 18:15
@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

@dmihalcik-virtru
dmihalcik-virtru marked this pull request as draft September 28, 2026 15:08
…4654)

Bounded segment scheduling was opt-in, selected only when a caller set
segmentBatchSize or maxConcurrentSegmentBatches. Everyone else got
updateChunkQueue, which fired 500-segment batches three at a time without
waiting for the consumer -- 1.5 GiB of payload in flight at the 1 MiB segment
size, and 24 GiB once chooseSegmentSize picks 16 MiB.

Route every decrypt through createBoundedSegmentScheduler and derive its
parameters from a byte budget rather than a segment count, so peak memory
stays flat as the segment size grows:

  1 MiB  -> 42 x 3 = 126 MiB     16 MiB -> 2 x 3 =  96 MiB
  4 MiB  -> 10 x 3 = 120 MiB     64 MiB -> 1 x 2 = 128 MiB

Concurrency yields before batch size, since overlapping reads are a better use
of a tight budget. MAX_SEGMENT_BATCH_SIZE caps the batch at the legacy 500 so a
tiny-segment file is not one absurd ranged read. A segment larger than the
budget gets a window of one and knowingly blows it; there is no smaller unit.

Deletes updateChunkQueue and the LEGACY_* / DEFAULT_BOUND_* constants outright
rather than leaving a second path to rot.

Behaviour change: the legacy path did `.catch(() => undefined)`, leaving the
consumer awaiting a mailbox that never settled. Batch failures now surface
through onError and reject the affected chunks -- a clean failure instead of a
hang. No existing test depended on the silent behaviour.

This does not bound decrypt memory on its own; readStream still materializes
one Chunk per segment up front. Documented in the spec as outstanding.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@github-actions

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@dmihalcik-virtru

Copy link
Copy Markdown
Member Author

Superseded by #1091, which is the same change rebased directly onto main without the stack beneath it. Nothing in the stack was needed: the bounded scheduler and encryptedSegmentSizeDefault are already on main, and derivePrefetchWindow now lives in its own lib/tdf3/src/utils/prefetch-window.ts instead of scale-limits.ts, so #1020–#1023 can still rebase without conflicts. #1091 includes the 64 GiB measurements (0.8 GB peak RSS, SHA-256 verified) and full lib test results. Cross-reference: https://virtru.atlassian.net/browse/DSPX-4502?focusedCommentId=274891

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