qvdjs 0.9.2 → 0.9.3
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 +180 -35
- package/dist/index.cjs +84 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +84 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,10 +16,31 @@ The library works with real-world QVD files and maintains compatibility with Qli
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
The _qvdjs_ library provides a simple API for reading
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
The _qvdjs_ library provides a simple API for reading and writing Qlik View Data (QVD) files in JavaScript.
|
|
20
|
+
It parses the binary QVD format into a JavaScript object structure and back again, and is written for Node.js
|
|
21
|
+
(20.10 or newer) exclusively.
|
|
22
|
+
|
|
23
|
+
**What it does well:**
|
|
24
|
+
|
|
25
|
+
- **Large files without loading them.** Pass `maxRows` and the library reads only the header, the symbols those
|
|
26
|
+
rows actually reference, and that slice of the index table. On a file whose symbol table is large because its
|
|
27
|
+
fields hold mostly unique values, this is the difference between minutes and under a second — and it is the
|
|
28
|
+
only way to read a file above 2 GiB at all. See [Lazy Loading](#lazy-loading).
|
|
29
|
+
- **Writing at real row counts.** `toQvd()` handles hundreds of thousands of rows, with optional progress
|
|
30
|
+
callbacks for long writes. Files it produces open in Qlik Sense and QlikView.
|
|
31
|
+
- **Refusing rather than crashing.** A load too large for the process throws a catchable `QvdValidationError`
|
|
32
|
+
naming the limit it hit and a row count that would fit, instead of a `FATAL ERROR: Reached heap limit` that no
|
|
33
|
+
`try`/`catch` can intercept. The budget comes from the V8 heap ceiling and any container memory limit — the
|
|
34
|
+
two things that actually kill a process — so it neither refuses work the machine can do nor waves through work
|
|
35
|
+
it cannot. See [QVD File Size Limitations](#qvd-file-size-limitations).
|
|
36
|
+
- **Corrupt files are detected, not silently misread.** Truncated index tables, missing header delimiters and
|
|
37
|
+
out-of-range offsets raise typed errors rather than returning short or fabricated data.
|
|
38
|
+
- **Path traversal protection by default.** File access is confined to the working directory unless you widen it,
|
|
39
|
+
and containment is decided by the filesystem — symlinks are resolved, so a link inside the allowed directory
|
|
40
|
+
pointing outside it is refused. See [Security Considerations](#security-considerations).
|
|
41
|
+
|
|
42
|
+
**What it does not do yet** is worth knowing before you start: see
|
|
43
|
+
[Known limitations](#known-limitations).
|
|
23
44
|
|
|
24
45
|
---
|
|
25
46
|
|
|
@@ -31,10 +52,13 @@ structure and vice versa. The library is written to be used in a Node.js environ
|
|
|
31
52
|
- [Important: Symbol Table and High-Cardinality Fields](#important-symbol-table-and-high-cardinality-fields)
|
|
32
53
|
- [Performance Optimizations](#performance-optimizations)
|
|
33
54
|
- [QVD File Size Limitations](#qvd-file-size-limitations)
|
|
55
|
+
- [The 2 GiB boundary](#the-2-gib-boundary)
|
|
34
56
|
- [Why Safety Limits Exist](#why-safety-limits-exist)
|
|
35
|
-
- [
|
|
57
|
+
- [Writing QVD files](#writing-qvd-files)
|
|
58
|
+
- [Progress tracking](#progress-tracking)
|
|
36
59
|
- [Working with Metadata](#working-with-metadata)
|
|
37
60
|
- [Security Considerations](#security-considerations)
|
|
61
|
+
- [Known limitations](#known-limitations)
|
|
38
62
|
- [QVD File Format](#qvd-file-format)
|
|
39
63
|
- [XML Header](#xml-header)
|
|
40
64
|
- [Symbol Table](#symbol-table)
|
|
@@ -131,8 +155,11 @@ console.log(df.shape); // [1000, numberOfColumns]
|
|
|
131
155
|
|
|
132
156
|
The QVD format stores data in two parts:
|
|
133
157
|
|
|
134
|
-
1. **Symbol table**: Contains
|
|
135
|
-
|
|
158
|
+
1. **Symbol table**: Contains every unique value for every field. It has to be _scanned_ in full, because a
|
|
159
|
+
symbol's length is only known once the previous one has been read — but with `maxRows` the library parses
|
|
160
|
+
only the symbols the requested rows actually reference and steps over the rest. Scanning is cheap; parsing
|
|
161
|
+
is what costs memory.
|
|
162
|
+
2. **Index table**: Contains row-by-row indices into the symbol table (only the requested rows are read)
|
|
136
163
|
|
|
137
164
|
⚠️ **Performance Impact of High-Cardinality Fields:**
|
|
138
165
|
|
|
@@ -142,12 +169,23 @@ If your QVD file contains fields with many unique values (high cardinality), suc
|
|
|
142
169
|
- Timestamps with millisecond precision
|
|
143
170
|
- Unique text fields
|
|
144
171
|
|
|
145
|
-
The symbol table
|
|
172
|
+
The symbol table then becomes very large, and has to be scanned end to end even when using `maxRows`. Parsing
|
|
173
|
+
is skipped for symbols the requested rows do not use, so the cost is I/O rather than memory:
|
|
146
174
|
|
|
147
175
|
- **Small symbol table** (fields with reusable values): Fast loading regardless of file size
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
176
|
+
- **Large symbol table** (fields with unique values per row): Slower, because the scan is proportional to the
|
|
177
|
+
symbol table's size — but memory stays proportional to the rows you asked for, not to the file
|
|
178
|
+
|
|
179
|
+
You can see what happened on any load through `loadStats` (see [QvdDataFrame](#qvddataframe)):
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
const df = await QvdDataFrame.fromQvd('large.qvd', {maxRows: 1000});
|
|
183
|
+
console.log(df.loadStats);
|
|
184
|
+
// { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
|
|
185
|
+
// symbolFiltering: true, symbolsKept: 3898 }
|
|
186
|
+
//
|
|
187
|
+
// 3,898 symbols parsed for 1,000 rows, out of a file holding 1.7 million.
|
|
188
|
+
```
|
|
151
189
|
|
|
152
190
|
To check your QVD's symbol table size, look at the `NoOfSymbols` in field metadata - values close to the total row count indicate high cardinality.
|
|
153
191
|
|
|
@@ -170,24 +208,28 @@ When you specify `maxRows`, the library:
|
|
|
170
208
|
2. Parses **only** those symbols from the symbol table
|
|
171
209
|
3. Skips parsing unused symbols entirely (not just filtering after parsing)
|
|
172
210
|
|
|
173
|
-
|
|
211
|
+
This matters most on files whose symbol table is large because most values are unique — exactly the files where
|
|
212
|
+
a naive `maxRows` would still pay for the whole table. Compared to parsing every symbol regardless of `maxRows`,
|
|
213
|
+
loading a few thousand rows from a multi-million-row file has been measured at roughly an order of magnitude
|
|
214
|
+
faster and an order of magnitude smaller in peak memory.
|
|
174
215
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
- **~10x faster load times** - Loading 5,000 rows from a 20M row file: 8.3 seconds → 0.9 seconds
|
|
178
|
-
- **~10x less memory usage** - Same operation: 1,616 MB → 180 MB
|
|
179
|
-
- **Native or better efficiency** - Memory overhead reduced from 6.0x to 0.7x of raw data size
|
|
180
|
-
|
|
181
|
-
**Real-World Example:**
|
|
216
|
+
The saving is visible on any file through `loadStats`. On a bundled 1.7-million-row fixture, asking for 1,000
|
|
217
|
+
rows parses 3,898 symbols:
|
|
182
218
|
|
|
183
219
|
```javascript
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
220
|
+
const df = await QvdDataFrame.fromQvd('chicago_taxi_rides_2016_01.qvd', {
|
|
221
|
+
maxRows: 1000,
|
|
222
|
+
symbolFilteringThreshold: 0, // this fixture is well under the 50 MB default
|
|
223
|
+
});
|
|
224
|
+
console.log(df.loadStats);
|
|
225
|
+
// { symbolTableBytes: 451704, totalRows: 1705805, rowsLoaded: 1000,
|
|
226
|
+
// symbolFiltering: true, symbolsKept: 3898 }
|
|
189
227
|
```
|
|
190
228
|
|
|
229
|
+
The optimisation engages automatically once the symbol table passes `symbolFilteringThreshold`, which defaults
|
|
230
|
+
to 50 MB — the point where the extra analysis pass pays for itself. The example lowers it so the path can be
|
|
231
|
+
demonstrated on a small bundled fixture; on a real high-cardinality file it engages on its own.
|
|
232
|
+
|
|
191
233
|
**Key Benefits:**
|
|
192
234
|
|
|
193
235
|
- Much faster previews of large QVD files
|
|
@@ -199,6 +241,28 @@ This optimization is particularly effective for files with many unique values (h
|
|
|
199
241
|
|
|
200
242
|
### QVD File Size Limitations
|
|
201
243
|
|
|
244
|
+
#### The 2 GiB boundary
|
|
245
|
+
|
|
246
|
+
There is one hard limit worth knowing before anything else: **a full load cannot read a file larger than
|
|
247
|
+
2 GiB**, because Node caps `fs.readFile` there. It fails with a raw Node error rather than a `QvdError`:
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
RangeError: File size (2362232013) is greater than 2 GiB
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**`maxRows` reads past that boundary**, because the lazy path reads the file in chunks instead:
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
// A 2.2 GiB file
|
|
257
|
+
await QvdDataFrame.fromQvd(huge, {}); // ❌ RangeError, above
|
|
258
|
+
await QvdDataFrame.fromQvd(huge, {maxRows: 1000}); // ✅ works
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
So above 2 GiB, `maxRows` is not an optimisation — it is the only way in. Removing the full-load ceiling is
|
|
262
|
+
tracked in [#122](https://github.com/ptarmiganlabs/qvdjs/issues/122).
|
|
263
|
+
|
|
264
|
+
Below that boundary, what limits you is memory rather than file size, which is the rest of this section.
|
|
265
|
+
|
|
202
266
|
**Simple Explanation:**
|
|
203
267
|
|
|
204
268
|
Node.js has memory limits that affect how large QVD files you can work with. By default, Node.js can use up to about 4GB of memory. This means if you try to load a very large QVD file, you might run out of memory and get an error. Think of it like trying to open a very large document on a computer with limited RAM - if the document is too big, it won't open.
|
|
@@ -231,6 +295,26 @@ The maximum QVD file size you can handle with qvdjs depends on several factors a
|
|
|
231
295
|
});
|
|
232
296
|
```
|
|
233
297
|
|
|
298
|
+
**What the budget is measured against.** The V8 heap ceiling, and any container memory limit reported by
|
|
299
|
+
`process.constrainedMemory()`. Those are the two limits that actually terminate a process — exceeding the heap
|
|
300
|
+
is a fatal, uncatchable V8 error, and exceeding a cgroup limit is a SIGKILL that arrives as exit 137 with no
|
|
301
|
+
JavaScript error at all.
|
|
302
|
+
|
|
303
|
+
What the operating system reports as _free_ memory is deliberately **not** part of it. It is recorded in the
|
|
304
|
+
error context for diagnostics and ignored for the decision, because it does not describe a wall the process
|
|
305
|
+
can hit — a machine with virtual memory gets slower, not fatal — and because the figure is not dependable:
|
|
306
|
+
on macOS it counts only free and speculative pages, excluding the file-cache pages the OS reclaims on demand,
|
|
307
|
+
and readings on one idle machine varied nine-fold within minutes. Budgeting from it meant the same file loaded
|
|
308
|
+
or was refused depending on when you asked.
|
|
309
|
+
|
|
310
|
+
**Turning the check off.** `memorySafetyFactor: 0` disables it entirely, for callers who would rather manage
|
|
311
|
+
memory themselves, or run on a runtime whose limits cannot be measured — Bun reports its current heap as its
|
|
312
|
+
heap limit, so the ceiling it advertises is meaningless.
|
|
313
|
+
|
|
314
|
+
```javascript
|
|
315
|
+
const df = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
|
|
316
|
+
```
|
|
317
|
+
|
|
234
318
|
To phrase it differently: There is no magic here - viewing a 40 GB QVD on a laptop with 24 GB RAM will not work. That laptop may in fact struggle with QVD files larger than 4-6 GB depending on data characteristics - or happily work with 10+ GB files if the data is very friendly.
|
|
235
319
|
|
|
236
320
|
- **Data Characteristics**: The actual memory consumption depends _heavily_ on what's inside your QVD:
|
|
@@ -284,7 +368,24 @@ try {
|
|
|
284
368
|
|
|
285
369
|
For more technical details about the memory safety system, including specific thresholds and the four-tier protection model, see [docs/DYNAMIC_SAFETY_LIMITS.md](docs/DYNAMIC_SAFETY_LIMITS.md). For practical examples, see [docs/examples/heap-scaling-example.md](docs/examples/heap-scaling-example.md).
|
|
286
370
|
|
|
287
|
-
###
|
|
371
|
+
### Writing QVD files
|
|
372
|
+
|
|
373
|
+
`toQvd()` builds the symbol and index tables in memory and writes the file in one pass. Two things are worth
|
|
374
|
+
knowing about it.
|
|
375
|
+
|
|
376
|
+
**Row count is no longer a barrier.** Writing used to fail outright above roughly 122,000 rows with a
|
|
377
|
+
`RangeError: Maximum call stack size exceeded`, because the bit width for each column was derived with
|
|
378
|
+
`Math.max(...oneArgumentPerRow)`. It is now derived from the symbol count in constant time, and writes of
|
|
379
|
+
hundreds of thousands of rows are routine. The regression suite round-trips a 200,000-row data frame and a
|
|
380
|
+
150,000-row slice of real Qlik output on every CI run, across Linux, Windows and macOS.
|
|
381
|
+
|
|
382
|
+
**Memory scales with the data, not with the file you are writing.** A write holds the whole data frame plus its
|
|
383
|
+
symbol and index tables, so it needs more memory than reading the equivalent file. There is no lazy equivalent
|
|
384
|
+
of `maxRows` for writing — if the data does not fit in the heap, split it across several QVDs.
|
|
385
|
+
|
|
386
|
+
Writes are not atomic; see [Known limitations](#known-limitations).
|
|
387
|
+
|
|
388
|
+
#### Progress tracking
|
|
288
389
|
|
|
289
390
|
When writing large QVD files, the `toQvd()` operation can take significant time. The library provides optional progress callbacks to track the write operation in real-time:
|
|
290
391
|
|
|
@@ -411,7 +512,25 @@ await df.toQvd('processed/sales-filtered.qvd', {
|
|
|
411
512
|
- **Null Byte Protection**: Detects and blocks null byte injection attempts
|
|
412
513
|
- **Default CWD Restriction**: By default, file operations are restricted to the current working directory (CWD) and its subdirectories to prevent path traversal attacks
|
|
413
514
|
- **Custom Directory Restriction**: Optional `allowedDir` parameter allows you to specify a different base directory
|
|
414
|
-
- **
|
|
515
|
+
- **Symlinks are resolved**: Containment is decided by the filesystem, not by comparing strings. Both paths are
|
|
516
|
+
resolved through symlinks and compared by device and inode, so a link _inside_ `allowedDir` that points
|
|
517
|
+
outside it is refused — for writes as well as reads. A string comparison sees only the link's own name, still
|
|
518
|
+
under `allowedDir`, and lets it through; that meant a link planted in an upload directory could be used to
|
|
519
|
+
read any file the process could read, and to overwrite and truncate any file it could write.
|
|
520
|
+
- **Case is handled as the filesystem handles it**: comparing by inode means `Qvd` and `qvd` are the same
|
|
521
|
+
directory on a case-insensitive volume and different directories on a case-sensitive one, without guessing
|
|
522
|
+
from `process.platform`. macOS supports both.
|
|
523
|
+
- **Security Errors**: Throws `QvdSecurityError` with detailed context when security violations are detected.
|
|
524
|
+
The context includes a `check` field saying whether the filesystem or the fallback string comparison refused,
|
|
525
|
+
so a rejection of a path that looks contained is traceable to a symlink or a case difference.
|
|
526
|
+
|
|
527
|
+
**A note on `allowedDir` values:** `null`, `undefined` and `''` all fall back to the current working directory
|
|
528
|
+
rather than meaning "no restriction" — callers routinely produce those from optional config or a JSON round
|
|
529
|
+
trip, and silently dropping the sandbox there would be a security hole. To permit an entire volume, pass its
|
|
530
|
+
root explicitly (`'/'` on POSIX, `'C:\\'` on Windows).
|
|
531
|
+
|
|
532
|
+
**Not covered:** this is a check on a path, so it remains open to a symlink swapped in between the check and the
|
|
533
|
+
open. Closing that would require opening with `O_NOFOLLOW` and verifying the descriptor.
|
|
415
534
|
|
|
416
535
|
**Best Practices:**
|
|
417
536
|
|
|
@@ -454,6 +573,22 @@ try {
|
|
|
454
573
|
}
|
|
455
574
|
```
|
|
456
575
|
|
|
576
|
+
### Known limitations
|
|
577
|
+
|
|
578
|
+
Honest boundaries rather than an issue list — these are the ones that change what you can do.
|
|
579
|
+
|
|
580
|
+
| Limitation | What it means in practice | Tracked as |
|
|
581
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
582
|
+
| **Full loads stop at 2 GiB** | Use `maxRows` above that; the lazy path reads in chunks and has no such ceiling. Failure is a raw Node `RangeError`, not a `QvdError`. | [#122](https://github.com/ptarmiganlabs/qvdjs/issues/122) |
|
|
583
|
+
| **The memory estimate is based on symbol table size, not on rows × columns** | A file with a modest symbol table but very many rows can pass every check and then exhaust the heap while materialising rows. Loading tens of millions of rows, size the heap for the result rather than for the file. | [#121](https://github.com/ptarmiganlabs/qvdjs/issues/121) |
|
|
584
|
+
| **Dual values are not round-tripped as pairs** | Qlik stores dates and timestamps as a number plus a display string. A data frame exposes one of the two, so a read-modify-write does not reproduce the original dual — a `BirthDate` field comes back as `24205`, not as a formatted date. | [#138](https://github.com/ptarmiganlabs/qvdjs/issues/138) |
|
|
585
|
+
| **Writes are not atomic** | `toQvd()` writes in place. A failure part-way through, or two writers targeting one path, leaves the previous file damaged rather than intact. Write to a temporary path and rename if that matters. | [#129](https://github.com/ptarmiganlabs/qvdjs/issues/129) |
|
|
586
|
+
| **Writer input is not validated** | Unsupported value types can produce a raw `TypeError`, or a file that is written but does not read back as intended. Feed `fromDict()` numbers, strings and `null`. | [#130](https://github.com/ptarmiganlabs/qvdjs/issues/130) |
|
|
587
|
+
|
|
588
|
+
Numeric-looking strings are currently coerced to numbers on read, which is being reconsidered as a breaking
|
|
589
|
+
change in [#120](https://github.com/ptarmiganlabs/qvdjs/issues/120). Empty and whitespace-only strings are _not_
|
|
590
|
+
coerced — that was a defect, and is fixed.
|
|
591
|
+
|
|
457
592
|
## QVD File Format
|
|
458
593
|
|
|
459
594
|
The QVD file format is a binary file format that is used by QlikView to store data. The format is proprietary. However,
|
|
@@ -567,13 +702,14 @@ Empty QVDs are fully supported for both reading and writing, maintaining compati
|
|
|
567
702
|
The `QvdDataFrame` class represents the data frame stored inside of a finally parsed QVD file. It provides a high-level
|
|
568
703
|
abstraction access to the QVD file content. This includes meta information as well as access to the actual data records.
|
|
569
704
|
|
|
570
|
-
| Property | Type | Description
|
|
571
|
-
| -------------- | ---------- |
|
|
572
|
-
| `shape` | `number[]` | The shape of the data table. The first element is the number of rows, the second element is the number of columns.
|
|
573
|
-
| `data` | `any[][]` | The actual data records of the QVD file. The first dimension represents the single rows.
|
|
574
|
-
| `columns` | `string[]` | The names of the fields that are contained in the QVD file.
|
|
575
|
-
| `metadata` | `object` | The complete metadata object from the QVD file header, or null if not loaded from a QVD file.
|
|
576
|
-
| `fileMetadata` | `object` | File-level metadata from the QVD header (qvBuildNo, tableName, createUtcTime, etc.).
|
|
705
|
+
| Property | Type | Description |
|
|
706
|
+
| -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
707
|
+
| `shape` | `number[]` | The shape of the data table. The first element is the number of rows, the second element is the number of columns. |
|
|
708
|
+
| `data` | `any[][]` | The actual data records of the QVD file. The first dimension represents the single rows. |
|
|
709
|
+
| `columns` | `string[]` | The names of the fields that are contained in the QVD file. |
|
|
710
|
+
| `metadata` | `object` | The complete metadata object from the QVD file header, or null if not loaded from a QVD file. |
|
|
711
|
+
| `fileMetadata` | `object` | File-level metadata from the QVD header (qvBuildNo, tableName, createUtcTime, etc.). |
|
|
712
|
+
| `loadStats` | `object` | What the read did: `symbolTableBytes`, `totalRows`, `rowsLoaded`, `symbolFiltering`, `symbolsKept`. `null` unless the frame came from `fromQvd()`. |
|
|
577
713
|
|
|
578
714
|
#### `static fromQvd(path: string, options?: object): Promise<QvdDataFrame>`
|
|
579
715
|
|
|
@@ -586,7 +722,8 @@ to a `QvdDataFrame` instance.
|
|
|
586
722
|
- `options` (object, optional): Loading options
|
|
587
723
|
- `maxRows` (number, optional): Maximum number of rows to load. If not specified, all rows are loaded. This is useful for loading only a subset of data from large QVD files to improve performance and reduce memory usage.
|
|
588
724
|
- `allowedDir` (string, optional): Base directory for file access validation. Defaults to current working directory (CWD). The file path must resolve to a location within this directory to prevent path traversal attacks. Set to a specific directory in production environments with user-provided paths.
|
|
589
|
-
- `memorySafetyFactor` (number, optional):
|
|
725
|
+
- `memorySafetyFactor` (number, optional): Fraction (0.0-1.0) of the memory budget a load may use. Default is 0.3 (30%). The budget is the smaller of the V8 heap limit and any container memory limit; see [QVD File Size Limitations](#qvd-file-size-limitations). Increase this (e.g., to 0.5 or 0.7) when running with a larger heap via `--max-old-space-size`. **`0` disables the memory check entirely.**
|
|
726
|
+
- `symbolFilteringThreshold` (number, optional): Symbol table size, in bytes, above which a lazy load switches to the two-pass filtering path. Defaults to 50 MB, the point where the extra analysis pass pays for itself. Lower it to use filtering on smaller files, raise it to keep the simpler single-pass read for longer.
|
|
590
727
|
|
|
591
728
|
**Example:**
|
|
592
729
|
|
|
@@ -604,8 +741,16 @@ const dfSecure = await QvdDataFrame.fromQvd('reports/sales.qvd', {
|
|
|
604
741
|
|
|
605
742
|
// Load with increased memory usage for large heap configurations
|
|
606
743
|
const dfLarge = await QvdDataFrame.fromQvd('large-file.qvd', {
|
|
607
|
-
memorySafetyFactor: 0.5, // Use 50% of
|
|
744
|
+
memorySafetyFactor: 0.5, // Use 50% of the budget instead of the default 30%
|
|
608
745
|
});
|
|
746
|
+
|
|
747
|
+
// Manage memory yourself: skip the check entirely
|
|
748
|
+
const dfUnchecked = await QvdDataFrame.fromQvd('large-file.qvd', {memorySafetyFactor: 0});
|
|
749
|
+
|
|
750
|
+
// Inspect what the read actually did
|
|
751
|
+
const preview = await QvdDataFrame.fromQvd('large-file.qvd', {maxRows: 500});
|
|
752
|
+
console.log(preview.loadStats.symbolFiltering); // true when the two-pass path ran
|
|
753
|
+
console.log(preview.loadStats.symbolsKept); // how many symbols it kept
|
|
609
754
|
```
|
|
610
755
|
|
|
611
756
|
#### `static fromDict(dict: object): Promise<QvdDataFrame>`
|
package/dist/index.cjs
CHANGED
|
@@ -746,6 +746,28 @@ var init_bitUtils = __esm({
|
|
|
746
746
|
function getHeapLimit() {
|
|
747
747
|
return v8__default.default.getHeapStatistics().heap_size_limit;
|
|
748
748
|
}
|
|
749
|
+
function heapLimitIsMeaningful() {
|
|
750
|
+
return !process.versions.bun && !process.versions.deno;
|
|
751
|
+
}
|
|
752
|
+
function getMemoryBudget() {
|
|
753
|
+
const candidates = [];
|
|
754
|
+
if (heapLimitIsMeaningful()) {
|
|
755
|
+
candidates.push({ source: "V8 heap limit", bytes: getHeapLimit() });
|
|
756
|
+
}
|
|
757
|
+
const constrained = typeof process.constrainedMemory === "function" ? process.constrainedMemory() : 0;
|
|
758
|
+
if (constrained > 0 && constrained < os__default.default.totalmem()) {
|
|
759
|
+
candidates.push({ source: "container memory limit", bytes: constrained });
|
|
760
|
+
}
|
|
761
|
+
if (candidates.length === 0) {
|
|
762
|
+
candidates.push({ source: "total system memory", bytes: os__default.default.totalmem() });
|
|
763
|
+
}
|
|
764
|
+
const observed = [{ source: "free memory (os.freemem)", bytes: os__default.default.freemem() }];
|
|
765
|
+
if (typeof process.availableMemory === "function") {
|
|
766
|
+
observed.push({ source: "available memory", bytes: process.availableMemory() });
|
|
767
|
+
}
|
|
768
|
+
const binding = candidates.reduce((lowest, candidate) => candidate.bytes < lowest.bytes ? candidate : lowest);
|
|
769
|
+
return { bytes: binding.bytes, limitedBy: binding.source, candidates, observed };
|
|
770
|
+
}
|
|
749
771
|
function estimateMemoryUsage(symbolTableSize, maxRows, totalRows) {
|
|
750
772
|
const FULL_PARSE_OVERHEAD = 6;
|
|
751
773
|
const MINIMAL_OVERHEAD = 0.01;
|
|
@@ -762,11 +784,14 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
762
784
|
if (typeof safetyFactor !== "number" || safetyFactor < 0 || safetyFactor > 1) {
|
|
763
785
|
throw new exports.QvdValidationError("safetyFactor must be a number between 0.0 and 1.0", { safetyFactor });
|
|
764
786
|
}
|
|
765
|
-
|
|
787
|
+
if (safetyFactor === 0) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const budget = getMemoryBudget();
|
|
766
791
|
const heapLimit = getHeapLimit();
|
|
792
|
+
const availableMemory = budget.bytes;
|
|
767
793
|
const estimatedMemory = estimateMemoryUsage(symbolTableSize, maxRows, totalRows);
|
|
768
|
-
const
|
|
769
|
-
const maxAllowedMemory = effectiveLimit * safetyFactor;
|
|
794
|
+
const maxAllowedMemory = budget.bytes * safetyFactor;
|
|
770
795
|
if (estimatedMemory > maxAllowedMemory) {
|
|
771
796
|
const safeSymbolPercentage = maxAllowedMemory / (symbolTableSize * 6);
|
|
772
797
|
const safeRowPercentage = Math.pow(safeSymbolPercentage, 2);
|
|
@@ -776,9 +801,12 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
776
801
|
const availableMB = Math.round(maxAllowedMemory / 1024 / 1024);
|
|
777
802
|
const heapLimitMB = Math.round(heapLimit / 1024 / 1024);
|
|
778
803
|
const availableRamMB = Math.round(availableMemory / 1024 / 1024);
|
|
779
|
-
const limitingFactor =
|
|
804
|
+
const limitingFactor = budget.limitedBy;
|
|
805
|
+
const budgetBreakdown = budget.candidates.map((candidate) => `${candidate.source} ${Math.round(candidate.bytes / 1024 / 1024)}MB`).join(", ");
|
|
806
|
+
const observedBreakdown = budget.observed.map((entry) => `${entry.source} ${Math.round(entry.bytes / 1024 / 1024)}MB`).join(", ");
|
|
807
|
+
const advice = limitingFactor === "container memory limit" ? `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 load fewer rows with maxRows (recommended: ${recommendedMaxRows.toLocaleString()} rows or less).` : `Try loading fewer rows using the maxRows parameter (recommended: ${recommendedMaxRows.toLocaleString()} rows or less), or raise the heap with --max-old-space-size.`;
|
|
780
808
|
throw new exports.QvdValidationError(
|
|
781
|
-
`Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}: ${
|
|
809
|
+
`Insufficient memory to load file safely. Symbol table: ${sizeMB}MB, Estimated memory needed: ${estimatedMB}MB, Available: ${availableMB}MB (limited by ${limitingFactor}; considered: ${budgetBreakdown}; observed but not used: ${observedBreakdown}). ` + advice,
|
|
782
810
|
{
|
|
783
811
|
file: filePath,
|
|
784
812
|
symbolTableSize,
|
|
@@ -788,6 +816,8 @@ function validateMemoryAvailability(symbolTableSize, maxRows, totalRows, filePat
|
|
|
788
816
|
heapLimitMB,
|
|
789
817
|
availableRamMB,
|
|
790
818
|
limitingFactor,
|
|
819
|
+
memoryBudget: budget.candidates,
|
|
820
|
+
memoryObserved: budget.observed,
|
|
791
821
|
totalRows,
|
|
792
822
|
maxRows,
|
|
793
823
|
recommendedMaxRows
|
|
@@ -1301,14 +1331,22 @@ var init_QvdFileReader = __esm({
|
|
|
1301
1331
|
* points outside it is rejected. Defaults to the current working directory. To permit
|
|
1302
1332
|
* an entire volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or
|
|
1303
1333
|
* empty value falls back to the working directory rather than removing the restriction.
|
|
1304
|
-
* @param {number} [options.memorySafetyFactor=0.3]
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1334
|
+
* @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a
|
|
1335
|
+
* load may use. The budget is the smallest of the V8 heap limit, any container memory limit,
|
|
1336
|
+
* and the memory the OS reports as available. Default is 0.3. **Zero disables the memory
|
|
1337
|
+
* check entirely**, which is the escape hatch for runtimes whose limits cannot be measured -
|
|
1338
|
+
* Bun reports its current heap as its heap limit - and for callers who would rather manage
|
|
1339
|
+
* memory themselves than trust the estimate.
|
|
1340
|
+
* @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes,
|
|
1341
|
+
* above which a lazy load switches to the two-pass filtering path. The default of 50MB is
|
|
1342
|
+
* the point where the extra analysis pass pays for itself; lower it to use filtering on
|
|
1343
|
+
* smaller files, raise it to keep the simpler single-pass read for longer.
|
|
1307
1344
|
*/
|
|
1308
1345
|
constructor(filePath, options = {}) {
|
|
1309
|
-
const { allowedDir, memorySafetyFactor = 0.3 } = options;
|
|
1346
|
+
const { allowedDir, memorySafetyFactor = 0.3, symbolFilteringThreshold = 50 * 1024 * 1024 } = options;
|
|
1310
1347
|
this._path = validatePath(filePath, allowedDir);
|
|
1311
1348
|
this._memorySafetyFactor = memorySafetyFactor;
|
|
1349
|
+
this._symbolFilteringThreshold = symbolFilteringThreshold;
|
|
1312
1350
|
this._buffer = null;
|
|
1313
1351
|
this._headerOffset = null;
|
|
1314
1352
|
this._symbolTableOffset = null;
|
|
@@ -1740,15 +1778,12 @@ var init_QvdFileReader = __esm({
|
|
|
1740
1778
|
await this._readData(maxRows);
|
|
1741
1779
|
await this._parseHeader();
|
|
1742
1780
|
let symbolsToKeep = null;
|
|
1781
|
+
let symbolsKept = null;
|
|
1743
1782
|
if (maxRows !== null && this._header) {
|
|
1744
1783
|
const symbolTableLength = parseInt(this._header["QvdTableHeader"]["Offset"], 10);
|
|
1745
|
-
|
|
1746
|
-
if (symbolTableLength > SYMBOL_FILTERING_THRESHOLD) {
|
|
1784
|
+
if (symbolTableLength > this._symbolFilteringThreshold) {
|
|
1747
1785
|
symbolsToKeep = await this._analyzeIndexTableSymbolUsage(maxRows);
|
|
1748
|
-
|
|
1749
|
-
console.log(
|
|
1750
|
-
`[Phase 2.5 Optimization] Using stream-and-skip parsing: keeping ${totalSymbols} symbols from ${(symbolTableLength / 1024 / 1024).toFixed(1)}MB symbol table`
|
|
1751
|
-
);
|
|
1786
|
+
symbolsKept = Array.from(symbolsToKeep.values()).reduce((sum, set) => sum + set.size, 0);
|
|
1752
1787
|
}
|
|
1753
1788
|
}
|
|
1754
1789
|
await this._parseSymbolTable(symbolsToKeep, maxRows);
|
|
@@ -1785,7 +1820,14 @@ var init_QvdFileReader = __esm({
|
|
|
1785
1820
|
const columns = fields.map((field) => field["FieldName"]);
|
|
1786
1821
|
const data = this._indexTable.map((_, index) => getRow(index));
|
|
1787
1822
|
const metadata = this._header["QvdTableHeader"];
|
|
1788
|
-
|
|
1823
|
+
const loadStats = {
|
|
1824
|
+
symbolTableBytes: parseInt(this._header["QvdTableHeader"]["Offset"], 10),
|
|
1825
|
+
totalRows: parseInt(this._header["QvdTableHeader"]["NoOfRecords"], 10),
|
|
1826
|
+
rowsLoaded: data.length,
|
|
1827
|
+
symbolFiltering: symbolsToKeep !== null,
|
|
1828
|
+
symbolsKept
|
|
1829
|
+
};
|
|
1830
|
+
return new exports.QvdDataFrame(data, columns, metadata, loadStats);
|
|
1789
1831
|
}
|
|
1790
1832
|
};
|
|
1791
1833
|
}
|
|
@@ -1802,11 +1844,13 @@ var init_QvdDataFrame = __esm({
|
|
|
1802
1844
|
* @param {Array<Array<any>>} data The data of the data frame.
|
|
1803
1845
|
* @param {Array<string>} columns The columns of the data frame.
|
|
1804
1846
|
* @param {QvdMetadata|null} metadata The metadata from the QVD file header (optional).
|
|
1847
|
+
* @param {QvdLoadStats|null} loadStats Statistics about the read (optional).
|
|
1805
1848
|
*/
|
|
1806
|
-
constructor(data, columns, metadata = null) {
|
|
1849
|
+
constructor(data, columns, metadata = null, loadStats = null) {
|
|
1807
1850
|
this._data = data;
|
|
1808
1851
|
this._columns = columns;
|
|
1809
1852
|
this._metadata = metadata;
|
|
1853
|
+
this._loadStats = loadStats;
|
|
1810
1854
|
}
|
|
1811
1855
|
/**
|
|
1812
1856
|
* Returns the data of the data frame.
|
|
@@ -1833,6 +1877,21 @@ var init_QvdDataFrame = __esm({
|
|
|
1833
1877
|
get metadata() {
|
|
1834
1878
|
return this._metadata;
|
|
1835
1879
|
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Returns statistics about the read that produced this data frame.
|
|
1882
|
+
*
|
|
1883
|
+
* Only a frame returned by fromQvd() carries these; fromDict(), head() and tail() produce
|
|
1884
|
+
* frames that describe no particular read, and report null rather than a stale figure.
|
|
1885
|
+
*
|
|
1886
|
+
* The main use is confirming that a lazy load actually filtered the symbol table:
|
|
1887
|
+
* `symbolFiltering` says whether the two-pass path ran, and `symbolsKept` how many symbols
|
|
1888
|
+
* survived it, which for a small maxRows should be a tiny fraction of the file's total.
|
|
1889
|
+
*
|
|
1890
|
+
* @return {QvdLoadStats|null} Load statistics, or null if this frame did not come from a file.
|
|
1891
|
+
*/
|
|
1892
|
+
get loadStats() {
|
|
1893
|
+
return this._loadStats;
|
|
1894
|
+
}
|
|
1836
1895
|
/**
|
|
1837
1896
|
* Returns file-level metadata from the QVD header.
|
|
1838
1897
|
* @return {Object} File-level metadata properties.
|
|
@@ -2208,9 +2267,12 @@ var init_QvdDataFrame = __esm({
|
|
|
2208
2267
|
* outside it is rejected. Defaults to the current working directory. To permit an entire
|
|
2209
2268
|
* volume, pass its root explicitly ('/' on POSIX, 'C:\\' on Windows); a null or empty value falls
|
|
2210
2269
|
* back to the working directory rather than removing the restriction.
|
|
2211
|
-
* @param {number} [options.memorySafetyFactor=0.3]
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2270
|
+
* @param {number} [options.memorySafetyFactor=0.3] Fraction (0.0-1.0) of the memory budget a load
|
|
2271
|
+
* may use. The budget is the smallest of the V8 heap limit, any container memory limit, and the
|
|
2272
|
+
* memory the OS reports as available. Default is 0.3; raise it when running with a larger heap via
|
|
2273
|
+
* --max-old-space-size. **Zero disables the memory check entirely.**
|
|
2274
|
+
* @param {number} [options.symbolFilteringThreshold=52428800] Symbol table size, in bytes, above which
|
|
2275
|
+
* a lazy load switches to the two-pass filtering path. Defaults to 50MB.
|
|
2214
2276
|
* @throws {QvdValidationError} If options.maxRows is neither null/undefined nor a non-negative integer.
|
|
2215
2277
|
* @return {Promise<QvdDataFrame>} The data frame of the QVD file.
|
|
2216
2278
|
*/
|
|
@@ -2218,7 +2280,8 @@ var init_QvdDataFrame = __esm({
|
|
|
2218
2280
|
const { QvdFileReader: QvdFileReader2 } = await Promise.resolve().then(() => (init_QvdFileReader(), QvdFileReader_exports));
|
|
2219
2281
|
const readerOptions = {
|
|
2220
2282
|
allowedDir: options.allowedDir,
|
|
2221
|
-
memorySafetyFactor: options.memorySafetyFactor
|
|
2283
|
+
memorySafetyFactor: options.memorySafetyFactor,
|
|
2284
|
+
symbolFilteringThreshold: options.symbolFilteringThreshold
|
|
2222
2285
|
};
|
|
2223
2286
|
return await new QvdFileReader2(path3, readerOptions).load(options.maxRows !== void 0 ? options.maxRows : null);
|
|
2224
2287
|
}
|