@naturalcycles/nodejs-lib 15.125.1 → 15.125.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/dist/stream/ndjson/createReadStreamAsNDJson.js +21 -12
- package/dist/stream/pipeline.d.ts +1 -0
- package/dist/stream/pipeline.js +9 -2
- package/dist/stream/transform/transformMap.js +14 -0
- package/dist/stream/transform/transformSplit.js +67 -49
- package/package.json +2 -2
- package/src/stream/ndjson/createReadStreamAsNDJson.ts +23 -11
- package/src/stream/pipeline.ts +10 -2
- package/src/stream/transform/transformMap.ts +16 -0
- package/src/stream/transform/transformSplit.ts +70 -52
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { pipeline } from 'node:stream';
|
|
1
2
|
import { createUnzip, createZstdDecompress } from 'node:zlib';
|
|
3
|
+
import { _noop } from '@naturalcycles/js-lib/types';
|
|
2
4
|
import { fs2 } from '../../fs/fs2.js';
|
|
3
5
|
import { transformSplitOnNewline } from '../transform/transformSplit.js';
|
|
6
|
+
import { transformJsonParse } from './transformJsonParse.js';
|
|
4
7
|
/**
|
|
5
8
|
Returns a Readable of [already parsed] NDJSON objects.
|
|
6
9
|
|
|
@@ -17,24 +20,30 @@ import { transformSplitOnNewline } from '../transform/transformSplit.js';
|
|
|
17
20
|
*/
|
|
18
21
|
export function createReadStreamAsNDJson(inputPath) {
|
|
19
22
|
fs2.requireFileToExist(inputPath);
|
|
20
|
-
|
|
21
|
-
.createReadStream(inputPath, {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const streams = [
|
|
24
|
+
fs2.createReadStream(inputPath, {
|
|
25
|
+
highWaterMark: 256 * 1024, // 64KB had no observed speedup over the default; 256KB is +6-12% end-to-end on ~9KB rows, neutral on ~180B rows, 1MB brings nothing more
|
|
26
|
+
}),
|
|
27
|
+
];
|
|
25
28
|
if (inputPath.endsWith('.gz')) {
|
|
26
|
-
|
|
29
|
+
streams.push(createUnzip({
|
|
27
30
|
chunkSize: 256 * 1024, // 64KB: speedup from ~3200 to 3800 rps! 256KB: +6-8% more on ~9KB rows, neutral on ~180B rows
|
|
28
31
|
}));
|
|
29
32
|
}
|
|
30
33
|
else if (inputPath.endsWith('.zst')) {
|
|
31
|
-
|
|
34
|
+
streams.push(createZstdDecompress({
|
|
32
35
|
chunkSize: 256 * 1024, // tested: 256KB is +10-12% over 64KB on ~9KB rows, neutral on ~180B rows
|
|
33
36
|
}));
|
|
34
37
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// .
|
|
38
|
+
const output = transformJsonParse();
|
|
39
|
+
streams.push(transformSplitOnNewline(), output);
|
|
40
|
+
// `pipeline` (unlike `.pipe`) forwards errors: when any stream fails (e.g. a corrupt .gz file),
|
|
41
|
+
// all of them are destroyed with that error, so the consumer of `output` gets a rejection,
|
|
42
|
+
// instead of the process crashing with an unhandled 'error' event.
|
|
43
|
+
// The error is also passed to the callback, where there's nothing more to do with it.
|
|
44
|
+
pipeline(streams, _noop);
|
|
45
|
+
return output;
|
|
46
|
+
// It used to be `.map(line => JSON.parse(line))`, because "for some crazy reason .map is much faster than transformJsonParse!
|
|
47
|
+
// ~5000 vs ~4000 rps !!!". Measured again on Node 24, it's the opposite: Readable.map costs ~1µs per row in promise machinery,
|
|
48
|
+
// transformJsonParse() is 1.5x (consumed via pipeline) to 1.75x (consumed via for-await) faster on ~180B rows.
|
|
40
49
|
}
|
|
@@ -116,4 +116,5 @@ export declare class Pipeline<T = unknown> {
|
|
|
116
116
|
forEach(fn: AbortableAsyncMapper<T, void>, opt?: TransformMapOptions<T, void> & TransformLogProgressOptions<T>): Promise<void>;
|
|
117
117
|
forEachSync(fn: AbortableMapper<T, void>, opt?: TransformMapSyncOptions<T, void> & TransformLogProgressOptions<T>): Promise<void>;
|
|
118
118
|
run(): Promise<void>;
|
|
119
|
+
private isGracefullyAborted;
|
|
119
120
|
}
|
package/dist/stream/pipeline.js
CHANGED
|
@@ -225,7 +225,7 @@ export class Pipeline {
|
|
|
225
225
|
}
|
|
226
226
|
parseNDJson() {
|
|
227
227
|
// It was said that transformJsonParse() separately is 10% or more slower than .map(line => JSON.parse(line))
|
|
228
|
-
//
|
|
228
|
+
// Measured again on Node 24: the opposite, transformJsonParse() is 1.5x faster (Readable.map costs ~1µs per row), see createReadStreamAsNDJson
|
|
229
229
|
this.transforms.push(transformSplitOnNewline(), transformJsonParse());
|
|
230
230
|
this.objectMode = true;
|
|
231
231
|
return this;
|
|
@@ -347,11 +347,18 @@ export class Pipeline {
|
|
|
347
347
|
});
|
|
348
348
|
}
|
|
349
349
|
catch (err) {
|
|
350
|
-
|
|
350
|
+
// The abort reason is checked on the signal rather than on `err.cause`: when the source has
|
|
351
|
+
// already emitted `end` by the time the abort lands (small `Readable.from` sources), node's
|
|
352
|
+
// pipeline reports ERR_STREAM_PREMATURE_CLOSE without a cause instead of the AbortError.
|
|
353
|
+
if (this.isGracefullyAborted()) {
|
|
351
354
|
console.log('pipeline gracefully aborted'); // todo: this message may be removed later
|
|
352
355
|
return;
|
|
353
356
|
}
|
|
354
357
|
throw err;
|
|
355
358
|
}
|
|
356
359
|
}
|
|
360
|
+
isGracefullyAborted() {
|
|
361
|
+
const { aborted, reason } = this.abortableSignal;
|
|
362
|
+
return aborted && reason instanceof Error && reason.message === PIPELINE_GRACEFUL_ABORT;
|
|
363
|
+
}
|
|
357
364
|
}
|
|
@@ -31,6 +31,7 @@ export function transformMap(mapper, opt = {}) {
|
|
|
31
31
|
let inFlight = 0;
|
|
32
32
|
let blockedCallback = null;
|
|
33
33
|
let flushBlocked = null;
|
|
34
|
+
let destroyBlocked = null;
|
|
34
35
|
// Warmup - cached concurrency to reduce Date.now() syscalls
|
|
35
36
|
let warmupComplete = warmupSeconds <= 0 || maxConcurrency <= 1;
|
|
36
37
|
let concurrency = warmupComplete ? maxConcurrency : 1;
|
|
@@ -117,7 +118,20 @@ export function transformMap(mapper, opt = {}) {
|
|
|
117
118
|
if (inFlight === 0 && flushBlocked) {
|
|
118
119
|
flushBlocked.resolve();
|
|
119
120
|
}
|
|
121
|
+
// Let a pending destroy complete once nothing is in flight any more
|
|
122
|
+
if (inFlight === 0 && destroyBlocked) {
|
|
123
|
+
destroyBlocked.resolve();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async destroy(err, cb) {
|
|
128
|
+
// An upstream abort (e.g. `limit` or END) destroys this stream while mappers are still running.
|
|
129
|
+
// The close is held until they settle, so that the pipeline resolves only once their side effects are done.
|
|
130
|
+
if (inFlight > 0) {
|
|
131
|
+
destroyBlocked = pDefer();
|
|
132
|
+
await destroyBlocked;
|
|
120
133
|
}
|
|
134
|
+
cb(err);
|
|
121
135
|
},
|
|
122
136
|
async flush(cb) {
|
|
123
137
|
// Wait for all in-flight operations to complete
|
|
@@ -36,61 +36,28 @@ export function transformSplit(separator = '\n') {
|
|
|
36
36
|
let pending = [];
|
|
37
37
|
let pendingLength = 0;
|
|
38
38
|
let edge; // last `overlap` bytes of pending data, only tracked when overlap > 0
|
|
39
|
-
|
|
39
|
+
const transform = new Transform({
|
|
40
40
|
writableObjectMode: false,
|
|
41
41
|
writableHighWaterMark: 64 * 1024,
|
|
42
42
|
readableObjectMode: true,
|
|
43
43
|
transform(chunk, _enc, cb) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// No separator yet: keep accumulating without copying
|
|
55
|
-
pending.push(chunk);
|
|
56
|
-
pendingLength += chunk.length;
|
|
57
|
-
if (overlap > 0) {
|
|
58
|
-
edge = Buffer.concat([edge, chunk.subarray(-overlap)]).subarray(-overlap);
|
|
59
|
-
}
|
|
60
|
-
cb();
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
idx = straddleIdx - edge.length;
|
|
66
|
-
}
|
|
67
|
-
// Complete the pending line with the head of this chunk
|
|
68
|
-
if (idx > 0)
|
|
69
|
-
pending.push(chunk.subarray(0, idx));
|
|
70
|
-
const lineLength = pendingLength + idx;
|
|
71
|
-
if (lineLength > 0) {
|
|
72
|
-
this.push(Buffer.concat(pending, lineLength));
|
|
44
|
+
// Deferred by a microtask, so that an async upstream (zlib, fs) can dispatch its next chunk to its thread
|
|
45
|
+
// before we do the (synchronous) splitting + whatever the consumer does with the lines:
|
|
46
|
+
// decompression of chunk N+1 then overlaps with processing of chunk N, measured +5-15% on gzip/zstd input.
|
|
47
|
+
// Without it all of that runs inside the upstream's push, and the two never overlap.
|
|
48
|
+
queueMicrotask(() => {
|
|
49
|
+
// Whatever is thrown here (by a consumer most likely, as push() runs 'data' listeners synchronously)
|
|
50
|
+
// must become a stream error, like it did when this ran inside transform() itself:
|
|
51
|
+
// thrown from a microtask it would be an uncaught exception and crash the process.
|
|
52
|
+
try {
|
|
53
|
+
processChunk(chunk);
|
|
73
54
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
let idx = chunk.indexOf(needle, start);
|
|
79
|
-
while (idx !== -1) {
|
|
80
|
-
if (idx > start) {
|
|
81
|
-
this.push(chunk.subarray(start, idx));
|
|
55
|
+
catch (err) {
|
|
56
|
+
cb(err);
|
|
57
|
+
return;
|
|
82
58
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
if (start < chunk.length) {
|
|
87
|
-
const rest = chunk.subarray(start);
|
|
88
|
-
pending.push(rest);
|
|
89
|
-
pendingLength = rest.length;
|
|
90
|
-
if (overlap > 0)
|
|
91
|
-
edge = rest.subarray(-overlap);
|
|
92
|
-
}
|
|
93
|
-
cb();
|
|
59
|
+
cb();
|
|
60
|
+
});
|
|
94
61
|
},
|
|
95
62
|
flush(cb) {
|
|
96
63
|
if (pendingLength > 0) {
|
|
@@ -99,4 +66,55 @@ export function transformSplit(separator = '\n') {
|
|
|
99
66
|
cb();
|
|
100
67
|
},
|
|
101
68
|
});
|
|
69
|
+
function processChunk(chunk) {
|
|
70
|
+
let start = 0;
|
|
71
|
+
if (pendingLength > 0) {
|
|
72
|
+
// A multi-byte separator may straddle the chunk boundary: look for it in the last `overlap` bytes
|
|
73
|
+
// of pending data followed by the first `overlap` bytes of this chunk
|
|
74
|
+
const straddleIdx = overlap > 0 ? Buffer.concat([edge, chunk.subarray(0, overlap)]).indexOf(matcher) : -1;
|
|
75
|
+
// Position of the first separator in chunk coordinates, negative when it started inside the pending data
|
|
76
|
+
let idx;
|
|
77
|
+
if (straddleIdx === -1) {
|
|
78
|
+
idx = chunk.indexOf(needle);
|
|
79
|
+
if (idx === -1) {
|
|
80
|
+
// No separator yet: keep accumulating without copying
|
|
81
|
+
pending.push(chunk);
|
|
82
|
+
pendingLength += chunk.length;
|
|
83
|
+
if (overlap > 0) {
|
|
84
|
+
edge = Buffer.concat([edge, chunk.subarray(-overlap)]).subarray(-overlap);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
idx = straddleIdx - edge.length;
|
|
91
|
+
}
|
|
92
|
+
// Complete the pending line with the head of this chunk
|
|
93
|
+
if (idx > 0)
|
|
94
|
+
pending.push(chunk.subarray(0, idx));
|
|
95
|
+
const lineLength = pendingLength + idx;
|
|
96
|
+
if (lineLength > 0) {
|
|
97
|
+
transform.push(Buffer.concat(pending, lineLength));
|
|
98
|
+
}
|
|
99
|
+
pending = [];
|
|
100
|
+
pendingLength = 0;
|
|
101
|
+
start = idx + matcherLength;
|
|
102
|
+
}
|
|
103
|
+
let idx = chunk.indexOf(needle, start);
|
|
104
|
+
while (idx !== -1) {
|
|
105
|
+
if (idx > start) {
|
|
106
|
+
transform.push(chunk.subarray(start, idx));
|
|
107
|
+
}
|
|
108
|
+
start = idx + matcherLength;
|
|
109
|
+
idx = chunk.indexOf(needle, start);
|
|
110
|
+
}
|
|
111
|
+
if (start < chunk.length) {
|
|
112
|
+
const rest = chunk.subarray(start);
|
|
113
|
+
pending.push(rest);
|
|
114
|
+
pendingLength = rest.length;
|
|
115
|
+
if (overlap > 0)
|
|
116
|
+
edge = rest.subarray(-overlap);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return transform;
|
|
102
120
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/nodejs-lib",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "15.125.
|
|
4
|
+
"version": "15.125.3",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@naturalcycles/js-lib": "^15",
|
|
7
7
|
"@standard-schema/spec": "^1",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"yaml": "^2"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@naturalcycles/dev-lib": "
|
|
17
|
+
"@naturalcycles/dev-lib": "20.65.1",
|
|
18
18
|
"typescript": "^7"
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream'
|
|
2
|
+
import { pipeline } from 'node:stream'
|
|
1
3
|
import { createUnzip, createZstdDecompress } from 'node:zlib'
|
|
4
|
+
import { _noop } from '@naturalcycles/js-lib/types'
|
|
2
5
|
import { fs2 } from '../../fs/fs2.js'
|
|
3
6
|
import type { ReadableTyped } from '../stream.model.js'
|
|
4
7
|
import { transformSplitOnNewline } from '../transform/transformSplit.js'
|
|
8
|
+
import { transformJsonParse } from './transformJsonParse.js'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
Returns a Readable of [already parsed] NDJSON objects.
|
|
@@ -21,29 +25,37 @@ import { transformSplitOnNewline } from '../transform/transformSplit.js'
|
|
|
21
25
|
export function createReadStreamAsNDJson<ROW = any>(inputPath: string): ReadableTyped<ROW> {
|
|
22
26
|
fs2.requireFileToExist(inputPath)
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
.createReadStream(inputPath, {
|
|
28
|
+
const streams: Readable[] = [
|
|
29
|
+
fs2.createReadStream(inputPath, {
|
|
26
30
|
highWaterMark: 256 * 1024, // 64KB had no observed speedup over the default; 256KB is +6-12% end-to-end on ~9KB rows, neutral on ~180B rows, 1MB brings nothing more
|
|
27
|
-
})
|
|
28
|
-
|
|
31
|
+
}),
|
|
32
|
+
]
|
|
29
33
|
|
|
30
34
|
if (inputPath.endsWith('.gz')) {
|
|
31
|
-
|
|
35
|
+
streams.push(
|
|
32
36
|
createUnzip({
|
|
33
37
|
chunkSize: 256 * 1024, // 64KB: speedup from ~3200 to 3800 rps! 256KB: +6-8% more on ~9KB rows, neutral on ~180B rows
|
|
34
38
|
}),
|
|
35
39
|
)
|
|
36
40
|
} else if (inputPath.endsWith('.zst')) {
|
|
37
|
-
|
|
41
|
+
streams.push(
|
|
38
42
|
createZstdDecompress({
|
|
39
43
|
chunkSize: 256 * 1024, // tested: 256KB is +10-12% over 64KB on ~9KB rows, neutral on ~180B rows
|
|
40
44
|
}),
|
|
41
45
|
)
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
48
|
+
const output = transformJsonParse<ROW>()
|
|
49
|
+
streams.push(transformSplitOnNewline(), output)
|
|
50
|
+
|
|
51
|
+
// `pipeline` (unlike `.pipe`) forwards errors: when any stream fails (e.g. a corrupt .gz file),
|
|
52
|
+
// all of them are destroyed with that error, so the consumer of `output` gets a rejection,
|
|
53
|
+
// instead of the process crashing with an unhandled 'error' event.
|
|
54
|
+
// The error is also passed to the callback, where there's nothing more to do with it.
|
|
55
|
+
pipeline(streams, _noop)
|
|
56
|
+
|
|
57
|
+
return output
|
|
58
|
+
// It used to be `.map(line => JSON.parse(line))`, because "for some crazy reason .map is much faster than transformJsonParse!
|
|
59
|
+
// ~5000 vs ~4000 rps !!!". Measured again on Node 24, it's the opposite: Readable.map costs ~1µs per row in promise machinery,
|
|
60
|
+
// transformJsonParse() is 1.5x (consumed via pipeline) to 1.75x (consumed via for-await) faster on ~180B rows.
|
|
49
61
|
}
|
package/src/stream/pipeline.ts
CHANGED
|
@@ -309,7 +309,7 @@ export class Pipeline<T = unknown> {
|
|
|
309
309
|
|
|
310
310
|
parseNDJson<TO = unknown>(this: Pipeline<Uint8Array>): Pipeline<TO> {
|
|
311
311
|
// It was said that transformJsonParse() separately is 10% or more slower than .map(line => JSON.parse(line))
|
|
312
|
-
//
|
|
312
|
+
// Measured again on Node 24: the opposite, transformJsonParse() is 1.5x faster (Readable.map costs ~1µs per row), see createReadStreamAsNDJson
|
|
313
313
|
this.transforms.push(transformSplitOnNewline(), transformJsonParse())
|
|
314
314
|
this.objectMode = true
|
|
315
315
|
return this as any
|
|
@@ -466,11 +466,19 @@ export class Pipeline<T = unknown> {
|
|
|
466
466
|
signal: this.abortableSignal,
|
|
467
467
|
})
|
|
468
468
|
} catch (err) {
|
|
469
|
-
|
|
469
|
+
// The abort reason is checked on the signal rather than on `err.cause`: when the source has
|
|
470
|
+
// already emitted `end` by the time the abort lands (small `Readable.from` sources), node's
|
|
471
|
+
// pipeline reports ERR_STREAM_PREMATURE_CLOSE without a cause instead of the AbortError.
|
|
472
|
+
if (this.isGracefullyAborted()) {
|
|
470
473
|
console.log('pipeline gracefully aborted') // todo: this message may be removed later
|
|
471
474
|
return
|
|
472
475
|
}
|
|
473
476
|
throw err
|
|
474
477
|
}
|
|
475
478
|
}
|
|
479
|
+
|
|
480
|
+
private isGracefullyAborted(): boolean {
|
|
481
|
+
const { aborted, reason } = this.abortableSignal
|
|
482
|
+
return aborted && reason instanceof Error && reason.message === PIPELINE_GRACEFUL_ABORT
|
|
483
|
+
}
|
|
476
484
|
}
|
|
@@ -127,6 +127,7 @@ export function transformMap<IN = any, OUT = IN>(
|
|
|
127
127
|
let inFlight = 0
|
|
128
128
|
let blockedCallback: (() => void) | null = null
|
|
129
129
|
let flushBlocked: DeferredPromise | null = null
|
|
130
|
+
let destroyBlocked: DeferredPromise | null = null
|
|
130
131
|
|
|
131
132
|
// Warmup - cached concurrency to reduce Date.now() syscalls
|
|
132
133
|
let warmupComplete = warmupSeconds <= 0 || maxConcurrency <= 1
|
|
@@ -220,7 +221,22 @@ export function transformMap<IN = any, OUT = IN>(
|
|
|
220
221
|
if (inFlight === 0 && flushBlocked) {
|
|
221
222
|
flushBlocked.resolve()
|
|
222
223
|
}
|
|
224
|
+
|
|
225
|
+
// Let a pending destroy complete once nothing is in flight any more
|
|
226
|
+
if (inFlight === 0 && destroyBlocked) {
|
|
227
|
+
destroyBlocked.resolve()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
async destroy(err, cb) {
|
|
232
|
+
// An upstream abort (e.g. `limit` or END) destroys this stream while mappers are still running.
|
|
233
|
+
// The close is held until they settle, so that the pipeline resolves only once their side effects are done.
|
|
234
|
+
if (inFlight > 0) {
|
|
235
|
+
destroyBlocked = pDefer()
|
|
236
|
+
await destroyBlocked
|
|
223
237
|
}
|
|
238
|
+
|
|
239
|
+
cb(err)
|
|
224
240
|
},
|
|
225
241
|
async flush(cb) {
|
|
226
242
|
// Wait for all in-flight operations to complete
|
|
@@ -42,73 +42,91 @@ export function transformSplit(separator = '\n'): TransformTyped<Buffer, Buffer>
|
|
|
42
42
|
let pendingLength = 0
|
|
43
43
|
let edge: Buffer | undefined // last `overlap` bytes of pending data, only tracked when overlap > 0
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const transform = new Transform({
|
|
46
46
|
writableObjectMode: false,
|
|
47
47
|
writableHighWaterMark: 64 * 1024,
|
|
48
48
|
readableObjectMode: true,
|
|
49
49
|
|
|
50
50
|
transform(chunk: Buffer, _enc, cb) {
|
|
51
|
-
|
|
51
|
+
// Deferred by a microtask, so that an async upstream (zlib, fs) can dispatch its next chunk to its thread
|
|
52
|
+
// before we do the (synchronous) splitting + whatever the consumer does with the lines:
|
|
53
|
+
// decompression of chunk N+1 then overlaps with processing of chunk N, measured +5-15% on gzip/zstd input.
|
|
54
|
+
// Without it all of that runs inside the upstream's push, and the two never overlap.
|
|
55
|
+
queueMicrotask(() => {
|
|
56
|
+
// Whatever is thrown here (by a consumer most likely, as push() runs 'data' listeners synchronously)
|
|
57
|
+
// must become a stream error, like it did when this ran inside transform() itself:
|
|
58
|
+
// thrown from a microtask it would be an uncaught exception and crash the process.
|
|
59
|
+
try {
|
|
60
|
+
processChunk(chunk)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
cb(err as Error)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
cb()
|
|
66
|
+
})
|
|
67
|
+
},
|
|
52
68
|
|
|
69
|
+
flush(cb) {
|
|
53
70
|
if (pendingLength > 0) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
this.push(Buffer.concat(pending, pendingLength))
|
|
72
|
+
}
|
|
73
|
+
cb()
|
|
74
|
+
},
|
|
75
|
+
})
|
|
58
76
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (overlap > 0) {
|
|
68
|
-
edge = Buffer.concat([edge!, chunk.subarray(-overlap)]).subarray(-overlap)
|
|
69
|
-
}
|
|
70
|
-
cb()
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
} else {
|
|
74
|
-
idx = straddleIdx - edge!.length
|
|
75
|
-
}
|
|
77
|
+
function processChunk(chunk: Buffer): void {
|
|
78
|
+
let start = 0
|
|
79
|
+
|
|
80
|
+
if (pendingLength > 0) {
|
|
81
|
+
// A multi-byte separator may straddle the chunk boundary: look for it in the last `overlap` bytes
|
|
82
|
+
// of pending data followed by the first `overlap` bytes of this chunk
|
|
83
|
+
const straddleIdx =
|
|
84
|
+
overlap > 0 ? Buffer.concat([edge!, chunk.subarray(0, overlap)]).indexOf(matcher) : -1
|
|
76
85
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
// Position of the first separator in chunk coordinates, negative when it started inside the pending data
|
|
87
|
+
let idx: number
|
|
88
|
+
if (straddleIdx === -1) {
|
|
89
|
+
idx = chunk.indexOf(needle)
|
|
90
|
+
if (idx === -1) {
|
|
91
|
+
// No separator yet: keep accumulating without copying
|
|
92
|
+
pending.push(chunk)
|
|
93
|
+
pendingLength += chunk.length
|
|
94
|
+
if (overlap > 0) {
|
|
95
|
+
edge = Buffer.concat([edge!, chunk.subarray(-overlap)]).subarray(-overlap)
|
|
96
|
+
}
|
|
97
|
+
return
|
|
82
98
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
start = idx + matcherLength
|
|
99
|
+
} else {
|
|
100
|
+
idx = straddleIdx - edge!.length
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
start = idx + matcherLength
|
|
94
|
-
idx = chunk.indexOf(needle, start)
|
|
103
|
+
// Complete the pending line with the head of this chunk
|
|
104
|
+
if (idx > 0) pending.push(chunk.subarray(0, idx))
|
|
105
|
+
const lineLength = pendingLength + idx
|
|
106
|
+
if (lineLength > 0) {
|
|
107
|
+
transform.push(Buffer.concat(pending, lineLength))
|
|
95
108
|
}
|
|
109
|
+
pending = []
|
|
110
|
+
pendingLength = 0
|
|
111
|
+
start = idx + matcherLength
|
|
112
|
+
}
|
|
96
113
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (overlap > 0) edge = rest.subarray(-overlap)
|
|
114
|
+
let idx = chunk.indexOf(needle, start)
|
|
115
|
+
while (idx !== -1) {
|
|
116
|
+
if (idx > start) {
|
|
117
|
+
transform.push(chunk.subarray(start, idx))
|
|
102
118
|
}
|
|
119
|
+
start = idx + matcherLength
|
|
120
|
+
idx = chunk.indexOf(needle, start)
|
|
121
|
+
}
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
if (start < chunk.length) {
|
|
124
|
+
const rest = chunk.subarray(start)
|
|
125
|
+
pending.push(rest)
|
|
126
|
+
pendingLength = rest.length
|
|
127
|
+
if (overlap > 0) edge = rest.subarray(-overlap)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
106
130
|
|
|
107
|
-
|
|
108
|
-
if (pendingLength > 0) {
|
|
109
|
-
this.push(Buffer.concat(pending, pendingLength))
|
|
110
|
-
}
|
|
111
|
-
cb()
|
|
112
|
-
},
|
|
113
|
-
})
|
|
131
|
+
return transform
|
|
114
132
|
}
|