qvdjs 2.0.5 → 2.0.6

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
@@ -1,6 +1,6 @@
1
1
  import fs2 from 'fs';
2
2
  import path2 from 'path';
3
- import assert3 from 'assert';
3
+ import assert4 from 'assert';
4
4
  import crypto2 from 'crypto';
5
5
  import { setTimeout } from 'timers/promises';
6
6
  import xml from 'xml2js';
@@ -959,7 +959,7 @@ function changedAfterCheck(checked, change) {
959
959
  });
960
960
  }
961
961
  async function openChecked(checked, purpose, failed, { nofollow = NOFOLLOW } = {}) {
962
- assert3(purpose === "read" || checked.stats !== null, "A rewrite in place is of a file that exists.");
962
+ assert4(purpose === "read" || checked.stats !== null, "A rewrite in place is of a file that exists.");
963
963
  const noFollow = checked.onDisk ? nofollow : 0;
964
964
  const flags = (purpose === "rewrite" ? O_WRONLY : O_RDONLY) | noFollow;
965
965
  let handle;
@@ -1535,9 +1535,9 @@ var init_QvdFileWriter = __esm({
1535
1535
  * Writes the data to the QVD file.
1536
1536
  */
1537
1537
  async _writeData() {
1538
- assert3(this._header, "The QVD file header has not been parsed.");
1539
- assert3(this._symbolBuffer, "The QVD file symbol table has not been parsed.");
1540
- assert3(this._indexBuffer, "The QVD file index table has not been parsed.");
1538
+ assert4(this._header, "The QVD file header has not been parsed.");
1539
+ assert4(this._symbolBuffer, "The QVD file symbol table has not been parsed.");
1540
+ assert4(this._indexBuffer, "The QVD file index table has not been parsed.");
1541
1541
  this._emitProgress("write", 0, 1);
1542
1542
  const headerBuffer = Buffer.concat([Buffer.from(this._header, "utf-8"), Buffer.from([0])]);
1543
1543
  const failed = rethrowAsIoError(this._path, "write");
@@ -1985,7 +1985,7 @@ var init_QvdFileWriter = __esm({
1985
1985
  const key = keys[slot];
1986
1986
  offset = typeof key === "number" ? writeSymbol(columnBuffer, offset, kinds[slot], key, texts[slot]) : writeSymbol(columnBuffer, offset, kinds[slot], null, key);
1987
1987
  }
1988
- assert3(offset === byteLength, "A column was encoded into a different number of bytes than it was sized for.");
1988
+ assert4(offset === byteLength, "A column was encoded into a different number of bytes than it was sized for.");
1989
1989
  columnBuffers.push(columnBuffer);
1990
1990
  this._symbolTableMetadata?.push([symbolsOffset, byteLength, containsNull[column]]);
1991
1991
  this._symbolCounts?.push(keys.length);
@@ -2024,9 +2024,9 @@ var init_QvdFileWriter = __esm({
2024
2024
  * @private
2025
2025
  */
2026
2026
  _buildIndexTable() {
2027
- assert3(this._symbolCounts, "The QVD file symbol table has not been built.");
2028
- assert3(this._symbolTableMetadata, "The QVD file symbol table metadata has not been built.");
2029
- assert3(this._symbolIndexByValue, "The QVD file symbol index has not been built.");
2027
+ assert4(this._symbolCounts, "The QVD file symbol table has not been built.");
2028
+ assert4(this._symbolTableMetadata, "The QVD file symbol table metadata has not been built.");
2029
+ assert4(this._symbolIndexByValue, "The QVD file symbol index has not been built.");
2030
2030
  this._indexTableMetadata = [];
2031
2031
  const columns = this._df.columns;
2032
2032
  const data = this._df.data;
@@ -2206,7 +2206,7 @@ function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, col
2206
2206
  }
2207
2207
  return low;
2208
2208
  }
2209
- function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null) {
2209
+ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null, bytesHeld = 0) {
2210
2210
  if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
2211
2211
  throw new QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
2212
2212
  }
@@ -2219,7 +2219,10 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2219
2219
  const liveRowsPerChunk = live === null ? 1 : live.perChunk;
2220
2220
  const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
2221
2221
  const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows, rowsLive);
2222
- const externalMemory = estimateExternalMemory(liveRows, columnCount);
2222
+ const held = typeof bytesHeld === "number" ? bytesHeld : bytesHeld.held;
2223
+ const heldForRows = typeof bytesHeld === "number" ? () => bytesHeld : bytesHeld.forRows;
2224
+ const heldForChunk = typeof bytesHeld === "number" ? () => bytesHeld : bytesHeld.forChunk;
2225
+ const externalMemory = estimateExternalMemory(liveRows, columnCount) + held;
2223
2226
  const bounded = budget.candidates.map((candidate) => {
2224
2227
  const heapOnly = candidate.source === "V8 heap limit";
2225
2228
  return {
@@ -2241,14 +2244,19 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2241
2244
  const maxAllowedMemory = binding ? binding.allowed : budget.bytes * safetyFactor;
2242
2245
  if (binding) {
2243
2246
  const includeExternal = !binding.heapOnly;
2244
- const recommendedMaxRows = recommendedRowsFor(
2245
- maxAllowedMemory,
2247
+ const rowsHeldBudget = /* @__PURE__ */ __name((rows) => maxAllowedMemory - (includeExternal ? heldForRows(rows) : 0), "rowsHeldBudget");
2248
+ const fitting = /* @__PURE__ */ __name((rows) => recommendedRowsFor(
2249
+ rowsHeldBudget(rows),
2246
2250
  symbolTableSize,
2247
2251
  totalRows,
2248
2252
  columnCount,
2249
2253
  materialisesRows,
2250
2254
  includeExternal
2251
- );
2255
+ ), "fitting");
2256
+ const firstGuess = fitting(liveRows);
2257
+ const over = fitting(firstGuess);
2258
+ const under = fitting(over);
2259
+ const recommendedMaxRows = Math.max(firstGuess, under);
2252
2260
  const sizeMB = Math.round(symbolTableSize / 1024 / 1024);
2253
2261
  const estimatedMB = Math.round(estimatedMemory / 1024 / 1024);
2254
2262
  const availableMB = Math.round(maxAllowedMemory / 1024 / 1024);
@@ -2261,15 +2269,20 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
2261
2269
  const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
2262
2270
  const containerBound = binding.source === "container memory limit";
2263
2271
  const chunked = rowsLive !== null;
2264
- const recommendedChunk = chunked ? recommendedChunkFor(
2265
- maxAllowedMemory,
2272
+ const chunkHeldBudget = /* @__PURE__ */ __name((rows) => maxAllowedMemory - (includeExternal ? heldForChunk(rows) : 0), "chunkHeldBudget");
2273
+ const chunkFitting = /* @__PURE__ */ __name((rows) => recommendedChunkFor(
2274
+ chunkHeldBudget(rows),
2266
2275
  symbolTableSize,
2267
2276
  maxRows,
2268
2277
  totalRows,
2269
2278
  columnCount,
2270
2279
  liveRowsPerChunk,
2271
2280
  includeExternal
2272
- ) : 0;
2281
+ ), "chunkFitting");
2282
+ const callersChunk = chunked ? Math.max(1, Math.floor(rowsLive / Math.max(1, liveRowsPerChunk))) : 0;
2283
+ const firstChunk = chunked ? chunkFitting(callersChunk) : 0;
2284
+ const overChunk = chunked ? chunkFitting(firstChunk) : 0;
2285
+ const recommendedChunk = chunked ? Math.max(firstChunk, chunkFitting(overChunk)) : 0;
2273
2286
  const knob = chunked ? "chunkSize" : "limit";
2274
2287
  const recommendedValue = chunked ? recommendedChunk : recommendedMaxRows;
2275
2288
  const nothingFits = recommendedValue === 0;
@@ -2464,6 +2477,7 @@ function validateSymbolTableSize(symbolTableLength, filePath, totalRows) {
2464
2477
  function validateFieldMetadata(field, symbolBufferLength, filePath) {
2465
2478
  const symbolsOffset = headerInteger(field["Offset"]);
2466
2479
  const symbolsLength = headerInteger(field["Length"]);
2480
+ const symbolCount = headerInteger(field["NoOfSymbols"]);
2467
2481
  if (isNaN(symbolsOffset) || !Number.isSafeInteger(symbolsOffset) || symbolsOffset < 0) {
2468
2482
  throw new QvdCorruptedError("Invalid symbol offset", {
2469
2483
  field: field["FieldName"],
@@ -2480,6 +2494,14 @@ function validateFieldMetadata(field, symbolBufferLength, filePath) {
2480
2494
  stage: "parseSymbolTable"
2481
2495
  });
2482
2496
  }
2497
+ if (isNaN(symbolCount) || !Number.isSafeInteger(symbolCount) || symbolCount < 0) {
2498
+ throw new QvdCorruptedError("Invalid symbol count", {
2499
+ field: field["FieldName"],
2500
+ noOfSymbols: symbolCount,
2501
+ file: filePath,
2502
+ stage: "parseSymbolTable"
2503
+ });
2504
+ }
2483
2505
  if (symbolsOffset + symbolsLength > symbolBufferLength) {
2484
2506
  throw new QvdCorruptedError("Symbol data extends beyond buffer", {
2485
2507
  field: field["FieldName"],
@@ -2735,12 +2757,25 @@ var init_validationUtils = __esm({
2735
2757
  __name(validateFieldBitMetadata, "validateFieldBitMetadata");
2736
2758
  }
2737
2759
  });
2738
-
2739
- // src/util/symbolParser.js
2740
- function textEnd(symbolBuffer, from, kind, fieldName, filePath) {
2741
- const bufferLength = symbolBuffer.length;
2742
- const found = symbolBuffer.indexOf(0, from);
2743
- if ((found === -1 ? bufferLength : found) - from > MAX_TEXT_BYTES) {
2760
+ function nulFinder(area, { reach, rebaseAfter }) {
2761
+ assert4(reach - rebaseAfter > MAX_TEXT_BYTES, "A text search view must hold the longest text a symbol may have.");
2762
+ if (area.length <= reach) {
2763
+ return (from) => area.indexOf(0, from);
2764
+ }
2765
+ let base = 0;
2766
+ let view = area.subarray(0, reach);
2767
+ return (from) => {
2768
+ if (from - base > rebaseAfter) {
2769
+ base = from;
2770
+ view = area.subarray(base, Math.min(area.length, base + reach));
2771
+ }
2772
+ const found = view.indexOf(0, from - base);
2773
+ return found === -1 ? -1 : base + found;
2774
+ };
2775
+ }
2776
+ function textEnd(findNul, areaEnd, from, kind, fieldName, filePath, base) {
2777
+ const found = findNul(from);
2778
+ if ((found === -1 ? areaEnd : found) - from > MAX_TEXT_BYTES) {
2744
2779
  throw new QvdCorruptedError(`${kind} exceeds maximum length`, {
2745
2780
  field: fieldName,
2746
2781
  maxLength: MAX_TEXT_BYTES,
@@ -2751,58 +2786,59 @@ function textEnd(symbolBuffer, from, kind, fieldName, filePath) {
2751
2786
  if (found === -1) {
2752
2787
  throw new QvdCorruptedError(`${kind} not null-terminated`, {
2753
2788
  field: fieldName,
2754
- pointer: bufferLength,
2755
- bufferSize: bufferLength,
2789
+ pointer: base + from,
2790
+ areaEnd: base + areaEnd,
2756
2791
  file: filePath,
2757
2792
  stage: "parseSymbolTable"
2758
2793
  });
2759
2794
  }
2760
2795
  return found;
2761
2796
  }
2762
- function overflow(message, pointer, bufferLength, fieldName, filePath) {
2797
+ function overflow(message, pointer, areaEnd, fieldName, filePath, base) {
2763
2798
  throw new QvdCorruptedError(message, {
2764
2799
  field: fieldName,
2765
- pointer,
2766
- bufferSize: bufferLength,
2800
+ pointer: base + pointer,
2801
+ areaEnd: base + areaEnd,
2767
2802
  file: filePath,
2768
2803
  stage: "parseSymbolTable"
2769
2804
  });
2770
2805
  }
2771
- function parseFieldSymbols(symbolBuffer, start, end, keep, fieldName, filePath) {
2772
- const bufferLength = symbolBuffer.length;
2806
+ function parseFieldSymbols(symbolBuffer, start, end, symbolCount, keep, fieldName, filePath, search = TEXT_SEARCH, base = 0) {
2807
+ const area = symbolBuffer.subarray(0, end);
2808
+ const findNul = nulFinder(area, search);
2773
2809
  const numbers = [];
2774
2810
  const texts = [];
2775
2811
  let pointer = start;
2776
2812
  while (pointer < end) {
2777
- const typeByte = symbolBuffer[pointer++];
2813
+ const typeByte = area[pointer++];
2778
2814
  const decode = keep === null || keep.has(numbers.length);
2779
2815
  let number = null;
2780
2816
  let text = null;
2781
2817
  switch (typeByte) {
2782
2818
  case 1: {
2819
+ if (pointer + 4 > end) {
2820
+ overflow("Buffer overflow reading integer symbol", pointer, end, fieldName, filePath, base);
2821
+ }
2783
2822
  if (decode) {
2784
- if (pointer + 4 > bufferLength) {
2785
- overflow("Buffer overflow reading integer symbol", pointer, bufferLength, fieldName, filePath);
2786
- }
2787
- number = symbolBuffer.readInt32LE(pointer);
2823
+ number = area.readInt32LE(pointer);
2788
2824
  }
2789
2825
  pointer += 4;
2790
2826
  break;
2791
2827
  }
2792
2828
  case 2: {
2829
+ if (pointer + 8 > end) {
2830
+ overflow("Buffer overflow reading double symbol", pointer, end, fieldName, filePath, base);
2831
+ }
2793
2832
  if (decode) {
2794
- if (pointer + 8 > bufferLength) {
2795
- overflow("Buffer overflow reading double symbol", pointer, bufferLength, fieldName, filePath);
2796
- }
2797
- number = symbolBuffer.readDoubleLE(pointer);
2833
+ number = area.readDoubleLE(pointer);
2798
2834
  }
2799
2835
  pointer += 8;
2800
2836
  break;
2801
2837
  }
2802
2838
  case 4: {
2803
- const terminator = textEnd(symbolBuffer, pointer, "String symbol", fieldName, filePath);
2839
+ const terminator = textEnd(findNul, end, pointer, "String symbol", fieldName, filePath, base);
2804
2840
  if (decode) {
2805
- text = symbolBuffer.toString("utf8", pointer, terminator);
2841
+ text = area.toString("utf8", pointer, terminator);
2806
2842
  }
2807
2843
  pointer = terminator + 1;
2808
2844
  break;
@@ -2810,14 +2846,22 @@ function parseFieldSymbols(symbolBuffer, start, end, keep, fieldName, filePath)
2810
2846
  case 5:
2811
2847
  case 6: {
2812
2848
  const numberBytes = typeByte === 5 ? 4 : 8;
2813
- if (pointer + numberBytes > bufferLength) {
2814
- const read = !decode ? "dual symbol" : typeByte === 5 ? "dual integer symbol" : "dual double symbol";
2815
- overflow(`Buffer overflow reading ${read}`, pointer, bufferLength, fieldName, filePath);
2849
+ if (pointer + numberBytes > end) {
2850
+ const read = typeByte === 5 ? "dual integer symbol" : "dual double symbol";
2851
+ overflow(`Buffer overflow reading ${read}`, pointer, end, fieldName, filePath, base);
2816
2852
  }
2817
- const terminator = textEnd(symbolBuffer, pointer + numberBytes, "Dual string symbol", fieldName, filePath);
2853
+ const terminator = textEnd(
2854
+ findNul,
2855
+ end,
2856
+ pointer + numberBytes,
2857
+ "Dual string symbol",
2858
+ fieldName,
2859
+ filePath,
2860
+ base
2861
+ );
2818
2862
  if (decode) {
2819
- number = typeByte === 5 ? symbolBuffer.readInt32LE(pointer) : symbolBuffer.readDoubleLE(pointer);
2820
- text = symbolBuffer.toString("utf8", pointer + numberBytes, terminator);
2863
+ number = typeByte === 5 ? area.readInt32LE(pointer) : area.readDoubleLE(pointer);
2864
+ text = area.toString("utf8", pointer + numberBytes, terminator);
2821
2865
  }
2822
2866
  pointer = terminator + 1;
2823
2867
  break;
@@ -2825,7 +2869,7 @@ function parseFieldSymbols(symbolBuffer, start, end, keep, fieldName, filePath)
2825
2869
  default: {
2826
2870
  throw new QvdParseError("Unknown symbol type byte", {
2827
2871
  typeByte: typeByte.toString(16),
2828
- offset: pointer - 1,
2872
+ offset: base + pointer - 1,
2829
2873
  file: filePath,
2830
2874
  stage: "parseSymbolTable"
2831
2875
  });
@@ -2834,16 +2878,28 @@ function parseFieldSymbols(symbolBuffer, start, end, keep, fieldName, filePath)
2834
2878
  numbers.push(number);
2835
2879
  texts.push(text);
2836
2880
  }
2881
+ assert4(pointer === end, `The symbols of ${fieldName} were walked to byte ${pointer} of an area ending at ${end}.`);
2882
+ if (numbers.length !== symbolCount) {
2883
+ throw new QvdCorruptedError("Symbol count mismatch", {
2884
+ field: fieldName,
2885
+ symbolCount: numbers.length,
2886
+ noOfSymbols: symbolCount,
2887
+ file: filePath,
2888
+ stage: "parseSymbolTable"
2889
+ });
2890
+ }
2837
2891
  return { numbers, texts };
2838
2892
  }
2839
- function countFieldSymbols(symbolBuffer, start, end, fieldName, filePath) {
2840
- return parseFieldSymbols(symbolBuffer, start, end, DECODE_NOTHING, fieldName, filePath).numbers.length;
2893
+ function countFieldSymbols(symbolBuffer, start, end, symbolCount, fieldName, filePath, base = 0) {
2894
+ return parseFieldSymbols(symbolBuffer, start, end, symbolCount, DECODE_NOTHING, fieldName, filePath, void 0, base).numbers.length;
2841
2895
  }
2842
- var MAX_TEXT_BYTES, DECODE_NOTHING;
2896
+ var MAX_TEXT_BYTES, TEXT_SEARCH, DECODE_NOTHING;
2843
2897
  var init_symbolParser = __esm({
2844
2898
  "src/util/symbolParser.js"() {
2845
2899
  init_QvdErrors();
2846
2900
  MAX_TEXT_BYTES = 1048576;
2901
+ TEXT_SEARCH = Object.freeze({ reach: 2 ** 31 - 1, rebaseAfter: 2 ** 30 });
2902
+ __name(nulFinder, "nulFinder");
2847
2903
  __name(textEnd, "textEnd");
2848
2904
  __name(overflow, "overflow");
2849
2905
  __name(parseFieldSymbols, "parseFieldSymbols");
@@ -3387,7 +3443,7 @@ async function parseHeaderXml(text, file, stage) {
3387
3443
  }
3388
3444
  return parsed;
3389
3445
  }
3390
- var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, COUNT_SYMBOLS_PAST, QvdFileReader;
3446
+ var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS, SLICE_BYTES, COUNT_SYMBOLS_PAST, QvdFileReader;
3391
3447
  var init_QvdFileReader = __esm({
3392
3448
  "src/QvdFileReader.js"() {
3393
3449
  init_QvdDataFrame();
@@ -3405,6 +3461,7 @@ var init_QvdFileReader = __esm({
3405
3461
  MAX_HEADER_SIZE = 16 * 1024 * 1024;
3406
3462
  READ_CHUNK_SIZE = 512 * 1024 * 1024;
3407
3463
  ANALYSIS_SLICE_ROWS = 65536;
3464
+ SLICE_BYTES = 16 * 1024 * 1024;
3408
3465
  COUNT_SYMBOLS_PAST = 65536;
3409
3466
  __name(chunksFrom, "chunksFrom");
3410
3467
  __name(parseHeaderXml, "parseHeaderXml");
@@ -3436,6 +3493,10 @@ var init_QvdFileReader = __esm({
3436
3493
  * above which a lazy load switches to the two-pass filtering path. The default of 50MB is
3437
3494
  * the point where the extra analysis pass pays for itself; lower it to use filtering on
3438
3495
  * smaller files, raise it to keep the simpler single-pass read for longer.
3496
+ * @param {number} [options.sliceBytes=16777216] The most bytes of records a read holds at a time. An
3497
+ * option rather than a constant for the reason `symbolFilteringThreshold` is one: so that a test can
3498
+ * cross the boundaries between slices in a small file. There is no other reason to change it, and it
3499
+ * is not one of the options a read through `QvdDataFrame` or `QvdColumnTable` passes on.
3439
3500
  * @param {Array<string>|null} [options.fields] Field names to read, in the order they should
3440
3501
  * appear. Null reads every field, in file order. An unknown or repeated name is refused.
3441
3502
  * @param {'number'|'text'|'both'} [options.duals='number'] What a dual symbol - a number with the
@@ -3460,6 +3521,7 @@ var init_QvdFileReader = __esm({
3460
3521
  allowedDir,
3461
3522
  memorySafetyFactor = 0.8,
3462
3523
  symbolFilteringThreshold = 50 * 1024 * 1024,
3524
+ sliceBytes = SLICE_BYTES,
3463
3525
  materialisesRows = true,
3464
3526
  fields = null,
3465
3527
  duals,
@@ -3475,6 +3537,14 @@ var init_QvdFileReader = __esm({
3475
3537
  this._coerceNumericStrings = normaliseCoerceNumericStrings(coerceNumericStrings, this._path);
3476
3538
  this._memorySafetyFactor = memorySafetyFactor;
3477
3539
  this._symbolFilteringThreshold = symbolFilteringThreshold;
3540
+ if (!Number.isSafeInteger(sliceBytes) || sliceBytes <= 0) {
3541
+ throw new QvdValidationError("sliceBytes must be a positive integer", {
3542
+ provided: sliceBytes,
3543
+ type: typeof sliceBytes,
3544
+ file: this._path
3545
+ });
3546
+ }
3547
+ this._sliceBytes = sliceBytes;
3478
3548
  if (onProgress !== void 0 && typeof onProgress !== "function") {
3479
3549
  throw new QvdValidationError("onProgress must be a function", {
3480
3550
  provided: onProgress,
@@ -3492,7 +3562,11 @@ var init_QvdFileReader = __esm({
3492
3562
  this._requestedFields = fields === void 0 ? null : fields;
3493
3563
  this._onProgress = onProgress;
3494
3564
  this._signal = signal;
3495
- this._buffer = null;
3565
+ this._headerBuffer = null;
3566
+ this._handle = null;
3567
+ this._failed = null;
3568
+ this._reading = false;
3569
+ this._symbolAreas = null;
3496
3570
  this._headerOffset = null;
3497
3571
  this._symbolTableOffset = null;
3498
3572
  this._indexTableOffset = null;
@@ -3503,7 +3577,6 @@ var init_QvdFileReader = __esm({
3503
3577
  this._symbolTable = null;
3504
3578
  this._indexColumns = null;
3505
3579
  this._rowsDecoded = 0;
3506
- this._bufferFirstRow = 0;
3507
3580
  this._fileSize = null;
3508
3581
  this._headerMatchesFile = false;
3509
3582
  }
@@ -3542,54 +3615,137 @@ var init_QvdFileReader = __esm({
3542
3615
  }
3543
3616
  }
3544
3617
  /**
3545
- * Reads the binary data of the QVD file.
3546
- *
3547
- * A windowed read - anything with `offset`, `limit` or `maxRows` - reads only the bytes it
3548
- * needs, rather than the file. Measured on `chicago_taxi_rides_2016_01.qvd`, 1,705,805 rows
3549
- * over 20 fields: the last thousand rows take 19 ms against 636 ms for the whole file.
3550
- *
3551
- * The saving is in the index table and the rows, not in the symbol table, which is read in
3552
- * full whatever the window because a stored index in any row can address any symbol. So the
3553
- * gain scales with how much of the file is rows: on a file whose bytes are mostly distinct
3554
- * values there is very little to save, which is what `symbolFilteringThreshold` and the
3555
- * two-pass path exist for.
3556
- *
3557
- * Algorithm for a windowed read:
3558
- * 1. Read the file a chunk at a time until the XML header delimiter is found
3559
- * 2. Parse header to determine symbol table and index table locations
3560
- * 3. Calculate bytes needed: header + full symbol table + partial index table
3561
- * 4. Read only those calculated bytes, by position
3562
- * 5. Rest of parsing proceeds normally with limited data
3563
- *
3564
- * WHY THIS APPROACH:
3565
- * - Symbol table must be fully loaded (contains all unique values)
3566
- * - Index table can be partially loaded (only rows we need)
3567
- * - Reading chunks to find the header is efficient for unknown header sizes
3568
- * - Direct byte-range reading for remaining data is fastest
3618
+ * Opens the file and reads its header, and for a read of rows checks that the read can be made.
3619
+ *
3620
+ * Nothing past the header is read here. The selected fields' symbols and the records are read by position
3621
+ * as they are parsed - see `_symbolAreaOf` and `_forEachSlice` - so no read holds the file in one buffer
3622
+ * (#122). A read of rows therefore leaves the file open, and whatever started the read closes it with
3623
+ * `_closeFile` once the last record it needs is decoded; a header-only read closes it here.
3624
+ *
3625
+ * A windowed read - anything with `offset`, `limit` or `maxRows` - reads the header, the symbols of the
3626
+ * fields it selects, and the window's records, and no byte between. Measured on
3627
+ * `chicago_taxi_rides_2016_01.qvd`, 1,705,805 rows over 20 fields: the last thousand rows take 19 ms
3628
+ * against 636 ms for the whole file. A selected field's area is read in full whatever the window,
3629
+ * because a stored index in any row can address any of that field's symbols; the areas of the fields
3630
+ * `fields` leaves out are not read at all. So a window's gain scales with how much of the file is
3631
+ * rows: on a file whose bytes are mostly distinct values of the fields it reads there is very little
3632
+ * to save, which is what `symbolFilteringThreshold` and the two-pass path exist for.
3569
3633
  *
3570
3634
  * All of it goes through one handle, opened once, on the file the containment check approved. See
3571
3635
  * `chunksFrom` and `openChecked` for why a read no longer opens the path more than once.
3572
3636
  *
3573
- * A window with a non-zero `offset` reads two ranges rather than one: the header and symbol
3574
- * table from the front of the file, and the window's records from wherever they sit. The bytes
3575
- * between are never read, which is what makes `{offset: 1_700_000, limit: 100}` on the taxi
3576
- * fixture a 0.4MB read rather than a 38MB one.
3577
- *
3578
3637
  * @param {QvdRowWindow} window The rows to read.
3579
- * @param {boolean} [headerOnly=false] Stop once the XML header has been read, leaving the
3580
- * symbol and index tables on disk. This is the metadata-only path: the header is a few
3581
- * kilobytes whatever the file's size, so reading a schema costs the same for a 40MB file as
3582
- * for a 40GB one.
3638
+ * @param {boolean} [headerOnly=false] Stop once the XML header has been read, and close the file. This
3639
+ * is the metadata-only path: the header is a few kilobytes whatever the file's size, so reading a
3640
+ * schema costs the same for a 40MB file as for a 40GB one.
3583
3641
  * @param {{rows: number, perChunk: number}|null} [liveRows=null] Rows held at one instant when
3584
3642
  * that is fewer than the window covers - see `_prepare`.
3585
3643
  * @private
3586
3644
  */
3587
3645
  async _readData(window = { offset: 0, limit: null }, headerOnly = false, liveRows = null) {
3646
+ assert4(this._reading, "A read opens the QVD file only once it has started, through _startRead.");
3647
+ this._symbolTable = null;
3648
+ this._indexColumns = null;
3649
+ this._rowsDecoded = 0;
3588
3650
  this._throwIfAborted();
3589
3651
  this._emitProgress("read", 0, 1);
3590
3652
  const failed = rethrowAsIoError(this._path, "read");
3591
3653
  const handle = await openChecked(checkPath(this._path, this._allowedDir), "read", failed);
3592
- await closeAfter(handle, failed, () => this._readFrom(handle, window, headerOnly, liveRows, failed));
3654
+ if (headerOnly) {
3655
+ await closeAfter(handle, failed, () => this._readFrom(handle, window, true, liveRows, failed));
3656
+ return;
3657
+ }
3658
+ this._handle = handle;
3659
+ this._failed = failed;
3660
+ try {
3661
+ await this._readFrom(handle, window, false, liveRows, failed);
3662
+ } catch (error) {
3663
+ await this._closeFile(true);
3664
+ throw error;
3665
+ }
3666
+ }
3667
+ /**
3668
+ * Starts a read on this reader, refusing it while another is under way.
3669
+ *
3670
+ * A read of rows holds the file, and what it has read of it, on the reader until it ends. A second read
3671
+ * started meanwhile - `load()` while an iteration is suspended, say - would take over that state and
3672
+ * leave the first read's file open. Reads one after another are fine. Called before a read takes charge
3673
+ * of closing the file, so that refusing the second read cannot close the first one's.
3674
+ *
3675
+ * The first thing every read does, and synchronous: the flag is set before the read's first `await`, so
3676
+ * two reads started together - `Promise.all([reader.load(), reader.load()])` - cannot both pass. The check
3677
+ * used to be of the handle, which is set only once the file has opened, and both did: the second read's
3678
+ * handle replaced the first's, which was never closed, and the first read to finish closed the file the
3679
+ * other was still reading.
3680
+ *
3681
+ * @throws {QvdValidationError} If a read is under way.
3682
+ * @private
3683
+ */
3684
+ _startRead() {
3685
+ if (this._reading) {
3686
+ throw new QvdValidationError("The reader is already reading this file: finish that read first", {
3687
+ file: this._path
3688
+ });
3689
+ }
3690
+ this._reading = true;
3691
+ }
3692
+ /**
3693
+ * Ends the read `_startRead` began: closes its file, if it still holds one, and lets the next read start.
3694
+ *
3695
+ * @param {boolean} failing Whether the read is already throwing - see `_closeFile`.
3696
+ * @private
3697
+ */
3698
+ async _endRead(failing) {
3699
+ try {
3700
+ await this._closeFile(failing);
3701
+ } finally {
3702
+ this._reading = false;
3703
+ }
3704
+ }
3705
+ /**
3706
+ * Closes the file a read of rows opened, and drops what it had read of it.
3707
+ *
3708
+ * The rule `closeAfter` follows: a close that fails is reported only when the read succeeded, so it can
3709
+ * never replace the error that says what went wrong. After a successful read it is the only news.
3710
+ *
3711
+ * @param {boolean} failing Whether the read is already throwing.
3712
+ * @private
3713
+ */
3714
+ async _closeFile(failing) {
3715
+ const handle = this._handle;
3716
+ const failed = this._failed;
3717
+ this._handle = null;
3718
+ this._failed = null;
3719
+ this._symbolAreas = null;
3720
+ if (handle === null || failed === null) {
3721
+ return;
3722
+ }
3723
+ if (failing) {
3724
+ await handle.close().catch(() => {
3725
+ });
3726
+ return;
3727
+ }
3728
+ await handle.close().catch(failed);
3729
+ }
3730
+ /**
3731
+ * Runs a read, the only one under way on this reader, and closes its file when it ends, however it ends.
3732
+ *
3733
+ * @template T
3734
+ * @param {() => Promise<T>} read The read, from opening the file to its last record.
3735
+ * @return {Promise<T>} What it returned.
3736
+ * @private
3737
+ */
3738
+ async _closingAfter(read) {
3739
+ this._startRead();
3740
+ let result;
3741
+ try {
3742
+ result = await read();
3743
+ } catch (error) {
3744
+ await this._endRead(true);
3745
+ throw error;
3746
+ }
3747
+ await this._endRead(false);
3748
+ return result;
3593
3749
  }
3594
3750
  /**
3595
3751
  * Reads what `_readData` was asked for, through the handle it opened.
@@ -3651,50 +3807,57 @@ var init_QvdFileReader = __esm({
3651
3807
  const recordSize = headerInteger(headerObj["QvdTableHeader"]["RecordByteSize"]);
3652
3808
  const totalRows = headerInteger(headerObj["QvdTableHeader"]["NoOfRecords"]);
3653
3809
  if (headerOnly) {
3654
- this._buffer = headerBuffer.subarray(0, headerEndIndex);
3810
+ this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3655
3811
  this._emitProgress("read", 1, 1);
3656
3812
  return;
3657
3813
  }
3658
- const columnCount = selectFields(headerFields, this._requestedFields, this._path).length;
3814
+ this._headerBuffer = headerBuffer.subarray(0, headerEndIndex);
3815
+ const selected = selectFields(headerFields, this._requestedFields, this._path);
3816
+ const columnCount = selected.length;
3817
+ const areaBytes = selected.map((field) => headerInteger(field["Length"]));
3818
+ const symbolBytes = areaBytes.every((bytes) => Number.isSafeInteger(bytes) && bytes >= 0) ? Math.min(
3819
+ symbolTableLength,
3820
+ areaBytes.reduce((sum, bytes) => sum + bytes, 0)
3821
+ ) : symbolTableLength;
3659
3822
  const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
3660
3823
  (value) => Number.isSafeInteger(value) && value >= 0
3661
3824
  );
3825
+ const { size: fileSize } = await handle.stat().catch(failed);
3826
+ this._fileSize = fileSize;
3827
+ this._headerMatchesFile = false;
3662
3828
  if (headerNumbersUsable) {
3663
- const { size: fileSize2 } = await handle.stat().catch(failed);
3664
- this._fileSize = fileSize2;
3665
- this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize2;
3829
+ this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
3666
3830
  }
3667
3831
  const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
3668
3832
  const windowRows = resolved.limit;
3669
3833
  if (headerNumbersUsable && this._headerMatchesFile) {
3670
3834
  validateMemoryAvailability(
3671
- symbolTableLength,
3835
+ symbolBytes,
3672
3836
  windowRows,
3673
3837
  totalRows,
3674
3838
  this._path,
3675
3839
  this._memorySafetyFactor,
3676
3840
  columnCount,
3677
3841
  this._materialisesRows,
3678
- liveRows
3842
+ liveRows,
3843
+ this._bytesHeld(
3844
+ symbolBytes,
3845
+ windowRows,
3846
+ recordSize,
3847
+ liveRows,
3848
+ this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)
3849
+ )
3679
3850
  );
3680
3851
  }
3681
3852
  if (window.offset === 0 && window.limit === null) {
3682
- this._buffer = await handle.readFile().catch(failed);
3683
- this._fileSize = this._buffer.length;
3684
- this._bufferFirstRow = 0;
3685
3853
  this._emitProgress("read", 1, 1);
3686
3854
  return;
3687
3855
  }
3688
3856
  const rowsToLoad = windowRows;
3689
- validateSymbolTableSizeEarly(symbolTableLength, this._path);
3857
+ validateSymbolTableSizeEarly(symbolBytes, this._path);
3690
3858
  validateRecordSize(recordSize, this._path, "readData");
3691
3859
  validateRecordCount(totalRows, this._path, "readData");
3692
- const skippedIndexBytes = resolved.offset * recordSize;
3693
- const indexTableBytesToRead = rowsToLoad * recordSize;
3694
- const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
3695
- const fileBytesRequired = indexTableOffset + skippedIndexBytes + indexTableBytesToRead;
3696
- const { size: fileSize } = await handle.stat().catch(failed);
3697
- this._fileSize = fileSize;
3860
+ const fileBytesRequired = indexTableOffset + (resolved.offset + rowsToLoad) * recordSize;
3698
3861
  if (fileBytesRequired > fileSize) {
3699
3862
  throw new QvdCorruptedError("The file is shorter than its header claims.", {
3700
3863
  file: this._path,
@@ -3703,47 +3866,35 @@ var init_QvdFileReader = __esm({
3703
3866
  stage: "readData"
3704
3867
  });
3705
3868
  }
3706
- this._buffer = Buffer.alloc(totalBytesToRead);
3707
- await this._readRange(handle, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
3708
- if (indexTableBytesToRead > 0) {
3709
- await this._readRange(
3710
- handle,
3711
- indexTableOffset,
3712
- indexTableBytesToRead,
3713
- indexTableOffset + skippedIndexBytes,
3714
- fileSize,
3715
- fileBytesRequired
3716
- );
3717
- }
3718
- this._bufferFirstRow = resolved.offset;
3719
3869
  this._emitProgress("read", 1, 1);
3720
3870
  }
3721
3871
  /**
3722
- * Reads one byte range of the file into the buffer.
3872
+ * Reads one byte range of the open file into a buffer.
3723
3873
  *
3724
3874
  * Read in bounded chunks, checking bytesRead each time. A single fs.read call with a length of
3725
3875
  * 2^31 or more does not throw - it trips a C++ assertion and aborts the whole process, which no
3726
3876
  * try/catch can intercept.
3727
3877
  *
3728
- * @param {import('fs/promises').FileHandle} fd The open file.
3729
- * @param {number} bufferOffset Where in the buffer to write.
3878
+ * @param {Buffer} target The buffer to read into.
3879
+ * @param {number} targetOffset Where in it to write.
3730
3880
  * @param {number} byteCount How many bytes to read.
3731
3881
  * @param {number} filePosition Where in the file to read from.
3732
- * @param {number} fileSize The file's size, for the error.
3733
- * @param {number} requiredBytes Bytes the whole read needs, for the error.
3882
+ * @param {number} requiredBytes How far into the file the read has to reach, for the error.
3883
+ * @throws {QvdCorruptedError} If the file ends before the range does.
3734
3884
  * @private
3735
3885
  */
3736
- async _readRange(fd, bufferOffset, byteCount, filePosition, fileSize, requiredBytes) {
3737
- assert3(this._buffer, "The read buffer has not been allocated.");
3738
- const failed = rethrowAsIoError(this._path, "read");
3886
+ async _readAt(target, targetOffset, byteCount, filePosition, requiredBytes) {
3887
+ assert4(this._handle && this._failed, "The QVD file is not open.");
3888
+ const handle = this._handle;
3889
+ const failed = this._failed;
3739
3890
  let done = 0;
3740
3891
  while (done < byteCount) {
3741
3892
  const length = Math.min(READ_CHUNK_SIZE, byteCount - done);
3742
- const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done).catch(failed);
3893
+ const { bytesRead } = await handle.read(target, targetOffset + done, length, filePosition + done).catch(failed);
3743
3894
  if (bytesRead === 0) {
3744
3895
  throw new QvdCorruptedError("Unexpected end of file while reading QVD data.", {
3745
3896
  file: this._path,
3746
- fileSize,
3897
+ fileSize: this._fileSize,
3747
3898
  // Two numbers, because they stopped being the same one when a window began reading two
3748
3899
  // ranges: `bytesRead` is how much of this range arrived, `filePosition` is where in the
3749
3900
  // file it gave up. Reporting the position under the name of the count made a windowed
@@ -3758,12 +3909,262 @@ var init_QvdFileReader = __esm({
3758
3909
  done += bytesRead;
3759
3910
  }
3760
3911
  }
3912
+ /**
3913
+ * Bytes of the file a read holds outside the heap while it works, beside the codes the guard counts for
3914
+ * itself: the symbol areas it reads, and the one buffer its records come through.
3915
+ *
3916
+ * The areas are counted whole, although the read lets each range go once its fields are parsed, because
3917
+ * two ranges are both live when a field of one is parsed between two fields of the other - the order the
3918
+ * caller asked for the fields decides it, so the sum is what holds in every order. The slice is what
3919
+ * `_forEachSlice` will allocate: `sliceBytes` unless the records are fewer.
3920
+ *
3921
+ * @param {number} symbolBytes Bytes of symbols the read will read.
3922
+ * @param {number} rows Records it will read.
3923
+ * @param {number} recordSize Bytes per record.
3924
+ * @return {number} Bytes.
3925
+ * @private
3926
+ */
3927
+ _bytesHeldBy(symbolBytes, rows, recordSize) {
3928
+ const records = Number.isSafeInteger(rows) && Number.isSafeInteger(recordSize) ? rows * recordSize : 0;
3929
+ return symbolBytes + Math.min(this._sliceBytes, Math.max(0, records));
3930
+ }
3931
+ /**
3932
+ * What a read holds in bytes of the file, for the memory guard: what it holds now, and what a read
3933
+ * following either piece of advice a refusal can carry would hold instead.
3934
+ *
3935
+ * The two knobs are not the same knob, which is why there are two functions rather than one. A smaller
3936
+ * `limit` is a smaller window, so every record the read touches is one of fewer - the symbol-usage pass
3937
+ * included, since it reads the window. A smaller `chunkSize` leaves the window exactly where it is and
3938
+ * only changes how much of it is decoded at a time, so a read with that pass still ahead of it holds the
3939
+ * window's slice however small the chunk. Priced with `forRows`, such a chunk was charged for the records
3940
+ * of one chunk and then held sixteen megabytes more than that.
3941
+ *
3942
+ * @param {number} symbolBytes Bytes of symbols the read will read.
3943
+ * @param {number} windowRows Rows the read covers.
3944
+ * @param {number} recordSize Bytes per record.
3945
+ * @param {{rows: number, perChunk: number}|null} liveRows Rows held at one instant - see `_prepare`.
3946
+ * @param {boolean} analysisAhead Whether the symbol-usage pass has still to run.
3947
+ * @return {{held: number, forRows: (rows: number) => number, forChunk: (rows: number) => number}} What
3948
+ * this read holds, what a read of so many rows would hold, and what one reading so many rows a chunk
3949
+ * would hold.
3950
+ * @private
3951
+ */
3952
+ _bytesHeld(symbolBytes, windowRows, recordSize, liveRows, analysisAhead) {
3953
+ return {
3954
+ held: this._bytesHeldBy(symbolBytes, this._recordsAtOnce(windowRows, liveRows, analysisAhead), recordSize),
3955
+ // A window of so many rows reads so many records at a time, and the pass that reads it ahead of the
3956
+ // decode reads the same rows, so the buffer is sized from the rows either way.
3957
+ forRows: /* @__PURE__ */ __name((rows) => this._bytesHeldBy(symbolBytes, rows, recordSize), "forRows"),
3958
+ // A chunk of so many rows, over this read's window - which is what `chunkSize` changes and what it
3959
+ // leaves alone. `_recordsAtOnce` is what answers that, given a chunk size as the rows held at once.
3960
+ forChunk: /* @__PURE__ */ __name((rows) => this._bytesHeldBy(symbolBytes, this._recordsAtOnce(windowRows, { rows, perChunk: 1 }, analysisAhead), recordSize), "forChunk")
3961
+ };
3962
+ }
3963
+ /**
3964
+ * Whether a read takes the symbol-usage pass, which reads the window's records before the symbol table
3965
+ * is parsed and so before the first chunk is built.
3966
+ *
3967
+ * The one statement of the condition. `_prepare` asks it to decide, and `_readData` asks it before the
3968
+ * header has been parsed, to know what to charge the memory guard: a read with the pass ahead of it
3969
+ * holds a whole slice of records, and a read without it holds only what it reads at a time. Said in two
3970
+ * places, the two would drift and a read would be charged for one path and take the other - which fails
3971
+ * open for a chunked read, and that is the direction the guard exists to prevent.
3972
+ *
3973
+ * Any window that does not cover the whole file is a candidate, which includes one bounded by its offset
3974
+ * rather than by its limit. Covering every row rules it out, however the window was spelled -
3975
+ * `{offset: 0, limit: n}` over all n rows, or an `iterate` of them. Such a read needs every symbol any
3976
+ * row uses, which is what `estimateMemoryUsage` assumes for a full read as well, so the pass has nothing
3977
+ * to filter. It is not free: since the records are read as they are decoded rather than held in one
3978
+ * buffer, the pass reads the window's records and the decode then reads them again. A window that really
3979
+ * is a window pays that for the symbols it saves parsing; a window that is a full read in disguise paid
3980
+ * it for nothing.
3981
+ *
3982
+ * The threshold is an option rather than a constant so this path can be exercised with a small fixture:
3983
+ * it is the most intricate code in the reader, and the only files large enough to reach the 50MB default
3984
+ * are ones no repository should be carrying around. It measures the whole table rather than the areas a
3985
+ * read selects, because what the pass saves is parsing work across the table.
3986
+ *
3987
+ * @param {QvdRowWindow} window The window as the caller spelled it.
3988
+ * @param {{offset: number, limit: number}} resolved Where it lands in this file.
3989
+ * @param {number} totalRows Rows the file declares.
3990
+ * @param {number} symbolTableLength The symbol table's declared length.
3991
+ * @return {boolean} Whether the pass will run.
3992
+ * @private
3993
+ */
3994
+ _analysisWouldRun(window, resolved, totalRows, symbolTableLength) {
3995
+ return resolved.limit < totalRows && (window.limit !== null || window.offset > 0) && symbolTableLength > this._symbolFilteringThreshold;
3996
+ }
3997
+ /**
3998
+ * Records a read holds at one time, which is what its record buffer is sized from.
3999
+ *
4000
+ * A slice holds `sliceBytes` of records, or every record the read has left to read when that is fewer -
4001
+ * so what it costs depends on how many a read asks for at a time, not on how many it covers. An
4002
+ * iteration asks for a chunk: `iterate({limit: 20_000_000, chunkSize: 1000})` reads a thousand records at
4003
+ * a time however many its window covers, and charging it a full slice would refuse it for 16 MiB it never
4004
+ * allocates. The symbol-usage pass is the exception, because it reads the whole window in slices of its
4005
+ * own before the first chunk is built, so a read that still has that pass ahead of it is charged for it.
4006
+ *
4007
+ * @param {number} windowRows Rows the read covers.
4008
+ * @param {{rows: number, perChunk: number}|null} liveRows Rows held at one instant - see `_prepare`.
4009
+ * @param {boolean} analysisAhead Whether the symbol-usage pass has still to run.
4010
+ * @return {number} Records read at one time.
4011
+ * @private
4012
+ */
4013
+ _recordsAtOnce(windowRows, liveRows, analysisAhead) {
4014
+ const chunkRows = liveRows === null ? windowRows : Math.max(1, Math.floor(liveRows.rows / Math.max(1, liveRows.perChunk)));
4015
+ return analysisAhead ? Math.max(windowRows, chunkRows) : chunkRows;
4016
+ }
4017
+ /**
4018
+ * The symbol table's length, as much of it as the file holds: what the header declares, cut short where
4019
+ * the file ends. Known before a byte of the table is read, so everything that can refuse the table is
4020
+ * checked on this, before the table is allocated.
4021
+ *
4022
+ * A file that ends inside its symbol table is measured to where it ends, and the fields whose areas it cut
4023
+ * short are refused as `Symbol data extends beyond buffer` when their metadata is checked - what a
4024
+ * whole-file read has always said of such a file. A window has refused it already, before reading anything.
4025
+ *
4026
+ * @return {number} Bytes.
4027
+ * @private
4028
+ */
4029
+ _symbolTableLength() {
4030
+ assert4(
4031
+ this._symbolTableOffset !== null && this._indexTableOffset !== null && this._fileSize !== null,
4032
+ "The QVD file header has not been parsed before its symbol table was measured."
4033
+ );
4034
+ const declared = this._indexTableOffset - this._symbolTableOffset;
4035
+ return Math.max(0, Math.min(declared, this._fileSize - this._symbolTableOffset));
4036
+ }
4037
+ /**
4038
+ * Where each selected field's symbols are, as ranges of the symbol table this read will read.
4039
+ *
4040
+ * A field's `Offset` and `Length` say exactly where its symbols are, so a read of some of a file's fields
4041
+ * has no reason to read the areas of the rest (#122). Qlik writes the areas one after another in field
4042
+ * order, so ranges that touch are merged: a read of every field is one range, and so is a read of fields
4043
+ * that happen to be neighbours. A read of one field of twenty reads that field's area alone.
4044
+ *
4045
+ * Built once per read, from the fields the read selected, and each field's metadata is checked as it is
4046
+ * added - a range is arithmetic on `Offset` and `Length`, and those have to be inside the table first.
4047
+ * `_parseSymbolTable` checks every field of the file, selected or not, before it parses any.
4048
+ *
4049
+ * @return {{ranges: Array<{start: number, end: number, fields: number, buffer: Buffer|null}>,
4050
+ * byField: Map<any, {range: {start: number, end: number, fields: number, buffer: Buffer|null},
4051
+ * start: number, end: number}>}} The ranges, and where in its range each field's area sits.
4052
+ * @private
4053
+ */
4054
+ _symbolAreaPlan() {
4055
+ if (this._symbolAreas !== null) {
4056
+ return this._symbolAreas;
4057
+ }
4058
+ assert4(this._selectedFields, "The QVD file fields have not been resolved before their symbols were read.");
4059
+ const tableLength = this._symbolTableLength();
4060
+ const areas = this._selectedFields.map((field) => {
4061
+ validateFieldMetadata(field, tableLength, this._path);
4062
+ const start = headerInteger(field["Offset"]);
4063
+ return { field, start, end: start + headerInteger(field["Length"]) };
4064
+ });
4065
+ const ranges = [];
4066
+ const byField = /* @__PURE__ */ new Map();
4067
+ for (const area of [...areas].sort((a, b) => a.start - b.start)) {
4068
+ const last = ranges.at(-1);
4069
+ const range = last !== void 0 && area.start <= last.end ? last : { start: area.start, end: area.end, fields: 0, buffer: null };
4070
+ if (range !== last) {
4071
+ ranges.push(range);
4072
+ }
4073
+ range.end = Math.max(range.end, area.end);
4074
+ range.fields += 1;
4075
+ byField.set(area.field, { range, start: area.start, end: area.end });
4076
+ }
4077
+ this._symbolAreas = { ranges, byField };
4078
+ return this._symbolAreas;
4079
+ }
4080
+ /**
4081
+ * One field's symbols, as bytes: the range that holds them, read from the open file the first time a field
4082
+ * of that range needs it.
4083
+ *
4084
+ * @param {any} field The field, one this read selected.
4085
+ * @return {Promise<{buffer: Buffer, start: number, end: number, base: number}>} Its area, as a range of
4086
+ * `buffer`, with where that buffer starts in the symbol table - what an error adds back to say where a
4087
+ * damaged symbol is in the file, rather than where it is in the bytes this read happened to read.
4088
+ * @throws {QvdValidationError} If the range is larger than half the heap.
4089
+ * @private
4090
+ */
4091
+ async _symbolAreaOf(field) {
4092
+ assert4(
4093
+ this._header && this._symbolTableOffset !== null,
4094
+ "The QVD file header has not been parsed before its symbols were read."
4095
+ );
4096
+ const area = this._symbolAreaPlan().byField.get(field);
4097
+ assert4(area, "A field this read did not select has no symbol area.");
4098
+ const { range } = area;
4099
+ if (range.buffer === null) {
4100
+ const length = range.end - range.start;
4101
+ validateSymbolTableSize(length, this._path, headerInteger(this._header["QvdTableHeader"]["NoOfRecords"]));
4102
+ const buffer = Buffer.alloc(length);
4103
+ const from = this._symbolTableOffset + range.start;
4104
+ await this._readAt(buffer, 0, length, from, from + length);
4105
+ range.buffer = buffer;
4106
+ }
4107
+ return { buffer: range.buffer, start: area.start - range.start, end: area.end - range.start, base: range.start };
4108
+ }
4109
+ /**
4110
+ * Lets go of a field's symbols once they are parsed, and of the bytes of its range once every field in it
4111
+ * has been.
4112
+ *
4113
+ * Every text is copied out of the bytes as it is decoded, so what the read keeps is the values. A read of
4114
+ * one field of a file whose other fields are large therefore holds that field's bytes and no others.
4115
+ *
4116
+ * @param {any} field The field whose symbols are parsed.
4117
+ * @private
4118
+ */
4119
+ _releaseSymbolArea(field) {
4120
+ const area = this._symbolAreaPlan().byField.get(field);
4121
+ assert4(area, "A field this read did not select has no symbol area.");
4122
+ area.range.fields -= 1;
4123
+ if (area.range.fields === 0) {
4124
+ area.range.buffer = null;
4125
+ }
4126
+ }
4127
+ /**
4128
+ * Reads records from the open file a slice at a time, and hands each slice to `visit`.
4129
+ *
4130
+ * One buffer of at most `sliceBytes` holds a slice, and is reused for the next one, so a read of any
4131
+ * number of records holds that much of them and no more. Cancellation is checked before each slice.
4132
+ *
4133
+ * @param {number} firstRow The file row of the first record.
4134
+ * @param {number} rowCount How many records.
4135
+ * @param {number} recordSize Bytes per record.
4136
+ * @param {(slice: Buffer, done: number, count: number) => void|Promise<void>} visit Called with each
4137
+ * slice's records, how many records came before it, and how many it holds.
4138
+ * @private
4139
+ */
4140
+ async _forEachSlice(firstRow, rowCount, recordSize, visit) {
4141
+ if (rowCount === 0) {
4142
+ return;
4143
+ }
4144
+ assert4(this._indexTableOffset !== null, "The QVD file header has not been parsed before its records were read.");
4145
+ const sliceRows = Math.max(1, Math.min(rowCount, Math.floor(this._sliceBytes / Math.max(1, recordSize))));
4146
+ const slice = Buffer.alloc(sliceRows * recordSize);
4147
+ const requiredBytes = this._indexTableOffset + (firstRow + rowCount) * recordSize;
4148
+ for (let done = 0; done < rowCount; done += sliceRows) {
4149
+ this._throwIfAborted();
4150
+ const count = Math.min(sliceRows, rowCount - done);
4151
+ const records = slice.subarray(0, count * recordSize);
4152
+ await this._readAt(
4153
+ records,
4154
+ 0,
4155
+ records.length,
4156
+ this._indexTableOffset + (firstRow + done) * recordSize,
4157
+ requiredBytes
4158
+ );
4159
+ await visit(records, done, count);
4160
+ }
4161
+ }
3761
4162
  /**
3762
4163
  * Parses the XML header of the QVD file. This method is part of the parsing process
3763
4164
  * and should not be called directly.
3764
4165
  */
3765
4166
  async _parseHeader() {
3766
- if (!this._buffer) {
4167
+ if (!this._headerBuffer) {
3767
4168
  throw new QvdCorruptedError(
3768
4169
  "The QVD file has not been loaded in the proper order or has not been loaded at all.",
3769
4170
  {
@@ -3774,7 +4175,7 @@ var init_QvdFileReader = __esm({
3774
4175
  }
3775
4176
  const HEADER_DELIMITER = "\r\n\0";
3776
4177
  const headerBeginIndex = 0;
3777
- const headerDelimiterIndex = this._buffer.indexOf(HEADER_DELIMITER, headerBeginIndex);
4178
+ const headerDelimiterIndex = this._headerBuffer.indexOf(HEADER_DELIMITER, headerBeginIndex);
3778
4179
  if (headerDelimiterIndex === -1) {
3779
4180
  throw new QvdCorruptedError(
3780
4181
  "The XML header section does not exist or is not properly delimited from the binary data.",
@@ -3785,7 +4186,7 @@ var init_QvdFileReader = __esm({
3785
4186
  );
3786
4187
  }
3787
4188
  const headerEndIndex = headerDelimiterIndex + HEADER_DELIMITER.length;
3788
- const headerBuffer = this._buffer.subarray(headerBeginIndex, headerEndIndex);
4189
+ const headerBuffer = this._headerBuffer.subarray(headerBeginIndex, headerEndIndex);
3789
4190
  this._fieldBitMetadataValidated = false;
3790
4191
  this._header = await parseHeaderXml(headerBuffer.toString(), this._path, "parseHeader");
3791
4192
  const fieldList = validateHeaderStructure(this._header, this._path, "parseHeader");
@@ -3807,12 +4208,12 @@ var init_QvdFileReader = __esm({
3807
4208
  * @param {QvdRowWindow} window The rows of interest, as file row indices.
3808
4209
  * @param {string} stage Stage name for any error raised here.
3809
4210
  * @return {{fields: Array<any>, recordSize: number, totalRows: number, rowsToLoad: number,
3810
- * indexBuffer: Buffer, firstRow: number}} The record geometry. `indexBuffer` starts at the
3811
- * window's first record, file row `firstRow`, so the decoder always counts from zero.
4211
+ * firstRow: number}} The record geometry: the window's `rowsToLoad` records start at file row
4212
+ * `firstRow`, and `_forEachSlice` reads them.
3812
4213
  * @private
3813
4214
  */
3814
4215
  _planIndexTable(window, stage) {
3815
- if (!this._buffer || !this._header || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
4216
+ if (!this._handle || !this._header || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
3816
4217
  throw new QvdCorruptedError(
3817
4218
  "The QVD file has not been loaded in the proper order or has not been loaded at all.",
3818
4219
  {
@@ -3827,22 +4228,18 @@ var init_QvdFileReader = __esm({
3827
4228
  const totalRows = headerInteger(this._header["QvdTableHeader"]["NoOfRecords"]);
3828
4229
  const indexTableLength = headerInteger(this._header["QvdTableHeader"]["Length"]);
3829
4230
  const { offset: firstRow, limit: rowsToLoad } = resolveWindow(window, totalRows);
4231
+ assert4(this._fileSize !== null, "The QVD file has not been measured before its records were planned.");
3830
4232
  validateIndexTableMetadata(
3831
4233
  recordSize,
3832
4234
  totalRows,
3833
4235
  indexTableLength,
3834
4236
  this._indexTableOffset,
3835
- this._buffer.length,
4237
+ this._fileSize,
3836
4238
  rowsToLoad,
3837
4239
  this._path,
3838
4240
  this._fileSize,
3839
4241
  firstRow,
3840
- this._bufferFirstRow
3841
- );
3842
- const bufferRecordStart = (firstRow - this._bufferFirstRow) * recordSize;
3843
- const indexBuffer = this._buffer.subarray(
3844
- this._indexTableOffset + bufferRecordStart,
3845
- this._indexTableOffset + bufferRecordStart + rowsToLoad * recordSize
4242
+ 0
3846
4243
  );
3847
4244
  if (!this._fieldBitMetadataValidated) {
3848
4245
  for (const field of allFields) {
@@ -3851,11 +4248,12 @@ var init_QvdFileReader = __esm({
3851
4248
  validateBitFields(allFields, this._path);
3852
4249
  this._fieldBitMetadataValidated = true;
3853
4250
  }
3854
- assert3(
3855
- rowsToLoad === 0 || recordSize === 0 || Math.floor(indexBuffer.length / recordSize) >= rowsToLoad,
3856
- `The index table holds ${Math.floor(indexBuffer.length / (recordSize || 1))} whole records but ${rowsToLoad} were validated as present.`
4251
+ const windowEnd = this._indexTableOffset + (firstRow + rowsToLoad) * recordSize;
4252
+ assert4(
4253
+ rowsToLoad === 0 || recordSize === 0 || windowEnd <= this._fileSize,
4254
+ `The window's records end at byte ${windowEnd} of a file of ${this._fileSize}, but ${rowsToLoad} were validated as present.`
3857
4255
  );
3858
- return { fields, recordSize, totalRows, rowsToLoad, indexBuffer, firstRow };
4256
+ return { fields, recordSize, totalRows, rowsToLoad, firstRow };
3859
4257
  }
3860
4258
  /**
3861
4259
  * Analyzes the index table to determine which symbols are actually needed.
@@ -3871,48 +4269,58 @@ var init_QvdFileReader = __esm({
3871
4269
  * @private
3872
4270
  */
3873
4271
  async _analyzeIndexTableSymbolUsage(window) {
3874
- const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(window, "analyzeIndexTableSymbolUsage");
4272
+ const { fields, recordSize, rowsToLoad, firstRow } = this._planIndexTable(window, "analyzeIndexTableSymbolUsage");
3875
4273
  const symbolUsage = [];
3876
4274
  const sliceRows = Math.min(rowsToLoad, ANALYSIS_SLICE_ROWS);
3877
4275
  const column = new Int32Array(sliceRows);
3878
- fields.forEach((field, position) => {
3879
- this._throwIfAborted();
4276
+ const state = fields.map((field) => {
3880
4277
  const needed = /* @__PURE__ */ new Set();
3881
- symbolUsage[position] = needed;
3882
- const bitOffset = headerInteger(field["BitOffset"]);
3883
- const bitWidth = headerInteger(field["BitWidth"]);
3884
- const bias = headerInteger(field["Bias"]);
4278
+ symbolUsage.push(needed);
3885
4279
  const length = headerInteger(field["Length"]);
3886
- let indexLimit = Number.isSafeInteger(length) && length >= 0 ? Math.ceil(length / 2) : Infinity;
3887
- let counted = false;
3888
- for (let first = 0; first < rowsToLoad; first += sliceRows) {
3889
- const count = Math.min(sliceRows, rowsToLoad - first);
3890
- decodeIndexColumn(
3891
- first === 0 ? indexBuffer : indexBuffer.subarray(first * recordSize),
3892
- recordSize,
3893
- count,
3894
- bitOffset,
3895
- bitWidth,
3896
- bias,
3897
- column
3898
- );
3899
- for (let row = 0; row < count; row++) {
3900
- if (column[row] >= 0 && column[row] < indexLimit) {
3901
- needed.add(column[row]);
4280
+ return {
4281
+ field,
4282
+ needed,
4283
+ bitOffset: headerInteger(field["BitOffset"]),
4284
+ bitWidth: headerInteger(field["BitWidth"]),
4285
+ bias: headerInteger(field["Bias"]),
4286
+ indexLimit: Number.isSafeInteger(length) && length >= 0 ? Math.ceil(length / 2) : Infinity,
4287
+ counted: false
4288
+ };
4289
+ });
4290
+ await this._forEachSlice(firstRow, rowsToLoad, recordSize, async (records, done, recordCount) => {
4291
+ for (let first = 0; first < recordCount; first += sliceRows) {
4292
+ const count = Math.min(sliceRows, recordCount - first);
4293
+ for (const field of state) {
4294
+ decodeIndexColumn(
4295
+ first === 0 ? records : records.subarray(first * recordSize),
4296
+ recordSize,
4297
+ count,
4298
+ field.bitOffset,
4299
+ field.bitWidth,
4300
+ field.bias,
4301
+ column
4302
+ );
4303
+ for (let row = 0; row < count; row++) {
4304
+ if (column[row] >= 0 && column[row] < field.indexLimit) {
4305
+ field.needed.add(column[row]);
4306
+ }
3902
4307
  }
3903
- }
3904
- if (!counted && needed.size > COUNT_SYMBOLS_PAST) {
3905
- counted = true;
3906
- indexLimit = Math.min(indexLimit, this._countFieldSymbols(field));
3907
- for (const index of needed) {
3908
- if (index >= indexLimit) {
3909
- needed.delete(index);
4308
+ if (!field.counted && field.needed.size > COUNT_SYMBOLS_PAST) {
4309
+ field.counted = true;
4310
+ field.indexLimit = Math.min(field.indexLimit, await this._countFieldSymbols(field.field));
4311
+ for (const index of field.needed) {
4312
+ if (index >= field.indexLimit) {
4313
+ field.needed.delete(index);
4314
+ }
3910
4315
  }
3911
4316
  }
3912
4317
  }
3913
4318
  }
3914
- this._emitProgress("symbol-analysis", position + 1, fields.length);
4319
+ this._emitProgress("symbol-analysis", done + recordCount, rowsToLoad);
3915
4320
  });
4321
+ if (rowsToLoad === 0) {
4322
+ this._emitProgress("symbol-analysis", 0, 0);
4323
+ }
3916
4324
  return symbolUsage;
3917
4325
  }
3918
4326
  /**
@@ -3920,28 +4328,27 @@ var init_QvdFileReader = __esm({
3920
4328
  *
3921
4329
  * The count is `countFieldSymbols`, the parse itself told to decode nothing, so it is the count
3922
4330
  * `_parseSymbolTable` will produce and `_parseIndexTable` will check against. The field's area is
3923
- * validated first, as `_parseSymbolTable` would, so a damaged `Offset` or `Length` is reported the
3924
- * same way wherever it is met.
4331
+ * validated before it is read, by `_symbolAreaPlan`, so a damaged `Offset`, `Length` or `NoOfSymbols` is
4332
+ * reported the same way wherever it is met. Its bytes are kept for the parse that follows. The walk checks the count against `NoOfSymbols` as the
4333
+ * parse does, so a field whose count is wrong is refused here, before the pass keeps anything on the
4334
+ * strength of it.
3925
4335
  *
3926
4336
  * @param {any} field The field's header.
3927
- * @return {number} Its symbols.
3928
- * @throws {QvdCorruptedError} If the area is not inside the symbol table, or a symbol runs past it.
4337
+ * @return {Promise<number>} Its symbols.
4338
+ * @throws {QvdCorruptedError} If the area is not inside the symbol table, a symbol runs past it, or it
4339
+ * holds a different number of symbols from its `NoOfSymbols`.
3929
4340
  * @private
3930
4341
  */
3931
- _countFieldSymbols(field) {
3932
- assert3(
3933
- this._buffer && this._symbolTableOffset && this._indexTableOffset,
3934
- "The QVD file has not been read before its symbols were counted."
3935
- );
3936
- const symbolBuffer = this._buffer.subarray(this._symbolTableOffset, this._indexTableOffset);
3937
- validateFieldMetadata(field, symbolBuffer.length, this._path);
3938
- const offset = headerInteger(field["Offset"]);
4342
+ async _countFieldSymbols(field) {
4343
+ const area = await this._symbolAreaOf(field);
3939
4344
  return countFieldSymbols(
3940
- symbolBuffer,
3941
- offset,
3942
- offset + headerInteger(field["Length"]),
4345
+ area.buffer,
4346
+ area.start,
4347
+ area.end,
4348
+ headerInteger(field["NoOfSymbols"]),
3943
4349
  field["FieldName"],
3944
- this._path
4350
+ this._path,
4351
+ area.base
3945
4352
  );
3946
4353
  }
3947
4354
  /**
@@ -3961,7 +4368,7 @@ var init_QvdFileReader = __esm({
3961
4368
  * that is fewer than the window covers - see `_prepare`.
3962
4369
  */
3963
4370
  async _parseSymbolTable(symbolsToKeep = null, rowsToLoad = 0, liveRows = null) {
3964
- if (!this._buffer || !this._header || !this._symbolTableOffset || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
4371
+ if (!this._handle || !this._header || !this._symbolTableOffset || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
3965
4372
  throw new QvdCorruptedError(
3966
4373
  "The QVD file has not been loaded in the proper order or has not been loaded at all.",
3967
4374
  {
@@ -3972,44 +4379,55 @@ var init_QvdFileReader = __esm({
3972
4379
  }
3973
4380
  const allFields = this._allFields;
3974
4381
  const fields = this._selectedFields;
3975
- const symbolBuffer = this._buffer.subarray(this._symbolTableOffset, this._indexTableOffset);
3976
- const symbolTableSize = symbolBuffer.length;
4382
+ const symbolTableSize = this._symbolTableLength();
4383
+ const plan = this._symbolAreaPlan();
4384
+ const symbolBytes = plan.ranges.reduce((sum, range) => sum + (range.end - range.start), 0);
3977
4385
  const totalRows = headerInteger(this._header["QvdTableHeader"]["NoOfRecords"]);
3978
- validateSymbolTableSize(symbolTableSize, this._path, totalRows);
4386
+ const recordSize = headerInteger(this._header["QvdTableHeader"]["RecordByteSize"]);
4387
+ validateSymbolTableSize(symbolBytes, this._path, totalRows);
3979
4388
  if (this._headerMatchesFile) {
3980
4389
  validateMemoryAvailability(
3981
- symbolTableSize,
4390
+ symbolBytes,
3982
4391
  rowsToLoad,
3983
4392
  totalRows,
3984
4393
  this._path,
3985
4394
  this._memorySafetyFactor,
3986
4395
  fields.length,
3987
4396
  this._materialisesRows,
3988
- liveRows
4397
+ liveRows,
4398
+ this._bytesHeld(symbolBytes, rowsToLoad, recordSize, liveRows, false)
3989
4399
  );
3990
4400
  }
3991
- warnLargeSymbolTable(symbolTableSize, rowsToLoad, totalRows, fields.length, this._materialisesRows);
4401
+ warnLargeSymbolTable(symbolBytes, rowsToLoad, totalRows, fields.length, this._materialisesRows);
3992
4402
  for (const field of allFields) {
3993
- validateFieldMetadata(field, symbolBuffer.length, this._path);
4403
+ validateFieldMetadata(field, symbolTableSize, this._path);
3994
4404
  }
3995
4405
  validateSymbolAreas(allFields, this._path);
3996
- this._symbolTable = fields.map((field, position) => {
4406
+ const symbolTable = [];
4407
+ for (const [position, field] of fields.entries()) {
3997
4408
  this._throwIfAborted();
3998
- const symbolsOffset = headerInteger(field["Offset"]);
3999
- const symbolsLength = headerInteger(field["Length"]);
4000
- const symbols = parseFieldSymbols(
4001
- symbolBuffer,
4002
- symbolsOffset,
4003
- symbolsOffset + symbolsLength,
4004
- // By position, matching how `_analyzeIndexTableSymbolUsage` built it. Both walk
4005
- // `this._selectedFields`, so position is the one key that cannot collide.
4006
- symbolsToKeep ? symbolsToKeep[position] : null,
4007
- field["FieldName"],
4008
- this._path
4409
+ const area = await this._symbolAreaOf(field);
4410
+ symbolTable.push(
4411
+ parseFieldSymbols(
4412
+ area.buffer,
4413
+ area.start,
4414
+ area.end,
4415
+ // Checked against the symbols the area holds, which is the one check that sees a terminator
4416
+ // damaged in the middle of it (#124).
4417
+ headerInteger(field["NoOfSymbols"]),
4418
+ // By position, matching how `_analyzeIndexTableSymbolUsage` built it. Both walk
4419
+ // `this._selectedFields`, so position is the one key that cannot collide.
4420
+ symbolsToKeep ? symbolsToKeep[position] : null,
4421
+ field["FieldName"],
4422
+ this._path,
4423
+ void 0,
4424
+ area.base
4425
+ )
4009
4426
  );
4427
+ this._releaseSymbolArea(field);
4010
4428
  this._emitProgress("symbol-table", position + 1, fields.length);
4011
- return symbols;
4012
- });
4429
+ }
4430
+ this._symbolTable = symbolTable;
4013
4431
  }
4014
4432
  /**
4015
4433
  * Parses the bit stuffed index table of the QVD file. This method is part of the parsing process
@@ -4036,27 +4454,46 @@ var init_QvdFileReader = __esm({
4036
4454
  * straight to the caller. Rows outside the window are not decoded, so they are not checked.
4037
4455
  *
4038
4456
  * @param {QvdRowWindow} window The rows to decode.
4457
+ * @param {number} [progressBase=0] Rows decoded before this call, so that progress over a chunked
4458
+ * iteration counts the whole window rather than restarting at every chunk - what `_buildRows` takes
4459
+ * for the same reason.
4460
+ * @param {number|null} [progressTotal=null] Rows the whole window covers, or null for this call's own.
4461
+ * @param {Array<Int32Array>|null} [into=null] Arrays to decode into, one per selected field and at least
4462
+ * `limit` long, for a caller that decodes chunk after chunk and keeps none of them. Null allocates.
4039
4463
  * @throws {QvdCorruptedError} If an index in the window addresses neither a symbol nor NULL.
4040
4464
  */
4041
- async _parseIndexTable(window) {
4042
- const { fields, recordSize, rowsToLoad, indexBuffer, firstRow } = this._planIndexTable(window, "parseIndexTable");
4043
- assert3(this._symbolTable, "The QVD file symbol table has not been parsed.");
4465
+ async _parseIndexTable(window, progressBase = 0, progressTotal = null, into = null) {
4466
+ const { fields, recordSize, rowsToLoad, firstRow } = this._planIndexTable(window, "parseIndexTable");
4467
+ const decodedBefore = progressBase;
4468
+ const decodedTotal = progressTotal === null ? rowsToLoad : progressTotal;
4469
+ assert4(this._symbolTable, "The QVD file symbol table has not been parsed.");
4044
4470
  const symbolTable = this._symbolTable;
4045
- const columns = fields.map((field, position) => {
4046
- this._throwIfAborted();
4047
- const column = decodeIndexColumn(
4048
- indexBuffer,
4049
- recordSize,
4050
- rowsToLoad,
4051
- headerInteger(field["BitOffset"]),
4052
- headerInteger(field["BitWidth"]),
4053
- headerInteger(field["Bias"]),
4054
- new Int32Array(rowsToLoad),
4055
- { symbolCount: symbolTable[position].numbers.length, field: field["FieldName"], file: this._path, firstRow }
4056
- );
4057
- this._emitProgress("index-table", position + 1, fields.length);
4058
- return column;
4471
+ const decoders = fields.map((field, position) => ({
4472
+ bitOffset: headerInteger(field["BitOffset"]),
4473
+ bitWidth: headerInteger(field["BitWidth"]),
4474
+ bias: headerInteger(field["Bias"]),
4475
+ symbolCount: symbolTable[position].numbers.length,
4476
+ name: field["FieldName"]
4477
+ }));
4478
+ const columns = into === null ? fields.map(() => new Int32Array(rowsToLoad)) : into.map((codes) => codes.subarray(0, rowsToLoad));
4479
+ await this._forEachSlice(firstRow, rowsToLoad, recordSize, (records, done, count) => {
4480
+ decoders.forEach((decoder, position) => {
4481
+ decodeIndexColumn(
4482
+ records,
4483
+ recordSize,
4484
+ count,
4485
+ decoder.bitOffset,
4486
+ decoder.bitWidth,
4487
+ decoder.bias,
4488
+ columns[position].subarray(done, done + count),
4489
+ { symbolCount: decoder.symbolCount, field: decoder.name, file: this._path, firstRow: firstRow + done }
4490
+ );
4491
+ });
4492
+ this._emitProgress("index-table", decodedBefore + done + count, decodedTotal);
4059
4493
  });
4494
+ if (rowsToLoad === 0) {
4495
+ this._emitProgress("index-table", decodedBefore, decodedTotal);
4496
+ }
4060
4497
  this._indexColumns = columns;
4061
4498
  this._rowsDecoded = rowsToLoad;
4062
4499
  }
@@ -4075,31 +4512,33 @@ var init_QvdFileReader = __esm({
4075
4512
  * @return {Promise<import('./QvdDataFrame.js').QvdFileMetadata>} The file's schema and header.
4076
4513
  */
4077
4514
  async loadMetadata() {
4078
- await this._readData({ offset: 0, limit: null }, true);
4079
- this._emitProgress("header", 0, 1);
4080
- await this._parseHeader();
4081
- this._emitProgress("header", 1, 1);
4082
- this._throwIfAborted();
4083
- assert3(this._header && this._allFields, "The QVD file header has not been parsed.");
4084
- const header = this._header["QvdTableHeader"];
4085
- const columns = this._allFields.map((field) => field["FieldName"]);
4086
- const rowCount = headerInteger(header["NoOfRecords"]);
4087
- validateRecordCount(rowCount, this._path, "readMetadata");
4088
- const shape = new QvdDataFrame([], columns, header, {
4089
- symbolTableBytes: headerInteger(header["Offset"]),
4090
- totalRows: rowCount,
4091
- rowsLoaded: 0,
4092
- symbolFiltering: false,
4093
- symbolsKept: null
4515
+ return await this._closingAfter(async () => {
4516
+ await this._readData({ offset: 0, limit: null }, true);
4517
+ this._emitProgress("header", 0, 1);
4518
+ await this._parseHeader();
4519
+ this._emitProgress("header", 1, 1);
4520
+ this._throwIfAborted();
4521
+ assert4(this._header && this._allFields, "The QVD file header has not been parsed.");
4522
+ const header = this._header["QvdTableHeader"];
4523
+ const columns = this._allFields.map((field) => field["FieldName"]);
4524
+ const rowCount = headerInteger(header["NoOfRecords"]);
4525
+ validateRecordCount(rowCount, this._path, "readMetadata");
4526
+ const shape = new QvdDataFrame([], columns, header, {
4527
+ symbolTableBytes: headerInteger(header["Offset"]),
4528
+ totalRows: rowCount,
4529
+ rowsLoaded: 0,
4530
+ symbolFiltering: false,
4531
+ symbolsKept: null
4532
+ });
4533
+ return {
4534
+ columns,
4535
+ rowCount,
4536
+ columnCount: columns.length,
4537
+ fields: columns.map((name) => shape.getFieldMetadata(name)),
4538
+ fileMetadata: shape.fileMetadata,
4539
+ metadata: header
4540
+ };
4094
4541
  });
4095
- return {
4096
- columns,
4097
- rowCount,
4098
- columnCount: columns.length,
4099
- fields: columns.map((name) => shape.getFieldMetadata(name)),
4100
- fileMetadata: shape.fileMetadata,
4101
- metadata: header
4102
- };
4103
4542
  }
4104
4543
  /**
4105
4544
  * Loads the QVD file into memory and parses it.
@@ -4114,19 +4553,21 @@ var init_QvdFileReader = __esm({
4114
4553
  */
4115
4554
  async load(window = null) {
4116
4555
  const rows = normaliseWindow(window, this._path);
4117
- const prepared = await this._prepare(rows);
4118
- await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
4119
- const data = this._buildRows(prepared.resolvedByField, 0, prepared.rowsAvailable);
4120
- return new QvdDataFrame(
4121
- data,
4122
- prepared.columns,
4123
- prepared.metadata,
4124
- {
4125
- ...prepared.loadStats,
4126
- rowsLoaded: data.length
4127
- },
4128
- prepared.storedSymbols
4129
- );
4556
+ return await this._closingAfter(async () => {
4557
+ const prepared = await this._prepare(rows);
4558
+ await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
4559
+ const data = this._buildRows(prepared.resolvedByField, 0, prepared.rowsAvailable);
4560
+ return new QvdDataFrame(
4561
+ data,
4562
+ prepared.columns,
4563
+ prepared.metadata,
4564
+ {
4565
+ ...prepared.loadStats,
4566
+ rowsLoaded: data.length
4567
+ },
4568
+ prepared.storedSymbols
4569
+ );
4570
+ });
4130
4571
  }
4131
4572
  /**
4132
4573
  * Reads the file as columns, without ever materialising rows.
@@ -4145,19 +4586,21 @@ var init_QvdFileReader = __esm({
4145
4586
  */
4146
4587
  async loadColumnar(window = null) {
4147
4588
  const rows = normaliseWindow(window, this._path);
4148
- const prepared = await this._prepare(rows, null, true);
4149
- await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
4150
- const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
4151
- assert3(this._indexColumns, "The QVD file index table has not been parsed.");
4152
- return new QvdColumnTable2({
4153
- columns: prepared.columns,
4154
- codesByField: this._indexColumns,
4155
- symbolsByField: prepared.resolvedByField,
4156
- halvesByField: prepared.halvesByField,
4157
- rowCount: this._rowsDecoded,
4158
- metadata: prepared.metadata,
4159
- storedSymbols: prepared.storedSymbols,
4160
- loadStats: { ...prepared.loadStats, rowsLoaded: this._rowsDecoded }
4589
+ return await this._closingAfter(async () => {
4590
+ const prepared = await this._prepare(rows, null, true);
4591
+ await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
4592
+ const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
4593
+ assert4(this._indexColumns, "The QVD file index table has not been parsed.");
4594
+ return new QvdColumnTable2({
4595
+ columns: prepared.columns,
4596
+ codesByField: this._indexColumns,
4597
+ symbolsByField: prepared.resolvedByField,
4598
+ halvesByField: prepared.halvesByField,
4599
+ rowCount: this._rowsDecoded,
4600
+ metadata: prepared.metadata,
4601
+ storedSymbols: prepared.storedSymbols,
4602
+ loadStats: { ...prepared.loadStats, rowsLoaded: this._rowsDecoded }
4603
+ });
4161
4604
  });
4162
4605
  }
4163
4606
  /**
@@ -4195,28 +4638,38 @@ var init_QvdFileReader = __esm({
4195
4638
  }
4196
4639
  const liveRows = { rows: chunkSize * 2, perChunk: 2 };
4197
4640
  const rows = normaliseWindow(window, this._path);
4198
- const prepared = await this._prepare(rows, liveRows);
4199
- if (prepared.rowsAvailable === 0) {
4200
- this._planIndexTable({ offset: prepared.offset, limit: 0 }, "parseIndexTable");
4201
- return;
4202
- }
4203
- for (let done = 0; done < prepared.rowsAvailable; done += chunkSize) {
4204
- this._throwIfAborted();
4205
- const count = Math.min(chunkSize, prepared.rowsAvailable - done);
4206
- const offset = prepared.offset + done;
4207
- await this._parseIndexTable({ offset, limit: count });
4208
- const data = this._buildRows(prepared.resolvedByField, done, prepared.rowsAvailable);
4209
- yield new QvdDataFrame(
4210
- data,
4211
- prepared.columns,
4212
- prepared.metadata,
4213
- {
4214
- ...prepared.loadStats,
4215
- offset,
4216
- rowsLoaded: data.length
4217
- },
4218
- prepared.storedSymbols
4219
- );
4641
+ this._startRead();
4642
+ let failing = false;
4643
+ try {
4644
+ const prepared = await this._prepare(rows, liveRows);
4645
+ if (prepared.rowsAvailable === 0) {
4646
+ this._planIndexTable({ offset: prepared.offset, limit: 0 }, "parseIndexTable");
4647
+ return;
4648
+ }
4649
+ const codes = prepared.columns.map(() => new Int32Array(Math.min(chunkSize, prepared.rowsAvailable)));
4650
+ for (let done = 0; done < prepared.rowsAvailable; done += chunkSize) {
4651
+ this._throwIfAborted();
4652
+ const count = Math.min(chunkSize, prepared.rowsAvailable - done);
4653
+ const offset = prepared.offset + done;
4654
+ await this._parseIndexTable({ offset, limit: count }, done, prepared.rowsAvailable, codes);
4655
+ const data = this._buildRows(prepared.resolvedByField, done, prepared.rowsAvailable);
4656
+ yield new QvdDataFrame(
4657
+ data,
4658
+ prepared.columns,
4659
+ prepared.metadata,
4660
+ {
4661
+ ...prepared.loadStats,
4662
+ offset,
4663
+ rowsLoaded: data.length
4664
+ },
4665
+ prepared.storedSymbols
4666
+ );
4667
+ }
4668
+ } catch (error) {
4669
+ failing = true;
4670
+ throw error;
4671
+ } finally {
4672
+ await this._endRead(failing);
4220
4673
  }
4221
4674
  }
4222
4675
  /**
@@ -4250,23 +4703,21 @@ var init_QvdFileReader = __esm({
4250
4703
  await this._parseHeader();
4251
4704
  this._emitProgress("header", 1, 1);
4252
4705
  this._throwIfAborted();
4253
- assert3(this._header, "The QVD file header has not been parsed.");
4706
+ assert4(this._header, "The QVD file header has not been parsed.");
4254
4707
  const totalRows = headerInteger(this._header["QvdTableHeader"]["NoOfRecords"]);
4255
4708
  const symbolTableLength = headerInteger(this._header["QvdTableHeader"]["Offset"]);
4256
4709
  const resolved = resolveWindow(window, totalRows);
4257
4710
  const rowsAvailable = resolved.limit;
4258
4711
  let symbolsToKeep = null;
4259
4712
  let symbolsKept = null;
4260
- if (window.limit !== null || window.offset > 0) {
4261
- if (symbolTableLength > this._symbolFilteringThreshold) {
4262
- symbolsToKeep = await this._analyzeIndexTableSymbolUsage({ offset: resolved.offset, limit: rowsAvailable });
4263
- symbolsKept = symbolsToKeep.reduce((sum, set) => sum + set.size, 0);
4264
- }
4713
+ if (this._analysisWouldRun(window, resolved, totalRows, symbolTableLength)) {
4714
+ symbolsToKeep = await this._analyzeIndexTableSymbolUsage({ offset: resolved.offset, limit: rowsAvailable });
4715
+ symbolsKept = symbolsToKeep.reduce((sum, set) => sum + set.size, 0);
4265
4716
  }
4266
4717
  await this._parseSymbolTable(symbolsToKeep, rowsAvailable, liveRows);
4267
- assert3(this._symbolTable, "The QVD file symbol table has not been parsed.");
4718
+ assert4(this._symbolTable, "The QVD file symbol table has not been parsed.");
4268
4719
  this._throwIfAborted();
4269
- assert3(this._selectedFields, "The QVD file fields have not been resolved.");
4720
+ assert4(this._selectedFields, "The QVD file fields have not been resolved.");
4270
4721
  const resolvedByField = [];
4271
4722
  const halvesByField = [];
4272
4723
  const entries = [];
@@ -4327,7 +4778,7 @@ var init_QvdFileReader = __esm({
4327
4778
  * @private
4328
4779
  */
4329
4780
  _buildRows(resolvedByField, progressBase, progressTotal) {
4330
- assert3(this._indexColumns, "The QVD file index table has not been parsed.");
4781
+ assert4(this._indexColumns, "The QVD file index table has not been parsed.");
4331
4782
  const indexColumns = this._indexColumns;
4332
4783
  const fieldCount = indexColumns.length;
4333
4784
  const rowCount = this._rowsDecoded;
@@ -5016,12 +5467,16 @@ var init_QvdDataFrame = __esm({
5016
5467
  /**
5017
5468
  * Reads a QVD file in chunks, as an async generator of data frames.
5018
5469
  *
5019
- * The file is opened, read and parsed once; only the index decode and the row building happen
5020
- * per chunk, so what this bounds is row materialisation - the part that actually dominates a
5021
- * large read's heap. It is **not** constant-memory reading of an arbitrarily large file: the
5022
- * symbol table is parsed in full whatever the chunk size, because a stored index in the last
5023
- * chunk can address the first symbol. On a high-cardinality file that table is the bulk of the
5024
- * cost, and `readMetadata` is the only read that avoids it.
5470
+ * The file is opened once and its symbol table parsed once. Each chunk's records are read from the
5471
+ * file when that chunk is built, so what this holds is the symbol table and two chunks of rows,
5472
+ * whatever the size of the file: a 20 GB QVD iterates in the memory its symbol table needs. That
5473
+ * table is still parsed in full whatever the chunk size, because a stored index in the last chunk
5474
+ * can address the first symbol. On a high-cardinality file that table is the bulk of the cost, and
5475
+ * `readMetadata` is the only read that avoids it.
5476
+ *
5477
+ * The file stays open until the iteration ends. Running it to the end closes it, and so do
5478
+ * `break` or a throw inside `for await` and a call to `return()` on the iterator; an iterator
5479
+ * abandoned part-way without any of those holds the file until it is garbage-collected.
5025
5480
  *
5026
5481
  * ```js
5027
5482
  * for await (const chunk of QvdDataFrame.iterate('big.qvd', {chunkSize: 50_000})) {