@naturalcycles/nodejs-lib 15.125.0 → 15.125.2

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.
@@ -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
- let stream = fs2
21
- .createReadStream(inputPath, {
22
- highWaterMark: 64 * 1024, // no observed speedup
23
- })
24
- .on('error', err => stream.destroy(err));
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
- stream = stream.pipe(createUnzip({
27
- chunkSize: 64 * 1024, // speedup from ~3200 to 3800 rps!
29
+ streams.push(createUnzip({
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
- stream = stream.pipe(createZstdDecompress({
32
- chunkSize: 64 * 1024, // todo: test it
34
+ streams.push(createZstdDecompress({
35
+ chunkSize: 256 * 1024, // tested: 256KB is +10-12% over 64KB on ~9KB rows, neutral on ~180B rows
33
36
  }));
34
37
  }
35
- return stream.pipe(transformSplitOnNewline()).map(line => JSON.parse(line));
36
- // For some crazy reason .map is much faster than transformJsonParse!
37
- // ~5000 vs ~4000 rps !!!
38
- // .on('error', err => stream.emit('error', err))
39
- // .pipe(transformJsonParse<ROW>())
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
  }
@@ -73,7 +73,7 @@ export class Pipeline {
73
73
  }
74
74
  static fromFile(sourceFilePath) {
75
75
  return new Pipeline(fs2.createReadStream(sourceFilePath, {
76
- highWaterMark: 64 * 1024, // no observed speedup
76
+ 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
77
77
  }), false);
78
78
  }
79
79
  /**
@@ -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
- // So, we can investigate a speedup
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;
@@ -254,7 +254,7 @@ export class Pipeline {
254
254
  }
255
255
  gunzip(opt) {
256
256
  this.transforms.push(createUnzip({
257
- chunkSize: 64 * 1024, // speedup from ~3200 to 3800 rps!
257
+ chunkSize: 256 * 1024, // 64KB: speedup from ~3200 to 3800 rps! 256KB: +6-8% more on ~9KB rows, neutral on ~180B rows
258
258
  ...opt,
259
259
  }));
260
260
  this.objectMode = false;
@@ -268,7 +268,7 @@ export class Pipeline {
268
268
  }
269
269
  zstdDecompress(opt) {
270
270
  this.transforms.push(createZstdDecompress({
271
- chunkSize: 64 * 1024, // todo: test it
271
+ chunkSize: 256 * 1024, // tested: 256KB is +10-12% over 64KB on ~9KB rows, neutral on ~180B rows
272
272
  ...opt,
273
273
  }));
274
274
  this.objectMode = false;
@@ -30,61 +30,91 @@ export function transformSplit(separator = '\n') {
30
30
  const needle = matcherLength === 1 ? matcher[0] : matcher;
31
31
  // a multi-byte separator may straddle a chunk boundary, so we keep that many trailing bytes around
32
32
  const overlap = matcherLength - 1;
33
- // Unterminated data is collected as a list of chunks and concatenated only once a separator arrives,
34
- // so a long line spanning many chunks is copied once rather than on every chunk (linear, not quadratic)
33
+ // Unterminated data is collected as a list of chunks and concatenated only once its separator arrives,
34
+ // so a line spanning many chunks is copied once rather than on every chunk (linear, not quadratic).
35
+ // Only that one line is copied: the rest of the chunk is scanned in place and emitted as views into it.
35
36
  let pending = [];
36
37
  let pendingLength = 0;
37
38
  let edge; // last `overlap` bytes of pending data, only tracked when overlap > 0
38
- return new Transform({
39
+ const transform = new Transform({
39
40
  writableObjectMode: false,
40
41
  writableHighWaterMark: 64 * 1024,
41
42
  readableObjectMode: true,
42
43
  transform(chunk, _enc, cb) {
43
- let buf = chunk;
44
- let offset = 0;
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);
54
+ }
55
+ catch (err) {
56
+ cb(err);
57
+ return;
58
+ }
59
+ cb();
60
+ });
61
+ },
62
+ flush(cb) {
45
63
  if (pendingLength > 0) {
46
- const straddles = overlap > 0 && Buffer.concat([edge, chunk.subarray(0, overlap)]).includes(matcher);
47
- const idxInChunk = straddles ? -1 : chunk.indexOf(needle);
48
- if (!straddles && idxInChunk === -1) {
64
+ this.push(Buffer.concat(pending, pendingLength));
65
+ }
66
+ cb();
67
+ },
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) {
49
80
  // No separator yet: keep accumulating without copying
50
81
  pending.push(chunk);
51
82
  pendingLength += chunk.length;
52
83
  if (overlap > 0) {
53
84
  edge = Buffer.concat([edge, chunk.subarray(-overlap)]).subarray(-overlap);
54
85
  }
55
- cb();
56
86
  return;
57
87
  }
58
- pending.push(chunk);
59
- buf = Buffer.concat(pending, pendingLength + chunk.length);
60
- // Start scanning where the first separator can possibly be
61
- offset = straddles ? Math.max(0, pendingLength - overlap) : pendingLength + idxInChunk;
62
- pending = [];
63
- pendingLength = 0;
64
88
  }
65
- let start = 0;
66
- let idx = buf.indexOf(needle, offset);
67
- while (idx !== -1) {
68
- if (idx > start) {
69
- this.push(buf.subarray(start, idx));
70
- }
71
- start = idx + matcherLength;
72
- idx = buf.indexOf(needle, start);
89
+ else {
90
+ idx = straddleIdx - edge.length;
73
91
  }
74
- if (start < buf.length) {
75
- const rest = buf.subarray(start);
76
- pending.push(rest);
77
- pendingLength = rest.length;
78
- if (overlap > 0)
79
- edge = rest.subarray(-overlap);
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));
80
98
  }
81
- cb();
82
- },
83
- flush(cb) {
84
- if (pendingLength > 0) {
85
- this.push(Buffer.concat(pending, pendingLength));
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));
86
107
  }
87
- cb();
88
- },
89
- });
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;
90
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.0",
4
+ "version": "15.125.2",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -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
- let stream: ReadableTyped<ROW> = fs2
25
- .createReadStream(inputPath, {
26
- highWaterMark: 64 * 1024, // no observed speedup
27
- })
28
- .on('error', err => stream.destroy(err))
28
+ const streams: Readable[] = [
29
+ fs2.createReadStream(inputPath, {
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
31
+ }),
32
+ ]
29
33
 
30
34
  if (inputPath.endsWith('.gz')) {
31
- stream = stream.pipe(
35
+ streams.push(
32
36
  createUnzip({
33
- chunkSize: 64 * 1024, // speedup from ~3200 to 3800 rps!
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
- stream = stream.pipe(
41
+ streams.push(
38
42
  createZstdDecompress({
39
- chunkSize: 64 * 1024, // todo: test it
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
- return stream.pipe(transformSplitOnNewline()).map(line => JSON.parse(line))
45
- // For some crazy reason .map is much faster than transformJsonParse!
46
- // ~5000 vs ~4000 rps !!!
47
- // .on('error', err => stream.emit('error', err))
48
- // .pipe(transformJsonParse<ROW>())
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
  }
@@ -114,7 +114,7 @@ export class Pipeline<T = unknown> {
114
114
  static fromFile(sourceFilePath: string): Pipeline<Uint8Array> {
115
115
  return new Pipeline(
116
116
  fs2.createReadStream(sourceFilePath, {
117
- highWaterMark: 64 * 1024, // no observed speedup
117
+ 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
118
118
  }),
119
119
  false,
120
120
  )
@@ -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
- // So, we can investigate a speedup
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
@@ -347,7 +347,7 @@ export class Pipeline<T = unknown> {
347
347
  gunzip(this: Pipeline<Uint8Array>, opt?: ZlibOptions): Pipeline<Uint8Array> {
348
348
  this.transforms.push(
349
349
  createUnzip({
350
- chunkSize: 64 * 1024, // speedup from ~3200 to 3800 rps!
350
+ chunkSize: 256 * 1024, // 64KB: speedup from ~3200 to 3800 rps! 256KB: +6-8% more on ~9KB rows, neutral on ~180B rows
351
351
  ...opt,
352
352
  }),
353
353
  )
@@ -368,7 +368,7 @@ export class Pipeline<T = unknown> {
368
368
  zstdDecompress(this: Pipeline<Uint8Array>, opt?: ZstdOptions): Pipeline<Uint8Array> {
369
369
  this.transforms.push(
370
370
  createZstdDecompress({
371
- chunkSize: 64 * 1024, // todo: test it
371
+ chunkSize: 256 * 1024, // tested: 256KB is +10-12% over 64KB on ~9KB rows, neutral on ~180B rows
372
372
  ...opt,
373
373
  }),
374
374
  )
@@ -35,68 +35,98 @@ export function transformSplit(separator = '\n'): TransformTyped<Buffer, Buffer>
35
35
  // a multi-byte separator may straddle a chunk boundary, so we keep that many trailing bytes around
36
36
  const overlap = matcherLength - 1
37
37
 
38
- // Unterminated data is collected as a list of chunks and concatenated only once a separator arrives,
39
- // so a long line spanning many chunks is copied once rather than on every chunk (linear, not quadratic)
38
+ // Unterminated data is collected as a list of chunks and concatenated only once its separator arrives,
39
+ // so a line spanning many chunks is copied once rather than on every chunk (linear, not quadratic).
40
+ // Only that one line is copied: the rest of the chunk is scanned in place and emitted as views into it.
40
41
  let pending: Buffer[] = []
41
42
  let pendingLength = 0
42
43
  let edge: Buffer | undefined // last `overlap` bytes of pending data, only tracked when overlap > 0
43
44
 
44
- return new Transform({
45
+ const transform = new Transform({
45
46
  writableObjectMode: false,
46
47
  writableHighWaterMark: 64 * 1024,
47
48
  readableObjectMode: true,
48
49
 
49
50
  transform(chunk: Buffer, _enc, cb) {
50
- let buf = chunk
51
- let offset = 0
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
- const straddles =
55
- overlap > 0 && Buffer.concat([edge!, chunk.subarray(0, overlap)]).includes(matcher)
56
- const idxInChunk = straddles ? -1 : chunk.indexOf(needle)
57
- if (!straddles && idxInChunk === -1) {
71
+ this.push(Buffer.concat(pending, pendingLength))
72
+ }
73
+ cb()
74
+ },
75
+ })
76
+
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
85
+
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) {
58
91
  // No separator yet: keep accumulating without copying
59
92
  pending.push(chunk)
60
93
  pendingLength += chunk.length
61
94
  if (overlap > 0) {
62
95
  edge = Buffer.concat([edge!, chunk.subarray(-overlap)]).subarray(-overlap)
63
96
  }
64
- cb()
65
97
  return
66
98
  }
67
- pending.push(chunk)
68
- buf = Buffer.concat(pending, pendingLength + chunk.length)
69
- // Start scanning where the first separator can possibly be
70
- offset = straddles ? Math.max(0, pendingLength - overlap) : pendingLength + idxInChunk
71
- pending = []
72
- pendingLength = 0
99
+ } else {
100
+ idx = straddleIdx - edge!.length
73
101
  }
74
102
 
75
- let start = 0
76
- let idx = buf.indexOf(needle, offset)
77
- while (idx !== -1) {
78
- if (idx > start) {
79
- this.push(buf.subarray(start, idx))
80
- }
81
- start = idx + matcherLength
82
- idx = buf.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))
83
108
  }
109
+ pending = []
110
+ pendingLength = 0
111
+ start = idx + matcherLength
112
+ }
84
113
 
85
- if (start < buf.length) {
86
- const rest = buf.subarray(start)
87
- pending.push(rest)
88
- pendingLength = rest.length
89
- 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))
90
118
  }
119
+ start = idx + matcherLength
120
+ idx = chunk.indexOf(needle, start)
121
+ }
91
122
 
92
- cb()
93
- },
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
+ }
94
130
 
95
- flush(cb) {
96
- if (pendingLength > 0) {
97
- this.push(Buffer.concat(pending, pendingLength))
98
- }
99
- cb()
100
- },
101
- })
131
+ return transform
102
132
  }