diff --git a/lib/src/opentdf.ts b/lib/src/opentdf.ts index 650b61683..23341da07 100644 --- a/lib/src/opentdf.ts +++ b/lib/src/opentdf.ts @@ -175,12 +175,20 @@ export type ReadOptions = { /** * Maximum number of payload segments to fetch and decrypt per batch. * Adjust together with `maxConcurrentSegmentBatches` for expected throughput. + * + * Defaults to a value derived from the TDF's own segment size, so that + * `segmentBatchSize * maxConcurrentSegmentBatches` segments stay inside a + * fixed byte budget (see `derivePrefetchWindow`). Raising this raises peak + * memory in proportion. */ segmentBatchSize?: number; /** * Maximum number of segment batches that may be fetched concurrently. * Adjust together with `segmentBatchSize` for expected throughput. + * + * Also derived from the TDF's segment size by default. Setting one of these + * two does not rescale the other -- the unset one keeps its derived value. */ maxConcurrentSegmentBatches?: number; diff --git a/lib/tdf3/src/client/builders.ts b/lib/tdf3/src/client/builders.ts index 07e857d87..0320ec045 100644 --- a/lib/tdf3/src/client/builders.ts +++ b/lib/tdf3/src/client/builders.ts @@ -757,6 +757,10 @@ class DecryptParamsBuilder { /** * Set the number of payload segments to fetch and decrypt per batch. * Adjust together with `withMaxConcurrentSegmentBatches()` for expected throughput. + * + * Left unset, this is derived from the TDF's segment size so that the + * prefetch window stays inside a fixed byte budget. Raising it raises peak + * memory in proportion. */ withSegmentBatchSize(size: number): DecryptParamsBuilder { this._params.segmentBatchSize = size; @@ -766,6 +770,8 @@ class DecryptParamsBuilder { /** * Set the maximum number of payload segment batches to fetch concurrently. * Adjust together with `withSegmentBatchSize()` for expected throughput. + * + * Also derived from the TDF's segment size when unset. */ withMaxConcurrentSegmentBatches(limit: number): DecryptParamsBuilder { this._params.maxConcurrentSegmentBatches = limit; diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index e555587a0..7f20d2094 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -80,6 +80,7 @@ import type { CentralDirectory } from './utils/zip-reader.js'; import { ByteAccumulator } from './utils/byte-accumulator.js'; import { DEFAULT_MANIFEST_MAX_SIZE, + derivePrefetchWindow, estimateManifestBytes, maxEncryptableBytes, maxSegmentsFor, @@ -137,10 +138,6 @@ export function manifestEntryName(centralDirectory: CentralDirectory[]): string } const HEX_SEMVER_VERSION = '4.2.2'; -const LEGACY_SEGMENTS_PER_DOWNLOAD = 500; -const LEGACY_MAX_CONCURRENT_SEGMENT_BATCHES = 3; -const DEFAULT_BOUND_SEGMENT_BATCH_SIZE = LEGACY_SEGMENTS_PER_DOWNLOAD; -const DEFAULT_BOUND_MAX_CONCURRENT_SEGMENT_BATCHES = LEGACY_MAX_CONCURRENT_SEGMENT_BATCHES; /** * Configuration for TDF3 @@ -1415,38 +1412,6 @@ async function decryptChunk( return await cipher.decrypt(encryptedChunk, reconstructedKey); } -async function updateChunkQueue( - chunks: Chunk[], - centralDirectory: CentralDirectory[], - zipReader: ZipReader, - reconstructedKey: SymmetricKey, - cipher: SymmetricCipher, - segmentIntegrityAlgorithm: SegmentIntegrityAlgorithm, - cryptoService: CryptoService, - specVersion: string -) { - let requests = []; - - for (let i = 0; i < chunks.length; i += LEGACY_SEGMENTS_PER_DOWNLOAD) { - if (requests.length === LEGACY_MAX_CONCURRENT_SEGMENT_BATCHES) { - await Promise.all(requests); - requests = []; - } - requests.push( - fetchAndDecryptChunkSlice({ - centralDirectory, - zipReader, - reconstructedKey, - cipher, - segmentIntegrityAlgorithm, - cryptoService, - specVersion, - slice: chunks.slice(i, i + LEGACY_SEGMENTS_PER_DOWNLOAD), - }).catch(() => undefined) - ); - } -} - function rejectChunks(chunks: Chunk[], error: Error) { for (const chunk of chunks) { chunk.decryptedChunk.reject(error); @@ -1629,28 +1594,35 @@ function normalizeSegmentBatchSetting( } /** - * Enables bounded scheduling only when at least one tuning knob is set. - * If callers set only one knob, the other falls back to the legacy value so - * throughput stays aligned with the pre-bounded path. Adjust both knobs together - * when tuning for predictable memory and performance characteristics. + * Prefetch window for a decrypt, in segments. + * + * Bounded scheduling is now the default rather than opt-in. The old default was + * `updateChunkQueue`, which fired every batch at once and never waited for the + * consumer: at 500 segments x 3 concurrent batches that is 1.5 GiB of decrypted + * payload in flight at the 1 MiB segment size, and 24 GiB once + * `chooseSegmentSize` picks 16 MiB. Neither number was chosen; they fell out of + * a segment count that nothing tied to memory. + * + * Explicit knobs still win, and still fall back to the derived value rather + * than each other, so setting one does not silently rescale the other. */ -function getBoundedSegmentSchedulerOptions({ +export function getBoundedSegmentSchedulerOptions({ segmentBatchSize, maxConcurrentSegmentBatches, -}: Pick) { - if (segmentBatchSize === undefined && maxConcurrentSegmentBatches === undefined) { - return undefined; - } - + segmentSize, +}: Pick & { + segmentSize: number; +}) { + const derived = derivePrefetchWindow({ segmentSize }); return { segmentBatchSize: normalizeSegmentBatchSetting( segmentBatchSize, - DEFAULT_BOUND_SEGMENT_BATCH_SIZE, + derived.segmentBatchSize, 'segmentBatchSize' ), maxConcurrentSegmentBatches: normalizeSegmentBatchSetting( maxConcurrentSegmentBatches, - DEFAULT_BOUND_MAX_CONCURRENT_SEGMENT_BATCHES, + derived.maxConcurrentSegmentBatches, 'maxConcurrentSegmentBatches' ), }; @@ -1839,40 +1811,27 @@ export async function decryptStreamFrom( // valid here; only unknown ones are rejected. const segmentIntegrityAlg = asSegmentIntegrityAlgorithm(segmentHashAlg || rootIntegrityAlgorithm); - const schedulerOptions = getBoundedSegmentSchedulerOptions(cfg); - let scheduler: SegmentBatchScheduler | undefined; - if (schedulerOptions) { - scheduler = createBoundedSegmentScheduler({ - totalSegments: chunks.length, - ...schedulerOptions, - onError: (error, startIndex) => { - rejectChunks(chunks.slice(startIndex), error); - }, - scheduleBatch: async (startIndex, endIndex) => - fetchAndDecryptChunkSlice({ - centralDirectory, - zipReader, - reconstructedKey: keyForDecryption, - cipher, - segmentIntegrityAlgorithm: segmentIntegrityAlg, - cryptoService: cfg.cryptoService, - specVersion, - slice: chunks.slice(startIndex, endIndex), - }), - }); - scheduler.fillWindow(); - } else { - void updateChunkQueue( - chunks, - centralDirectory, - zipReader, - keyForDecryption, - cipher, - segmentIntegrityAlg, - cfg.cryptoService, - specVersion - ); - } + const scheduler = createBoundedSegmentScheduler({ + totalSegments: chunks.length, + // The encrypted size is what the window actually holds -- these are ciphertext + // segments fetched from the archive, not yet-decrypted plaintext. + ...getBoundedSegmentSchedulerOptions({ ...cfg, segmentSize: encryptedSegmentSizeDefault }), + onError: (error, startIndex) => { + rejectChunks(chunks.slice(startIndex), error); + }, + scheduleBatch: async (startIndex, endIndex) => + fetchAndDecryptChunkSlice({ + centralDirectory, + zipReader, + reconstructedKey: keyForDecryption, + cipher, + segmentIntegrityAlgorithm: segmentIntegrityAlg, + cryptoService: cfg.cryptoService, + specVersion, + slice: chunks.slice(startIndex, endIndex), + }), + }); + scheduler.fillWindow(); let progress = 0; let nextChunkIndex = 0; @@ -1898,7 +1857,7 @@ export async function decryptStreamFrom( decryptedChunk: mailbox(), }; nextChunkIndex += 1; - scheduler?.markConsumed(); + scheduler.markConsumed(); }, ...(cfg.fileStreamServiceWorker && { fileStreamServiceWorker: cfg.fileStreamServiceWorker }), }; diff --git a/lib/tdf3/src/utils/scale-limits.ts b/lib/tdf3/src/utils/scale-limits.ts index 48910de9c..170522a3c 100644 --- a/lib/tdf3/src/utils/scale-limits.ts +++ b/lib/tdf3/src/utils/scale-limits.ts @@ -338,6 +338,78 @@ export function maxOutputBytes({ */ const ZIP_CONTAINER_OVERHEAD = 64 * 1024; +/** + * How much decrypted-but-unconsumed payload the reader will hold ahead of the + * consumer. + * + * The prefetch window has to be a *byte* budget, not a segment count. A window + * of "8 segments x 3 batches" is 24 MiB at the 1 MiB default and 384 MiB once + * {@link chooseSegmentSize} picks 16 MiB for a large file -- the same + * configuration, sixteen times the memory, with nothing in the numbers to + * suggest it. Budgeting bytes makes the window mean the same thing at every + * segment size. + * + * 128 MiB is enough to keep several ranged reads in flight on a fast link while + * staying somewhere a browser tab can live. + */ +export const DEFAULT_PREFETCH_BYTE_BUDGET = 128 * 1024 * 1024; + +/** + * Most batches to keep in flight at once, before the byte budget reduces it. + * + * Concurrency hides request latency; past a handful it mostly adds memory. The + * legacy path used 3 and there is no evidence more helped. + */ +export const DEFAULT_MAX_CONCURRENT_SEGMENT_BATCHES = 3; + +/** + * Most segments in a single batch, i.e. a single ranged read. + * + * The byte budget alone would allow enormous batches at small segment sizes + * (43,690 segments per batch at 1 KiB), which stays within budget but makes the + * consumer wait on one absurd ranged read before the first byte. This is the + * legacy batch size, so tiny-segment files keep behaving as they always did. + */ +export const MAX_SEGMENT_BATCH_SIZE = 500; + +/** + * Splits a byte budget into a batch size and a concurrency level. + * + * The product of the two is the window, so both have to come out of the same + * division. Concurrency yields first: three batches of one segment is a better + * use of a tight budget than one batch of three, because the reads overlap. + * + * When a single segment already exceeds the budget the window is one segment + * and the budget is knowingly blown -- there is no smaller unit to schedule, + * and refusing to decrypt would be worse than using the memory. + */ +export function derivePrefetchWindow({ + segmentSize, + byteBudget = DEFAULT_PREFETCH_BYTE_BUDGET, + maxConcurrentSegmentBatches = DEFAULT_MAX_CONCURRENT_SEGMENT_BATCHES, + maxSegmentBatchSize = MAX_SEGMENT_BATCH_SIZE, +}: { + segmentSize: number; + byteBudget?: number; + maxConcurrentSegmentBatches?: number; + maxSegmentBatchSize?: number; +}): { segmentBatchSize: number; maxConcurrentSegmentBatches: number } { + assertPositiveInteger(segmentSize, 'segmentSize'); + assertPositiveInteger(byteBudget, 'byteBudget'); + assertPositiveInteger(maxConcurrentSegmentBatches, 'maxConcurrentSegmentBatches'); + assertPositiveInteger(maxSegmentBatchSize, 'maxSegmentBatchSize'); + + const segmentsInWindow = Math.max(1, Math.floor(byteBudget / segmentSize)); + const batches = Math.min(maxConcurrentSegmentBatches, segmentsInWindow); + return { + segmentBatchSize: Math.min( + maxSegmentBatchSize, + Math.max(1, Math.floor(segmentsInWindow / batches)) + ), + maxConcurrentSegmentBatches: batches, + }; +} + function assertPositiveInteger(value: number, name: string): void { if (!Number.isInteger(value) || value < 1) { throw new ConfigurationError(`${name} must be a positive integer; got [${value}]`); diff --git a/lib/tests/mocha/unit/scale-limits.spec.ts b/lib/tests/mocha/unit/scale-limits.spec.ts index df55d8874..312b5e092 100644 --- a/lib/tests/mocha/unit/scale-limits.spec.ts +++ b/lib/tests/mocha/unit/scale-limits.spec.ts @@ -10,10 +10,14 @@ import { base64Length, chooseSegmentSize, DEFAULT_MANIFEST_MAX_SIZE, + DEFAULT_MAX_CONCURRENT_SEGMENT_BATCHES, + DEFAULT_PREFETCH_BYTE_BUDGET, + derivePrefetchWindow, estimateSegmentsArrayBytes, FIFTY_TEBIBYTES, finalSegmentEntryBytes, MAX_PAYLOAD_SEGMENTS_PER_KEY, + MAX_SEGMENT_BATCH_SIZE, maxEncryptableBytes, maxOutputBytes, maxSegmentsFor, @@ -340,6 +344,89 @@ describe('scale-limits', () => { } }); }); + + describe('derivePrefetchWindow', () => { + const windowBytes = (segmentSize: number, opts = {}) => { + const { segmentBatchSize, maxConcurrentSegmentBatches } = derivePrefetchWindow({ + segmentSize, + ...opts, + }); + return segmentBatchSize * maxConcurrentSegmentBatches * segmentSize; + }; + + // The whole point of the change: the window is a byte count, not a segment + // count, so growing the segment size must not grow peak memory. + it('keeps the window inside the byte budget at every rung of the ladder', () => { + for (const segmentSize of SEGMENT_SIZE_LADDER) { + if (segmentSize > DEFAULT_PREFETCH_BYTE_BUDGET) { + continue; + } + expect(windowBytes(segmentSize), `${segmentSize} bytes`).to.be.at.most( + DEFAULT_PREFETCH_BYTE_BUDGET + ); + } + }); + + it('spends the budget on concurrency before batch size', () => { + // 128 MiB / 16 MiB = 8 segments, split 3 ways -> 2 per batch, 3 batches. + expect(derivePrefetchWindow({ segmentSize: 16 * MIB })).to.deep.equal({ + segmentBatchSize: 2, + maxConcurrentSegmentBatches: 3, + }); + // 128 MiB / 64 MiB = 2 segments: not enough for three batches, so + // concurrency drops rather than rounding the batch size down to zero. + expect(derivePrefetchWindow({ segmentSize: 64 * MIB })).to.deep.equal({ + segmentBatchSize: 1, + maxConcurrentSegmentBatches: 2, + }); + }); + + it('caps the batch size so a small-segment file is not one enormous read', () => { + // 128 MiB / 1 KiB = 131,072 segments; within budget, but the consumer + // would wait on a single request for all of them. + const { segmentBatchSize, maxConcurrentSegmentBatches } = derivePrefetchWindow({ + segmentSize: 1024, + }); + expect(segmentBatchSize).to.equal(MAX_SEGMENT_BATCH_SIZE); + expect(maxConcurrentSegmentBatches).to.equal(DEFAULT_MAX_CONCURRENT_SEGMENT_BATCHES); + }); + + it('keeps the legacy window for the legacy 1 MiB segment size', () => { + expect(derivePrefetchWindow({ segmentSize: MIB })).to.deep.equal({ + segmentBatchSize: 42, + maxConcurrentSegmentBatches: 3, + }); + }); + + // No smaller unit exists, so the alternative to blowing the budget is + // refusing to decrypt at all. + it('falls back to a single segment when one segment exceeds the budget', () => { + expect(derivePrefetchWindow({ segmentSize: 256 * MIB })).to.deep.equal({ + segmentBatchSize: 1, + maxConcurrentSegmentBatches: 1, + }); + expect(windowBytes(256 * MIB)).to.be.above(DEFAULT_PREFETCH_BYTE_BUDGET); + }); + + it('honours a caller-supplied budget', () => { + expect(windowBytes(MIB, { byteBudget: 6 * MIB })).to.be.at.most(6 * MIB); + expect(derivePrefetchWindow({ segmentSize: MIB, byteBudget: 6 * MIB })).to.deep.equal({ + segmentBatchSize: 2, + maxConcurrentSegmentBatches: 3, + }); + }); + + it('rejects nonsense inputs rather than deriving a nonsense window', () => { + for (const bad of [0, -1, 1.5, NaN]) { + expect(() => derivePrefetchWindow({ segmentSize: bad }), `${bad}`).to.throw( + ConfigurationError + ); + expect(() => derivePrefetchWindow({ segmentSize: MIB, byteBudget: bad })).to.throw( + ConfigurationError + ); + } + }); + }); }); // Guards the constants above against drifting from what `writeStream` really diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index d47bc7671..81f23b61c 100644 --- a/lib/tests/mocha/unit/tdf.spec.ts +++ b/lib/tests/mocha/unit/tdf.spec.ts @@ -348,6 +348,87 @@ describe('bounded segment scheduler', () => { [4, 6], ]); }); + + // The legacy prefetch path swallowed batch failures (`.catch(() => undefined)`), + // leaving the consumer awaiting a mailbox that would never settle. Surfacing + // them is the point of routing every decrypt through the scheduler. + it('surfaces a failing batch instead of stalling, and stops scheduling', async () => { + const started: Array<[number, number]> = []; + const errors: Array<[string, number, number]> = []; + const scheduler = TDF.createBoundedSegmentScheduler({ + totalSegments: 10, + segmentBatchSize: 2, + maxConcurrentSegmentBatches: 1, + onError: (error, startIndex, endIndex) => { + errors.push([error.message, startIndex, endIndex]); + }, + scheduleBatch: (startIndex, endIndex) => { + started.push([startIndex, endIndex]); + return Promise.reject(new Error('kaboom')); + }, + }); + + scheduler.fillWindow(); + // A rejection travels through more links of the promise chain than a + // resolution does, so this needs a deeper flush than the tests above. + for (let i = 0; i < 8; i++) { + await flushScheduler(); + } + + expect(errors).to.have.lengthOf(1); + expect(errors[0][0]).to.contain('kaboom'); + expect(errors[0].slice(1)).to.deep.equal([0, 2]); + + // A stopped scheduler must not keep issuing reads for a decrypt that + // already failed. + scheduler.markConsumed(2); + await flushScheduler(); + expect(started).to.deep.equal([[0, 2]]); + }); +}); + +describe('getBoundedSegmentSchedulerOptions', () => { + const MIB = 1024 * 1024; + + it('derives a window from the segment size when nothing is configured', () => { + // Bounded scheduling is the default now, and the bound scales with the + // segment size rather than being a fixed segment count. + expect(TDF.getBoundedSegmentSchedulerOptions({ segmentSize: MIB })).to.deep.equal({ + segmentBatchSize: 42, + maxConcurrentSegmentBatches: 3, + }); + expect(TDF.getBoundedSegmentSchedulerOptions({ segmentSize: 16 * MIB })).to.deep.equal({ + segmentBatchSize: 2, + maxConcurrentSegmentBatches: 3, + }); + }); + + it('lets an explicit setting win without rescaling the other', () => { + expect( + TDF.getBoundedSegmentSchedulerOptions({ segmentSize: 16 * MIB, segmentBatchSize: 500 }) + ).to.deep.equal({ + segmentBatchSize: 500, + maxConcurrentSegmentBatches: 3, + }); + expect( + TDF.getBoundedSegmentSchedulerOptions({ + segmentSize: 16 * MIB, + maxConcurrentSegmentBatches: 1, + }) + ).to.deep.equal({ + segmentBatchSize: 2, + maxConcurrentSegmentBatches: 1, + }); + }); + + it('rejects a non-positive setting', () => { + expect(() => + TDF.getBoundedSegmentSchedulerOptions({ segmentSize: MIB, segmentBatchSize: 0 }) + ).to.throw(ConfigurationError, 'segmentBatchSize'); + expect(() => + TDF.getBoundedSegmentSchedulerOptions({ segmentSize: MIB, maxConcurrentSegmentBatches: -1 }) + ).to.throw(ConfigurationError, 'maxConcurrentSegmentBatches'); + }); }); describe('splitLookupTableFactory', () => { diff --git a/spec/DSPX-4648-web-sdk-large-files.md b/spec/DSPX-4648-web-sdk-large-files.md index b11a99576..d40741b0f 100644 --- a/spec/DSPX-4648-web-sdk-large-files.md +++ b/spec/DSPX-4648-web-sdk-large-files.md @@ -210,18 +210,18 @@ Two scope corrections to the previous revision: (`maxSegmentsFor` / `maxEncryptableBytes` / `maxOutputBytes` in `scale-limits.ts`): ``` - maxSegments = min( - invocationLimit - 1, // encryptor's IV ceiling - floor(manifestMaxSize / perSegmentEntryBytes) // manifest ceiling - ) - maxEncryptableBytes = segmentSize * maxSegments // plaintext in - maxOutputBytes = maxSegments * (segmentSize + 28) - + manifestMaxSize + 64 KiB // zip bytes out - ``` - - At the recommended configuration (16 MiB segments, 256 MiB cap, HS256 at 56 B/entry) this - yields a manifest ceiling of 4,793,490 segments and `maxEncryptableBytes = 80,421,417,123,840` - (~80.4 TB) — comfortably above 50 TiB, with the IV ceiling not binding. + maxSegments = min( + invocationLimit - 1, // encryptor's IV ceiling + floor(manifestMaxSize / perSegmentEntryBytes) // manifest ceiling + ) + maxEncryptableBytes = segmentSize * maxSegments // plaintext in + maxOutputBytes = maxSegments * (segmentSize + 28) + + manifestMaxSize + 64 KiB // zip bytes out + ``` + + At the recommended configuration (16 MiB segments, 256 MiB cap, HS256 at 56 B/entry) this + yields a manifest ceiling of 4,793,490 segments and `maxEncryptableBytes = 80,421,417,123,840` + (~80.4 TB) — comfortably above 50 TiB, with the IV ceiling not binding. - [x] Apply the same up-front check as item 1: when source length is known, compare against `maxEncryptableBytes` before encrypting rather than after 64GB of output has been emitted. @@ -318,28 +318,65 @@ Not done, deliberately deferred: The safe paths remain `'chunker'`, `'file-browser'` (Blob), and `'remote'`, which is now ranged on the encrypt path as well as during segment decryption. -### 4. Default decrypt prefetch scheduler isn't consumption-paced +### 4. Default decrypt prefetch scheduler isn't consumption-paced — **DONE** (`DSPX-4654`) -`updateChunkQueue` (`lib/tdf3/src/tdf.ts:1091`) prefetches in batches of 500 segments, up to 3 +`updateChunkQueue` (`lib/tdf3/src/tdf.ts:1091`) prefetched in batches of 500 segments, up to 3 concurrent batches, without waiting for the consumer. The paced alternative -`createBoundedSegmentScheduler` (`:1207`) is opt-in only, selected at `:1509` when the caller sets +`createBoundedSegmentScheduler` (`:1207`) was opt-in only, selected at `:1509` when the caller set `segmentBatchSize`/`maxConcurrentSegmentBatches`. -- [ ] Make `createBoundedSegmentScheduler` the default, or auto-select it once file size/segment - count crosses a threshold. -- [ ] **Budget the prefetch window in bytes, not segments.** This is a new constraint created by - item 1: once segment size can be 16 MiB, a window expressed in segment counts is dangerous. - The current opt-in defaults pattern of e.g. 8 segments × 3 batches is 24 MiB at 1 MiB segments - but **384 MiB at 16 MiB segments** — untenable in a browser tab. Define the window as a byte - budget (suggested: 64–128 MiB) and derive `segmentBatchSize` from `budget / segmentSize`. -- [ ] Choose and document concrete defaults for `segmentBatchSize` and - `maxConcurrentSegmentBatches`; the plan previously named neither. -- [ ] **Call out the error-behavior change.** The legacy path swallows errors - (`.catch(() => undefined)`, `lib/tdf3/src/tdf.ts:1118`) while the scheduler surfaces them via - `onError`. Switching the default is arguably a bug fix, but it changes observable behavior and - will break tests that depend on silent failure. Treat it as an intentional, documented change. -- [ ] Note that this item does **not** by itself bound decrypt memory — the eager `chunks` - allocation in item 1 must also be addressed. +- [x] Make `createBoundedSegmentScheduler` the default, or auto-select it once file size/segment + count crosses a threshold. — Made it the unconditional default. `updateChunkQueue` and the + `LEGACY_*` / `DEFAULT_BOUND_*` constants are deleted, not merely bypassed; there is now one + prefetch path, so the paced one cannot rot. +- [x] **Budget the prefetch window in bytes, not segments.** — `derivePrefetchWindow` + (`lib/tdf3/src/utils/scale-limits.ts`) divides `DEFAULT_PREFETCH_BYTE_BUDGET = 128 MiB` by the + segment size. The budget is charged against the _encrypted_ segment size + (`encryptedSegmentSizeDefault`), since ciphertext segments are what the window actually holds. +- [x] Choose and document concrete defaults for `segmentBatchSize` and + `maxConcurrentSegmentBatches`. Derived, so they move with the segment size: + + | 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. And `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 131,072 segments, i.e. 43,690 per batch, which is inside 128 MiB but makes + the consumer wait on one absurd ranged read for the first byte. Below ~87 KiB segments that + cap binds and the window comes out *under* budget — the legacy behaviour tiny-segment files + already had. + + The one case that exceeds the budget is a segment larger than the budget itself (the 256 MiB + rung). There is no smaller unit to schedule, and refusing to decrypt a validly-formed TDF + would be worse than spending the memory. + +- [x] **Call out the error-behavior change.** Confirmed and intentional. The legacy path did + `.catch(() => undefined)`, which left the consumer awaiting a mailbox that would never settle + — a hang, not a clean failure. Every decrypt now surfaces batch failures through the + scheduler's `onError`, which rejects the affected chunks. Covered by + `'surfaces a failing batch instead of stalling, and stops scheduling'`. No existing test + depended on the silent-failure behaviour; all three tiers pass unchanged. +- [x] Note that this item does **not** by itself bound decrypt memory — the eager `chunks` + allocation in item 1 must also be addressed. Still true, and still not addressed. `readStream` + materializes one `Chunk` object (hash string + mailbox promise) per segment before the first + byte is read. At the 16 MiB segment size a 50 TiB file is 3.28 M segments, so that array is on + the order of hundreds of MB of small objects — bounded by the manifest cap from item 1, but + not by this item's byte budget. Bounding it means streaming the segment list rather than + mapping it, which is a separate change. + +Not done, deliberately deferred: + +- The byte budget is a fixed 128 MiB rather than a fraction of an observed device memory + (`navigator.deviceMemory`, `performance.memory`). A fixed number is predictable across browsers + and testable; adaptive sizing can come later if a real workload asks for it. +- No `progressHandler`-driven backpressure. Pacing is by consumption of the output stream only. ## Explicit non-goals