qvdjs 0.10.0 → 0.11.0
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/README.md +310 -43
- package/dist/index.cjs +723 -212
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +723 -212
- package/dist/index.js.map +1 -1
- package/img/logo/qvdjs_logo-512.png +0 -0
- package/package.json +5 -2
package/dist/index.cjs
CHANGED
|
@@ -262,6 +262,128 @@ var init_QvdSymbol = __esm({
|
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
// src/util/readOptions.js
|
|
267
|
+
function requireRowCount(value, name, filePath) {
|
|
268
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
269
|
+
throw new exports.QvdValidationError(`${name} must be a non-negative integer`, {
|
|
270
|
+
option: name,
|
|
271
|
+
provided: value,
|
|
272
|
+
type: typeof value,
|
|
273
|
+
file: filePath
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
function normaliseWindow(window, filePath) {
|
|
279
|
+
if (window === null || window === void 0) {
|
|
280
|
+
return { offset: 0, limit: null };
|
|
281
|
+
}
|
|
282
|
+
if (typeof window === "number") {
|
|
283
|
+
return { offset: 0, limit: requireRowCount(window, "maxRows", filePath) };
|
|
284
|
+
}
|
|
285
|
+
if (typeof window !== "object" || Array.isArray(window)) {
|
|
286
|
+
throw new exports.QvdValidationError("The row window must be a number, null, or an {offset, limit} object", {
|
|
287
|
+
provided: window,
|
|
288
|
+
type: typeof window,
|
|
289
|
+
file: filePath
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const { offset, limit, maxRows } = window;
|
|
293
|
+
const limitGiven = limit !== void 0 && limit !== null;
|
|
294
|
+
const maxRowsGiven = maxRows !== void 0 && maxRows !== null;
|
|
295
|
+
if (limitGiven && maxRowsGiven) {
|
|
296
|
+
throw new exports.QvdValidationError("maxRows and limit are two names for the same option; pass one of them, not both", {
|
|
297
|
+
maxRows,
|
|
298
|
+
limit,
|
|
299
|
+
file: filePath
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
offset: offset === void 0 || offset === null ? 0 : requireRowCount(offset, "offset", filePath),
|
|
304
|
+
limit: limitGiven ? requireRowCount(limit, "limit", filePath) : maxRowsGiven ? requireRowCount(maxRows, "maxRows", filePath) : null
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function resolveWindow(window, totalRows) {
|
|
308
|
+
const rows = Number.isSafeInteger(totalRows) && totalRows > 0 ? totalRows : 0;
|
|
309
|
+
const offset = Math.min(window.offset, rows);
|
|
310
|
+
return {
|
|
311
|
+
offset,
|
|
312
|
+
limit: Math.max(0, Math.min(window.limit === null ? Infinity : window.limit, rows - offset))
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function selectFields(fields, requested, filePath) {
|
|
316
|
+
if (requested === null || requested === void 0) {
|
|
317
|
+
return fields;
|
|
318
|
+
}
|
|
319
|
+
if (!Array.isArray(requested)) {
|
|
320
|
+
throw new exports.QvdValidationError("fields must be an array of field names", {
|
|
321
|
+
provided: requested,
|
|
322
|
+
type: typeof requested,
|
|
323
|
+
file: filePath
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const available = fields.map((field) => field["FieldName"]);
|
|
327
|
+
if (requested.length === 0) {
|
|
328
|
+
throw new exports.QvdValidationError("fields must name at least one field", {
|
|
329
|
+
availableColumns: available,
|
|
330
|
+
file: filePath
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const seen = /* @__PURE__ */ new Set();
|
|
334
|
+
return requested.map((name) => {
|
|
335
|
+
if (typeof name !== "string") {
|
|
336
|
+
throw new exports.QvdValidationError("Field names must be strings", {
|
|
337
|
+
provided: name,
|
|
338
|
+
type: typeof name,
|
|
339
|
+
availableColumns: available,
|
|
340
|
+
file: filePath
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (seen.has(name)) {
|
|
344
|
+
throw new exports.QvdValidationError(`Field '${name}' is listed twice`, {
|
|
345
|
+
column: name,
|
|
346
|
+
fields: requested,
|
|
347
|
+
file: filePath
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
seen.add(name);
|
|
351
|
+
const index = available.indexOf(name);
|
|
352
|
+
if (index === -1) {
|
|
353
|
+
throw new exports.QvdValidationError(`Column '${name}' does not exist`, {
|
|
354
|
+
column: name,
|
|
355
|
+
availableColumns: available,
|
|
356
|
+
file: filePath
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return fields[index];
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function readerOptionsFrom(options) {
|
|
363
|
+
return {
|
|
364
|
+
allowedDir: options.allowedDir,
|
|
365
|
+
memorySafetyFactor: options.memorySafetyFactor,
|
|
366
|
+
symbolFilteringThreshold: options.symbolFilteringThreshold,
|
|
367
|
+
fields: options.fields === void 0 ? null : options.fields,
|
|
368
|
+
onProgress: options.onProgress,
|
|
369
|
+
signal: options.signal
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function metadataOptionsFrom(options) {
|
|
373
|
+
return {
|
|
374
|
+
allowedDir: options.allowedDir,
|
|
375
|
+
onProgress: options.onProgress,
|
|
376
|
+
signal: options.signal
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function windowFrom(options) {
|
|
380
|
+
return { offset: options.offset, limit: options.limit, maxRows: options.maxRows };
|
|
381
|
+
}
|
|
382
|
+
var init_readOptions = __esm({
|
|
383
|
+
"src/util/readOptions.js"() {
|
|
384
|
+
init_QvdErrors();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
265
387
|
function isWithinDirectoryLexically(resolvedBaseDir, resolvedPath) {
|
|
266
388
|
const isCaseInsensitiveFS = process.platform === "win32";
|
|
267
389
|
const base = isCaseInsensitiveFS ? resolvedBaseDir.toLowerCase() : resolvedBaseDir;
|
|
@@ -819,11 +941,12 @@ function estimateRowMemory(rows, columnCount) {
|
|
|
819
941
|
}
|
|
820
942
|
return BASE_BYTES + rows * (ROW_BASE_BYTES + PER_CELL_BYTES * columnCount);
|
|
821
943
|
}
|
|
822
|
-
function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true) {
|
|
944
|
+
function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true, rowsLive = null) {
|
|
823
945
|
const FULL_PARSE_OVERHEAD = 6;
|
|
824
946
|
const MINIMAL_OVERHEAD = 0.01;
|
|
825
947
|
const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
|
|
826
|
-
const
|
|
948
|
+
const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
|
|
949
|
+
const rowMemory = materialisesRows ? estimateRowMemory(liveRows, columnCount) : BASE_BYTES;
|
|
827
950
|
if (maxRows === null || maxRows >= totalRows) {
|
|
828
951
|
return symbolTableSize * FULL_PARSE_OVERHEAD + rowMemory;
|
|
829
952
|
}
|
|
@@ -833,18 +956,44 @@ function estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount =
|
|
|
833
956
|
const skippedSymbolsMemory = symbolTableSize * (1 - symbolPercentage) * MINIMAL_OVERHEAD;
|
|
834
957
|
return keptSymbolsMemory + skippedSymbolsMemory + rowMemory;
|
|
835
958
|
}
|
|
836
|
-
function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true) {
|
|
837
|
-
|
|
959
|
+
function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, materialisesRows = true, includeExternal = false) {
|
|
960
|
+
const costOf = (rows) => estimateMemoryUsage(symbolTableSize, rows, totalRows, columnCount, materialisesRows) + (includeExternal ? estimateExternalMemory(Math.min(rows, totalRows), columnCount) : 0);
|
|
961
|
+
if (costOf(totalRows) <= budget) {
|
|
838
962
|
return totalRows;
|
|
839
963
|
}
|
|
840
|
-
if (
|
|
964
|
+
if (costOf(0) > budget) {
|
|
841
965
|
return 0;
|
|
842
966
|
}
|
|
843
967
|
let low = 0;
|
|
844
968
|
let high = totalRows;
|
|
845
969
|
while (high - low > 1) {
|
|
846
970
|
const mid = Math.floor((low + high) / 2);
|
|
847
|
-
if (
|
|
971
|
+
if (costOf(mid) <= budget) {
|
|
972
|
+
low = mid;
|
|
973
|
+
} else {
|
|
974
|
+
high = mid;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return low;
|
|
978
|
+
}
|
|
979
|
+
function recommendedChunkFor(budget, symbolTableSize, windowRows, totalRows, columnCount, liveRowsPerChunk = 1, includeExternal = false) {
|
|
980
|
+
const covered = windowRows === null || windowRows >= totalRows ? totalRows : windowRows;
|
|
981
|
+
const fits = (chunk) => {
|
|
982
|
+
const live = Math.min(chunk * liveRowsPerChunk, covered);
|
|
983
|
+
const cost = estimateMemoryUsage(symbolTableSize, windowRows, totalRows, columnCount, true, chunk * liveRowsPerChunk) + (includeExternal ? estimateExternalMemory(live, columnCount) : 0);
|
|
984
|
+
return cost <= budget;
|
|
985
|
+
};
|
|
986
|
+
if (fits(covered)) {
|
|
987
|
+
return covered;
|
|
988
|
+
}
|
|
989
|
+
if (!fits(1)) {
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
let low = 1;
|
|
993
|
+
let high = covered;
|
|
994
|
+
while (high - low > 1) {
|
|
995
|
+
const mid = Math.floor((low + high) / 2);
|
|
996
|
+
if (fits(mid)) {
|
|
848
997
|
low = mid;
|
|
849
998
|
} else {
|
|
850
999
|
high = mid;
|
|
@@ -852,7 +1001,7 @@ function recommendedRowsFor(budget, symbolTableSize, totalRows, columnCount, mat
|
|
|
852
1001
|
}
|
|
853
1002
|
return low;
|
|
854
1003
|
}
|
|
855
|
-
function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true) {
|
|
1004
|
+
function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePath, safetyFactor = 0.8, columnCount = 0, materialisesRows = true, live = null) {
|
|
856
1005
|
if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
|
|
857
1006
|
throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
|
|
858
1007
|
}
|
|
@@ -861,12 +1010,16 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
861
1010
|
}
|
|
862
1011
|
const budget = getMemoryBudget();
|
|
863
1012
|
const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
|
|
864
|
-
const
|
|
865
|
-
const
|
|
1013
|
+
const rowsLive = live === null ? null : live.rows;
|
|
1014
|
+
const liveRowsPerChunk = live === null ? 1 : live.perChunk;
|
|
1015
|
+
const liveRows = rowsLive === null ? rowsToLoad : Math.min(rowsLive, rowsToLoad);
|
|
1016
|
+
const heapMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows, rowsLive);
|
|
1017
|
+
const externalMemory = estimateExternalMemory(liveRows, columnCount);
|
|
866
1018
|
const bounded = budget.candidates.map((candidate) => {
|
|
867
1019
|
const heapOnly = candidate.source === "V8 heap limit";
|
|
868
1020
|
return {
|
|
869
1021
|
...candidate,
|
|
1022
|
+
heapOnly,
|
|
870
1023
|
needs: heapOnly ? heapMemory : heapMemory + externalMemory,
|
|
871
1024
|
allowed: candidate.bytes * safetyFactor,
|
|
872
1025
|
bounds: heapOnly ? "the V8 heap" : "the whole process"
|
|
@@ -882,12 +1035,14 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
882
1035
|
const estimatedMemory = binding ? binding.needs : heapMemory;
|
|
883
1036
|
const maxAllowedMemory = binding ? binding.allowed : budget.bytes * safetyFactor;
|
|
884
1037
|
if (binding) {
|
|
1038
|
+
const includeExternal = !binding.heapOnly;
|
|
885
1039
|
const recommendedMaxRows = recommendedRowsFor(
|
|
886
1040
|
maxAllowedMemory,
|
|
887
1041
|
symbolTableSize,
|
|
888
1042
|
totalRows,
|
|
889
1043
|
columnCount,
|
|
890
|
-
materialisesRows
|
|
1044
|
+
materialisesRows,
|
|
1045
|
+
includeExternal
|
|
891
1046
|
);
|
|
892
1047
|
const sizeMB = Math.round(symbolTableSize / 1024 / 1024);
|
|
893
1048
|
const estimatedMB = Math.round(estimatedMemory / 1024 / 1024);
|
|
@@ -899,15 +1054,27 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
899
1054
|
const limitingScope = binding.bounds;
|
|
900
1055
|
const budgetBreakdown = budget.candidates.map((candidate) => `${candidate.source} ${Math.round(candidate.bytes / 1024 / 1024)}MB`).join(", ");
|
|
901
1056
|
const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
|
|
902
|
-
const nothingFits = recommendedMaxRows === 0;
|
|
903
1057
|
const containerBound = binding.source === "container memory limit";
|
|
1058
|
+
const chunked = rowsLive !== null;
|
|
1059
|
+
const recommendedChunk = chunked ? recommendedChunkFor(
|
|
1060
|
+
maxAllowedMemory,
|
|
1061
|
+
symbolTableSize,
|
|
1062
|
+
maxRows,
|
|
1063
|
+
totalRows,
|
|
1064
|
+
columnCount,
|
|
1065
|
+
liveRowsPerChunk,
|
|
1066
|
+
includeExternal
|
|
1067
|
+
) : 0;
|
|
1068
|
+
const knob = chunked ? "chunkSize" : "limit";
|
|
1069
|
+
const recommendedValue = chunked ? recommendedChunk : recommendedMaxRows;
|
|
1070
|
+
const nothingFits = recommendedValue === 0;
|
|
904
1071
|
let advice;
|
|
905
1072
|
if (nothingFits) {
|
|
906
|
-
advice = `No row count fits this budget - the symbol table alone exceeds it, so
|
|
1073
|
+
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.`);
|
|
907
1074
|
} else if (containerBound) {
|
|
908
|
-
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
|
|
1075
|
+
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: ${recommendedValue.toLocaleString()} rows or less).`;
|
|
909
1076
|
} else {
|
|
910
|
-
advice = `Try
|
|
1077
|
+
advice = `Try holding fewer rows using the ${knob} parameter (recommended: ${recommendedValue.toLocaleString()} rows or less), or raise the heap with --max-old-space-size.`;
|
|
911
1078
|
}
|
|
912
1079
|
throw new exports.QvdValidationError(
|
|
913
1080
|
`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,
|
|
@@ -927,22 +1094,30 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
927
1094
|
columnCount,
|
|
928
1095
|
totalRows,
|
|
929
1096
|
maxRows,
|
|
930
|
-
recommendedMaxRows
|
|
1097
|
+
recommendedMaxRows,
|
|
1098
|
+
// Only present when a chunk size is what overflowed, so a caller cannot mistake one
|
|
1099
|
+
// recommendation for the other.
|
|
1100
|
+
...chunked ? { rowsLive, recommendedChunkSize: recommendedChunk } : {}
|
|
931
1101
|
}
|
|
932
1102
|
);
|
|
933
1103
|
}
|
|
934
1104
|
}
|
|
935
1105
|
function warnLargeSymbolTable(symbolTableSize, maxRows, totalRows, columnCount = 0, materialisesRows = true) {
|
|
936
1106
|
const LARGE_SYMBOL_TABLE_WARNING = usableOldSpaceLimit() * 0.125;
|
|
937
|
-
if (symbolTableSize
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
);
|
|
1107
|
+
if (symbolTableSize <= LARGE_SYMBOL_TABLE_WARNING) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const rowsToLoad = maxRows === null || maxRows >= totalRows ? totalRows : maxRows;
|
|
1111
|
+
const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows, columnCount, materialisesRows);
|
|
1112
|
+
if (estimatedMemory <= LARGE_SYMBOL_TABLE_WARNING) {
|
|
1113
|
+
return;
|
|
945
1114
|
}
|
|
1115
|
+
const sizeMB = Math.round(symbolTableSize / 1024 / 1024);
|
|
1116
|
+
const estimatedMB = Math.round(estimatedMemory / 1024 / 1024);
|
|
1117
|
+
const warnMB = Math.round(LARGE_SYMBOL_TABLE_WARNING / 1024 / 1024);
|
|
1118
|
+
console.warn(
|
|
1119
|
+
`\u26A0\uFE0F Large symbol table detected (${sizeMB}MB > ${warnMB}MB threshold). This read materialises ${rowsToLoad.toLocaleString()} of ${totalRows.toLocaleString()} rows and will use ~${estimatedMB}MB RAM. Reading fewer rows - with limit, maxRows, or a narrower offset window - lowers the row cost, though the symbol table is read in full either way.`
|
|
1120
|
+
);
|
|
946
1121
|
}
|
|
947
1122
|
var HEAP_LIMIT_OVERSTATEMENT_BYTES, MINIMUM_BUDGET_BYTES, BASE_BYTES, ROW_BASE_BYTES, PER_CELL_BYTES;
|
|
948
1123
|
var init_memoryUtils = __esm({
|
|
@@ -957,6 +1132,24 @@ var init_memoryUtils = __esm({
|
|
|
957
1132
|
});
|
|
958
1133
|
|
|
959
1134
|
// src/util/validationUtils.js
|
|
1135
|
+
function validateHeaderStructure(headerObj, filePath, stage) {
|
|
1136
|
+
const tableHeader = headerObj?.["QvdTableHeader"];
|
|
1137
|
+
if (tableHeader === null || typeof tableHeader !== "object" || Array.isArray(tableHeader)) {
|
|
1138
|
+
throw new exports.QvdCorruptedError("The XML header contains no usable QvdTableHeader element", {
|
|
1139
|
+
rootElements: headerObj && typeof headerObj === "object" ? Object.keys(headerObj) : [],
|
|
1140
|
+
file: filePath,
|
|
1141
|
+
stage
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
const symbolTableLength = parseInt(tableHeader["Offset"], 10);
|
|
1145
|
+
if (isNaN(symbolTableLength) || !Number.isSafeInteger(symbolTableLength) || symbolTableLength < 0) {
|
|
1146
|
+
throw new exports.QvdCorruptedError("Invalid symbol table offset", {
|
|
1147
|
+
offset: tableHeader["Offset"],
|
|
1148
|
+
file: filePath,
|
|
1149
|
+
stage
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
960
1153
|
function validateSymbolTableSizeEarly(symbolTableLength, filePath) {
|
|
961
1154
|
const heapLimit = getHeapLimit();
|
|
962
1155
|
const MAX_SYMBOL_TABLE_SIZE = heapLimit * 0.125;
|
|
@@ -965,7 +1158,7 @@ function validateSymbolTableSizeEarly(symbolTableLength, filePath) {
|
|
|
965
1158
|
const maxMB = Math.round(MAX_SYMBOL_TABLE_SIZE / 1024 / 1024);
|
|
966
1159
|
const heapMB = Math.round(heapLimit / 1024 / 1024);
|
|
967
1160
|
throw new exports.QvdValidationError(
|
|
968
|
-
`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 maxRows, (2) increasing heap size with --max-old-space-size, or (3) aggregating high-cardinality fields.`,
|
|
1161
|
+
`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.`,
|
|
969
1162
|
{
|
|
970
1163
|
file: filePath,
|
|
971
1164
|
symbolTableSize: symbolTableLength,
|
|
@@ -1039,7 +1232,7 @@ function validateRecordCount(totalRows, filePath, stage = "parseIndexTable") {
|
|
|
1039
1232
|
});
|
|
1040
1233
|
}
|
|
1041
1234
|
}
|
|
1042
|
-
function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null) {
|
|
1235
|
+
function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, indexTableOffset, bufferLength, rowsToLoad, filePath, fileSize = null, windowFirstRow = 0, bufferFirstRow = 0) {
|
|
1043
1236
|
if (isNaN(recordSize) || !Number.isSafeInteger(recordSize) || recordSize < 0) {
|
|
1044
1237
|
throw new exports.QvdCorruptedError("Invalid record byte size", {
|
|
1045
1238
|
recordSize,
|
|
@@ -1101,23 +1294,28 @@ function validateIndexTableMetadata(recordSize, totalRows, indexTableLength, ind
|
|
|
1101
1294
|
}
|
|
1102
1295
|
}
|
|
1103
1296
|
const requiredIndexBytes = rowsToLoad * recordSize;
|
|
1104
|
-
|
|
1297
|
+
const bufferRecordStart = (windowFirstRow - bufferFirstRow) * recordSize;
|
|
1298
|
+
if (indexTableOffset + bufferRecordStart + requiredIndexBytes > bufferLength) {
|
|
1105
1299
|
throw new exports.QvdCorruptedError("Index table truncated", {
|
|
1106
1300
|
indexTableOffset,
|
|
1107
1301
|
requiredBytes: requiredIndexBytes,
|
|
1108
|
-
availableBytes: Math.max(0, bufferLength - indexTableOffset),
|
|
1302
|
+
availableBytes: Math.max(0, bufferLength - indexTableOffset - bufferRecordStart),
|
|
1109
1303
|
rowsToLoad,
|
|
1304
|
+
windowFirstRow,
|
|
1305
|
+
bufferFirstRow,
|
|
1110
1306
|
recordSize,
|
|
1111
1307
|
bufferSize: bufferLength,
|
|
1112
1308
|
file: filePath,
|
|
1113
1309
|
stage: "parseIndexTable"
|
|
1114
1310
|
});
|
|
1115
1311
|
}
|
|
1116
|
-
|
|
1312
|
+
const requiredTableBytes = (windowFirstRow + rowsToLoad) * recordSize;
|
|
1313
|
+
if (indexTableLength < requiredTableBytes) {
|
|
1117
1314
|
throw new exports.QvdCorruptedError("Index table length smaller than required", {
|
|
1118
1315
|
indexTableLength,
|
|
1119
|
-
requiredBytes:
|
|
1316
|
+
requiredBytes: requiredTableBytes,
|
|
1120
1317
|
rowsToLoad,
|
|
1318
|
+
windowFirstRow,
|
|
1121
1319
|
recordSize,
|
|
1122
1320
|
file: filePath,
|
|
1123
1321
|
stage: "parseIndexTable"
|
|
@@ -1447,6 +1645,7 @@ exports.QvdColumn = void 0; exports.QvdColumnTable = void 0;
|
|
|
1447
1645
|
var init_QvdColumnTable = __esm({
|
|
1448
1646
|
"src/QvdColumnTable.js"() {
|
|
1449
1647
|
init_QvdErrors();
|
|
1648
|
+
init_readOptions();
|
|
1450
1649
|
exports.QvdColumn = class {
|
|
1451
1650
|
/**
|
|
1452
1651
|
* @param {string} name The field name.
|
|
@@ -1641,9 +1840,21 @@ var init_QvdColumnTable = __esm({
|
|
|
1641
1840
|
/**
|
|
1642
1841
|
* Reads a QVD file as columns.
|
|
1643
1842
|
*
|
|
1843
|
+
* Takes the same options as `QvdDataFrame.fromQvd`, with the same meanings - one option
|
|
1844
|
+
* vocabulary for both read paths, because they are two answers about the same file rather than
|
|
1845
|
+
* two features. `{offset, limit}` is how a caller pages through a file columnwise; there is no
|
|
1846
|
+
* columnar `iterate()` because there is nothing for it to bound - a columnar read materialises
|
|
1847
|
+
* no rows, which is the memory chunking exists to cap.
|
|
1848
|
+
*
|
|
1644
1849
|
* @param {string} path The path to the QVD file.
|
|
1645
1850
|
* @param {Object} [options] Loading options, with the same meanings they have on `fromQvd`.
|
|
1646
|
-
* @param {number|null} [options.maxRows]
|
|
1851
|
+
* @param {number|null} [options.maxRows] Rows to decode. The older name for `limit`.
|
|
1852
|
+
* @param {number|null} [options.limit] Rows to decode, counting from `offset`.
|
|
1853
|
+
* @param {number} [options.offset] File row to start at.
|
|
1854
|
+
* @param {Array<string>|null} [options.fields] Field names to read, in the order they should
|
|
1855
|
+
* appear. Unselected fields have their symbols skipped entirely.
|
|
1856
|
+
* @param {Function} [options.onProgress] Progress callback, `{stage, current, total, percent}`.
|
|
1857
|
+
* @param {AbortSignal} [options.signal] Cancels the read.
|
|
1647
1858
|
* @param {string} [options.allowedDir] Directory the path must resolve inside.
|
|
1648
1859
|
* @param {number} [options.memorySafetyFactor] Fraction of the memory budget a load may use.
|
|
1649
1860
|
* @param {number} [options.symbolFilteringThreshold] Symbol table size above which a limited
|
|
@@ -1653,15 +1864,13 @@ var init_QvdColumnTable = __esm({
|
|
|
1653
1864
|
static async fromQvd(path3, options = {}) {
|
|
1654
1865
|
const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
|
|
1655
1866
|
const reader = new QvdFileReader2(path3, {
|
|
1656
|
-
|
|
1657
|
-
memorySafetyFactor: options.memorySafetyFactor,
|
|
1658
|
-
symbolFilteringThreshold: options.symbolFilteringThreshold,
|
|
1867
|
+
...readerOptionsFrom(options),
|
|
1659
1868
|
// This read builds no rows, so the memory guard must not charge it for them. A columnar
|
|
1660
1869
|
// read of the 38MB taxi fixture completes in a 15MB heap; charged the row cost it was
|
|
1661
1870
|
// refused below a 2GB one.
|
|
1662
1871
|
materialisesRows: false
|
|
1663
1872
|
});
|
|
1664
|
-
return await reader.loadColumnar(options
|
|
1873
|
+
return await reader.loadColumnar(windowFrom(options));
|
|
1665
1874
|
}
|
|
1666
1875
|
/** @return {Array<string>} Field names, in file order. */
|
|
1667
1876
|
get columns() {
|
|
@@ -1709,7 +1918,7 @@ var QvdFileReader_exports = {};
|
|
|
1709
1918
|
__export(QvdFileReader_exports, {
|
|
1710
1919
|
QvdFileReader: () => exports.QvdFileReader
|
|
1711
1920
|
});
|
|
1712
|
-
var MAX_HEADER_SIZE, READ_CHUNK_SIZE; exports.QvdFileReader = void 0;
|
|
1921
|
+
var MAX_HEADER_SIZE, READ_CHUNK_SIZE, ANALYSIS_SLICE_ROWS; exports.QvdFileReader = void 0;
|
|
1713
1922
|
var init_QvdFileReader = __esm({
|
|
1714
1923
|
"src/QvdFileReader.js"() {
|
|
1715
1924
|
init_QvdDataFrame();
|
|
@@ -1719,8 +1928,10 @@ var init_QvdFileReader = __esm({
|
|
|
1719
1928
|
init_memoryUtils();
|
|
1720
1929
|
init_validationUtils();
|
|
1721
1930
|
init_symbolParser();
|
|
1931
|
+
init_readOptions();
|
|
1722
1932
|
MAX_HEADER_SIZE = 16 * 1024 * 1024;
|
|
1723
1933
|
READ_CHUNK_SIZE = 512 * 1024 * 1024;
|
|
1934
|
+
ANALYSIS_SLICE_ROWS = 65536;
|
|
1724
1935
|
exports.QvdFileReader = class {
|
|
1725
1936
|
/**
|
|
1726
1937
|
* Constructs a new QVD file parser.
|
|
@@ -1732,9 +1943,10 @@ var init_QvdFileReader = __esm({
|
|
|
1732
1943
|
* points outside it is rejected. Defaults to the current working directory. To permit
|
|
1733
1944
|
* an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
|
|
1734
1945
|
* empty value falls back to the working directory rather than removing the restriction.
|
|
1735
|
-
* @param {number} [options.memorySafetyFactor=0.
|
|
1736
|
-
* load may use. The budget is the
|
|
1737
|
-
*
|
|
1946
|
+
* @param {number} [options.memorySafetyFactor=0.8] Fraction (0.0-1.0) of the memory budget a
|
|
1947
|
+
* load may use. The budget is the smaller of the V8 heap limit and any container memory limit;
|
|
1948
|
+
* what the OS reports as available is recorded for diagnostics and deliberately not allowed to
|
|
1949
|
+
* bind - see `getMemoryBudget`. Default is 0.8. **Zero disables the memory
|
|
1738
1950
|
* check entirely**, which is the escape hatch for runtimes whose limits cannot be measured -
|
|
1739
1951
|
* Bun reports its current heap as its heap limit - and for callers who would rather manage
|
|
1740
1952
|
* memory themselves than trust the estimate.
|
|
@@ -1745,29 +1957,93 @@ var init_QvdFileReader = __esm({
|
|
|
1745
1957
|
* above which a lazy load switches to the two-pass filtering path. The default of 50MB is
|
|
1746
1958
|
* the point where the extra analysis pass pays for itself; lower it to use filtering on
|
|
1747
1959
|
* smaller files, raise it to keep the simpler single-pass read for longer.
|
|
1960
|
+
* @param {Array<string>|null} [options.fields] Field names to read, in the order they should
|
|
1961
|
+
* appear. Null reads every field, in file order. An unknown or repeated name is refused.
|
|
1962
|
+
* @param {Function} [options.onProgress] Called with `{stage, current, total, percent}` as the
|
|
1963
|
+
* read proceeds - the same shape `QvdFileWriter` emits.
|
|
1964
|
+
* @param {AbortSignal} [options.signal] Cancels the read. When it is aborted the read throws
|
|
1965
|
+
* `signal.reason`, exactly as `signal.throwIfAborted()` does.
|
|
1748
1966
|
*/
|
|
1749
1967
|
constructor(filePath, options = {}) {
|
|
1750
1968
|
const {
|
|
1751
1969
|
allowedDir,
|
|
1752
1970
|
memorySafetyFactor = 0.8,
|
|
1753
1971
|
symbolFilteringThreshold = 50 * 1024 * 1024,
|
|
1754
|
-
materialisesRows = true
|
|
1972
|
+
materialisesRows = true,
|
|
1973
|
+
fields = null,
|
|
1974
|
+
onProgress,
|
|
1975
|
+
signal
|
|
1755
1976
|
} = options;
|
|
1756
1977
|
this._materialisesRows = materialisesRows;
|
|
1757
1978
|
this._path = validatePath(filePath, allowedDir);
|
|
1758
1979
|
this._memorySafetyFactor = memorySafetyFactor;
|
|
1759
1980
|
this._symbolFilteringThreshold = symbolFilteringThreshold;
|
|
1981
|
+
if (onProgress !== void 0 && typeof onProgress !== "function") {
|
|
1982
|
+
throw new exports.QvdValidationError("onProgress must be a function", {
|
|
1983
|
+
provided: onProgress,
|
|
1984
|
+
type: typeof onProgress,
|
|
1985
|
+
file: this._path
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
if (signal !== void 0 && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean")) {
|
|
1989
|
+
throw new exports.QvdValidationError("signal must be an AbortSignal", {
|
|
1990
|
+
provided: signal,
|
|
1991
|
+
type: typeof signal,
|
|
1992
|
+
file: this._path
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
this._requestedFields = fields === void 0 ? null : fields;
|
|
1996
|
+
this._onProgress = onProgress;
|
|
1997
|
+
this._signal = signal;
|
|
1760
1998
|
this._buffer = null;
|
|
1761
1999
|
this._headerOffset = null;
|
|
1762
2000
|
this._symbolTableOffset = null;
|
|
1763
2001
|
this._indexTableOffset = null;
|
|
1764
2002
|
this._header = null;
|
|
2003
|
+
this._allFields = null;
|
|
2004
|
+
this._selectedFields = null;
|
|
2005
|
+
this._fieldBitMetadataValidated = false;
|
|
1765
2006
|
this._symbolTable = null;
|
|
1766
2007
|
this._indexColumns = null;
|
|
1767
2008
|
this._rowsDecoded = 0;
|
|
2009
|
+
this._bufferFirstRow = 0;
|
|
1768
2010
|
this._fileSize = null;
|
|
1769
2011
|
this._headerMatchesFile = false;
|
|
1770
2012
|
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Emits a progress event if a callback is registered.
|
|
2015
|
+
*
|
|
2016
|
+
* The same shape `QvdFileWriter._emitProgress` emits, deliberately: a caller who has written a
|
|
2017
|
+
* progress bar for a write should not have to write a second one for a read. The stage names
|
|
2018
|
+
* differ because the stages differ, but `symbol-table` and `index-table` mean the same thing on
|
|
2019
|
+
* both sides.
|
|
2020
|
+
*
|
|
2021
|
+
* @param {string} stage The current stage of the read.
|
|
2022
|
+
* @param {number} current The current progress value.
|
|
2023
|
+
* @param {number} total The total progress value.
|
|
2024
|
+
* @private
|
|
2025
|
+
*/
|
|
2026
|
+
_emitProgress(stage, current, total) {
|
|
2027
|
+
if (this._onProgress) {
|
|
2028
|
+
const percent = total > 0 ? Math.round(current / total * 100) : 100;
|
|
2029
|
+
this._onProgress({ stage, current, total, percent });
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Throws if the caller has cancelled the read.
|
|
2034
|
+
*
|
|
2035
|
+
* Throws `signal.reason` - a `DOMException` named `AbortError` unless the caller aborted with a
|
|
2036
|
+
* reason of their own. That is what `AbortSignal` means everywhere else in Node, and inventing
|
|
2037
|
+
* a `QvdAbortError` here would make this library's cancellation the one a caller has to special
|
|
2038
|
+
* case.
|
|
2039
|
+
*
|
|
2040
|
+
* @private
|
|
2041
|
+
*/
|
|
2042
|
+
_throwIfAborted() {
|
|
2043
|
+
if (this._signal) {
|
|
2044
|
+
this._signal.throwIfAborted();
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
1771
2047
|
/**
|
|
1772
2048
|
* Reads the binary data of the QVD file.
|
|
1773
2049
|
*
|
|
@@ -1792,14 +2068,23 @@ var init_QvdFileReader = __esm({
|
|
|
1792
2068
|
* - Streaming for header finding is efficient for unknown header sizes
|
|
1793
2069
|
* - Direct byte-range reading for remaining data is fastest
|
|
1794
2070
|
*
|
|
1795
|
-
*
|
|
2071
|
+
* A window with a non-zero `offset` reads two ranges rather than one: the header and symbol
|
|
2072
|
+
* table from the front of the file, and the window's records from wherever they sit. The bytes
|
|
2073
|
+
* between are never read, which is what makes `{offset: 1_700_000, limit: 100}` on the taxi
|
|
2074
|
+
* fixture a 0.4MB read rather than a 38MB one.
|
|
2075
|
+
*
|
|
2076
|
+
* @param {QvdRowWindow} window The rows to read.
|
|
1796
2077
|
* @param {boolean} [headerOnly=false] Stop once the XML header has been read, leaving the
|
|
1797
2078
|
* symbol and index tables on disk. This is the metadata-only path: the header is a few
|
|
1798
2079
|
* kilobytes whatever the file's size, so reading a schema costs the same for a 40MB file as
|
|
1799
2080
|
* for a 40GB one.
|
|
2081
|
+
* @param {{rows: number, perChunk: number}|null} [liveRows=null] Rows held at one instant when
|
|
2082
|
+
* that is fewer than the window covers - see `_prepare`.
|
|
1800
2083
|
* @private
|
|
1801
2084
|
*/
|
|
1802
|
-
async _readData(
|
|
2085
|
+
async _readData(window = { offset: 0, limit: null }, headerOnly = false, liveRows = null) {
|
|
2086
|
+
this._throwIfAborted();
|
|
2087
|
+
this._emitProgress("read", 0, 1);
|
|
1803
2088
|
const HEADER_DELIMITER = "\r\n\0";
|
|
1804
2089
|
const CHUNK_SIZE = 64 * 1024;
|
|
1805
2090
|
const stream = fs__default.default.createReadStream(this._path, {
|
|
@@ -1861,6 +2146,7 @@ var init_QvdFileReader = __esm({
|
|
|
1861
2146
|
stage: "readData"
|
|
1862
2147
|
});
|
|
1863
2148
|
}
|
|
2149
|
+
validateHeaderStructure(headerObj, this._path, "readData");
|
|
1864
2150
|
const symbolTableOffset = headerEndIndex;
|
|
1865
2151
|
const symbolTableLength = parseInt(headerObj["QvdTableHeader"]["Offset"], 10);
|
|
1866
2152
|
const indexTableOffset = symbolTableOffset + symbolTableLength;
|
|
@@ -1868,13 +2154,14 @@ var init_QvdFileReader = __esm({
|
|
|
1868
2154
|
const totalRows = parseInt(headerObj["QvdTableHeader"]["NoOfRecords"], 10);
|
|
1869
2155
|
if (headerOnly) {
|
|
1870
2156
|
this._buffer = headerBuffer.subarray(0, headerEndIndex);
|
|
2157
|
+
this._emitProgress("read", 1, 1);
|
|
1871
2158
|
return;
|
|
1872
2159
|
}
|
|
1873
2160
|
let headerFields = headerObj["QvdTableHeader"]?.["Fields"]?.["QvdFieldHeader"];
|
|
1874
2161
|
if (headerFields && !Array.isArray(headerFields)) {
|
|
1875
2162
|
headerFields = [headerFields];
|
|
1876
2163
|
}
|
|
1877
|
-
const columnCount = Array.isArray(headerFields) ? headerFields.length : 0;
|
|
2164
|
+
const columnCount = Array.isArray(headerFields) ? selectFields(headerFields, this._requestedFields, this._path).length : 0;
|
|
1878
2165
|
const headerNumbersUsable = [symbolTableLength, recordSize, totalRows].every(
|
|
1879
2166
|
(value) => Number.isSafeInteger(value) && value >= 0
|
|
1880
2167
|
);
|
|
@@ -1883,23 +2170,28 @@ var init_QvdFileReader = __esm({
|
|
|
1883
2170
|
this._fileSize = fileSize;
|
|
1884
2171
|
this._headerMatchesFile = headerEndIndex + symbolTableLength + totalRows * recordSize <= fileSize;
|
|
1885
2172
|
}
|
|
2173
|
+
const resolved = headerNumbersUsable ? resolveWindow(window, totalRows) : { offset: 0, limit: 0 };
|
|
2174
|
+
const windowRows = resolved.limit;
|
|
1886
2175
|
if (headerNumbersUsable && this._headerMatchesFile) {
|
|
1887
2176
|
validateMemoryAvailability(
|
|
1888
2177
|
symbolTableLength,
|
|
1889
|
-
|
|
2178
|
+
windowRows,
|
|
1890
2179
|
totalRows,
|
|
1891
2180
|
this._path,
|
|
1892
2181
|
this._memorySafetyFactor,
|
|
1893
2182
|
columnCount,
|
|
1894
|
-
this._materialisesRows
|
|
2183
|
+
this._materialisesRows,
|
|
2184
|
+
liveRows
|
|
1895
2185
|
);
|
|
1896
2186
|
}
|
|
1897
|
-
if (
|
|
2187
|
+
if (window.offset === 0 && window.limit === null) {
|
|
1898
2188
|
this._buffer = await fs__default.default.promises.readFile(this._path);
|
|
1899
2189
|
this._fileSize = this._buffer.length;
|
|
2190
|
+
this._bufferFirstRow = 0;
|
|
2191
|
+
this._emitProgress("read", 1, 1);
|
|
1900
2192
|
return;
|
|
1901
2193
|
}
|
|
1902
|
-
const rowsToLoad =
|
|
2194
|
+
const rowsToLoad = windowRows;
|
|
1903
2195
|
validateSymbolTableSizeEarly(symbolTableLength, this._path);
|
|
1904
2196
|
for (const [name, value] of [
|
|
1905
2197
|
["Offset", symbolTableLength],
|
|
@@ -1915,39 +2207,78 @@ var init_QvdFileReader = __esm({
|
|
|
1915
2207
|
});
|
|
1916
2208
|
}
|
|
1917
2209
|
}
|
|
2210
|
+
const skippedIndexBytes = resolved.offset * recordSize;
|
|
1918
2211
|
const indexTableBytesToRead = rowsToLoad * recordSize;
|
|
1919
2212
|
const totalBytesToRead = indexTableOffset + indexTableBytesToRead;
|
|
2213
|
+
const fileBytesRequired = indexTableOffset + skippedIndexBytes + indexTableBytesToRead;
|
|
1920
2214
|
const fd = await fs__default.default.promises.open(this._path, "r");
|
|
1921
2215
|
try {
|
|
1922
2216
|
const { size: fileSize } = await fd.stat();
|
|
1923
2217
|
this._fileSize = fileSize;
|
|
1924
|
-
if (
|
|
2218
|
+
if (fileBytesRequired > fileSize) {
|
|
1925
2219
|
throw new exports.QvdCorruptedError("The file is shorter than its header claims.", {
|
|
1926
2220
|
file: this._path,
|
|
1927
2221
|
fileSize,
|
|
1928
|
-
requiredBytes:
|
|
2222
|
+
requiredBytes: fileBytesRequired,
|
|
1929
2223
|
stage: "readData"
|
|
1930
2224
|
});
|
|
1931
2225
|
}
|
|
1932
2226
|
this._buffer = Buffer.alloc(totalBytesToRead);
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
stage: "readData"
|
|
1944
|
-
});
|
|
1945
|
-
}
|
|
1946
|
-
position += bytesRead;
|
|
2227
|
+
await this._readRange(fd, 0, indexTableOffset, 0, fileSize, totalBytesToRead);
|
|
2228
|
+
if (indexTableBytesToRead > 0) {
|
|
2229
|
+
await this._readRange(
|
|
2230
|
+
fd,
|
|
2231
|
+
indexTableOffset,
|
|
2232
|
+
indexTableBytesToRead,
|
|
2233
|
+
indexTableOffset + skippedIndexBytes,
|
|
2234
|
+
fileSize,
|
|
2235
|
+
fileBytesRequired
|
|
2236
|
+
);
|
|
1947
2237
|
}
|
|
2238
|
+
this._bufferFirstRow = resolved.offset;
|
|
1948
2239
|
} finally {
|
|
1949
2240
|
await fd.close();
|
|
1950
2241
|
}
|
|
2242
|
+
this._emitProgress("read", 1, 1);
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* Reads one byte range of the file into the buffer.
|
|
2246
|
+
*
|
|
2247
|
+
* Read in bounded chunks, checking bytesRead each time. A single fs.read call with a length of
|
|
2248
|
+
* 2^31 or more does not throw - it trips a C++ assertion and aborts the whole process, which no
|
|
2249
|
+
* try/catch can intercept.
|
|
2250
|
+
*
|
|
2251
|
+
* @param {import('fs/promises').FileHandle} fd The open file.
|
|
2252
|
+
* @param {number} bufferOffset Where in the buffer to write.
|
|
2253
|
+
* @param {number} byteCount How many bytes to read.
|
|
2254
|
+
* @param {number} filePosition Where in the file to read from.
|
|
2255
|
+
* @param {number} fileSize The file's size, for the error.
|
|
2256
|
+
* @param {number} requiredBytes Bytes the whole read needs, for the error.
|
|
2257
|
+
* @private
|
|
2258
|
+
*/
|
|
2259
|
+
async _readRange(fd, bufferOffset, byteCount, filePosition, fileSize, requiredBytes) {
|
|
2260
|
+
assert2__default.default(this._buffer, "The read buffer has not been allocated.");
|
|
2261
|
+
let done = 0;
|
|
2262
|
+
while (done < byteCount) {
|
|
2263
|
+
const length = Math.min(READ_CHUNK_SIZE, byteCount - done);
|
|
2264
|
+
const { bytesRead } = await fd.read(this._buffer, bufferOffset + done, length, filePosition + done);
|
|
2265
|
+
if (bytesRead === 0) {
|
|
2266
|
+
throw new exports.QvdCorruptedError("Unexpected end of file while reading QVD data.", {
|
|
2267
|
+
file: this._path,
|
|
2268
|
+
fileSize,
|
|
2269
|
+
// Two numbers, because they stopped being the same one when a window began reading two
|
|
2270
|
+
// ranges: `bytesRead` is how much of this range arrived, `filePosition` is where in the
|
|
2271
|
+
// file it gave up. Reporting the position under the name of the count made a windowed
|
|
2272
|
+
// read of a truncated file claim tens of megabytes had been read when a few hundred
|
|
2273
|
+
// bytes had.
|
|
2274
|
+
bytesRead: done,
|
|
2275
|
+
filePosition: filePosition + done,
|
|
2276
|
+
requiredBytes,
|
|
2277
|
+
stage: "readData"
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
done += bytesRead;
|
|
2281
|
+
}
|
|
1951
2282
|
}
|
|
1952
2283
|
/**
|
|
1953
2284
|
* Parses the XML header of the QVD file. This method is part of the parsing process
|
|
@@ -1984,17 +2315,31 @@ var init_QvdFileReader = __esm({
|
|
|
1984
2315
|
stage: "parseHeader"
|
|
1985
2316
|
});
|
|
1986
2317
|
}
|
|
2318
|
+
validateHeaderStructure(this._header, this._path, "parseHeader");
|
|
1987
2319
|
const fields = this._header["QvdTableHeader"]?.["Fields"]?.["QvdFieldHeader"];
|
|
1988
|
-
const
|
|
1989
|
-
if (
|
|
2320
|
+
const fieldList = fields === void 0 || fields === null ? [] : Array.isArray(fields) ? fields : [fields];
|
|
2321
|
+
if (fieldList.length === 0) {
|
|
1990
2322
|
throw new exports.QvdCorruptedError("The QVD file header declares no fields", {
|
|
1991
2323
|
file: this._path,
|
|
1992
2324
|
stage: "parseHeader"
|
|
1993
2325
|
});
|
|
1994
2326
|
}
|
|
2327
|
+
const malformedIndex = fieldList.findIndex(
|
|
2328
|
+
(field) => field === null || typeof field !== "object" || Array.isArray(field)
|
|
2329
|
+
);
|
|
2330
|
+
if (malformedIndex !== -1) {
|
|
2331
|
+
throw new exports.QvdCorruptedError("The QVD file header declares a field with no properties", {
|
|
2332
|
+
fieldIndex: malformedIndex,
|
|
2333
|
+
fieldCount: fieldList.length,
|
|
2334
|
+
file: this._path,
|
|
2335
|
+
stage: "parseHeader"
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
1995
2338
|
this._headerOffset = headerBeginIndex;
|
|
1996
2339
|
this._symbolTableOffset = headerEndIndex;
|
|
1997
2340
|
this._indexTableOffset = this._symbolTableOffset + parseInt(this._header["QvdTableHeader"]["Offset"], 10);
|
|
2341
|
+
this._allFields = fieldList;
|
|
2342
|
+
this._selectedFields = selectFields(this._allFields, this._requestedFields, this._path);
|
|
1998
2343
|
}
|
|
1999
2344
|
/**
|
|
2000
2345
|
* Establishes the geometry of the index table, and validates it.
|
|
@@ -2005,14 +2350,15 @@ var init_QvdFileReader = __esm({
|
|
|
2005
2350
|
* about keeping the sign in step with the other one: the two could drift, and #113 is what
|
|
2006
2351
|
* that looks like when they do. There is one copy now.
|
|
2007
2352
|
*
|
|
2008
|
-
* @param {
|
|
2353
|
+
* @param {QvdRowWindow} window The rows of interest, as file row indices.
|
|
2009
2354
|
* @param {string} stage Stage name for any error raised here.
|
|
2010
2355
|
* @return {{fields: Array<any>, recordSize: number, totalRows: number, rowsToLoad: number,
|
|
2011
|
-
* indexBuffer: Buffer}} The record geometry.
|
|
2356
|
+
* indexBuffer: Buffer}} The record geometry. `indexBuffer` starts at the window's first
|
|
2357
|
+
* record, so the decoder always counts from zero.
|
|
2012
2358
|
* @private
|
|
2013
2359
|
*/
|
|
2014
|
-
_planIndexTable(
|
|
2015
|
-
if (!this._buffer || !this._header || !this._indexTableOffset) {
|
|
2360
|
+
_planIndexTable(window, stage) {
|
|
2361
|
+
if (!this._buffer || !this._header || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
|
|
2016
2362
|
throw new exports.QvdCorruptedError(
|
|
2017
2363
|
"The QVD file has not been loaded in the proper order or has not been loaded at all.",
|
|
2018
2364
|
{
|
|
@@ -2021,14 +2367,12 @@ var init_QvdFileReader = __esm({
|
|
|
2021
2367
|
}
|
|
2022
2368
|
);
|
|
2023
2369
|
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
fields = [fields];
|
|
2027
|
-
}
|
|
2370
|
+
const allFields = this._allFields;
|
|
2371
|
+
const fields = this._selectedFields;
|
|
2028
2372
|
const recordSize = parseInt(this._header["QvdTableHeader"]["RecordByteSize"], 10);
|
|
2029
2373
|
const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
|
|
2030
|
-
const rowsToLoad = rowLimit !== null ? Math.min(rowLimit, totalRows) : totalRows;
|
|
2031
2374
|
const indexTableLength = parseInt(this._header["QvdTableHeader"]["Length"], 10);
|
|
2375
|
+
const { offset: firstRow, limit: rowsToLoad } = resolveWindow(window, totalRows);
|
|
2032
2376
|
validateIndexTableMetadata(
|
|
2033
2377
|
recordSize,
|
|
2034
2378
|
totalRows,
|
|
@@ -2037,11 +2381,20 @@ var init_QvdFileReader = __esm({
|
|
|
2037
2381
|
this._buffer.length,
|
|
2038
2382
|
rowsToLoad,
|
|
2039
2383
|
this._path,
|
|
2040
|
-
this._fileSize
|
|
2384
|
+
this._fileSize,
|
|
2385
|
+
firstRow,
|
|
2386
|
+
this._bufferFirstRow
|
|
2041
2387
|
);
|
|
2042
|
-
const
|
|
2043
|
-
|
|
2044
|
-
|
|
2388
|
+
const bufferRecordStart = (firstRow - this._bufferFirstRow) * recordSize;
|
|
2389
|
+
const indexBuffer = this._buffer.subarray(
|
|
2390
|
+
this._indexTableOffset + bufferRecordStart,
|
|
2391
|
+
this._indexTableOffset + bufferRecordStart + rowsToLoad * recordSize
|
|
2392
|
+
);
|
|
2393
|
+
if (!this._fieldBitMetadataValidated) {
|
|
2394
|
+
for (const field of allFields) {
|
|
2395
|
+
validateFieldBitMetadata(field, recordSize, this._path);
|
|
2396
|
+
}
|
|
2397
|
+
this._fieldBitMetadataValidated = true;
|
|
2045
2398
|
}
|
|
2046
2399
|
assert2__default.default(
|
|
2047
2400
|
rowsToLoad === 0 || recordSize === 0 || Math.floor(indexBuffer.length / recordSize) >= rowsToLoad,
|
|
@@ -2053,31 +2406,45 @@ var init_QvdFileReader = __esm({
|
|
|
2053
2406
|
* Analyzes the index table to determine which symbols are actually needed.
|
|
2054
2407
|
* This is used for two-pass symbol filtering optimization.
|
|
2055
2408
|
*
|
|
2056
|
-
*
|
|
2057
|
-
*
|
|
2409
|
+
* Only the selected fields are analysed. An unselected field's symbols are never parsed, so
|
|
2410
|
+
* there is nothing for a usage set to filter and decoding its column would be a pass over the
|
|
2411
|
+
* whole window for an answer nobody reads.
|
|
2412
|
+
*
|
|
2413
|
+
* @param {QvdRowWindow} window The rows to analyse.
|
|
2414
|
+
* @return {Promise<Array<Set<number>>>} One set of needed symbol indices per selected field, in
|
|
2415
|
+
* the same order `_parseSymbolTable` walks them.
|
|
2058
2416
|
* @private
|
|
2059
2417
|
*/
|
|
2060
|
-
async _analyzeIndexTableSymbolUsage(
|
|
2061
|
-
const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(
|
|
2062
|
-
const symbolUsage =
|
|
2063
|
-
const
|
|
2064
|
-
|
|
2418
|
+
async _analyzeIndexTableSymbolUsage(window) {
|
|
2419
|
+
const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(window, "analyzeIndexTableSymbolUsage");
|
|
2420
|
+
const symbolUsage = [];
|
|
2421
|
+
const sliceRows = Math.min(rowsToLoad, ANALYSIS_SLICE_ROWS);
|
|
2422
|
+
const column = new Int32Array(sliceRows);
|
|
2423
|
+
fields.forEach((field, position) => {
|
|
2424
|
+
this._throwIfAborted();
|
|
2065
2425
|
const needed = /* @__PURE__ */ new Set();
|
|
2066
|
-
symbolUsage
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2426
|
+
symbolUsage[position] = needed;
|
|
2427
|
+
const bitOffset = parseInt(field["BitOffset"], 10);
|
|
2428
|
+
const bitWidth = parseInt(field["BitWidth"], 10);
|
|
2429
|
+
const bias = parseInt(field["Bias"], 10);
|
|
2430
|
+
for (let first = 0; first < rowsToLoad; first += sliceRows) {
|
|
2431
|
+
const count = Math.min(sliceRows, rowsToLoad - first);
|
|
2432
|
+
decodeIndexColumn(
|
|
2433
|
+
first === 0 ? indexBuffer : indexBuffer.subarray(first * recordSize),
|
|
2434
|
+
recordSize,
|
|
2435
|
+
count,
|
|
2436
|
+
bitOffset,
|
|
2437
|
+
bitWidth,
|
|
2438
|
+
bias,
|
|
2439
|
+
column
|
|
2440
|
+
);
|
|
2441
|
+
for (let row = 0; row < count; row++) {
|
|
2442
|
+
if (column[row] >= 0) {
|
|
2443
|
+
needed.add(column[row]);
|
|
2444
|
+
}
|
|
2079
2445
|
}
|
|
2080
2446
|
}
|
|
2447
|
+
this._emitProgress("symbol-analysis", position + 1, fields.length);
|
|
2081
2448
|
});
|
|
2082
2449
|
return symbolUsage;
|
|
2083
2450
|
}
|
|
@@ -2085,12 +2452,20 @@ var init_QvdFileReader = __esm({
|
|
|
2085
2452
|
* Parses the symbol table of the QVD file. This method is part of the parsing process
|
|
2086
2453
|
* and should not be called directly.
|
|
2087
2454
|
*
|
|
2088
|
-
*
|
|
2089
|
-
*
|
|
2090
|
-
*
|
|
2455
|
+
* A field the caller did not select is skipped whole. Its symbol area is neither scanned nor
|
|
2456
|
+
* parsed - the per-field `Offset` and `Length` say exactly where it is, so there is nothing to
|
|
2457
|
+
* walk past - and that is where field selection earns its keep. The index decode is cheap by
|
|
2458
|
+
* comparison; parsing symbols is not.
|
|
2459
|
+
*
|
|
2460
|
+
* @param {Array<Set<number>>|null} symbolsToKeep Optional set of symbol indices to keep per
|
|
2461
|
+
* selected field, indexed by position. If provided, only these symbols will be parsed
|
|
2462
|
+
* (two-pass filtering optimization).
|
|
2463
|
+
* @param {number} rowsToLoad Rows the read covers, for memory estimation.
|
|
2464
|
+
* @param {{rows: number, perChunk: number}|null} [liveRows=null] Rows held at one instant when
|
|
2465
|
+
* that is fewer than the window covers - see `_prepare`.
|
|
2091
2466
|
*/
|
|
2092
|
-
async _parseSymbolTable(symbolsToKeep = null,
|
|
2093
|
-
if (!this._buffer || !this._header || !this._symbolTableOffset || !this._indexTableOffset) {
|
|
2467
|
+
async _parseSymbolTable(symbolsToKeep = null, rowsToLoad = 0, liveRows = null) {
|
|
2468
|
+
if (!this._buffer || !this._header || !this._symbolTableOffset || !this._indexTableOffset || !this._selectedFields || !this._allFields) {
|
|
2094
2469
|
throw new exports.QvdCorruptedError(
|
|
2095
2470
|
"The QVD file has not been loaded in the proper order or has not been loaded at all.",
|
|
2096
2471
|
{
|
|
@@ -2099,7 +2474,8 @@ var init_QvdFileReader = __esm({
|
|
|
2099
2474
|
}
|
|
2100
2475
|
);
|
|
2101
2476
|
}
|
|
2102
|
-
|
|
2477
|
+
const allFields = this._allFields;
|
|
2478
|
+
const fields = this._selectedFields;
|
|
2103
2479
|
const symbolBuffer = this._buffer.subarray(this._symbolTableOffset, this._indexTableOffset);
|
|
2104
2480
|
const symbolTableSize = symbolBuffer.length;
|
|
2105
2481
|
const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
|
|
@@ -2107,32 +2483,25 @@ var init_QvdFileReader = __esm({
|
|
|
2107
2483
|
if (this._headerMatchesFile) {
|
|
2108
2484
|
validateMemoryAvailability(
|
|
2109
2485
|
symbolTableSize,
|
|
2110
|
-
|
|
2486
|
+
rowsToLoad,
|
|
2111
2487
|
totalRows,
|
|
2112
2488
|
this._path,
|
|
2113
2489
|
this._memorySafetyFactor,
|
|
2114
|
-
|
|
2115
|
-
this._materialisesRows
|
|
2490
|
+
fields.length,
|
|
2491
|
+
this._materialisesRows,
|
|
2492
|
+
liveRows
|
|
2116
2493
|
);
|
|
2117
2494
|
}
|
|
2118
|
-
warnLargeSymbolTable(
|
|
2119
|
-
|
|
2120
|
-
maxRows,
|
|
2121
|
-
totalRows,
|
|
2122
|
-
Array.isArray(fields) ? fields.length : 1,
|
|
2123
|
-
this._materialisesRows
|
|
2124
|
-
);
|
|
2125
|
-
if (!Array.isArray(fields)) {
|
|
2126
|
-
fields = [fields];
|
|
2127
|
-
}
|
|
2128
|
-
for (const field of fields) {
|
|
2495
|
+
warnLargeSymbolTable(symbolTableSize, rowsToLoad, totalRows, fields.length, this._materialisesRows);
|
|
2496
|
+
for (const field of allFields) {
|
|
2129
2497
|
validateFieldMetadata(field, symbolBuffer.length, this._path);
|
|
2130
2498
|
}
|
|
2131
|
-
this._symbolTable = fields.map((field) => {
|
|
2499
|
+
this._symbolTable = fields.map((field, position) => {
|
|
2500
|
+
this._throwIfAborted();
|
|
2132
2501
|
const symbolsOffset = parseInt(field["Offset"], 10);
|
|
2133
2502
|
const symbolsLength = parseInt(field["Length"], 10);
|
|
2134
2503
|
const fieldName = field["FieldName"];
|
|
2135
|
-
const neededSymbols = symbolsToKeep ? symbolsToKeep
|
|
2504
|
+
const neededSymbols = symbolsToKeep ? symbolsToKeep[position] : null;
|
|
2136
2505
|
const filteringEnabled = neededSymbols !== null;
|
|
2137
2506
|
const symbols = [];
|
|
2138
2507
|
let symbolIndex = 0;
|
|
@@ -2152,6 +2521,7 @@ var init_QvdFileReader = __esm({
|
|
|
2152
2521
|
pointer += bytesRead - 1;
|
|
2153
2522
|
symbolIndex++;
|
|
2154
2523
|
}
|
|
2524
|
+
this._emitProgress("symbol-table", position + 1, fields.length);
|
|
2155
2525
|
return symbols;
|
|
2156
2526
|
});
|
|
2157
2527
|
}
|
|
@@ -2170,13 +2540,18 @@ var init_QvdFileReader = __esm({
|
|
|
2170
2540
|
* same for every row, so they are hoisted out of the loop and the inner loop does arithmetic
|
|
2171
2541
|
* into a typed array and nothing else. Rows are assembled later, once, in `load()`.
|
|
2172
2542
|
*
|
|
2173
|
-
*
|
|
2543
|
+
* The window is what makes chunked iteration cheap: `decodeIndexColumn` walks records by
|
|
2544
|
+
* `base += recordSize`, so decoding rows k to k+n is a question of where the buffer slice starts
|
|
2545
|
+
* and how many iterations run. Nothing about the decoder changed to support it.
|
|
2546
|
+
*
|
|
2547
|
+
* @param {QvdRowWindow} window The rows to decode.
|
|
2174
2548
|
*/
|
|
2175
|
-
async _parseIndexTable(
|
|
2176
|
-
const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(
|
|
2549
|
+
async _parseIndexTable(window) {
|
|
2550
|
+
const { fields, recordSize, rowsToLoad, indexBuffer } = this._planIndexTable(window, "parseIndexTable");
|
|
2177
2551
|
this._rowsDecoded = rowsToLoad;
|
|
2178
|
-
this._indexColumns = fields.map(
|
|
2179
|
-
(
|
|
2552
|
+
this._indexColumns = fields.map((field, position) => {
|
|
2553
|
+
this._throwIfAborted();
|
|
2554
|
+
const column = decodeIndexColumn(
|
|
2180
2555
|
indexBuffer,
|
|
2181
2556
|
recordSize,
|
|
2182
2557
|
rowsToLoad,
|
|
@@ -2184,8 +2559,10 @@ var init_QvdFileReader = __esm({
|
|
|
2184
2559
|
parseInt(field["BitWidth"], 10),
|
|
2185
2560
|
parseInt(field["Bias"], 10),
|
|
2186
2561
|
new Int32Array(rowsToLoad)
|
|
2187
|
-
)
|
|
2188
|
-
|
|
2562
|
+
);
|
|
2563
|
+
this._emitProgress("index-table", position + 1, fields.length);
|
|
2564
|
+
return column;
|
|
2565
|
+
});
|
|
2189
2566
|
}
|
|
2190
2567
|
/**
|
|
2191
2568
|
* Reads the file's schema and header metadata, without touching the symbol or index tables.
|
|
@@ -2202,8 +2579,11 @@ var init_QvdFileReader = __esm({
|
|
|
2202
2579
|
* @return {Promise<import('./QvdDataFrame.js').QvdFileMetadata>} The file's schema and header.
|
|
2203
2580
|
*/
|
|
2204
2581
|
async loadMetadata() {
|
|
2205
|
-
await this._readData(null, true);
|
|
2582
|
+
await this._readData({ offset: 0, limit: null }, true);
|
|
2583
|
+
this._emitProgress("header", 0, 1);
|
|
2206
2584
|
await this._parseHeader();
|
|
2585
|
+
this._emitProgress("header", 1, 1);
|
|
2586
|
+
this._throwIfAborted();
|
|
2207
2587
|
assert2__default.default(this._header, "The QVD file header has not been parsed.");
|
|
2208
2588
|
const header = this._header["QvdTableHeader"];
|
|
2209
2589
|
let fields = header["Fields"]?.["QvdFieldHeader"] ?? [];
|
|
@@ -2238,114 +2618,200 @@ var init_QvdFileReader = __esm({
|
|
|
2238
2618
|
/**
|
|
2239
2619
|
* Loads the QVD file into memory and parses it.
|
|
2240
2620
|
*
|
|
2241
|
-
* @param {number|null
|
|
2242
|
-
*
|
|
2243
|
-
*
|
|
2621
|
+
* @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [window]
|
|
2622
|
+
* The rows to load. A number or null means what it always meant - the first N rows, or all of
|
|
2623
|
+
* them - and `{offset, limit}` is the same thing said more precisely, so `5` and
|
|
2624
|
+
* `{offset: 0, limit: 5}` are one read. `maxRows` is accepted as a second name for `limit`.
|
|
2625
|
+
* @throws {QvdValidationError} If the window is not a non-negative integer, null, or a valid
|
|
2626
|
+
* `{offset, limit}` object.
|
|
2244
2627
|
* @return {Promise<QvdDataFrame>} The loaded QVD file.
|
|
2245
2628
|
*/
|
|
2246
|
-
async load(
|
|
2247
|
-
const
|
|
2629
|
+
async load(window = null) {
|
|
2630
|
+
const rows = normaliseWindow(window, this._path);
|
|
2631
|
+
const prepared = await this._prepare(rows);
|
|
2632
|
+
await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
|
|
2633
|
+
const data = this._buildRows(prepared.resolvedByField, 0, prepared.rowsAvailable);
|
|
2634
|
+
return new exports.QvdDataFrame(data, prepared.columns, prepared.metadata, {
|
|
2635
|
+
...prepared.loadStats,
|
|
2636
|
+
rowsLoaded: data.length
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Reads the file as columns, without ever materialising rows.
|
|
2641
|
+
*
|
|
2642
|
+
* Shares every step with `load()` up to the point where rows would be built - see `_prepare`.
|
|
2643
|
+
* What it keeps instead is what the decoder already produced: one `Int32Array` of stored
|
|
2644
|
+
* indices per field, and one resolved value per distinct symbol. On the 1.7M x 20 taxi
|
|
2645
|
+
* fixture that is 38.6 MiB against the 352.8 MiB `data` retains, because a column costs four
|
|
2646
|
+
* bytes per row rather than a boxed value per cell, and the symbols are a few thousand
|
|
2647
|
+
* entries shared across every row that uses them.
|
|
2648
|
+
*
|
|
2649
|
+
* @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} [window]
|
|
2650
|
+
* The rows to decode, in the same spellings `load()` accepts.
|
|
2651
|
+
* @return {Promise<import('./QvdColumnTable.js').QvdColumnTable>} The decoded columns.
|
|
2652
|
+
*/
|
|
2653
|
+
async loadColumnar(window = null) {
|
|
2654
|
+
const rows = normaliseWindow(window, this._path);
|
|
2655
|
+
const prepared = await this._prepare(rows);
|
|
2656
|
+
await this._parseIndexTable({ offset: prepared.offset, limit: prepared.rowsAvailable });
|
|
2657
|
+
const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
|
|
2248
2658
|
assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
}
|
|
2258
|
-
data[row] = values;
|
|
2259
|
-
}
|
|
2260
|
-
loadStats.rowsLoaded = data.length;
|
|
2261
|
-
return new exports.QvdDataFrame(data, columns, metadata, loadStats);
|
|
2659
|
+
return new QvdColumnTable2({
|
|
2660
|
+
columns: prepared.columns,
|
|
2661
|
+
codesByField: this._indexColumns,
|
|
2662
|
+
symbolsByField: prepared.resolvedByField,
|
|
2663
|
+
rowCount: this._rowsDecoded,
|
|
2664
|
+
metadata: prepared.metadata,
|
|
2665
|
+
loadStats: { ...prepared.loadStats, rowsLoaded: this._rowsDecoded }
|
|
2666
|
+
});
|
|
2262
2667
|
}
|
|
2263
2668
|
/**
|
|
2264
|
-
*
|
|
2669
|
+
* Yields the window as data frames of at most `chunkSize` rows.
|
|
2265
2670
|
*
|
|
2266
|
-
*
|
|
2267
|
-
*
|
|
2268
|
-
*
|
|
2269
|
-
*
|
|
2270
|
-
*
|
|
2671
|
+
* The file is opened, read and parsed **once**; only the index decode and the row building
|
|
2672
|
+
* happen per chunk. That is the whole reason this exists as a method rather than as a loop of
|
|
2673
|
+
* `load({offset, limit})` calls at the call site: the symbol table has to be parsed in full
|
|
2674
|
+
* whatever the chunk size - a stored index in the last chunk can address the first symbol -
|
|
2675
|
+
* and re-parsing it per chunk is what makes the obvious implementation cost more than a plain
|
|
2676
|
+
* load rather than less. PyQvd's chunked read does re-read it, and the comment on #140 records
|
|
2677
|
+
* that as a limitation rather than a design.
|
|
2271
2678
|
*
|
|
2272
|
-
*
|
|
2273
|
-
*
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2679
|
+
* What it bounds is row materialisation, which is what actually dominates a large read's heap.
|
|
2680
|
+
* Two chunks of rows are alive at a time, not one - `for await` keeps the yielded frame
|
|
2681
|
+
* reachable while this generator builds the next - which is why `liveRows` below is
|
|
2682
|
+
* `chunkSize * 2`, and why the heap it needs is twice what one chunk suggests.
|
|
2683
|
+
*
|
|
2684
|
+
* A window covering no rows yields nothing at all, rather than one empty frame - so
|
|
2685
|
+
* `for await` over an exhausted offset does nothing, which is what a paging loop wants.
|
|
2686
|
+
*
|
|
2687
|
+
* @param {number|null|{offset?: number, limit?: number|null, maxRows?: number|null}} window
|
|
2688
|
+
* The rows to cover, in the same spellings `load()` accepts.
|
|
2689
|
+
* @param {number} chunkSize Rows per frame. Must be a positive integer.
|
|
2690
|
+
* @return {AsyncGenerator<QvdDataFrame>} The chunks, in order.
|
|
2276
2691
|
*/
|
|
2277
|
-
async
|
|
2278
|
-
if (
|
|
2279
|
-
throw new exports.QvdValidationError("
|
|
2280
|
-
provided:
|
|
2281
|
-
type: typeof
|
|
2692
|
+
async *iterateRows(window, chunkSize) {
|
|
2693
|
+
if (typeof chunkSize !== "number" || !Number.isInteger(chunkSize) || chunkSize <= 0) {
|
|
2694
|
+
throw new exports.QvdValidationError("chunkSize must be a positive integer", {
|
|
2695
|
+
provided: chunkSize,
|
|
2696
|
+
type: typeof chunkSize,
|
|
2282
2697
|
file: this._path
|
|
2283
2698
|
});
|
|
2284
2699
|
}
|
|
2285
|
-
|
|
2700
|
+
const liveRows = { rows: chunkSize * 2, perChunk: 2 };
|
|
2701
|
+
const rows = normaliseWindow(window, this._path);
|
|
2702
|
+
const prepared = await this._prepare(rows, liveRows);
|
|
2703
|
+
for (let done = 0; done < prepared.rowsAvailable; done += chunkSize) {
|
|
2704
|
+
this._throwIfAborted();
|
|
2705
|
+
const count = Math.min(chunkSize, prepared.rowsAvailable - done);
|
|
2706
|
+
const offset = prepared.offset + done;
|
|
2707
|
+
await this._parseIndexTable({ offset, limit: count });
|
|
2708
|
+
const data = this._buildRows(prepared.resolvedByField, done, prepared.rowsAvailable);
|
|
2709
|
+
yield new exports.QvdDataFrame(data, prepared.columns, prepared.metadata, {
|
|
2710
|
+
...prepared.loadStats,
|
|
2711
|
+
offset,
|
|
2712
|
+
rowsLoaded: data.length
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* Reads the file and resolves its symbols, stopping short of decoding any rows.
|
|
2718
|
+
*
|
|
2719
|
+
* Everything `load()`, `loadColumnar()` and `iterateRows()` have in common, which is everything
|
|
2720
|
+
* that depends on the file rather than on the window. Two read paths for one binary format is
|
|
2721
|
+
* the drift risk #113 is the standing example of - a stored index resolved one way here and
|
|
2722
|
+
* another way there returns plausible wrong values and throws nothing - so there is one path,
|
|
2723
|
+
* and the entry points differ only in what they do with what it returns and how many rows they
|
|
2724
|
+
* ask for at a time.
|
|
2725
|
+
*
|
|
2726
|
+
* @param {QvdRowWindow} window The rows the read covers.
|
|
2727
|
+
* @param {{rows: number, perChunk: number}|null} [liveRows] Rows held at one instant when that
|
|
2728
|
+
* is fewer than the window covers, and how many of them one row of the caller's chunk size
|
|
2729
|
+
* accounts for. Only `iterateRows` passes it; every other read holds what it covers.
|
|
2730
|
+
* @return {Promise<{columns: Array<string>, metadata: any, loadStats: any,
|
|
2731
|
+
* resolvedByField: Array<Array<any>>, rowsAvailable: number, offset: number}>} The parsed
|
|
2732
|
+
* file, with the window as it resolved against it.
|
|
2733
|
+
* @private
|
|
2734
|
+
*/
|
|
2735
|
+
async _prepare(window, liveRows = null) {
|
|
2736
|
+
this._throwIfAborted();
|
|
2737
|
+
await this._readData(window, false, liveRows);
|
|
2738
|
+
this._emitProgress("header", 0, 1);
|
|
2286
2739
|
await this._parseHeader();
|
|
2740
|
+
this._emitProgress("header", 1, 1);
|
|
2741
|
+
this._throwIfAborted();
|
|
2742
|
+
assert2__default.default(this._header, "The QVD file header has not been parsed.");
|
|
2743
|
+
const totalRows = parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10);
|
|
2744
|
+
const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
|
|
2745
|
+
const resolved = resolveWindow(window, totalRows);
|
|
2746
|
+
const rowsAvailable = resolved.limit;
|
|
2287
2747
|
let symbolsToKeep = null;
|
|
2288
2748
|
let symbolsKept = null;
|
|
2289
|
-
if (
|
|
2290
|
-
const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
|
|
2749
|
+
if (window.limit !== null || window.offset > 0) {
|
|
2291
2750
|
if (symbolTableLength > this._symbolFilteringThreshold) {
|
|
2292
|
-
symbolsToKeep = await this._analyzeIndexTableSymbolUsage(
|
|
2293
|
-
symbolsKept =
|
|
2751
|
+
symbolsToKeep = await this._analyzeIndexTableSymbolUsage({ offset: resolved.offset, limit: rowsAvailable });
|
|
2752
|
+
symbolsKept = symbolsToKeep.reduce((sum, set) => sum + set.size, 0);
|
|
2294
2753
|
}
|
|
2295
2754
|
}
|
|
2296
|
-
await this._parseSymbolTable(symbolsToKeep,
|
|
2297
|
-
await this._parseIndexTable(maxRows);
|
|
2298
|
-
assert2__default.default(this._header, "The QVD file header has not been parsed.");
|
|
2755
|
+
await this._parseSymbolTable(symbolsToKeep, rowsAvailable, liveRows);
|
|
2299
2756
|
assert2__default.default(this._symbolTable, "The QVD file symbol table has not been parsed.");
|
|
2300
|
-
|
|
2757
|
+
this._throwIfAborted();
|
|
2301
2758
|
const resolvedByField = this._symbolTable.map((symbols) => {
|
|
2302
|
-
const
|
|
2759
|
+
const resolved2 = new Array(symbols.length);
|
|
2303
2760
|
for (let index = 0; index < symbols.length; index++) {
|
|
2304
2761
|
const value = symbols[index]?.toPrimaryValue();
|
|
2305
|
-
|
|
2762
|
+
resolved2[index] = typeof value === "string" && value.trim() !== "" && !isNaN(Number(value)) ? Number(value) : value;
|
|
2306
2763
|
}
|
|
2307
|
-
return
|
|
2764
|
+
return resolved2;
|
|
2308
2765
|
});
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
fields = [fields];
|
|
2312
|
-
}
|
|
2313
|
-
const columns = fields.map((field) => field["FieldName"]);
|
|
2766
|
+
assert2__default.default(this._selectedFields, "The QVD file fields have not been resolved.");
|
|
2767
|
+
const columns = this._selectedFields.map((field) => field["FieldName"]);
|
|
2314
2768
|
const metadata = this._header["QvdTableHeader"];
|
|
2315
2769
|
const loadStats = {
|
|
2316
|
-
symbolTableBytes:
|
|
2317
|
-
totalRows
|
|
2318
|
-
rowsLoaded:
|
|
2770
|
+
symbolTableBytes: symbolTableLength,
|
|
2771
|
+
totalRows,
|
|
2772
|
+
rowsLoaded: 0,
|
|
2773
|
+
offset: resolved.offset,
|
|
2319
2774
|
symbolFiltering: symbolsToKeep !== null,
|
|
2320
2775
|
symbolsKept
|
|
2321
2776
|
};
|
|
2322
|
-
return { columns, metadata, loadStats, resolvedByField };
|
|
2777
|
+
return { columns, metadata, loadStats, resolvedByField, rowsAvailable, offset: resolved.offset };
|
|
2323
2778
|
}
|
|
2324
2779
|
/**
|
|
2325
|
-
*
|
|
2780
|
+
* Builds rows from the columns currently decoded.
|
|
2326
2781
|
*
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2331
|
-
*
|
|
2332
|
-
* entries shared across every row that uses them.
|
|
2782
|
+
* `data` stays eager: of the four ways this library is used - a full read, a preview already
|
|
2783
|
+
* bounded by a limit, writing an array out, and reading metadata - not one is helped by
|
|
2784
|
+
* materialising a row only when it is touched, and a lazy accessor would cost a proxy, a cache
|
|
2785
|
+
* and mutation semantics to serve none of them. A caller who wants columns without paying for
|
|
2786
|
+
* rows uses `QvdColumnTable`, which stops before this loop.
|
|
2333
2787
|
*
|
|
2334
|
-
* @param {
|
|
2335
|
-
* @
|
|
2788
|
+
* @param {Array<Array<any>>} resolvedByField One resolved value per distinct symbol, per field.
|
|
2789
|
+
* @param {number} progressBase Rows already delivered before this call, so that progress over a
|
|
2790
|
+
* chunked iteration counts the whole window rather than restarting at every chunk.
|
|
2791
|
+
* @param {number} progressTotal Rows the whole window covers.
|
|
2792
|
+
* @return {Array<Array<any>>} The rows.
|
|
2793
|
+
* @private
|
|
2336
2794
|
*/
|
|
2337
|
-
|
|
2338
|
-
const { columns, metadata, loadStats, resolvedByField } = await this._decode(maxRows);
|
|
2339
|
-
const { QvdColumnTable: QvdColumnTable2 } = await Promise.resolve().then(() => (init_QvdColumnTable(), QvdColumnTable_exports));
|
|
2795
|
+
_buildRows(resolvedByField, progressBase, progressTotal) {
|
|
2340
2796
|
assert2__default.default(this._indexColumns, "The QVD file index table has not been parsed.");
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2797
|
+
const indexColumns = this._indexColumns;
|
|
2798
|
+
const fieldCount = indexColumns.length;
|
|
2799
|
+
const rowCount = this._rowsDecoded;
|
|
2800
|
+
const data = new Array(rowCount);
|
|
2801
|
+
const reportInterval = Math.max(1, Math.floor(progressTotal / 100));
|
|
2802
|
+
for (let row = 0; row < rowCount; row++) {
|
|
2803
|
+
const values = new Array(fieldCount);
|
|
2804
|
+
for (let field = 0; field < fieldCount; field++) {
|
|
2805
|
+
const symbolIndex = indexColumns[field][row];
|
|
2806
|
+
values[field] = symbolIndex < 0 ? null : resolvedByField[field][symbolIndex];
|
|
2807
|
+
}
|
|
2808
|
+
data[row] = values;
|
|
2809
|
+
if ((progressBase + row + 1) % reportInterval === 0 || row + 1 === rowCount) {
|
|
2810
|
+
this._throwIfAborted();
|
|
2811
|
+
this._emitProgress("rows", progressBase + row + 1, progressTotal);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
return data;
|
|
2349
2815
|
}
|
|
2350
2816
|
};
|
|
2351
2817
|
}
|
|
@@ -2356,6 +2822,7 @@ exports.QvdDataFrame = void 0;
|
|
|
2356
2822
|
var init_QvdDataFrame = __esm({
|
|
2357
2823
|
"src/QvdDataFrame.js"() {
|
|
2358
2824
|
init_QvdErrors();
|
|
2825
|
+
init_readOptions();
|
|
2359
2826
|
exports.QvdDataFrame = class _QvdDataFrame {
|
|
2360
2827
|
/**
|
|
2361
2828
|
* Represents the data frame stored inside a QVD file.
|
|
@@ -2398,12 +2865,15 @@ var init_QvdDataFrame = __esm({
|
|
|
2398
2865
|
/**
|
|
2399
2866
|
* Returns statistics about the read that produced this data frame.
|
|
2400
2867
|
*
|
|
2401
|
-
*
|
|
2402
|
-
*
|
|
2868
|
+
* Carried by every frame that came from a file - `fromQvd()`, and each chunk `iterate()` yields,
|
|
2869
|
+
* which is how a chunk reports its `offset`. `fromDict()`, `head()`, `tail()`, `rows()` and
|
|
2870
|
+
* `select()` describe no particular read and report null rather than a stale figure.
|
|
2403
2871
|
*
|
|
2404
2872
|
* The main use is confirming that a lazy load actually filtered the symbol table:
|
|
2405
2873
|
* `symbolFiltering` says whether the two-pass path ran, and `symbolsKept` how many symbols
|
|
2406
|
-
* survived it
|
|
2874
|
+
* survived it. Note that a bounded read does not filter on its own - the two-pass path engages
|
|
2875
|
+
* only above `symbolFilteringThreshold`, so on a file below it this reports false and every
|
|
2876
|
+
* symbol was parsed however few rows were asked for.
|
|
2407
2877
|
*
|
|
2408
2878
|
* @return {QvdLoadStats|null} Load statistics, or null if this frame did not come from a file.
|
|
2409
2879
|
*/
|
|
@@ -2780,6 +3250,18 @@ var init_QvdDataFrame = __esm({
|
|
|
2780
3250
|
* @param {Object} [options] Optional loading options.
|
|
2781
3251
|
* @param {number|null} [options.maxRows] The maximum number of rows to load. Must be a non-negative
|
|
2782
3252
|
* integer; if not specified or null, all rows are loaded. Anything else throws a QvdValidationError.
|
|
3253
|
+
* This is the older name for `limit`; the two are the same option and passing both throws.
|
|
3254
|
+
* @param {number|null} [options.limit] Rows to read, counting from `offset`. The same number as
|
|
3255
|
+
* `maxRows`, spelled so that it reads correctly beside an offset.
|
|
3256
|
+
* @param {number} [options.offset=0] File row to start at. An offset past the end of the file
|
|
3257
|
+
* returns no rows rather than throwing, so a paging loop terminates on its own.
|
|
3258
|
+
* @param {Array<string>|null} [options.fields] Field names to read, in the order they should
|
|
3259
|
+
* appear in the result. Unselected fields have their symbols skipped entirely rather than
|
|
3260
|
+
* parsed and discarded. An unknown or repeated name throws.
|
|
3261
|
+
* @param {Function} [options.onProgress] Called with `{stage, current, total, percent}` as the
|
|
3262
|
+
* read proceeds - the same shape `toQvd`'s callback receives.
|
|
3263
|
+
* @param {AbortSignal} [options.signal] Cancels the read. The rejection is `signal.reason`,
|
|
3264
|
+
* which is a `DOMException` named `AbortError` unless you aborted with a reason of your own.
|
|
2783
3265
|
* @param {string} [options.allowedDir] Optional allowed directory path. If provided, the file path
|
|
2784
3266
|
* must be within this directory, with symlinks resolved first, so a link inside it that points
|
|
2785
3267
|
* outside it is rejected. Defaults to the current working directory. To permit an entire
|
|
@@ -2792,17 +3274,42 @@ var init_QvdDataFrame = __esm({
|
|
|
2792
3274
|
* **Zero disables the memory check entirely.**
|
|
2793
3275
|
* @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes, above which
|
|
2794
3276
|
* a lazy load switches to the two-pass filtering path. Defaults to 50MB.
|
|
2795
|
-
* @throws {QvdValidationError} If
|
|
3277
|
+
* @throws {QvdValidationError} If a window option is not a non-negative integer, if both
|
|
3278
|
+
* `maxRows` and `limit` are given, or if `fields` names a column the file does not have.
|
|
2796
3279
|
* @return {Promise<QvdDataFrame>} The data frame of the QVD file.
|
|
2797
3280
|
*/
|
|
2798
3281
|
static async fromQvd(path3, options = {}) {
|
|
2799
3282
|
const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
3283
|
+
return await new QvdFileReader2(path3, readerOptionsFrom(options)).load(windowFrom(options));
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Reads a QVD file in chunks, as an async generator of data frames.
|
|
3287
|
+
*
|
|
3288
|
+
* The file is opened, read and parsed once; only the index decode and the row building happen
|
|
3289
|
+
* per chunk, so what this bounds is row materialisation - the part that actually dominates a
|
|
3290
|
+
* large read's heap. It is **not** constant-memory reading of an arbitrarily large file: the
|
|
3291
|
+
* symbol table is parsed in full whatever the chunk size, because a stored index in the last
|
|
3292
|
+
* chunk can address the first symbol. On a high-cardinality file that table is the bulk of the
|
|
3293
|
+
* cost, and `readMetadata` is the only read that avoids it.
|
|
3294
|
+
*
|
|
3295
|
+
* ```js
|
|
3296
|
+
* for await (const chunk of QvdDataFrame.iterate('big.qvd', {chunkSize: 50_000})) {
|
|
3297
|
+
* process(chunk.data);
|
|
3298
|
+
* }
|
|
3299
|
+
* ```
|
|
3300
|
+
*
|
|
3301
|
+
* A window covering no rows yields nothing, so a loop over an exhausted offset simply does not
|
|
3302
|
+
* run its body.
|
|
3303
|
+
*
|
|
3304
|
+
* @param {string} path The path to the QVD file.
|
|
3305
|
+
* @param {Object} [options] The same options `fromQvd` takes, plus:
|
|
3306
|
+
* @param {number} [options.chunkSize=100000] Rows per frame. Must be a positive integer.
|
|
3307
|
+
* @return {AsyncGenerator<QvdDataFrame>} The chunks, in file order.
|
|
3308
|
+
*/
|
|
3309
|
+
static async *iterate(path3, options = {}) {
|
|
3310
|
+
const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
|
|
3311
|
+
const reader = new QvdFileReader2(path3, readerOptionsFrom(options));
|
|
3312
|
+
yield* reader.iterateRows(windowFrom(options), options.chunkSize === void 0 ? 1e5 : options.chunkSize);
|
|
2806
3313
|
}
|
|
2807
3314
|
/**
|
|
2808
3315
|
* Reads a QVD file's schema and header metadata, without reading its data.
|
|
@@ -2826,11 +3333,15 @@ var init_QvdDataFrame = __esm({
|
|
|
2826
3333
|
* @param {Object} [options] Optional reading options.
|
|
2827
3334
|
* @param {string} [options.allowedDir] Optional allowed directory path, applied exactly as it
|
|
2828
3335
|
* is for `fromQvd`.
|
|
3336
|
+
* @param {Function} [options.onProgress] Called with `{stage, current, total, percent}`, as on
|
|
3337
|
+
* the reads that return data. Only the `read` and `header` stages occur here; there are no
|
|
3338
|
+
* symbols to parse and no rows to build.
|
|
3339
|
+
* @param {AbortSignal} [options.signal] Cancels the read, rejecting with `signal.reason`.
|
|
2829
3340
|
* @return {Promise<QvdFileMetadata>} The file's schema and header metadata.
|
|
2830
3341
|
*/
|
|
2831
3342
|
static async readMetadata(path3, options = {}) {
|
|
2832
3343
|
const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
|
|
2833
|
-
return await new QvdFileReader2(path3,
|
|
3344
|
+
return await new QvdFileReader2(path3, metadataOptionsFrom(options)).loadMetadata();
|
|
2834
3345
|
}
|
|
2835
3346
|
/**
|
|
2836
3347
|
* Constructs a data frame from a dictionary.
|