Skip to content

fix(sdk): make consumption-paced decrypt prefetch the default (DSPX-4654) - #1091

Closed
dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4654-paced-prefetch-on-main
Closed

dmihalcik-virtru wants to merge 1 commit into
mainfrom
DSPX-4654-paced-prefetch-on-main

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Jira: https://virtru.atlassian.net/browse/DSPX-4654 (epic DSPX-4502)
Related PRs: cross-reference table of all related PRs and their tickets

Supersedes #1024. This is the same change, rebased directly onto main without the stack beneath it (#1063 → #1018 → #1019 → #1020 → #1021 → #1022 → #1023). That stack wasn't needed: the bounded scheduler, its option normalisation and encryptedSegmentSizeDefault are already on main. The only thing #1024 took from the stack was a location: it put derivePrefetchWindow in scale-limits.ts, which #1020 creates. Here the helper lives in its own lib/tdf3/src/utils/prefetch-window.ts, so the stack's scale-limits.ts is untouched and #1020–#1023 can still rebase on top without conflicts.

What

Bounded segment scheduling was opt-in, used only when a caller set segmentBatchSize or maxConcurrentSegmentBatches. Everyone else got updateChunkQueue, which sent 500-segment batches three at a time without waiting for the consumer.

Every decrypt now goes through createBoundedSegmentScheduler. Its window comes from a 128 MiB byte budget instead of a segment count, so peak memory stays flat as the segment size grows:

segment size batch × concurrency window
1 MiB 42 × 3 126 MiB
2 MiB 21 × 3 126 MiB
16 MiB 2 × 3 96 MiB
64 MiB 1 × 2 128 MiB
  • Explicit segmentBatchSize / maxConcurrentSegmentBatches still take precedence.
  • updateChunkQueue and the LEGACY_* / DEFAULT_BOUND_* constants are deleted.
  • Failure handling: the old path kept issuing reads after a batch failed. Now the scheduler stops at the first failure, and onError rejects every chunk the reader hasn't consumed yet.
  • Unhandled rejections: each chunk mailbox now gets a no-op .catch, so the rejected chunks the reader never reaches don't raise unhandled rejections. Before, one failed decrypt could crash a Node process. Awaiting a chunk still throws.
  • Behaviour change: a manifest encryptedSegmentSizeDefault that is present but not a positive integer is now rejected with InvalidFileError before the KAS rewrap. Missing or 0 still falls back to 1 MiB.

Risk: this changes default decrypt memory and throughput for every caller, on a crypto path. Callers who relied on the old aggressive prefetch can raise segmentBatchSize / maxConcurrentSegmentBatches.

Measurements

These were taken before the review fixes, which only touch error paths.

Node CLI built with this lib plus #1089 (DSPX-5160, positional file reads), decrypting real 64 GiB TDFs (2 MiB segments, 32768 segments) against a live KAS on darwin-arm64. One run per cell.

build output wall peak RSS result
#1089 on main (unbounded) /dev/null 28.0 s 7.10 GB ok
#1089 on main (unbounded) FIFO → openssl dgst — >16 GB killed by watchdog at 224 s
this PR + #1089 /dev/null (go / java TDF) 12.9 s / 12.9 s 0.80 / 0.82 GB ok
this PR + #1089 FIFO → openssl dgst (go / java) 65.7 s / 68.9 s 0.68 / 0.71 GB SHA-256 matches plaintext

The FIFO runs take ~66 s because openssl is the slow consumer. With this PR, memory stays flat while waiting on it.

Tests

  • Latest push (review fixes): mocha 587 passing, 6 pending. tsc, ESLint and Prettier are clean.
  • Before the review fixes, cd lib && npm test passed in full: web-test-runner 253 passed, karma (Chrome Headless) 586 executed, 6 skipped. I haven't re-run the browser suites since.
  • New: tests/mocha/unit/prefetch-window.spec.ts (derivePrefetchWindow), plus getBoundedSegmentSchedulerOptions and failing-batch cases in tdf.spec.ts.
  • npm run lint is clean, and prettier passes on all changed files.

Not verified

  • Browser memory and throughput. Only the Node CLI was measured at 64 GiB; the browser runners only ran the unit suites.
  • Real files with segments much larger than 2 MiB (covered by unit tests only).
  • Run-to-run variance.
  • No end-to-end test yet for a failure in a later batch, or for multi-batch pacing on the derived defaults. Both are covered only by unit tests. Follow-up.
  • readStream still creates one Chunk per segment up front, so this bounds in-flight payload bytes, not all decrypt bookkeeping.

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9ed331e3-cd1e-4653-bcfc-e2147aa93402
📥 Commits

Reviewing files that changed from the base of the PR and between a7bd3ca and 57281d2.

📒 Files selected for processing: 6
  • lib/src/opentdf.ts
  • lib/tdf3/src/client/builders.ts
  • lib/tdf3/src/tdf.ts
  • lib/tdf3/src/utils/prefetch-window.ts
  • lib/tests/mocha/unit/prefetch-window.spec.ts
  • lib/tests/mocha/unit/tdf.spec.ts
  • 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.

…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, so a decrypt read and decrypted the whole payload
as fast as it could regardless of how fast the output was drained. Decrypting
a 64 GiB TDF with the CLI peaked at ~7 GB RSS writing to /dev/null, and grew
without bound when the consumer was slower than reads.

Route every decrypt through createBoundedSegmentScheduler and derive its
parameters from a byte budget (DEFAULT_PREFETCH_BYTE_BUDGET, 128 MiB) 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
  2 MiB  -> 21 x 3 = 126 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.
Explicit segmentBatchSize / maxConcurrentSegmentBatches still win, and an unset
one keeps its derived value rather than being rescaled from the other.

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.

This does not bound decrypt memory on its own; readStream still materializes
one Chunk per segment up front.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-4654-paced-prefetch-on-main branch from 471349b to 57281d2 Compare October 15, 2026 20:01
@dmihalcik-virtru dmihalcik-virtru changed the title feat(sdk): make consumption-paced decrypt prefetch the default (DSPX-4654) fix(sdk): make consumption-paced decrypt prefetch the default (DSPX-4654) Oct 15, 2026
@sonarqubecloud

Copy link
Copy Markdown

@dmihalcik-virtru
dmihalcik-virtru marked this pull request as ready for review October 15, 2026 20:27
@dmihalcik-virtru
dmihalcik-virtru requested a review from a team as a code owner October 15, 2026 20:27
dmihalcik-virtru added a commit that referenced this pull request Oct 16, 2026
…SPX-4654)

Prototype alternative to #1091 for comparison. Same defaults and knobs
(derivePrefetchWindow, 128 MiB budget, explicit segmentBatchSize /
maxConcurrentSegmentBatches win), but the prefetch window is enforced by a
ReadableStream's high-water mark instead of a hand-rolled scheduler.

prefetchSegmentBatches() returns a ReadableStream whose pull() starts a
batch's fetch-and-decrypt and enqueues the pending promise without awaiting
it, so the stream keeps pulling until CountQueuingStrategy's high-water mark
(maxConcurrentSegmentBatches - 1; the reader holds the batch it is draining)
is reached. The output stream's pull() drains one batch a segment at a time.

This deletes the Mailbox type, createBoundedSegmentScheduler and its state
types, updateChunkQueue, rejectChunks, and the consumed-mailbox swapping:
errors propagate through the stream, the queue holds order, and a drained
batch is garbage as soon as the reader moves on. Cancelling the output stream
now cancels the batch stream, so no further ranged reads are issued.

Behaviour change: a segment that fails its integrity check fails its whole
batch, so earlier segments in that batch are no longer released to the reader.

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

Copy link
Copy Markdown
Member Author

Closed in favor of #1092

dmihalcik-virtru added a commit that referenced this pull request Oct 16, 2026
…SPX-4654)

Prototype alternative to #1091 for comparison. Same defaults and knobs
(derivePrefetchWindow, 128 MiB budget, explicit segmentBatchSize /
maxConcurrentSegmentBatches win), but the prefetch window is enforced by a
ReadableStream's high-water mark instead of a hand-rolled scheduler.

prefetchSegmentBatches() returns a ReadableStream whose pull() starts a
batch's fetch-and-decrypt and enqueues the pending promise without awaiting
it, so the stream keeps pulling until CountQueuingStrategy's high-water mark
(maxConcurrentSegmentBatches - 1; the reader holds the batch it is draining)
is reached. The output stream's pull() drains one batch a segment at a time.

This deletes the Mailbox type, createBoundedSegmentScheduler and its state
types, updateChunkQueue, rejectChunks, and the consumed-mailbox swapping:
errors propagate through the stream, the queue holds order, and a drained
batch is garbage as soon as the reader moves on. Cancelling the output stream
now cancels the batch stream, so no further ranged reads are issued.

Behaviour change: a segment that fails its integrity check fails its whole
batch, so earlier segments in that batch are no longer released to the reader.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
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