@naturalcycles/nodejs-lib 15.67.0 → 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.
- package/dist/stream/index.d.ts +2 -0
- package/dist/stream/index.js +2 -0
- package/dist/stream/pipeline.d.ts +10 -0
- package/dist/stream/pipeline.js +19 -0
- package/dist/stream/transform/transformMap2.d.ts +66 -0
- package/dist/stream/transform/transformMap2.js +183 -0
- package/dist/stream/transform/transformWarmup.d.ts +24 -0
- package/dist/stream/transform/transformWarmup.js +79 -0
- package/dist/zip/zip.util.js +2 -2
- package/package.json +1 -1
- package/src/stream/index.ts +2 -0
- package/src/stream/pipeline.ts +26 -0
- package/src/stream/transform/transformMap2.ts +298 -0
- package/src/stream/transform/transformWarmup.ts +105 -0
- package/src/zip/zip.util.ts +2 -2
package/dist/stream/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/stream/index.js
CHANGED
|
@@ -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.
|
package/dist/stream/pipeline.js
CHANGED
|
@@ -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/dist/zip/zip.util.js
CHANGED
|
@@ -34,7 +34,7 @@ export async function inflateBuffer(buf, options = {}) {
|
|
|
34
34
|
* It's 9 bytes shorter than `gzip`.
|
|
35
35
|
*/
|
|
36
36
|
export async function deflateString(s, options) {
|
|
37
|
-
return await
|
|
37
|
+
return await deflate(s, options);
|
|
38
38
|
}
|
|
39
39
|
export async function inflateToString(buf, options) {
|
|
40
40
|
return (await inflateBuffer(buf, options)).toString();
|
|
@@ -54,7 +54,7 @@ export async function gunzipBuffer(buf, options = {}) {
|
|
|
54
54
|
* It's 9 bytes longer than `deflate`.
|
|
55
55
|
*/
|
|
56
56
|
export async function gzipString(s, options) {
|
|
57
|
-
return await
|
|
57
|
+
return await gzip(s, options);
|
|
58
58
|
}
|
|
59
59
|
export async function gunzipToString(buf, options) {
|
|
60
60
|
return (await gunzipBuffer(buf, options)).toString();
|
package/package.json
CHANGED
package/src/stream/index.ts
CHANGED
|
@@ -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'
|
package/src/stream/pipeline.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/zip/zip.util.ts
CHANGED
|
@@ -50,7 +50,7 @@ export async function deflateString(
|
|
|
50
50
|
s: string,
|
|
51
51
|
options?: ZlibOptions,
|
|
52
52
|
): Promise<Buffer<ArrayBuffer>> {
|
|
53
|
-
return await
|
|
53
|
+
return await deflate(s, options)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export async function inflateToString(buf: Buffer, options?: ZlibOptions): Promise<string> {
|
|
@@ -80,7 +80,7 @@ export async function gunzipBuffer(
|
|
|
80
80
|
* It's 9 bytes longer than `deflate`.
|
|
81
81
|
*/
|
|
82
82
|
export async function gzipString(s: string, options?: ZlibOptions): Promise<Buffer<ArrayBuffer>> {
|
|
83
|
-
return await
|
|
83
|
+
return await gzip(s, options)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export async function gunzipToString(buf: Buffer, options?: ZlibOptions): Promise<string> {
|