@naturalcycles/nodejs-lib 15.67.1 → 15.68.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.
@@ -16,6 +16,7 @@ export * from './transform/transformFork.js';
16
16
  export * from './transform/transformLimit.js';
17
17
  export * from './transform/transformLogProgress.js';
18
18
  export * from './transform/transformMap.js';
19
+ export * from './transform/transformMap2.js';
19
20
  export * from './transform/transformMapSimple.js';
20
21
  export * from './transform/transformMapSync.js';
21
22
  export * from './transform/transformNoOp.js';
@@ -23,6 +24,7 @@ export * from './transform/transformOffset.js';
23
24
  export * from './transform/transformSplit.js';
24
25
  export * from './transform/transformTap.js';
25
26
  export * from './transform/transformThrottle.js';
27
+ export * from './transform/transformWarmup.js';
26
28
  export * from './transform/worker/baseWorkerClass.js';
27
29
  export * from './transform/worker/transformMultiThreaded.js';
28
30
  export * from './transform/worker/transformMultiThreaded.model.js';
@@ -16,6 +16,7 @@ export * from './transform/transformFork.js';
16
16
  export * from './transform/transformLimit.js';
17
17
  export * from './transform/transformLogProgress.js';
18
18
  export * from './transform/transformMap.js';
19
+ export * from './transform/transformMap2.js';
19
20
  export * from './transform/transformMapSimple.js';
20
21
  export * from './transform/transformMapSync.js';
21
22
  export * from './transform/transformNoOp.js';
@@ -23,6 +24,7 @@ export * from './transform/transformOffset.js';
23
24
  export * from './transform/transformSplit.js';
24
25
  export * from './transform/transformTap.js';
25
26
  export * from './transform/transformThrottle.js';
27
+ export * from './transform/transformWarmup.js';
26
28
  export * from './transform/worker/baseWorkerClass.js';
27
29
  export * from './transform/worker/transformMultiThreaded.js';
28
30
  export * from './transform/worker/transformMultiThreaded.model.js';
@@ -5,10 +5,12 @@ import { type AbortableAsyncMapper, type AsyncIndexedMapper, type AsyncPredicate
5
5
  import type { ReadableTyped, TransformOptions, TransformTyped, WritableTyped } from './stream.model.js';
6
6
  import { type TransformLogProgressOptions } from './transform/transformLogProgress.js';
7
7
  import { type TransformMapOptions } from './transform/transformMap.js';
8
+ import { type TransformMap2Options } from './transform/transformMap2.js';
8
9
  import { type TransformMapSimpleOptions } from './transform/transformMapSimple.js';
9
10
  import { type TransformMapSyncOptions } from './transform/transformMapSync.js';
10
11
  import { type TransformOffsetOptions } from './transform/transformOffset.js';
11
12
  import { type TransformThrottleOptions } from './transform/transformThrottle.js';
13
+ import { type TransformWarmupOptions } from './transform/transformWarmup.js';
12
14
  export declare class Pipeline<T = unknown> {
13
15
  private readonly source;
14
16
  private transforms;
@@ -61,6 +63,10 @@ export declare class Pipeline<T = unknown> {
61
63
  flattenIfNeeded(): Pipeline<T extends readonly (infer TO)[] ? TO : T>;
62
64
  logProgress(opt?: TransformLogProgressOptions): this;
63
65
  map<TO>(mapper: AbortableAsyncMapper<T, TO | typeof SKIP | typeof END>, opt?: TransformMapOptions<T, TO>): Pipeline<TO>;
66
+ /**
67
+ * @experimental if proven to be stable - will replace transformMap
68
+ */
69
+ map2<TO>(mapper: AbortableAsyncMapper<T, TO | typeof SKIP | typeof END>, opt?: TransformMap2Options<T, TO>): Pipeline<TO>;
64
70
  mapSync<TO>(mapper: IndexedMapper<T, TO | typeof SKIP | typeof END>, opt?: TransformMapSyncOptions): Pipeline<TO>;
65
71
  mapSimple<TO>(mapper: IndexedMapper<T, TO>, opt?: TransformMapSimpleOptions): Pipeline<TO>;
66
72
  filter(asyncPredicate: AsyncPredicate<T>, opt?: TransformMapOptions): this;
@@ -69,6 +75,10 @@ export declare class Pipeline<T = unknown> {
69
75
  tap(fn: AsyncIndexedMapper<T, any>, opt?: TransformOptions): this;
70
76
  tapSync(fn: IndexedMapper<T, any>, opt?: TransformOptions): this;
71
77
  throttle(opt: TransformThrottleOptions): this;
78
+ /**
79
+ * @experimental to be removed after transformMap2 is stable
80
+ */
81
+ warmup(opt: TransformWarmupOptions): this;
72
82
  transform<TO>(transform: TransformTyped<T, TO>): Pipeline<TO>;
73
83
  /**
74
84
  * Helper method to add multiple transforms at once.
@@ -16,12 +16,14 @@ import { transformFork } from './transform/transformFork.js';
16
16
  import { transformLimit } from './transform/transformLimit.js';
17
17
  import { transformLogProgress, } from './transform/transformLogProgress.js';
18
18
  import { transformMap } from './transform/transformMap.js';
19
+ import { transformMap2 } from './transform/transformMap2.js';
19
20
  import { transformMapSimple, } from './transform/transformMapSimple.js';
20
21
  import { transformMapSync } from './transform/transformMapSync.js';
21
22
  import { transformOffset } from './transform/transformOffset.js';
22
23
  import { transformSplitOnNewline } from './transform/transformSplit.js';
23
24
  import { transformTap, transformTapSync } from './transform/transformTap.js';
24
25
  import { transformThrottle } from './transform/transformThrottle.js';
26
+ import { transformWarmup } from './transform/transformWarmup.js';
25
27
  import { writablePushToArray } from './writable/writablePushToArray.js';
26
28
  import { writableVoid } from './writable/writableVoid.js';
27
29
  export class Pipeline {
@@ -133,6 +135,16 @@ export class Pipeline {
133
135
  }));
134
136
  return this;
135
137
  }
138
+ /**
139
+ * @experimental if proven to be stable - will replace transformMap
140
+ */
141
+ map2(mapper, opt) {
142
+ this.transforms.push(transformMap2(mapper, {
143
+ ...opt,
144
+ signal: this.abortableSignal,
145
+ }));
146
+ return this;
147
+ }
136
148
  mapSync(mapper, opt) {
137
149
  this.transforms.push(transformMapSync(mapper, {
138
150
  ...opt,
@@ -172,6 +184,13 @@ export class Pipeline {
172
184
  this.transforms.push(transformThrottle(opt));
173
185
  return this;
174
186
  }
187
+ /**
188
+ * @experimental to be removed after transformMap2 is stable
189
+ */
190
+ warmup(opt) {
191
+ this.transforms.push(transformWarmup(opt));
192
+ return this;
193
+ }
175
194
  transform(transform) {
176
195
  this.transforms.push(transform);
177
196
  return this;
@@ -0,0 +1,66 @@
1
+ import type { AbortableSignal } from '@naturalcycles/js-lib';
2
+ import { ErrorMode } from '@naturalcycles/js-lib/error';
3
+ import { type AbortableAsyncMapper, type AsyncPredicate, END, type NumberOfSeconds, type PositiveInteger, type Predicate, type Promisable, SKIP } from '@naturalcycles/js-lib/types';
4
+ import type { TransformOptions, TransformTyped } from '../stream.model.js';
5
+ import type { TransformMapStats } from './transformMap.js';
6
+ export interface TransformMap2Options<IN = any, OUT = IN> extends TransformOptions {
7
+ /**
8
+ * Predicate to filter outgoing results (after mapper).
9
+ * Allows to not emit all results.
10
+ *
11
+ * Defaults to "pass everything" (including null, undefined, etc).
12
+ * Simpler way to exclude certain cases is to return SKIP symbol from the mapper.
13
+ */
14
+ predicate?: Predicate<OUT>;
15
+ asyncPredicate?: AsyncPredicate<OUT>;
16
+ /**
17
+ * Number of concurrently pending promises returned by `mapper`.
18
+ *
19
+ * @default 16
20
+ */
21
+ concurrency?: PositiveInteger;
22
+ /**
23
+ * Time in seconds to gradually increase concurrency from 1 to `concurrency`.
24
+ * Useful for warming up connections to databases, APIs, etc.
25
+ *
26
+ * Set to 0 to disable warmup (default).
27
+ */
28
+ warmupSeconds?: NumberOfSeconds;
29
+ /**
30
+ * @default THROW_IMMEDIATELY
31
+ */
32
+ errorMode?: ErrorMode;
33
+ /**
34
+ * If defined - will be called on every error happening in the stream.
35
+ * Called BEFORE observable will emit error (unless skipErrors is set to true).
36
+ */
37
+ onError?: (err: Error, input: IN) => any;
38
+ /**
39
+ * A hook that is called when the last item is finished processing.
40
+ * stats object is passed, containing countIn and countOut -
41
+ * number of items that entered the transform and number of items that left it.
42
+ *
43
+ * Callback is called **before** [possible] Aggregated error is thrown,
44
+ * and before [possible] THROW_IMMEDIATELY error.
45
+ *
46
+ * onDone callback will be awaited before Error is thrown.
47
+ */
48
+ onDone?: (stats: TransformMapStats) => Promisable<any>;
49
+ /**
50
+ * Progress metric
51
+ *
52
+ * @default `stream`
53
+ */
54
+ metric?: string;
55
+ /**
56
+ * Allows to abort (gracefully stop) the stream from inside the Transform.
57
+ */
58
+ signal?: AbortableSignal;
59
+ }
60
+ /**
61
+ * Like transformMap, but with native concurrency control (no through2-concurrent dependency)
62
+ * and support for gradual warmup.
63
+ *
64
+ * @experimental
65
+ */
66
+ export declare function transformMap2<IN = any, OUT = IN>(mapper: AbortableAsyncMapper<IN, OUT | typeof SKIP | typeof END>, opt?: TransformMap2Options<IN, OUT>): TransformTyped<IN, OUT>;
@@ -0,0 +1,183 @@
1
+ import { Transform } from 'node:stream';
2
+ import { _anyToError, _assert, ErrorMode } from '@naturalcycles/js-lib/error';
3
+ import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log';
4
+ import { pDefer } from '@naturalcycles/js-lib/promise/pDefer.js';
5
+ import { END, SKIP, } from '@naturalcycles/js-lib/types';
6
+ import { yellow } from '../../colors/colors.js';
7
+ import { PIPELINE_GRACEFUL_ABORT } from '../stream.util.js';
8
+ /**
9
+ * Like transformMap, but with native concurrency control (no through2-concurrent dependency)
10
+ * and support for gradual warmup.
11
+ *
12
+ * @experimental
13
+ */
14
+ export function transformMap2(mapper, opt = {}) {
15
+ const { concurrency = 16, warmupSeconds = 0, predicate, asyncPredicate, errorMode = ErrorMode.THROW_IMMEDIATELY, onError, onDone, metric = 'stream', signal, objectMode = true, highWaterMark = 64, } = opt;
16
+ const warmupMs = warmupSeconds * 1000;
17
+ const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel);
18
+ // Stats
19
+ const started = Date.now();
20
+ let index = -1;
21
+ let countOut = 0;
22
+ let isSettled = false;
23
+ let ok = true;
24
+ let errors = 0;
25
+ const collectedErrors = [];
26
+ // Concurrency control
27
+ let startTime = 0;
28
+ let warmupComplete = warmupSeconds <= 0 || concurrency <= 1;
29
+ let inFlight = 0;
30
+ const waiters = [];
31
+ // Track pending operations for proper flush
32
+ let pendingOperations = 0;
33
+ return new Transform({
34
+ objectMode,
35
+ readableHighWaterMark: highWaterMark,
36
+ writableHighWaterMark: highWaterMark,
37
+ async transform(chunk, _, cb) {
38
+ // Initialize start time on first item
39
+ if (startTime === 0) {
40
+ startTime = Date.now();
41
+ }
42
+ // Stop processing if isSettled
43
+ if (isSettled)
44
+ return cb();
45
+ const currentIndex = ++index;
46
+ const currentConcurrency = getCurrentConcurrency();
47
+ // Wait for a slot if at capacity
48
+ if (inFlight >= currentConcurrency) {
49
+ const waiter = pDefer();
50
+ waiters.push(waiter);
51
+ await waiter;
52
+ }
53
+ else {
54
+ inFlight++;
55
+ }
56
+ // Signal that we're ready for more input
57
+ cb();
58
+ // Track this operation
59
+ pendingOperations++;
60
+ // Process the item asynchronously
61
+ try {
62
+ const res = await mapper(chunk, currentIndex);
63
+ if (isSettled) {
64
+ release();
65
+ pendingOperations--;
66
+ return;
67
+ }
68
+ if (res === END) {
69
+ isSettled = true;
70
+ logger.log(`transformMap2 END received at index ${currentIndex}`);
71
+ _assert(signal, 'signal is required when using END');
72
+ signal.abort(new Error(PIPELINE_GRACEFUL_ABORT));
73
+ release();
74
+ pendingOperations--;
75
+ return;
76
+ }
77
+ if (res === SKIP) {
78
+ release();
79
+ pendingOperations--;
80
+ return;
81
+ }
82
+ let shouldPush = true;
83
+ if (predicate) {
84
+ shouldPush = predicate(res, currentIndex);
85
+ }
86
+ else if (asyncPredicate) {
87
+ shouldPush = (await asyncPredicate(res, currentIndex)) && !isSettled;
88
+ }
89
+ if (shouldPush) {
90
+ countOut++;
91
+ this.push(res);
92
+ }
93
+ }
94
+ catch (err) {
95
+ logger.error(err);
96
+ errors++;
97
+ logErrorStats();
98
+ if (onError) {
99
+ try {
100
+ onError(_anyToError(err), chunk);
101
+ }
102
+ catch { }
103
+ }
104
+ if (errorMode === ErrorMode.THROW_IMMEDIATELY) {
105
+ isSettled = true;
106
+ ok = false;
107
+ // Call onDone before destroying, since flush won't be called
108
+ await callOnDone();
109
+ this.destroy(_anyToError(err));
110
+ }
111
+ else if (errorMode === ErrorMode.THROW_AGGREGATED) {
112
+ collectedErrors.push(_anyToError(err));
113
+ }
114
+ }
115
+ finally {
116
+ release();
117
+ pendingOperations--;
118
+ }
119
+ },
120
+ async flush(cb) {
121
+ // Wait for all pending operations to complete
122
+ // Polling is simple and race-condition-free
123
+ // Timeout prevents infinite loop if something goes wrong
124
+ const flushStart = Date.now();
125
+ const flushTimeoutMs = 60_000;
126
+ while (pendingOperations > 0) {
127
+ await new Promise(resolve => setImmediate(resolve));
128
+ if (Date.now() - flushStart > flushTimeoutMs) {
129
+ logger.error(`transformMap2 flush timeout: ${pendingOperations} operations still pending after ${flushTimeoutMs}ms`);
130
+ break;
131
+ }
132
+ }
133
+ logErrorStats(true);
134
+ await callOnDone();
135
+ if (collectedErrors.length) {
136
+ cb(new AggregateError(collectedErrors, `transformMap2 resulted in ${collectedErrors.length} error(s)`));
137
+ }
138
+ else {
139
+ cb();
140
+ }
141
+ },
142
+ });
143
+ function getCurrentConcurrency() {
144
+ if (warmupComplete)
145
+ return concurrency;
146
+ const elapsed = Date.now() - startTime;
147
+ if (elapsed >= warmupMs) {
148
+ warmupComplete = true;
149
+ logger.debug('warmup complete');
150
+ return concurrency;
151
+ }
152
+ const progress = elapsed / warmupMs;
153
+ return Math.max(1, Math.floor(1 + (concurrency - 1) * progress));
154
+ }
155
+ function release() {
156
+ inFlight--;
157
+ const currentConcurrency = getCurrentConcurrency();
158
+ while (waiters.length && inFlight < currentConcurrency) {
159
+ inFlight++;
160
+ waiters.shift().resolve();
161
+ }
162
+ }
163
+ function logErrorStats(final = false) {
164
+ if (!errors)
165
+ return;
166
+ logger.log(`${metric} ${final ? 'final ' : ''}errors: ${yellow(errors)}`);
167
+ }
168
+ async function callOnDone() {
169
+ try {
170
+ await onDone?.({
171
+ ok: collectedErrors.length === 0 && ok,
172
+ collectedErrors,
173
+ countErrors: errors,
174
+ countIn: index + 1,
175
+ countOut,
176
+ started,
177
+ });
178
+ }
179
+ catch (err) {
180
+ logger.error(err);
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,24 @@
1
+ import type { NumberOfSeconds, PositiveInteger } from '@naturalcycles/js-lib/types';
2
+ import type { TransformOptions, TransformTyped } from '../stream.model.js';
3
+ export interface TransformWarmupOptions extends TransformOptions {
4
+ /**
5
+ * Target concurrency after warmup completes.
6
+ */
7
+ concurrency: PositiveInteger;
8
+ /**
9
+ * Time in seconds to gradually increase concurrency from 1 to `concurrency`.
10
+ * Set to 0 to disable warmup (pass-through mode from the start).
11
+ */
12
+ warmupSeconds: NumberOfSeconds;
13
+ }
14
+ /**
15
+ * Transform that gradually increases concurrency from 1 to the configured maximum
16
+ * over a warmup period. Useful for scenarios where you want to avoid overwhelming
17
+ * a system at startup (e.g., database connections, API rate limits).
18
+ *
19
+ * During warmup: limits concurrent items based on elapsed time.
20
+ * After warmup: passes items through immediately with zero overhead.
21
+ *
22
+ * @experimental
23
+ */
24
+ export declare function transformWarmup<T>(opt: TransformWarmupOptions): TransformTyped<T, T>;
@@ -0,0 +1,79 @@
1
+ import { Transform } from 'node:stream';
2
+ import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log';
3
+ import { pDefer } from '@naturalcycles/js-lib/promise/pDefer.js';
4
+ /**
5
+ * Transform that gradually increases concurrency from 1 to the configured maximum
6
+ * over a warmup period. Useful for scenarios where you want to avoid overwhelming
7
+ * a system at startup (e.g., database connections, API rate limits).
8
+ *
9
+ * During warmup: limits concurrent items based on elapsed time.
10
+ * After warmup: passes items through immediately with zero overhead.
11
+ *
12
+ * @experimental
13
+ */
14
+ export function transformWarmup(opt) {
15
+ const { concurrency, warmupSeconds, objectMode = true, highWaterMark } = opt;
16
+ const warmupMs = warmupSeconds * 1000;
17
+ const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel);
18
+ let startTime = 0;
19
+ let warmupComplete = warmupSeconds <= 0 || concurrency <= 1;
20
+ let inFlight = 0;
21
+ const waiters = [];
22
+ return new Transform({
23
+ objectMode,
24
+ highWaterMark,
25
+ async transform(item, _, cb) {
26
+ // Initialize start time on first item
27
+ if (startTime === 0) {
28
+ startTime = Date.now();
29
+ }
30
+ // Fast-path: after warmup, just pass through with zero overhead
31
+ if (warmupComplete) {
32
+ cb(null, item);
33
+ return;
34
+ }
35
+ const currentConcurrency = getCurrentConcurrency();
36
+ if (inFlight < currentConcurrency) {
37
+ // Have room, proceed immediately
38
+ inFlight++;
39
+ logger.debug(`inFlight++ ${inFlight}/${currentConcurrency}, waiters ${waiters.length}`);
40
+ }
41
+ else {
42
+ // Wait for a slot
43
+ const waiter = pDefer();
44
+ waiters.push(waiter);
45
+ logger.debug(`inFlight ${inFlight}/${currentConcurrency}, waiters++ ${waiters.length}`);
46
+ await waiter;
47
+ logger.debug(`waiter resolved, inFlight ${inFlight}/${getCurrentConcurrency()}`);
48
+ }
49
+ // Push the item
50
+ cb(null, item);
51
+ // Release slot on next microtask - essential for concurrency control.
52
+ // Without this, the slot would be freed immediately and items would
53
+ // flow through without any limiting effect.
54
+ queueMicrotask(release);
55
+ },
56
+ });
57
+ function getCurrentConcurrency() {
58
+ if (warmupComplete)
59
+ return concurrency;
60
+ const elapsed = Date.now() - startTime;
61
+ if (elapsed >= warmupMs) {
62
+ warmupComplete = true;
63
+ logger.debug('warmup complete');
64
+ return concurrency;
65
+ }
66
+ // Linear interpolation from 1 to concurrency
67
+ const progress = elapsed / warmupMs;
68
+ return Math.max(1, Math.floor(1 + (concurrency - 1) * progress));
69
+ }
70
+ function release() {
71
+ inFlight--;
72
+ // Wake up waiters based on current concurrency (may have increased)
73
+ const currentConcurrency = getCurrentConcurrency();
74
+ while (waiters.length && inFlight < currentConcurrency) {
75
+ inFlight++;
76
+ waiters.shift().resolve();
77
+ }
78
+ }
79
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.67.1",
4
+ "version": "15.68.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@types/js-yaml": "^4",
@@ -16,6 +16,7 @@ export * from './transform/transformFork.js'
16
16
  export * from './transform/transformLimit.js'
17
17
  export * from './transform/transformLogProgress.js'
18
18
  export * from './transform/transformMap.js'
19
+ export * from './transform/transformMap2.js'
19
20
  export * from './transform/transformMapSimple.js'
20
21
  export * from './transform/transformMapSync.js'
21
22
  export * from './transform/transformNoOp.js'
@@ -23,6 +24,7 @@ export * from './transform/transformOffset.js'
23
24
  export * from './transform/transformSplit.js'
24
25
  export * from './transform/transformTap.js'
25
26
  export * from './transform/transformThrottle.js'
27
+ export * from './transform/transformWarmup.js'
26
28
  export * from './transform/worker/baseWorkerClass.js'
27
29
  export * from './transform/worker/transformMultiThreaded.js'
28
30
  export * from './transform/worker/transformMultiThreaded.model.js'
@@ -45,6 +45,7 @@ import {
45
45
  type TransformLogProgressOptions,
46
46
  } from './transform/transformLogProgress.js'
47
47
  import { transformMap, type TransformMapOptions } from './transform/transformMap.js'
48
+ import { transformMap2, type TransformMap2Options } from './transform/transformMap2.js'
48
49
  import {
49
50
  transformMapSimple,
50
51
  type TransformMapSimpleOptions,
@@ -54,6 +55,7 @@ import { transformOffset, type TransformOffsetOptions } from './transform/transf
54
55
  import { transformSplitOnNewline } from './transform/transformSplit.js'
55
56
  import { transformTap, transformTapSync } from './transform/transformTap.js'
56
57
  import { transformThrottle, type TransformThrottleOptions } from './transform/transformThrottle.js'
58
+ import { transformWarmup, type TransformWarmupOptions } from './transform/transformWarmup.js'
57
59
  import { writablePushToArray } from './writable/writablePushToArray.js'
58
60
  import { writableVoid } from './writable/writableVoid.js'
59
61
 
@@ -196,6 +198,22 @@ export class Pipeline<T = unknown> {
196
198
  return this as any
197
199
  }
198
200
 
201
+ /**
202
+ * @experimental if proven to be stable - will replace transformMap
203
+ */
204
+ map2<TO>(
205
+ mapper: AbortableAsyncMapper<T, TO | typeof SKIP | typeof END>,
206
+ opt?: TransformMap2Options<T, TO>,
207
+ ): Pipeline<TO> {
208
+ this.transforms.push(
209
+ transformMap2(mapper, {
210
+ ...opt,
211
+ signal: this.abortableSignal,
212
+ }),
213
+ )
214
+ return this as any
215
+ }
216
+
199
217
  mapSync<TO>(
200
218
  mapper: IndexedMapper<T, TO | typeof SKIP | typeof END>,
201
219
  opt?: TransformMapSyncOptions,
@@ -250,6 +268,14 @@ export class Pipeline<T = unknown> {
250
268
  return this
251
269
  }
252
270
 
271
+ /**
272
+ * @experimental to be removed after transformMap2 is stable
273
+ */
274
+ warmup(opt: TransformWarmupOptions): this {
275
+ this.transforms.push(transformWarmup(opt))
276
+ return this
277
+ }
278
+
253
279
  transform<TO>(transform: TransformTyped<T, TO>): Pipeline<TO> {
254
280
  this.transforms.push(transform)
255
281
  return this as any
@@ -0,0 +1,298 @@
1
+ import { Transform } from 'node:stream'
2
+ import type { AbortableSignal } from '@naturalcycles/js-lib'
3
+ import { _anyToError, _assert, ErrorMode } from '@naturalcycles/js-lib/error'
4
+ import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log'
5
+ import type { DeferredPromise } from '@naturalcycles/js-lib/promise'
6
+ import { pDefer } from '@naturalcycles/js-lib/promise/pDefer.js'
7
+ import {
8
+ type AbortableAsyncMapper,
9
+ type AsyncPredicate,
10
+ END,
11
+ type NumberOfSeconds,
12
+ type PositiveInteger,
13
+ type Predicate,
14
+ type Promisable,
15
+ SKIP,
16
+ type UnixTimestampMillis,
17
+ } from '@naturalcycles/js-lib/types'
18
+ import { yellow } from '../../colors/colors.js'
19
+ import type { TransformOptions, TransformTyped } from '../stream.model.js'
20
+ import { PIPELINE_GRACEFUL_ABORT } from '../stream.util.js'
21
+ import type { TransformMapStats } from './transformMap.js'
22
+
23
+ export interface TransformMap2Options<IN = any, OUT = IN> extends TransformOptions {
24
+ /**
25
+ * Predicate to filter outgoing results (after mapper).
26
+ * Allows to not emit all results.
27
+ *
28
+ * Defaults to "pass everything" (including null, undefined, etc).
29
+ * Simpler way to exclude certain cases is to return SKIP symbol from the mapper.
30
+ */
31
+ predicate?: Predicate<OUT>
32
+
33
+ asyncPredicate?: AsyncPredicate<OUT>
34
+
35
+ /**
36
+ * Number of concurrently pending promises returned by `mapper`.
37
+ *
38
+ * @default 16
39
+ */
40
+ concurrency?: PositiveInteger
41
+
42
+ /**
43
+ * Time in seconds to gradually increase concurrency from 1 to `concurrency`.
44
+ * Useful for warming up connections to databases, APIs, etc.
45
+ *
46
+ * Set to 0 to disable warmup (default).
47
+ */
48
+ warmupSeconds?: NumberOfSeconds
49
+
50
+ /**
51
+ * @default THROW_IMMEDIATELY
52
+ */
53
+ errorMode?: ErrorMode
54
+
55
+ /**
56
+ * If defined - will be called on every error happening in the stream.
57
+ * Called BEFORE observable will emit error (unless skipErrors is set to true).
58
+ */
59
+ onError?: (err: Error, input: IN) => any
60
+
61
+ /**
62
+ * A hook that is called when the last item is finished processing.
63
+ * stats object is passed, containing countIn and countOut -
64
+ * number of items that entered the transform and number of items that left it.
65
+ *
66
+ * Callback is called **before** [possible] Aggregated error is thrown,
67
+ * and before [possible] THROW_IMMEDIATELY error.
68
+ *
69
+ * onDone callback will be awaited before Error is thrown.
70
+ */
71
+ onDone?: (stats: TransformMapStats) => Promisable<any>
72
+
73
+ /**
74
+ * Progress metric
75
+ *
76
+ * @default `stream`
77
+ */
78
+ metric?: string
79
+
80
+ /**
81
+ * Allows to abort (gracefully stop) the stream from inside the Transform.
82
+ */
83
+ signal?: AbortableSignal
84
+ }
85
+
86
+ /**
87
+ * Like transformMap, but with native concurrency control (no through2-concurrent dependency)
88
+ * and support for gradual warmup.
89
+ *
90
+ * @experimental
91
+ */
92
+ export function transformMap2<IN = any, OUT = IN>(
93
+ mapper: AbortableAsyncMapper<IN, OUT | typeof SKIP | typeof END>,
94
+ opt: TransformMap2Options<IN, OUT> = {},
95
+ ): TransformTyped<IN, OUT> {
96
+ const {
97
+ concurrency = 16,
98
+ warmupSeconds = 0,
99
+ predicate,
100
+ asyncPredicate,
101
+ errorMode = ErrorMode.THROW_IMMEDIATELY,
102
+ onError,
103
+ onDone,
104
+ metric = 'stream',
105
+ signal,
106
+ objectMode = true,
107
+ highWaterMark = 64,
108
+ } = opt
109
+
110
+ const warmupMs = warmupSeconds * 1000
111
+ const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel)
112
+
113
+ // Stats
114
+ const started = Date.now() as UnixTimestampMillis
115
+ let index = -1
116
+ let countOut = 0
117
+ let isSettled = false
118
+ let ok = true
119
+ let errors = 0
120
+ const collectedErrors: Error[] = []
121
+
122
+ // Concurrency control
123
+ let startTime = 0
124
+ let warmupComplete = warmupSeconds <= 0 || concurrency <= 1
125
+ let inFlight = 0
126
+ const waiters: DeferredPromise[] = []
127
+
128
+ // Track pending operations for proper flush
129
+ let pendingOperations = 0
130
+
131
+ return new Transform({
132
+ objectMode,
133
+ readableHighWaterMark: highWaterMark,
134
+ writableHighWaterMark: highWaterMark,
135
+ async transform(this: Transform, chunk: IN, _, cb) {
136
+ // Initialize start time on first item
137
+ if (startTime === 0) {
138
+ startTime = Date.now()
139
+ }
140
+
141
+ // Stop processing if isSettled
142
+ if (isSettled) return cb()
143
+
144
+ const currentIndex = ++index
145
+ const currentConcurrency = getCurrentConcurrency()
146
+
147
+ // Wait for a slot if at capacity
148
+ if (inFlight >= currentConcurrency) {
149
+ const waiter = pDefer()
150
+ waiters.push(waiter)
151
+ await waiter
152
+ } else {
153
+ inFlight++
154
+ }
155
+
156
+ // Signal that we're ready for more input
157
+ cb()
158
+
159
+ // Track this operation
160
+ pendingOperations++
161
+
162
+ // Process the item asynchronously
163
+ try {
164
+ const res: OUT | typeof SKIP | typeof END = await mapper(chunk, currentIndex)
165
+
166
+ if (isSettled) {
167
+ release()
168
+ pendingOperations--
169
+ return
170
+ }
171
+
172
+ if (res === END) {
173
+ isSettled = true
174
+ logger.log(`transformMap2 END received at index ${currentIndex}`)
175
+ _assert(signal, 'signal is required when using END')
176
+ signal.abort(new Error(PIPELINE_GRACEFUL_ABORT))
177
+ release()
178
+ pendingOperations--
179
+ return
180
+ }
181
+
182
+ if (res === SKIP) {
183
+ release()
184
+ pendingOperations--
185
+ return
186
+ }
187
+
188
+ let shouldPush = true
189
+ if (predicate) {
190
+ shouldPush = predicate(res, currentIndex)
191
+ } else if (asyncPredicate) {
192
+ shouldPush = (await asyncPredicate(res, currentIndex)) && !isSettled
193
+ }
194
+
195
+ if (shouldPush) {
196
+ countOut++
197
+ this.push(res)
198
+ }
199
+ } catch (err) {
200
+ logger.error(err)
201
+ errors++
202
+ logErrorStats()
203
+
204
+ if (onError) {
205
+ try {
206
+ onError(_anyToError(err), chunk)
207
+ } catch {}
208
+ }
209
+
210
+ if (errorMode === ErrorMode.THROW_IMMEDIATELY) {
211
+ isSettled = true
212
+ ok = false
213
+ // Call onDone before destroying, since flush won't be called
214
+ await callOnDone()
215
+ this.destroy(_anyToError(err))
216
+ } else if (errorMode === ErrorMode.THROW_AGGREGATED) {
217
+ collectedErrors.push(_anyToError(err))
218
+ }
219
+ } finally {
220
+ release()
221
+ pendingOperations--
222
+ }
223
+ },
224
+ async flush(cb) {
225
+ // Wait for all pending operations to complete
226
+ // Polling is simple and race-condition-free
227
+ // Timeout prevents infinite loop if something goes wrong
228
+ const flushStart = Date.now()
229
+ const flushTimeoutMs = 60_000
230
+ while (pendingOperations > 0) {
231
+ await new Promise(resolve => setImmediate(resolve))
232
+ if (Date.now() - flushStart > flushTimeoutMs) {
233
+ logger.error(
234
+ `transformMap2 flush timeout: ${pendingOperations} operations still pending after ${flushTimeoutMs}ms`,
235
+ )
236
+ break
237
+ }
238
+ }
239
+
240
+ logErrorStats(true)
241
+ await callOnDone()
242
+
243
+ if (collectedErrors.length) {
244
+ cb(
245
+ new AggregateError(
246
+ collectedErrors,
247
+ `transformMap2 resulted in ${collectedErrors.length} error(s)`,
248
+ ),
249
+ )
250
+ } else {
251
+ cb()
252
+ }
253
+ },
254
+ })
255
+
256
+ function getCurrentConcurrency(): number {
257
+ if (warmupComplete) return concurrency
258
+
259
+ const elapsed = Date.now() - startTime
260
+ if (elapsed >= warmupMs) {
261
+ warmupComplete = true
262
+ logger.debug('warmup complete')
263
+ return concurrency
264
+ }
265
+
266
+ const progress = elapsed / warmupMs
267
+ return Math.max(1, Math.floor(1 + (concurrency - 1) * progress))
268
+ }
269
+
270
+ function release(): void {
271
+ inFlight--
272
+ const currentConcurrency = getCurrentConcurrency()
273
+ while (waiters.length && inFlight < currentConcurrency) {
274
+ inFlight++
275
+ waiters.shift()!.resolve()
276
+ }
277
+ }
278
+
279
+ function logErrorStats(final = false): void {
280
+ if (!errors) return
281
+ logger.log(`${metric} ${final ? 'final ' : ''}errors: ${yellow(errors)}`)
282
+ }
283
+
284
+ async function callOnDone(): Promise<void> {
285
+ try {
286
+ await onDone?.({
287
+ ok: collectedErrors.length === 0 && ok,
288
+ collectedErrors,
289
+ countErrors: errors,
290
+ countIn: index + 1,
291
+ countOut,
292
+ started,
293
+ })
294
+ } catch (err) {
295
+ logger.error(err)
296
+ }
297
+ }
298
+ }
@@ -0,0 +1,105 @@
1
+ import { Transform } from 'node:stream'
2
+ import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log'
3
+ import type { DeferredPromise } from '@naturalcycles/js-lib/promise'
4
+ import { pDefer } from '@naturalcycles/js-lib/promise/pDefer.js'
5
+ import type { NumberOfSeconds, PositiveInteger } from '@naturalcycles/js-lib/types'
6
+ import type { TransformOptions, TransformTyped } from '../stream.model.js'
7
+
8
+ export interface TransformWarmupOptions extends TransformOptions {
9
+ /**
10
+ * Target concurrency after warmup completes.
11
+ */
12
+ concurrency: PositiveInteger
13
+
14
+ /**
15
+ * Time in seconds to gradually increase concurrency from 1 to `concurrency`.
16
+ * Set to 0 to disable warmup (pass-through mode from the start).
17
+ */
18
+ warmupSeconds: NumberOfSeconds
19
+ }
20
+
21
+ /**
22
+ * Transform that gradually increases concurrency from 1 to the configured maximum
23
+ * over a warmup period. Useful for scenarios where you want to avoid overwhelming
24
+ * a system at startup (e.g., database connections, API rate limits).
25
+ *
26
+ * During warmup: limits concurrent items based on elapsed time.
27
+ * After warmup: passes items through immediately with zero overhead.
28
+ *
29
+ * @experimental
30
+ */
31
+ export function transformWarmup<T>(opt: TransformWarmupOptions): TransformTyped<T, T> {
32
+ const { concurrency, warmupSeconds, objectMode = true, highWaterMark } = opt
33
+ const warmupMs = warmupSeconds * 1000
34
+ const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel)
35
+
36
+ let startTime = 0
37
+ let warmupComplete = warmupSeconds <= 0 || concurrency <= 1
38
+ let inFlight = 0
39
+ const waiters: DeferredPromise[] = []
40
+
41
+ return new Transform({
42
+ objectMode,
43
+ highWaterMark,
44
+ async transform(item: T, _, cb) {
45
+ // Initialize start time on first item
46
+ if (startTime === 0) {
47
+ startTime = Date.now()
48
+ }
49
+
50
+ // Fast-path: after warmup, just pass through with zero overhead
51
+ if (warmupComplete) {
52
+ cb(null, item)
53
+ return
54
+ }
55
+
56
+ const currentConcurrency = getCurrentConcurrency()
57
+
58
+ if (inFlight < currentConcurrency) {
59
+ // Have room, proceed immediately
60
+ inFlight++
61
+ logger.debug(`inFlight++ ${inFlight}/${currentConcurrency}, waiters ${waiters.length}`)
62
+ } else {
63
+ // Wait for a slot
64
+ const waiter = pDefer()
65
+ waiters.push(waiter)
66
+ logger.debug(`inFlight ${inFlight}/${currentConcurrency}, waiters++ ${waiters.length}`)
67
+ await waiter
68
+ logger.debug(`waiter resolved, inFlight ${inFlight}/${getCurrentConcurrency()}`)
69
+ }
70
+
71
+ // Push the item
72
+ cb(null, item)
73
+
74
+ // Release slot on next microtask - essential for concurrency control.
75
+ // Without this, the slot would be freed immediately and items would
76
+ // flow through without any limiting effect.
77
+ queueMicrotask(release)
78
+ },
79
+ })
80
+
81
+ function getCurrentConcurrency(): number {
82
+ if (warmupComplete) return concurrency
83
+
84
+ const elapsed = Date.now() - startTime
85
+ if (elapsed >= warmupMs) {
86
+ warmupComplete = true
87
+ logger.debug('warmup complete')
88
+ return concurrency
89
+ }
90
+
91
+ // Linear interpolation from 1 to concurrency
92
+ const progress = elapsed / warmupMs
93
+ return Math.max(1, Math.floor(1 + (concurrency - 1) * progress))
94
+ }
95
+
96
+ function release(): void {
97
+ inFlight--
98
+ // Wake up waiters based on current concurrency (may have increased)
99
+ const currentConcurrency = getCurrentConcurrency()
100
+ while (waiters.length && inFlight < currentConcurrency) {
101
+ inFlight++
102
+ waiters.shift()!.resolve()
103
+ }
104
+ }
105
+ }