qvdjs 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2185,13 +2185,13 @@ function estimateRowMemory(rows, columnCount) {
2185
2185
  }
2186
2186
  return BASE_BYTES + rows * (ROW_BASE_BYTES + PER_CELL_BYTES * columnCount);
2187
2187
  }
2188
- function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, rowsLive = null) {
2188
+ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, rowsLive = null, wholeSymbols = false) {
2189
2189
  const FULL_PARSE_OVERHEAD = 6;
2190
2190
  const MINIMAL_OVERHEAD = 0.01;
2191
2191
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2192
2192
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2193
2193
  const rowMemory = materialisesRows ? estimateRowMemory(liveRows, columnCount) : BASE_BYTES;
2194
- if (maxRows === null || maxRows >= totalRows) {
2194
+ if (maxRows === null || maxRows >= totalRows || wholeSymbols) {
2195
2195
  return symbolTableSize * FULL_PARSE_OVERHEAD + rowMemory;
2196
2196
  }
2197
2197
  const rowPercentage = maxRows / totalRows;
@@ -2200,8 +2200,8 @@ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount =
2200
2200
  const skippedSymbolsMemory = symbolTableSize * (1 - symbolPercentage) * MINIMAL_OVERHEAD;
2201
2201
  return keptSymbolsMemory + skippedSymbolsMemory + rowMemory;
2202
2202
  }
2203
- function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true, includeExternal = false) {
2204
- const costOf = /* @__PURE__ */ __name((rows) => estimateMemoryUsage(symbolTableSize, rows, totalRows, columnCount, materialisesRows) + (includeExternal ? estimateExternalMemory(Math.min(rows, totalRows), columnCount) : 0), "costOf");
2203
+ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true, includeExternal = false, wholeSymbols = false) {
2204
+ const costOf = /* @__PURE__ */ __name((rows) => estimateMemoryUsage(symbolTableSize, rows, totalRows, columnCount, materialisesRows, null, wholeSymbols) + (includeExternal ? estimateExternalMemory(Math.min(rows, totalRows), columnCount) : 0), "costOf");
2205
2205
  if (costOf(totalRows) <= budget) {
2206
2206
  return totalRows;
2207
2207
  }
@@ -2220,11 +2220,19 @@ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, mat
2220
2220
  }
2221
2221
  return low;
2222
2222
  }
2223
- function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, columnCount, liveRowsPerChunk = 1, includeExternal = false) {
2223
+ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, columnCount, liveRowsPerChunk = 1, includeExternal = false, wholeSymbols = false) {
2224
2224
  const covered = windowRows === null || windowRows >= totalRows ? totalRows : windowRows;
2225
2225
  const fits = /* @__PURE__ */ __name((chunk) => {
2226
2226
  const live = Math.min(chunk * liveRowsPerChunk, covered);
2227
- const cost = estimateMemoryUsage(symbolTableSize, windowRows, totalRows, columnCount, true, chunk * liveRowsPerChunk) + (includeExternal ? estimateExternalMemory(live, columnCount) : 0);
2227
+ const cost = estimateMemoryUsage(
2228
+ symbolTableSize,
2229
+ windowRows,
2230
+ totalRows,
2231
+ columnCount,
2232
+ true,
2233
+ chunk * liveRowsPerChunk,
2234
+ wholeSymbols
2235
+ ) + (includeExternal ? estimateExternalMemory(live, columnCount) : 0);
2228
2236
  return cost <= budget;
2229
2237
  }, "fits");
2230
2238
  if (fits(covered)) {
@@ -2245,8 +2253,10 @@ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, col
2245
2253
  }
2246
2254
  return low;
2247
2255
  }
2248
- function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = null, readBytes = null) {
2256
+ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = null, readBytes = null, wholeSymbols = false, retainedBytes = 0) {
2249
2257
  const answer = checkMemory({
2258
+ wholeSymbols,
2259
+ retainedBytes,
2250
2260
  symbolTableSize,
2251
2261
  maxRows,
2252
2262
  totalRows,
@@ -2278,7 +2288,9 @@ function checkMemory({
2278
2288
  live = null,
2279
2289
  bytesHeld = null,
2280
2290
  readBytes = null,
2281
- measured = null
2291
+ measured = null,
2292
+ wholeSymbols = false,
2293
+ retainedBytes = 0
2282
2294
  }) {
2283
2295
  if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2284
2296
  throw new QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", {
@@ -2293,8 +2305,21 @@ function checkMemory({
2293
2305
  const rowsLive = live === null ? null : live.rows;
2294
2306
  const liveRowsPerChunk = live === null ? 1 : live.perChunk;
2295
2307
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2296
- const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows, rowsLive);
2297
- const { held, forRows: heldForRows, forChunk: heldForChunk } = bytesHeld ?? noBytesHeld;
2308
+ const heapMemory = estimateMemoryUsage(
2309
+ symbolTableSize,
2310
+ maxRows,
2311
+ totalRows,
2312
+ columnCount,
2313
+ materialisesRows,
2314
+ rowsLive,
2315
+ wholeSymbols
2316
+ );
2317
+ const {
2318
+ held,
2319
+ afterRelease: heldAfterRelease = null,
2320
+ forRows: heldForRows,
2321
+ forChunk: heldForChunk
2322
+ } = bytesHeld ?? noBytesHeld;
2298
2323
  const externalMemory = estimateExternalMemory(liveRows, columnCount) + held;
2299
2324
  const bounded = budget.candidates.map((candidate) => {
2300
2325
  const heapOnly = candidate.source === "V8 heap limit";
@@ -2330,6 +2355,25 @@ function checkMemory({
2330
2355
  const availableMemory = binding ? binding.bytes : budget.bytes;
2331
2356
  const estimatedMemory = binding ? binding.needs : heapMemory;
2332
2357
  const maxAllowedMemory = binding ? binding.allowed : budget.bytes * safetyFactor;
2358
+ const retainedIsTheReason = (() => {
2359
+ if (binding === null || retainedBytes <= 0) {
2360
+ return false;
2361
+ }
2362
+ const heapAfter = estimateMemoryUsage(
2363
+ Math.max(0, symbolTableSize - retainedBytes),
2364
+ maxRows,
2365
+ totalRows,
2366
+ columnCount,
2367
+ materialisesRows,
2368
+ rowsLive,
2369
+ wholeSymbols
2370
+ );
2371
+ const externalAfter = estimateExternalMemory(liveRows, columnCount) + (heldAfterRelease ?? held);
2372
+ return budget.candidates.every((candidate) => {
2373
+ const heapOnly = candidate.source === "V8 heap limit";
2374
+ return (heapOnly ? heapAfter : heapAfter + externalAfter) <= candidate.bytes * safetyFactor;
2375
+ });
2376
+ })();
2333
2377
  if (binding) {
2334
2378
  const includeExternal = !binding.heapOnly;
2335
2379
  const rowsHeldBudget = /* @__PURE__ */ __name((rows) => maxAllowedMemory - (includeExternal ? heldForRows(rows) : 0), "rowsHeldBudget");
@@ -2339,7 +2383,8 @@ function checkMemory({
2339
2383
  totalRows,
2340
2384
  columnCount,
2341
2385
  materialisesRows,
2342
- includeExternal
2386
+ includeExternal,
2387
+ wholeSymbols
2343
2388
  ), "fitting");
2344
2389
  const firstGuess = fitting(liveRows);
2345
2390
  const over = fitting(firstGuess);
@@ -2365,7 +2410,8 @@ function checkMemory({
2365
2410
  totalRows,
2366
2411
  columnCount,
2367
2412
  liveRowsPerChunk,
2368
- includeExternal
2413
+ includeExternal,
2414
+ wholeSymbols
2369
2415
  ), "chunkFitting");
2370
2416
  const callersChunk = chunked ? Math.max(1, Math.floor(rowsLive / Math.max(1, liveRowsPerChunk))) : 0;
2371
2417
  const firstChunk = chunked ? chunkFitting(callersChunk) : 0;
@@ -2374,13 +2420,17 @@ function checkMemory({
2374
2420
  const knob = chunked ? "chunkSize" : "limit";
2375
2421
  const recommendedValue = chunked ? recommendedChunk : recommendedMaxRows;
2376
2422
  const nothingFits = recommendedValue === 0;
2423
+ const held2 = retainedIsTheReason;
2424
+ const fixedCost = held2 ? "the columns this file is holding exceed" : "the symbol table alone exceeds";
2425
+ const release = held2 ? `Close the file and open it again to release them, or raise ` : `Raise `;
2426
+ const releaseFirst = held2 ? `Close the file and open it again to release the columns it is holding, which is the direct remedy. ` : "";
2377
2427
  let advice;
2378
2428
  if (nothingFits) {
2379
- advice = `No row count fits this budget - the symbol table alone exceeds it, so ${knob} cannot help. ` + (containerBound ? `Raise the container's memory limit.` : `Raise the heap with --max-old-space-size, or raise memorySafetyFactor.`);
2429
+ advice = `No row count fits this budget - ${fixedCost} it, so ${knob} cannot help. ` + (containerBound ? `${release}the container's memory limit.` : `${release}the heap with --max-old-space-size, or raise memorySafetyFactor.`);
2380
2430
  } else if (containerBound) {
2381
- advice = `The binding limit is the container's, so raising --max-old-space-size would let V8 grow past it and be killed by the OOM killer instead. Set it below the container limit, raise the limit, or hold fewer rows with ${knob} (recommended: ${formatCount(recommendedValue)} rows or less).`;
2431
+ advice = releaseFirst + `The binding limit is the container's, so raising --max-old-space-size would let V8 grow past it and be killed by the OOM killer instead. Set it below the container limit, raise the limit, or hold fewer rows with ${knob} (recommended: ${formatCount(recommendedValue)} rows or less).`;
2382
2432
  } else {
2383
- advice = `Try holding fewer rows using the ${knob} parameter (recommended: ${formatCount(recommendedValue)} rows or less), or raise the heap with --max-old-space-size.`;
2433
+ advice = releaseFirst + `Try holding fewer rows using the ${knob} parameter (recommended: ${formatCount(recommendedValue)} rows or less), or raise the heap with --max-old-space-size.`;
2384
2434
  }
2385
2435
  const suggestions = [];
2386
2436
  if (!nothingFits) {
@@ -2404,10 +2454,16 @@ function checkMemory({
2404
2454
  exact: symbolTableSize === 0,
2405
2455
  suggestions,
2406
2456
  refusal: {
2407
- message: `Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}, which bounds ${limitingScope}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
2457
+ message: `Insufficient memory to load file safely. ${retainedIsTheReason ? "Columns held" : "Symbol table"}: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}, which bounds ${limitingScope}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
2408
2458
  context: {
2409
2459
  symbolTableSize,
2410
2460
  symbolTableSizeMB: sizeMB,
2461
+ // Whether that figure is the file's symbol table or what an open file is still holding. A
2462
+ // paging read is charged for every column any of its pages decoded and kept, so a caller
2463
+ // branching on the refusal needs to know which of the two it is looking at - the remedies
2464
+ // differ, and for this one releasing is a remedy where raising the limit is only a workaround.
2465
+ holdsDecodedColumns: retainedIsTheReason,
2466
+ retainedSymbolBytes: retainedBytes,
2411
2467
  estimatedMemoryMB: estimatedMB,
2412
2468
  availableMemoryMB: availableMB,
2413
2469
  heapLimitMB,
@@ -2455,13 +2511,21 @@ function budgetOf(budget, tightest, safetyFactor) {
2455
2511
  function formatCount(value) {
2456
2512
  return value.toLocaleString("en-US");
2457
2513
  }
2458
- function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true) {
2514
+ function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, wholeSymbols = false) {
2459
2515
  const LARGE_SYMBOL_TABLE_WARNING = usableOldSpaceLimit() * 0.125;
2460
2516
  if (symbolTableSize <= LARGE_SYMBOL_TABLE_WARNING) {
2461
2517
  return;
2462
2518
  }
2463
2519
  const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
2464
- const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows);
2520
+ const estimatedMemory = estimateMemoryUsage(
2521
+ symbolTableSize,
2522
+ maxRows,
2523
+ totalRows,
2524
+ columnCount,
2525
+ materialisesRows,
2526
+ null,
2527
+ wholeSymbols
2528
+ );
2465
2529
  if (estimatedMemory <= LARGE_SYMBOL_TABLE_WARNING) {
2466
2530
  return;
2467
2531
  }
@@ -2567,19 +2631,39 @@ function validateHeaderStructure(headerObj, filePath, stage) {
2567
2631
  });
2568
2632
  return fieldList;
2569
2633
  }
2570
- function validateSymbolTableSizeEarly(symbolTableLength, filePath) {
2634
+ function validateSymbolTableSizeEarly(symbolTableLength, filePath, retainedBytes = 0) {
2571
2635
  const heapLimit = getHeapLimit();
2572
2636
  const MAX_SYMBOL_TABLE_SIZE = heapLimit * 0.125;
2573
2637
  if (symbolTableLength > MAX_SYMBOL_TABLE_SIZE) {
2574
2638
  const sizeMB = Math.round(symbolTableLength / 1024 / 1024);
2575
2639
  const maxMB = Math.round(MAX_SYMBOL_TABLE_SIZE / 1024 / 1024);
2576
2640
  const heapMB = Math.round(heapLimit / 1024 / 1024);
2641
+ if (retainedBytes > 0 && symbolTableLength - retainedBytes <= MAX_SYMBOL_TABLE_SIZE) {
2642
+ throw new QvdValidationError(
2643
+ `Columns held too large (${sizeMB}MB exceeds ${maxMB}MB limit). This open file is holding the columns its pages have decoded, and they have grown past the ceiling rather than the file's own symbol table being large. Limit scales with heap size (current: ${heapMB}MB, limit: 12.5% = ${maxMB}MB). Consider: (1) closing the file and opening it again, which releases what the pages decoded, (2) paging over fewer columns with fields, or (3) increasing heap size with --max-old-space-size.`,
2644
+ {
2645
+ file: filePath,
2646
+ symbolTableSize: symbolTableLength,
2647
+ symbolTableSizeMB: sizeMB,
2648
+ holdsDecodedColumns: true,
2649
+ retainedSymbolBytes: retainedBytes,
2650
+ maxAllowed: MAX_SYMBOL_TABLE_SIZE,
2651
+ maxAllowedMB: maxMB,
2652
+ heapLimitMB: heapMB,
2653
+ reason: "memory"
2654
+ }
2655
+ );
2656
+ }
2577
2657
  throw new QvdValidationError(
2578
2658
  `Symbol table too large (${sizeMB}MB exceeds ${maxMB}MB limit for lazy loading). This QVD file contains extremely high-cardinality fields. Limit scales with heap size (current: ${heapMB}MB, limit: 12.5% = ${maxMB}MB). Consider: (1) loading the full file without a row window - maxRows, limit or offset - since the symbol table is read in full either way, (2) increasing heap size with --max-old-space-size, or (3) aggregating high-cardinality fields.`,
2579
2659
  {
2580
2660
  file: filePath,
2581
2661
  symbolTableSize: symbolTableLength,
2582
2662
  symbolTableSizeMB: sizeMB,
2663
+ // Present and false, not absent. A caller told it can branch on this has to find it on both
2664
+ // refusals, or the branch reads `undefined` for the commoner of the two.
2665
+ holdsDecodedColumns: false,
2666
+ retainedSymbolBytes: retainedBytes,
2583
2667
  maxAllowed: MAX_SYMBOL_TABLE_SIZE,
2584
2668
  maxAllowedMB: maxMB,
2585
2669
  heapLimitMB: heapMB,
@@ -3589,6 +3673,26 @@ function symbolBytesOf(selected, symbolTableLength) {
3589
3673
  function readPasses(analysisAhead) {
3590
3674
  return analysisAhead ? 2 : 1;
3591
3675
  }
3676
+ function validateWatchers(onProgress, signal, path5) {
3677
+ if (onProgress !== void 0 && typeof onProgress !== "function") {
3678
+ throw new QvdValidationError("onProgress must be a function", {
3679
+ provided: onProgress,
3680
+ type: typeof onProgress,
3681
+ reason: "option",
3682
+ option: "onProgress",
3683
+ file: path5
3684
+ });
3685
+ }
3686
+ if (signal !== void 0 && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean")) {
3687
+ throw new QvdValidationError("signal must be an AbortSignal", {
3688
+ provided: signal,
3689
+ type: typeof signal,
3690
+ reason: "option",
3691
+ option: "signal",
3692
+ file: path5
3693
+ });
3694
+ }
3695
+ }
3592
3696
  var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, SLICE_BYTES, COUNT_SYMBOLS_PAST, QvdFileReader;
3593
3697
  var init_QvdFileReader = __esm({
3594
3698
  "src/QvdFileReader.js"() {
@@ -3613,6 +3717,7 @@ var init_QvdFileReader = __esm({
3613
3717
  __name(parseHeaderXml, "parseHeaderXml");
3614
3718
  __name(symbolBytesOf, "symbolBytesOf");
3615
3719
  __name(readPasses, "readPasses");
3720
+ __name(validateWatchers, "validateWatchers");
3616
3721
  QvdFileReader = class {
3617
3722
  static {
3618
3723
  __name(this, "QvdFileReader");
@@ -3693,20 +3798,7 @@ var init_QvdFileReader = __esm({
3693
3798
  });
3694
3799
  }
3695
3800
  this._sliceBytes = sliceBytes;
3696
- if (onProgress !== void 0 && typeof onProgress !== "function") {
3697
- throw new QvdValidationError("onProgress must be a function", {
3698
- provided: onProgress,
3699
- type: typeof onProgress,
3700
- file: this._path
3701
- });
3702
- }
3703
- if (signal !== void 0 && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean")) {
3704
- throw new QvdValidationError("signal must be an AbortSignal", {
3705
- provided: signal,
3706
- type: typeof signal,
3707
- file: this._path
3708
- });
3709
- }
3801
+ validateWatchers(onProgress, signal, this._path);
3710
3802
  this._requestedFields = fields === void 0 ? null : fields;
3711
3803
  this._onProgress = onProgress;
3712
3804
  this._signal = signal;
@@ -3715,6 +3807,8 @@ var init_QvdFileReader = __esm({
3715
3807
  this._failed = null;
3716
3808
  this._reading = false;
3717
3809
  this._symbolAreas = null;
3810
+ this._symbolCache = null;
3811
+ this._cachedFor = null;
3718
3812
  this._headerOffset = null;
3719
3813
  this._symbolTableOffset = null;
3720
3814
  this._indexTableOffset = null;
@@ -3726,6 +3820,7 @@ var init_QvdFileReader = __esm({
3726
3820
  this._indexColumns = null;
3727
3821
  this._rowsDecoded = 0;
3728
3822
  this._fileSize = null;
3823
+ this._fileIdentity = null;
3729
3824
  this._headerMatchesFile = false;
3730
3825
  }
3731
3826
  /**
@@ -3954,8 +4049,9 @@ var init_QvdFileReader = __esm({
3954
4049
  const indexTableOffset = symbolTableOffset + symbolTableLength;
3955
4050
  const recordSize = headerInteger(headerObj["QvdTableHeader"]["RecordByteSize"]);
3956
4051
  const totalRows = headerInteger(headerObj["QvdTableHeader"]["NoOfRecords"]);
3957
- const { size: fileSize } = await handle.stat().catch(failed);
4052
+ const { size: fileSize, ino, dev, mtimeMs } = await handle.stat().catch(failed);
3958
4053
  this._fileSize = fileSize;
4054
+ this._fileIdentity = `${dev}:${ino}:${mtimeMs}:${fileSize}`;
3959
4055
  this._headerMatchesFile = false;
3960
4056
  const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
3961
4057
  (value) => Number.isSafeInteger(value) && value >= 0
@@ -3971,7 +4067,12 @@ var init_QvdFileReader = __esm({
3971
4067
  this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3972
4068
  const selected = selectFields(headerFields, this._requestedFields, this._path);
3973
4069
  const columnCount = selected.length;
3974
- const symbolBytes = symbolBytesOf(selected, symbolTableLength);
4070
+ const symbolBytes = symbolBytesOf(this._fieldsHeldAfter(selected, headerFields), symbolTableLength);
4071
+ const readSymbolBytes = symbolBytesOf(this._fieldsReadBy(selected), symbolTableLength);
4072
+ const retainedBytes = symbolBytesOf(
4073
+ this._fieldsHeldAfter(selected, headerFields).slice(selected.length),
4074
+ symbolTableLength
4075
+ );
3975
4076
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3976
4077
  const windowRows = resolved.limit;
3977
4078
  if (headerNumbersUsable && this._headerMatchesFile) {
@@ -3985,11 +4086,12 @@ var init_QvdFileReader = __esm({
3985
4086
  this._materialisesRows,
3986
4087
  liveRows,
3987
4088
  this._bytesHeld(
3988
- symbolBytes,
4089
+ readSymbolBytes,
3989
4090
  windowRows,
3990
4091
  recordSize,
3991
4092
  liveRows,
3992
- this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)
4093
+ this._analysisAhead(window, resolved, totalRows, symbolTableLength),
4094
+ symbolBytes - retainedBytes
3993
4095
  ),
3994
4096
  // What it reads, which is not what it holds - the records go through one buffer and are not
3995
4097
  // kept. Carried so that a refusal's `check` says everything the pre-flight would have said.
@@ -3997,7 +4099,12 @@ var init_QvdFileReader = __esm({
3997
4099
  // Twice over where the symbol-usage pass is still ahead of it: that pass reads the window's
3998
4100
  // records to find which symbols the rows use, and the decode then reads them again. Counted
3999
4101
  // once, the figure understated the I/O of exactly the reads that do the most of it.
4000
- symbolBytes + readPasses(this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)) * windowRows * recordSize
4102
+ readSymbolBytes + readPasses(this._analysisAhead(window, resolved, totalRows, symbolTableLength)) * windowRows * recordSize,
4103
+ // A paging read keeps whole columns, so the estimate must not discount its symbols as a window's
4104
+ // sample of them - see `estimateMemoryUsage`. Under-charging is the direction that ends in a
4105
+ // heap-limit abort rather than an error.
4106
+ this._symbolCache !== null,
4107
+ retainedBytes
4001
4108
  );
4002
4109
  }
4003
4110
  if (window.offset === 0 && window.limit === null) {
@@ -4005,7 +4112,7 @@ var init_QvdFileReader = __esm({
4005
4112
  return;
4006
4113
  }
4007
4114
  const rowsToLoad = windowRows;
4008
- validateSymbolTableSizeEarly(symbolBytes, this._path);
4115
+ validateSymbolTableSizeEarly(symbolBytes, this._path, retainedBytes);
4009
4116
  validateRecordSize(recordSize, this._path, "readData");
4010
4117
  validateRecordCount(totalRows, this._path, "readData");
4011
4118
  const fileBytesRequired = indexTableOffset + (resolved.offset + rowsToLoad) * recordSize;
@@ -4117,9 +4224,14 @@ var init_QvdFileReader = __esm({
4117
4224
  * would hold.
4118
4225
  * @private
4119
4226
  */
4120
- _bytesHeld(symbolBytes, windowRows, recordSize, liveRows, analysisAhead) {
4227
+ _bytesHeld(symbolBytes, windowRows, recordSize, liveRows, analysisAhead, freshSymbolBytes = null) {
4121
4228
  return {
4122
4229
  held: this._bytesHeldBy(symbolBytes, this._recordsAtOnce(windowRows, liveRows, analysisAhead), recordSize),
4230
+ // What it would hold having closed the file and opened it again: every selected column read fresh,
4231
+ // because nothing is cached any more. Higher than `held`, not lower - a cached column this read
4232
+ // selects costs nothing to read now and would cost its area then. Without it the counterfactual
4233
+ // that decides whether releasing helps was answered against the warm figure and said yes too often.
4234
+ afterRelease: freshSymbolBytes === null ? null : this._bytesHeldBy(freshSymbolBytes, this._recordsAtOnce(windowRows, liveRows, analysisAhead), recordSize),
4123
4235
  // A window of so many rows reads so many records at a time, and the pass that reads it ahead of the
4124
4236
  // decode reads the same rows, so the buffer is sized from the rows either way.
4125
4237
  forRows: /* @__PURE__ */ __name((rows) => this._bytesHeldBy(symbolBytes, rows, recordSize), "forRows"),
@@ -4182,6 +4294,191 @@ var init_QvdFileReader = __esm({
4182
4294
  const chunkRows = liveRows === null ? windowRows : Math.max(1, Math.floor(liveRows.rows / Math.max(1, liveRows.perChunk)));
4183
4295
  return analysisAhead ? Math.max(windowRows, chunkRows) : chunkRows;
4184
4296
  }
4297
+ /**
4298
+ * The fields this reader will be holding the symbols of once this read has finished.
4299
+ *
4300
+ * The ones it selects, and - while paging - the ones it decoded for an earlier page and kept. That
4301
+ * union is what the memory checks have to be sized by, because it is what is live: a reader four
4302
+ * pages into a wide file holds four columns' values whether or not this page asks about them, and a
4303
+ * check sized by this page alone would approve a fifth column that does not fit beside them.
4304
+ *
4305
+ * The same set for every check, so the ceilings, the guard and the pre-flight cannot disagree about
4306
+ * what a paging read costs. Without a cache it is just the selection, which is what every one-shot
4307
+ * read has always been sized by.
4308
+ *
4309
+ * @param {Array<any>} selected The fields this read selects.
4310
+ * @param {Array<any>} all Every field in the header, to find a cached one by name.
4311
+ * @return {Array<any>} The fields whose symbols will be live.
4312
+ * @private
4313
+ */
4314
+ _fieldsHeldAfter(selected, all) {
4315
+ if (this._symbolCache === null || this._symbolCache.size === 0) {
4316
+ return selected;
4317
+ }
4318
+ const names = new Set(selected.map((field) => field["FieldName"]));
4319
+ const cached = all.filter(
4320
+ (field) => !names.has(field["FieldName"]) && this._symbolCache !== null && this._symbolCache.has(field["FieldName"])
4321
+ );
4322
+ return [...selected, ...cached];
4323
+ }
4324
+ /**
4325
+ * The fields whose symbol areas this read will actually read.
4326
+ *
4327
+ * The selection, less anything already decoded and kept. `_fieldsHeldAfter` answers what the read will
4328
+ * be *holding*, which is the right figure for the heap; this is the right one for the bytes it buffers
4329
+ * while parsing and for the I/O it reports, because a cached column's area is left out of the plan
4330
+ * entirely and never read.
4331
+ *
4332
+ * Sized by the wrong one of the two, a warm page was charged external bytes for buffers it never
4333
+ * allocates - and external bytes bind against a container limit, so a page that fits could be refused -
4334
+ * and `estimate.readBytes` claimed I/O it does not perform: on four columns with three cached it
4335
+ * reported 3,155,600 bytes for a read of 788,930.
4336
+ *
4337
+ * @param {Array<any>} selected The fields this read selects.
4338
+ * @return {Array<any>} The fields whose areas will be read.
4339
+ * @private
4340
+ */
4341
+ _fieldsReadBy(selected) {
4342
+ if (this._symbolCache === null || this._symbolCache.size === 0) {
4343
+ return selected;
4344
+ }
4345
+ return selected.filter(
4346
+ (field) => this._symbolCache !== null && !this._symbolCache.has(field["FieldName"])
4347
+ );
4348
+ }
4349
+ /**
4350
+ * Keeps what this reader decodes, so that a later read of the same file does not decode it again.
4351
+ *
4352
+ * For a caller reading one file many times over - a `QvdFile` and its pages - and off by default,
4353
+ * because every other entry point is one read and would only be holding values nobody will ask for
4354
+ * again. Decoding the symbols is 72% of a page of a hundred rows from a 300,000-row file; the rest is
4355
+ * the open, the header, the records and the rows.
4356
+ *
4357
+ * It turns the two-pass symbol path off with it. That path decodes only the symbols a window's rows
4358
+ * use, which is right for one read and wrong for a cache: a later page asking for a row that uses a
4359
+ * skipped symbol would read `undefined` where the value is. So a cached field is always a whole
4360
+ * field, walked and checked against its `NoOfSymbols` like any other.
4361
+ *
4362
+ * @return {void}
4363
+ */
4364
+ beginPaging() {
4365
+ this._symbolCache = /* @__PURE__ */ new Map();
4366
+ }
4367
+ /**
4368
+ * Reads with the fields the caller names for this read alone, rather than the reader's own.
4369
+ *
4370
+ * A `QvdFile` is opened once and its pages may each name a projection, so the selection cannot be
4371
+ * fixed at construction as it is for every other entry point.
4372
+ *
4373
+ * It holds until the next call replaces it rather than being cleared by the read, so **every caller
4374
+ * sets it before every read**, passing the file's own fields where the page named none. A caller that
4375
+ * relied on it being empty would instead get the projection of whatever ran last: that is what made a
4376
+ * `check()` naming no fields answer for the previous page's columns.
4377
+ *
4378
+ * @param {Array<string>|null|undefined} fields The fields, or undefined to use the reader's own.
4379
+ * @return {void}
4380
+ */
4381
+ selectForNextRead(fields) {
4382
+ if (fields !== void 0) {
4383
+ this._requestedFields = fields;
4384
+ }
4385
+ }
4386
+ /**
4387
+ * Watches the next read with the caller's `onProgress` and `signal`, rather than the reader's own.
4388
+ *
4389
+ * Both belong to one call, and a reader is told them when it is built - so a `QvdFile` page that named
4390
+ * either used to get a reader of its own. That made passing a progress callback change what the read
4391
+ * did rather than only observing it: a fresh reader is not paging, so it took the two-pass symbol
4392
+ * path, reported a different `loadStats.symbolFiltering`, and cached nothing. An observer must not
4393
+ * change what it observes, and a caller must not have to choose between cancelling a page and paging
4394
+ * cheaply.
4395
+ *
4396
+ * Like `selectForNextRead`, it holds until the next call replaces it rather than being cleared by the
4397
+ * read, so a caller that sets it for one page and not the next is still watched on the next - pass the
4398
+ * file's own watchers explicitly, as `QvdFile._page` does, rather than leaving them out.
4399
+ *
4400
+ * @param {{onProgress?: Function, signal?: AbortSignal}} [watchers] What this read is watched with.
4401
+ * @return {void}
4402
+ */
4403
+ observeNextRead({ onProgress, signal } = {}) {
4404
+ validateWatchers(onProgress, signal, this._path);
4405
+ this._onProgress = onProgress;
4406
+ this._signal = signal;
4407
+ }
4408
+ /**
4409
+ * Whether the symbol-usage pass runs for this read, cache and all.
4410
+ *
4411
+ * `_analysisWouldRun` answers whether the window wants the pass; a paging read never takes it, because
4412
+ * a column decoded in part cannot be kept. Asked in one place because it was asked in two and they
4413
+ * disagreed: the pass was gated on the cache while the memory charge and `estimate.readBytes` were
4414
+ * not, so every page of a file above the threshold was charged a slice of records it never buffered
4415
+ * and reported twice the bytes it read.
4416
+ *
4417
+ * @param {QvdRowWindow} window The window as the caller spelled it.
4418
+ * @param {{offset: number, limit: number}} resolved Where it lands in this file.
4419
+ * @param {number} totalRows Rows the file declares.
4420
+ * @param {number} symbolTableLength The symbol table's declared length.
4421
+ * @return {boolean} Whether the pass will run.
4422
+ * @private
4423
+ */
4424
+ _analysisAhead(window, resolved, totalRows, symbolTableLength) {
4425
+ return this._symbolCache === null && this._analysisWouldRun(window, resolved, totalRows, symbolTableLength);
4426
+ }
4427
+ /**
4428
+ * Empties the cache when the header in front of us is not the one it was decoded from.
4429
+ *
4430
+ * The fingerprint is what a rewrite moves: the file's identity on disk - device, inode, modification
4431
+ * time and size - and then the header numbers, down to each cached field's own offset, length and
4432
+ * symbol count.
4433
+ *
4434
+ * The header numbers alone were not enough, and the gap is not exotic. `QvdFileWriter` carries
4435
+ * `CreateUtcTime` over from the metadata it is handed, so reading a QVD, changing one text to another
4436
+ * of the same byte length and writing it back leaves `CreateUtcTime`, `NoOfRecords`, `Offset` and
4437
+ * every field's `Offset`, `Length` and `NoOfSymbols` exactly as they were - a different file the
4438
+ * fingerprint could not tell from the first. The filesystem sees it either way: an atomic write
4439
+ * renames a new file into place, which changes the inode, and an in-place one moves `mtimeMs`.
4440
+ *
4441
+ * @return {void}
4442
+ * @private
4443
+ */
4444
+ _forgetCacheIfFileChanged() {
4445
+ if (this._symbolCache === null || this._symbolCache.size === 0) {
4446
+ return;
4447
+ }
4448
+ if (this._cachedFor !== this._fileFingerprint()) {
4449
+ this._symbolCache = /* @__PURE__ */ new Map();
4450
+ this._cachedFor = null;
4451
+ }
4452
+ }
4453
+ /**
4454
+ * What identifies the file this reader's cache was decoded from.
4455
+ *
4456
+ * @return {string} The fingerprint.
4457
+ * @private
4458
+ */
4459
+ _fileFingerprint() {
4460
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
4461
+ const header = this._header["QvdTableHeader"];
4462
+ return [
4463
+ // First, because it is the only part that moves when a rewrite preserves the header's numbers.
4464
+ this._fileIdentity,
4465
+ header["CreateUtcTime"],
4466
+ header["NoOfRecords"],
4467
+ header["Offset"],
4468
+ ...this._allFields.map(
4469
+ (field) => `${field["FieldName"]}:${field["Offset"]}:${field["Length"]}:${field["NoOfSymbols"]}`
4470
+ )
4471
+ ].join("|");
4472
+ }
4473
+ /**
4474
+ * Drops everything this reader has decoded, so that nothing outlives the caller that wanted it.
4475
+ *
4476
+ * @return {void}
4477
+ */
4478
+ endPaging() {
4479
+ this._symbolCache = null;
4480
+ this._cachedFor = null;
4481
+ }
4185
4482
  /**
4186
4483
  * The symbol table's length, as much of it as the file holds: what the header declares, cut short where
4187
4484
  * the file ends. Known before a byte of the table is read, so everything that can refuse the table is
@@ -4210,7 +4507,13 @@ var init_QvdFileReader = __esm({
4210
4507
  * order, so ranges that touch are merged: a read of every field is one range, and so is a read of fields
4211
4508
  * that happen to be neighbours. A read of one field of twenty reads that field's area alone.
4212
4509
  *
4213
- * Built once per read, from the fields the read selected, and each field's metadata is checked as it is
4510
+ * A field whose symbols this reader already holds is left out, because its bytes are not wanted: the
4511
+ * ranges are what gets read, and including a cached field's span had a page read every byte of every
4512
+ * column it named, cached or not. Measured on four columns of 20,000 distinct texts, a page naming all
4513
+ * four with three of them cached read all four columns' bytes - 1,155,600 of them, where 288,930 were
4514
+ * needed. The decode was saved and the I/O was not, which on the files #122 is about is the whole cost.
4515
+ *
4516
+ * Built once per read, from the fields the read must read, and each field's metadata is checked as it is
4214
4517
  * added - a range is arithmetic on `Offset` and `Length`, and those have to be inside the table first.
4215
4518
  * `_parseSymbolTable` checks every field of the file, selected or not, before it parses any.
4216
4519
  *
@@ -4225,7 +4528,10 @@ var init_QvdFileReader = __esm({
4225
4528
  }
4226
4529
  assert4(this._selectedFields, "The QVD file fields have not been resolved before their symbols were read.");
4227
4530
  const tableLength = this._symbolTableLength();
4228
- const areas = this._selectedFields.map((field) => {
4531
+ const toRead = this._selectedFields.filter(
4532
+ (field) => this._symbolCache === null || !this._symbolCache.has(field["FieldName"])
4533
+ );
4534
+ const areas = toRead.map((field) => {
4229
4535
  validateFieldMetadata(field, tableLength, this._path);
4230
4536
  const start = headerInteger(field["Offset"]);
4231
4537
  return { field, start, end: start + headerInteger(field["Length"]) };
@@ -4547,9 +4853,12 @@ var init_QvdFileReader = __esm({
4547
4853
  }
4548
4854
  const allFields = this._allFields;
4549
4855
  const fields = this._selectedFields;
4856
+ this._forgetCacheIfFileChanged();
4550
4857
  const symbolTableSize = this._symbolTableLength();
4551
4858
  const plan = this._symbolAreaPlan();
4552
- const symbolBytes = plan.ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
4859
+ const readSymbolBytes = plan.ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
4860
+ const retainedBytes = symbolBytesOf(this._fieldsHeldAfter(fields, allFields).slice(fields.length), symbolTableSize);
4861
+ const symbolBytes = readSymbolBytes + retainedBytes;
4553
4862
  const totalRows = headerInteger(this._header["QvdTableHeader"]["NoOfRecords"]);
4554
4863
  const recordSize = headerInteger(this._header["QvdTableHeader"]["RecordByteSize"]);
4555
4864
  validateSymbolTableSize(symbolBytes, this._path, totalRows);
@@ -4563,13 +4872,22 @@ var init_QvdFileReader = __esm({
4563
4872
  fields.length,
4564
4873
  this._materialisesRows,
4565
4874
  liveRows,
4566
- this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false),
4875
+ this._bytesHeld(readSymbolBytes, rowsToLoad, recordSize, liveRows, false, symbolBytes - retainedBytes),
4567
4876
  // `symbolsToKeep` is non-null exactly when the symbol-usage pass has run, and a pass that has
4568
4877
  // run has read the window's records once already - so the read's total is two passes over them.
4569
- symbolBytes + readPasses(symbolsToKeep !== null) * rowsToLoad * recordSize
4878
+ readSymbolBytes + readPasses(symbolsToKeep !== null) * rowsToLoad * recordSize,
4879
+ this._symbolCache !== null,
4880
+ retainedBytes
4570
4881
  );
4571
4882
  }
4572
- warnLargeSymbolTable(symbolBytes, rowsToLoad, totalRows, fields.length, this._materialisesRows);
4883
+ warnLargeSymbolTable(
4884
+ symbolBytes,
4885
+ rowsToLoad,
4886
+ totalRows,
4887
+ fields.length,
4888
+ this._materialisesRows,
4889
+ this._symbolCache !== null
4890
+ );
4573
4891
  for (const field of allFields) {
4574
4892
  validateFieldMetadata(field, symbolTableSize, this._path);
4575
4893
  }
@@ -4577,24 +4895,36 @@ var init_QvdFileReader = __esm({
4577
4895
  const symbolTable = [];
4578
4896
  for (const [position, field] of fields.entries()) {
4579
4897
  this._throwIfAborted();
4898
+ const cached = this._symbolCache?.get(field["FieldName"]);
4899
+ if (cached) {
4900
+ symbolTable.push(cached);
4901
+ this._emitProgress("symbol-table", position + 1, fields.length);
4902
+ continue;
4903
+ }
4580
4904
  const area = await this._symbolAreaOf(field);
4581
- symbolTable.push(
4582
- parseFieldSymbols(
4583
- area.buffer,
4584
- area.start,
4585
- area.end,
4586
- // Checked against the symbols the area holds, which is the one check that sees a terminator
4587
- // damaged in the middle of it (#124).
4588
- headerInteger(field["NoOfSymbols"]),
4589
- // By position, matching how `_analyzeIndexTableSymbolUsage` built it. Both walk
4590
- // `this._selectedFields`, so position is the one key that cannot collide.
4591
- symbolsToKeep ? symbolsToKeep[position] : null,
4592
- field["FieldName"],
4593
- this._path,
4594
- void 0,
4595
- area.base
4596
- )
4905
+ const parsed = parseFieldSymbols(
4906
+ area.buffer,
4907
+ area.start,
4908
+ area.end,
4909
+ // Checked against the symbols the area holds, which is the one check that sees a terminator
4910
+ // damaged in the middle of it (#124). A cached field was checked when it was decoded, which is
4911
+ // why the cache may only hold a field a full walk produced.
4912
+ headerInteger(field["NoOfSymbols"]),
4913
+ // By position, matching how `_analyzeIndexTableSymbolUsage` built it. Both walk
4914
+ // `this._selectedFields`, so position is the one key that cannot collide.
4915
+ symbolsToKeep ? symbolsToKeep[position] : null,
4916
+ field["FieldName"],
4917
+ this._path,
4918
+ void 0,
4919
+ area.base
4597
4920
  );
4921
+ symbolTable.push(parsed);
4922
+ if (this._symbolCache && symbolsToKeep === null) {
4923
+ if (this._symbolCache.size === 0) {
4924
+ this._cachedFor = this._fileFingerprint();
4925
+ }
4926
+ this._symbolCache.set(field["FieldName"], parsed);
4927
+ }
4598
4928
  this._releaseSymbolArea(field);
4599
4929
  this._emitProgress("symbol-table", position + 1, fields.length);
4600
4930
  }
@@ -4689,28 +5019,39 @@ var init_QvdFileReader = __esm({
4689
5019
  await this._parseHeader();
4690
5020
  this._emitProgress("header", 1, 1);
4691
5021
  this._throwIfAborted();
4692
- assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
4693
- const header = this._header["QvdTableHeader"];
4694
- const columns = this._allFields.map((field) => field["FieldName"]);
4695
- const rowCount = headerInteger(header["NoOfRecords"]);
4696
- validateRecordCount(rowCount, this._path, "readMetadata");
4697
- const shape = new QvdDataFrame([], columns, header, {
4698
- symbolTableBytes: headerInteger(header["Offset"]),
4699
- totalRows: rowCount,
4700
- rowsLoaded: 0,
4701
- symbolFiltering: false,
4702
- symbolsKept: null
4703
- });
4704
- return {
4705
- columns,
4706
- rowCount,
4707
- columnCount: columns.length,
4708
- fields: columns.map((name) => shape.getFieldMetadata(name)),
4709
- fileMetadata: shape.fileMetadata,
4710
- metadata: header
4711
- };
5022
+ return this.describeParsed();
4712
5023
  });
4713
5024
  }
5025
+ /**
5026
+ * The schema and header of the file this reader has parsed, as `readMetadata()` reports them.
5027
+ *
5028
+ * Built from the parsed header and nothing else, so a caller holding a header - a `QvdFile` - can have
5029
+ * it without reading the file a second time.
5030
+ *
5031
+ * @return {any} The metadata.
5032
+ */
5033
+ describeParsed() {
5034
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
5035
+ const header = this._header["QvdTableHeader"];
5036
+ const columns = this._allFields.map((field) => field["FieldName"]);
5037
+ const rowCount = headerInteger(header["NoOfRecords"]);
5038
+ validateRecordCount(rowCount, this._path, "readMetadata");
5039
+ const shape = new QvdDataFrame([], columns, header, {
5040
+ symbolTableBytes: headerInteger(header["Offset"]),
5041
+ totalRows: rowCount,
5042
+ rowsLoaded: 0,
5043
+ symbolFiltering: false,
5044
+ symbolsKept: null
5045
+ });
5046
+ return {
5047
+ columns,
5048
+ rowCount,
5049
+ columnCount: columns.length,
5050
+ fields: columns.map((name) => shape.getFieldMetadata(name)),
5051
+ fileMetadata: shape.fileMetadata,
5052
+ metadata: header
5053
+ };
5054
+ }
4714
5055
  /**
4715
5056
  * What a read of this file would cost, and whether it fits, without reading it.
4716
5057
  *
@@ -4728,77 +5069,127 @@ var init_QvdFileReader = __esm({
4728
5069
  async checkRead(rawWindow, { chunkSize = null } = {}) {
4729
5070
  const window = normaliseWindow(rawWindow, this._path);
4730
5071
  return await this._closingAfter(async () => {
4731
- await this._readData({ offset: 0, limit: null }, true);
4732
- this._emitProgress("header", 0, 1);
4733
- await this._parseHeader();
4734
- this._emitProgress("header", 1, 1);
4735
- this._throwIfAborted();
4736
- assert4(
4737
- this._header && this._selectedFields && this._allFields && this._symbolTableOffset !== null,
4738
- "The QVD file header has not been parsed."
5072
+ await this._parseHeaderChecked();
5073
+ return this.checkParsed(window, { chunkSize });
5074
+ });
5075
+ }
5076
+ /**
5077
+ * Reads this file's header, and nothing else, leaving it parsed on the reader.
5078
+ *
5079
+ * What `checkRead` and `QvdFile` both start with: the second asks many questions of one header, so the
5080
+ * read that produces it is separate from the questions. Every check a read makes before it trusts the
5081
+ * header's numbers is made here, so that nothing downstream has to wonder whether they hold.
5082
+ *
5083
+ * @return {Promise<void>} When the header is parsed and checked.
5084
+ */
5085
+ async parseHeaderOnly() {
5086
+ return await this._closingAfter(async () => await this._parseHeaderChecked());
5087
+ }
5088
+ /**
5089
+ * `parseHeaderOnly`'s body, for a caller already inside a read session - `checkRead` is one.
5090
+ *
5091
+ * @return {Promise<void>} When the header is parsed and checked.
5092
+ * @private
5093
+ */
5094
+ async _parseHeaderChecked() {
5095
+ await this._readData({ offset: 0, limit: null }, true);
5096
+ this._emitProgress("header", 0, 1);
5097
+ await this._parseHeader();
5098
+ this._emitProgress("header", 1, 1);
5099
+ this._throwIfAborted();
5100
+ assert4(
5101
+ this._header && this._selectedFields && this._allFields && this._symbolTableOffset !== null,
5102
+ "The QVD file header has not been parsed."
5103
+ );
5104
+ const header = this._header["QvdTableHeader"];
5105
+ const totalRows = headerInteger(header["NoOfRecords"]);
5106
+ const recordSize = headerInteger(header["RecordByteSize"]);
5107
+ const symbolTableLength = headerInteger(header["Offset"]);
5108
+ validateRecordSize(recordSize, this._path, "checkRead");
5109
+ validateRecordCount(totalRows, this._path, "checkRead");
5110
+ if (!this._headerMatchesFile) {
5111
+ throw new QvdCorruptedError("The file is shorter than its header claims.", {
5112
+ file: this._path,
5113
+ fileSize: this._fileSize,
5114
+ requiredBytes: this._symbolTableOffset + symbolTableLength + totalRows * recordSize,
5115
+ stage: "checkRead"
5116
+ });
5117
+ }
5118
+ const tableLength = this._symbolTableLength();
5119
+ for (const field of this._allFields) {
5120
+ validateFieldMetadata(field, tableLength, this._path);
5121
+ validateFieldBitMetadata(field, recordSize, this._path);
5122
+ }
5123
+ validateSymbolAreas(this._allFields, this._path);
5124
+ }
5125
+ /**
5126
+ * What a read of the parsed header's file would cost, with no I/O at all.
5127
+ *
5128
+ * Separate from `checkRead` because a `QvdFile` asks this of one header many times - once per page a
5129
+ * viewer scrolls to - and the header is already in hand. `parseHeaderOnly` has to have run.
5130
+ *
5131
+ * @param {QvdRowWindow} window The rows the read would cover, normalised.
5132
+ * @param {{chunkSize?: number|null, fields?: Array<string>|null, materialisesRows?: boolean}} [options]
5133
+ * `chunkSize` for an `iterate()`; `fields` and `materialisesRows` to ask about a read other than the
5134
+ * one this reader was built for, which is what a `QvdFile` does per call.
5135
+ * @return {any} The answer - see `checkMemory`.
5136
+ */
5137
+ checkParsed(window, { chunkSize = null, fields = void 0, materialisesRows = void 0 } = {}) {
5138
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
5139
+ const header = this._header["QvdTableHeader"];
5140
+ const totalRows = headerInteger(header["NoOfRecords"]);
5141
+ const recordSize = headerInteger(header["RecordByteSize"]);
5142
+ const symbolTableLength = headerInteger(header["Offset"]);
5143
+ const builds = materialisesRows === void 0 ? this._materialisesRows : materialisesRows;
5144
+ const selected = selectFields(this._allFields, fields === void 0 ? this._requestedFields : fields, this._path);
5145
+ const resolved = resolveWindow(window, totalRows);
5146
+ const windowRows = resolved.limit;
5147
+ const liveRows = chunkSize === null ? null : { rows: chunkSize * 2, perChunk: 2 };
5148
+ const analysisAhead = this._analysisAhead(window, resolved, totalRows, symbolTableLength);
5149
+ const measured = getMemoryBudget();
5150
+ const ask = /* @__PURE__ */ __name((asked, rows) => {
5151
+ const bytes = symbolBytesOf(this._fieldsHeldAfter(asked, this._allFields), symbolTableLength);
5152
+ const read = symbolBytesOf(this._fieldsReadBy(asked), symbolTableLength);
5153
+ const retained = symbolBytesOf(
5154
+ this._fieldsHeldAfter(asked, this._allFields).slice(asked.length),
5155
+ symbolTableLength
4739
5156
  );
4740
- const header = this._header["QvdTableHeader"];
4741
- const totalRows = headerInteger(header["NoOfRecords"]);
4742
- const recordSize = headerInteger(header["RecordByteSize"]);
4743
- const symbolTableLength = headerInteger(header["Offset"]);
4744
- const selected = this._selectedFields;
4745
- validateRecordSize(recordSize, this._path, "checkRead");
4746
- validateRecordCount(totalRows, this._path, "checkRead");
4747
- if (!this._headerMatchesFile) {
4748
- throw new QvdCorruptedError("The file is shorter than its header claims.", {
4749
- file: this._path,
4750
- fileSize: this._fileSize,
4751
- requiredBytes: this._symbolTableOffset + symbolTableLength + totalRows * recordSize,
4752
- stage: "checkRead"
4753
- });
4754
- }
4755
- const tableLength = this._symbolTableLength();
4756
- for (const field of this._allFields) {
4757
- validateFieldMetadata(field, tableLength, this._path);
4758
- validateFieldBitMetadata(field, recordSize, this._path);
4759
- }
4760
- validateSymbolAreas(this._allFields, this._path);
4761
- const resolved = resolveWindow(window, totalRows);
4762
- const windowRows = resolved.limit;
4763
- const liveRows = chunkSize === null ? null : { rows: chunkSize * 2, perChunk: 2 };
4764
- const analysisAhead = this._analysisWouldRun(window, resolved, totalRows, symbolTableLength);
4765
- const measured = getMemoryBudget();
4766
- const ask = /* @__PURE__ */ __name((fields, rows) => {
4767
- const bytes = symbolBytesOf(fields, symbolTableLength);
4768
- return checkMemory({
4769
- measured,
4770
- symbolTableSize: bytes,
4771
- maxRows: rows,
4772
- totalRows,
4773
- safetyFactor: this._memorySafetyFactor,
4774
- columnCount: fields.length,
4775
- materialisesRows: this._materialisesRows,
4776
- live: liveRows,
4777
- bytesHeld: this._bytesHeld(bytes, rows, recordSize, liveRows, analysisAhead),
4778
- // What it reads from the file, which is not what it holds: the symbol areas, and every record
4779
- // the window covers, read a slice at a time and not kept - twice over where the symbol-usage
4780
- // pass will run, since it reads them before the decode reads them again.
4781
- readBytes: bytes + readPasses(analysisAhead) * windowRows * recordSize
4782
- });
4783
- }, "ask");
4784
- const answer = ask(selected, windowRows);
4785
- if (!answer.fits && selected.length > 1) {
4786
- const bySize = [...selected].sort(
4787
- (a, b) => headerInteger(a["Length"]) - headerInteger(b["Length"])
4788
- );
4789
- for (let take = selected.length - 1; take >= 1; take -= 1) {
4790
- const fewer = bySize.slice(0, take);
4791
- if (ask(fewer, windowRows).fits) {
4792
- answer.suggestions.push({
4793
- option: "fields",
4794
- value: fewer.map((field) => field["FieldName"])
4795
- });
4796
- break;
4797
- }
5157
+ return checkMemory({
5158
+ measured,
5159
+ symbolTableSize: bytes,
5160
+ maxRows: rows,
5161
+ totalRows,
5162
+ safetyFactor: this._memorySafetyFactor,
5163
+ columnCount: asked.length,
5164
+ materialisesRows: builds,
5165
+ live: liveRows,
5166
+ // A paging read keeps whole columns, so it is charged for whole columns - see `estimateMemoryUsage`.
5167
+ wholeSymbols: this._symbolCache !== null,
5168
+ retainedBytes: retained,
5169
+ bytesHeld: this._bytesHeld(read, rows, recordSize, liveRows, analysisAhead, bytes - retained),
5170
+ // What it reads from the file, which is not what it holds: the symbol areas it has still to read,
5171
+ // and every record the window covers, read a slice at a time and not kept - twice over where the
5172
+ // symbol-usage pass will run, since it reads them before the decode reads them again.
5173
+ readBytes: read + readPasses(analysisAhead) * windowRows * recordSize
5174
+ });
5175
+ }, "ask");
5176
+ const answer = ask(selected, windowRows);
5177
+ if (!answer.fits && selected.length > 1) {
5178
+ const bySize = [...selected].sort(
5179
+ (a, b) => headerInteger(a["Length"]) - headerInteger(b["Length"])
5180
+ );
5181
+ for (let take = selected.length - 1; take >= 1; take -= 1) {
5182
+ const fewer = bySize.slice(0, take);
5183
+ if (ask(fewer, windowRows).fits) {
5184
+ answer.suggestions.push({
5185
+ option: "fields",
5186
+ value: fewer.map((field) => field["FieldName"])
5187
+ });
5188
+ break;
4798
5189
  }
4799
5190
  }
4800
- return answer;
4801
- });
5191
+ }
5192
+ return answer;
4802
5193
  }
4803
5194
  /**
4804
5195
  * Loads the QVD file into memory and parses it.
@@ -4964,7 +5355,7 @@ var init_QvdFileReader = __esm({
4964
5355
  const rowsAvailable = resolved.limit;
4965
5356
  let symbolsToKeep = null;
4966
5357
  let symbolsKept = null;
4967
- if (this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)) {
5358
+ if (this._analysisAhead(window, resolved, totalRows, symbolTableLength)) {
4968
5359
  symbolsToKeep = await this._analyzeIndexTableSymbolUsage({ offset: resolved.offset, limit: rowsAvailable });
4969
5360
  symbolsKept = symbolsToKeep.reduce((sum, set) => sum + set.size, 0);
4970
5361
  }
@@ -5056,6 +5447,224 @@ var init_QvdFileReader = __esm({
5056
5447
  }
5057
5448
  });
5058
5449
 
5450
+ // src/QvdFile.js
5451
+ var QvdFile_exports = {};
5452
+ __export(QvdFile_exports, {
5453
+ QvdFile: () => QvdFile
5454
+ });
5455
+ var QvdFile;
5456
+ var init_QvdFile = __esm({
5457
+ "src/QvdFile.js"() {
5458
+ init_QvdErrors();
5459
+ init_readOptions();
5460
+ QvdFile = class {
5461
+ static {
5462
+ __name(this, "QvdFile");
5463
+ }
5464
+ /**
5465
+ * Not called directly - `QvdDataFrame.open()` is the way in, because a `QvdFile` is only ever a file
5466
+ * whose header has been read, and a constructor cannot wait for that.
5467
+ *
5468
+ * @param {any} reader The reader holding the parsed header.
5469
+ * @param {any} metadata What `readMetadata()` returns for this file.
5470
+ * @param {any} options The options the file was opened with.
5471
+ * @private
5472
+ */
5473
+ constructor(reader, metadata, options) {
5474
+ this._reader = reader;
5475
+ this._metadata = metadata;
5476
+ this._options = options;
5477
+ this._closed = false;
5478
+ this._tail = Promise.resolve();
5479
+ this._readers = { rows: null, columns: null };
5480
+ }
5481
+ /**
5482
+ * The file's header and schema, as `QvdDataFrame.readMetadata()` returns them.
5483
+ *
5484
+ * Read when the file was opened, so this costs nothing and cannot fail.
5485
+ *
5486
+ * @return {any} The metadata.
5487
+ */
5488
+ get metadata() {
5489
+ return this._metadata;
5490
+ }
5491
+ /**
5492
+ * Whether `close()` has been called.
5493
+ *
5494
+ * @return {boolean} True once it has.
5495
+ */
5496
+ get closed() {
5497
+ return this._closed;
5498
+ }
5499
+ /**
5500
+ * What a read of this file would cost, and whether it fits - with no I/O at all.
5501
+ *
5502
+ * The same answer `QvdDataFrame.checkRead()` gives, from the header this file already holds, so a
5503
+ * viewer can size a page before asking for it without touching the disk.
5504
+ *
5505
+ * @param {{offset?: number, limit?: number|null, maxRows?: number|null, fields?: Array<string>|null,
5506
+ * as?: 'rows'|'columns', chunkSize?: number|null}} [options] The read being asked about - the same
5507
+ * bag `rows()` takes, plus `as` and `chunkSize` to say which shape of read it is.
5508
+ * @return {any} The answer - `fits`, `reason`, `estimate`, `budget`, `exact`, `suggestions`.
5509
+ * @throws {QvdValidationError} If the file is closed, or an option's value is not valid.
5510
+ */
5511
+ check(options = {}) {
5512
+ this._refuseWhenClosed("check");
5513
+ const { as = "rows", chunkSize = null } = options;
5514
+ if (as !== "rows" && as !== "columns") {
5515
+ throw new QvdValidationError("as must be 'rows' or 'columns'", {
5516
+ provided: as,
5517
+ reason: "option",
5518
+ option: "as",
5519
+ value: as,
5520
+ file: this._options.path
5521
+ });
5522
+ }
5523
+ if (chunkSize !== null) {
5524
+ requireChunkSize(chunkSize, this._options.path);
5525
+ }
5526
+ const reader = this._readers[as] ?? this._reader;
5527
+ return reader.checkParsed(normaliseWindow(windowFrom(options), this._options.path), {
5528
+ chunkSize,
5529
+ // Resolved here rather than left to the reader, exactly as `_page` resolves it. A warm reader is
5530
+ // still holding the last page's selection, and `checkParsed` falls back to it - so a `check()`
5531
+ // naming no fields answered for whatever the previous page happened to name. On a four-column
5532
+ // file after a page naming one of them, it reported 27,490 bytes for a read that costs 108,160:
5533
+ // understating, which is the direction that approves a read the read then refuses.
5534
+ fields: options.fields === void 0 ? this._options.fields ?? null : options.fields,
5535
+ materialisesRows: as === "rows"
5536
+ });
5537
+ }
5538
+ /**
5539
+ * Reads a page of rows.
5540
+ *
5541
+ * @param {{offset?: number, limit?: number|null, maxRows?: number|null, fields?: Array<string>|null,
5542
+ * onProgress?: Function, signal?: AbortSignal}} [options] The page, and how to read it. One bag,
5543
+ * as every other entry point takes: `offset` and `limit` say which rows, `fields` names a projection
5544
+ * for this page alone, and anything left out falls back to what the file was opened with.
5545
+ * @return {Promise<any>} The page, as a `QvdDataFrame`.
5546
+ * @throws {QvdValidationError} If the file is closed.
5547
+ */
5548
+ async rows(options = {}) {
5549
+ this._refuseWhenClosed("rows");
5550
+ return await this._serialised(async () => await this._page(options, true, (reader, window) => reader.load(window)));
5551
+ }
5552
+ /**
5553
+ * Reads a page as columns, building no rows.
5554
+ *
5555
+ * @param {{offset?: number, limit?: number|null, maxRows?: number|null, fields?: Array<string>|null,
5556
+ * onProgress?: Function, signal?: AbortSignal}} [options] The page, as `rows()` takes it.
5557
+ * @return {Promise<any>} The page, as a `QvdColumnTable`.
5558
+ * @throws {QvdValidationError} If the file is closed.
5559
+ */
5560
+ async columns(options = {}) {
5561
+ this._refuseWhenClosed("columns");
5562
+ return await this._serialised(
5563
+ async () => await this._page(options, false, (reader, window) => reader.loadColumnar(window))
5564
+ );
5565
+ }
5566
+ /**
5567
+ * Closes the file.
5568
+ *
5569
+ * Every call after it is refused with `reason: 'closed'`. Calling it twice is not an error: a
5570
+ * `finally` that closes and an `await using` that closes are both right, and both may run.
5571
+ *
5572
+ * @return {Promise<void>} When the pages already in flight have finished.
5573
+ */
5574
+ async close() {
5575
+ if (this._closed) {
5576
+ return;
5577
+ }
5578
+ this._closed = true;
5579
+ await this._tail;
5580
+ for (const reader of Object.values(this._readers)) {
5581
+ reader?.endPaging();
5582
+ }
5583
+ this._readers = { rows: null, columns: null };
5584
+ this._reader.endPaging();
5585
+ this._reader = null;
5586
+ }
5587
+ /**
5588
+ * `await using` support, where the runtime has it.
5589
+ *
5590
+ * @return {Promise<void>} When closed.
5591
+ */
5592
+ async [Symbol.asyncDispose]() {
5593
+ await this.close();
5594
+ }
5595
+ /**
5596
+ * Refuses a call on a closed file, in the vocabulary the rest of the API uses.
5597
+ *
5598
+ * @param {string} call The method the caller reached for, for the error.
5599
+ * @private
5600
+ */
5601
+ _refuseWhenClosed(call) {
5602
+ if (this._closed) {
5603
+ throw new QvdValidationError("The file is closed: open it again to read from it", {
5604
+ reason: "closed",
5605
+ call,
5606
+ file: this._options.path
5607
+ });
5608
+ }
5609
+ }
5610
+ /**
5611
+ * Runs `work` after everything asked for before it, and before everything asked for after.
5612
+ *
5613
+ * @param {() => Promise<any>} work The page to read.
5614
+ * @return {Promise<any>} Its result.
5615
+ * @private
5616
+ */
5617
+ async _serialised(work) {
5618
+ const run = this._tail.then(work, work);
5619
+ this._tail = run.then(
5620
+ () => void 0,
5621
+ () => void 0
5622
+ );
5623
+ return await run;
5624
+ }
5625
+ /**
5626
+ * Reads one page, through the reader that keeps what the pages before it decoded.
5627
+ *
5628
+ * One reader for every page rather than one per page, which is what makes the symbol cache possible:
5629
+ * decoding the symbols is 72% of a page of a hundred rows from a 300,000-row file, and a reader built
5630
+ * fresh each time did all of it again. Two readers, because a columnar page builds no rows and a row
5631
+ * page does, and `materialisesRows` is fixed when a reader is constructed - so each shape keeps its
5632
+ * own, and its own cache.
5633
+ *
5634
+ * Options that belong to one call rather than to the file - the fields this page alone wants, and the
5635
+ * `onProgress` and `signal` watching it - are told to that shared reader for the next read and no
5636
+ * further. A page naming one of them used to build a reader of its own instead, which quietly turned
5637
+ * the cache off and the two-pass symbol path on: watching a page changed what the page did.
5638
+ *
5639
+ * @param {any} options What the call passed.
5640
+ * @param {boolean} builds Whether the page materialises rows.
5641
+ * @param {(reader: any, window: any) => Promise<any>} read The read to make.
5642
+ * @return {Promise<any>} The page.
5643
+ * @private
5644
+ */
5645
+ async _page(options, builds, read) {
5646
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
5647
+ const kept = builds ? "rows" : "columns";
5648
+ if (!this._readers[kept]) {
5649
+ const reader2 = new QvdFileReader2(this._options.path, {
5650
+ ...readerOptionsFrom(this._options),
5651
+ materialisesRows: builds
5652
+ });
5653
+ reader2.beginPaging();
5654
+ this._readers[kept] = reader2;
5655
+ }
5656
+ const reader = this._readers[kept];
5657
+ reader.selectForNextRead(options.fields === void 0 ? this._options.fields ?? null : options.fields);
5658
+ reader.observeNextRead({
5659
+ onProgress: options.onProgress ?? this._options.onProgress,
5660
+ signal: options.signal ?? this._options.signal
5661
+ });
5662
+ return await read(reader, windowFrom(options));
5663
+ }
5664
+ };
5665
+ }
5666
+ });
5667
+
5059
5668
  // src/QvdDataFrame.js
5060
5669
  function defaultFieldHeader(fieldName) {
5061
5670
  return {
@@ -5866,6 +6475,49 @@ var init_QvdDataFrame = __esm({
5866
6475
  const reader = new QvdFileReader2(path5, { ...readerOptionsFrom(options), materialisesRows: as === "rows" });
5867
6476
  return await reader.checkRead(windowFrom(options), { chunkSize });
5868
6477
  }
6478
+ /**
6479
+ * Opens a QVD file for paging, reading its header and nothing else.
6480
+ *
6481
+ * Every other entry point is one read from start to finish. A viewer showing a hundred rows at a time
6482
+ * pays the header again on every page, and `iterate()` goes forwards only - it cannot jump to row five
6483
+ * million and it cannot go back. This holds the header so that `check()` costs nothing and a page can
6484
+ * be asked for by position.
6485
+ *
6486
+ * ```js
6487
+ * const qvd = await QvdDataFrame.open('sales.qvd', {allowedDir: '/data'});
6488
+ *
6489
+ * qvd.metadata; // read once, when it opened
6490
+ * const answer = qvd.check({offset: 0, limit: 100}); // no I/O at all
6491
+ * const page = await qvd.rows({offset: 5_000_000, limit: 100});
6492
+ * const cols = await qvd.columns({offset: 0, limit: 100, fields: ['Amount']});
6493
+ *
6494
+ * await qvd.close();
6495
+ * ```
6496
+ *
6497
+ * The header is read once, and so is each column: a column decoded for one page is kept for the pages
6498
+ * after it, so the first page costs about what a single read costs and the ones after it are cheap.
6499
+ * What a file has decoded is charged to the memory check, so a page is refused rather than the process
6500
+ * aborting, and `close()` releases it - close a file you have finished with.
6501
+ *
6502
+ * Each page still opens the file, and a first touch still decodes a whole column rather than only as
6503
+ * far as the page needs.
6504
+ *
6505
+ * @param {string} path The QVD file.
6506
+ * @param {object} [options] What `fromQvd()` takes - `allowedDir`, `fields`, `duals`,
6507
+ * `coerceNumericStrings`, `memorySafetyFactor` - describing the file and how its values read. A
6508
+ * window means nothing here: pages carry their own.
6509
+ * @return {Promise<import('./QvdFile.js').QvdFile>} The open file.
6510
+ * @throws {QvdValidationError} If an option's value is not valid, with `context.reason` of `option`.
6511
+ * @throws {QvdCorruptedError} If the header cannot be read, or describes a file this is not.
6512
+ */
6513
+ static async open(path5, options = {}) {
6514
+ const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
6515
+ const { QvdFile: QvdFile2 } = await Promise.resolve().then(() => (init_QvdFile(), QvdFile_exports));
6516
+ const reader = new QvdFileReader2(path5, readerOptionsFrom(options));
6517
+ reader.beginPaging();
6518
+ await reader.parseHeaderOnly();
6519
+ return new QvdFile2(reader, reader.describeParsed(), { ...options, path: path5 });
6520
+ }
5869
6521
  /**
5870
6522
  * Constructs a data frame from a dictionary.
5871
6523
  *
@@ -6170,10 +6822,11 @@ __name(dateToQlikSerial, "dateToQlikSerial");
6170
6822
  // src/index.js
6171
6823
  init_QvdDataFrame();
6172
6824
  init_QvdColumnTable();
6825
+ init_QvdFile();
6173
6826
  init_QvdFileReader();
6174
6827
  init_QvdFileWriter();
6175
6828
  init_QvdErrors();
6176
6829
 
6177
- export { QvdColumn, QvdColumnTable, QvdCorruptedError, QvdDataFrame, QvdDual, QvdError, QvdFileReader, QvdFileWriter, QvdIOError, QvdParseError, QvdSecurityError, QvdSymbol, QvdValidationError, dateToQlikSerial, qlikSerialToDate };
6830
+ export { QvdColumn, QvdColumnTable, QvdCorruptedError, QvdDataFrame, QvdDual, QvdError, QvdFile, QvdFileReader, QvdFileWriter, QvdIOError, QvdParseError, QvdSecurityError, QvdSymbol, QvdValidationError, dateToQlikSerial, qlikSerialToDate };
6178
6831
  //# sourceMappingURL=index.js.map
6179
6832
  //# sourceMappingURL=index.js.map