@naturalcycles/nodejs-lib 15.102.0 → 15.103.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.
@@ -23,6 +23,7 @@ export * from './transform/transformOffset.js';
23
23
  export * from './transform/transformSplit.js';
24
24
  export * from './transform/transformTap.js';
25
25
  export * from './transform/transformThrottle.js';
26
+ export * from './transform/transformThrottleByRSS.js';
26
27
  export * from './transform/transformWarmup.js';
27
28
  export * from './transform/worker/baseWorkerClass.js';
28
29
  export * from './transform/worker/transformMultiThreaded.js';
@@ -23,6 +23,7 @@ export * from './transform/transformOffset.js';
23
23
  export * from './transform/transformSplit.js';
24
24
  export * from './transform/transformTap.js';
25
25
  export * from './transform/transformThrottle.js';
26
+ export * from './transform/transformThrottleByRSS.js';
26
27
  export * from './transform/transformWarmup.js';
27
28
  export * from './transform/worker/baseWorkerClass.js';
28
29
  export * from './transform/worker/transformMultiThreaded.js';
@@ -9,6 +9,7 @@ import type { TransformMapSimpleOptions } from './transform/transformMapSimple.j
9
9
  import type { TransformMapSyncOptions } from './transform/transformMapSync.js';
10
10
  import type { TransformOffsetOptions } from './transform/transformOffset.js';
11
11
  import type { TransformThrottleOptions } from './transform/transformThrottle.js';
12
+ import type { TransformThrottleByRSSOptions } from './transform/transformThrottleByRSS.js';
12
13
  import type { TransformWarmupOptions } from './transform/transformWarmup.js';
13
14
  export declare class Pipeline<T = unknown> {
14
15
  private readonly source;
@@ -70,6 +71,7 @@ export declare class Pipeline<T = unknown> {
70
71
  tap(fn: AsyncIndexedMapper<T, any>, opt?: TransformOptions): this;
71
72
  tapSync(fn: IndexedMapper<T, any>, opt?: TransformOptions): this;
72
73
  throttle(opt: TransformThrottleOptions): this;
74
+ throttleByRSS(opt: TransformThrottleByRSSOptions): this;
73
75
  /**
74
76
  * @experimental to be removed after transformMap2 is stable
75
77
  */
@@ -24,6 +24,7 @@ import { transformOffset } from './transform/transformOffset.js';
24
24
  import { transformSplitOnNewline } from './transform/transformSplit.js';
25
25
  import { transformTap, transformTapSync } from './transform/transformTap.js';
26
26
  import { transformThrottle } from './transform/transformThrottle.js';
27
+ import { transformThrottleByRSS } from './transform/transformThrottleByRSS.js';
27
28
  import { transformWarmup } from './transform/transformWarmup.js';
28
29
  import { writablePushToArray } from './writable/writablePushToArray.js';
29
30
  import { writableVoid } from './writable/writableVoid.js';
@@ -175,6 +176,10 @@ export class Pipeline {
175
176
  this.transforms.push(transformThrottle(opt));
176
177
  return this;
177
178
  }
179
+ throttleByRSS(opt) {
180
+ this.transforms.push(transformThrottleByRSS(opt));
181
+ return this;
182
+ }
178
183
  /**
179
184
  * @experimental to be removed after transformMap2 is stable
180
185
  */
@@ -0,0 +1,43 @@
1
+ import type { Integer, NumberOfMilliseconds } from '@naturalcycles/js-lib/types';
2
+ import type { TransformOptions, TransformTyped } from '../stream.model.js';
3
+ export interface TransformThrottleByRSSOptions extends TransformOptions {
4
+ /**
5
+ * Maximum RSS (Resident Set Size) in megabytes.
6
+ * When process RSS exceeds this value, the stream will pause
7
+ * until RSS drops below the threshold.
8
+ */
9
+ maxRSS: Integer;
10
+ /**
11
+ * How often to re-check RSS (in milliseconds) while paused.
12
+ *
13
+ * @default 5000
14
+ */
15
+ pollInterval?: NumberOfMilliseconds;
16
+ /**
17
+ * If this timeout is reached while RSS is above the limit -
18
+ * the transform will "give up", log the bold warning, and "open the gateways".
19
+ * Things will likely OOM after that, but at least it will not "hang forever".
20
+ *
21
+ * @default 30 minutes
22
+ */
23
+ pollTimeout?: NumberOfMilliseconds;
24
+ /**
25
+ * What to do if pollTimeout is reached.
26
+ * 'open-the-floodgates' will disable this throttle completely (YOLO).
27
+ * 'throw' will throw an error, which will destroy the stream/Pipeline.
28
+ *
29
+ * @default 'open-the-floodgates'
30
+ */
31
+ onPollTimeout?: 'open-the-floodgates' | 'throw';
32
+ }
33
+ /**
34
+ * Throttles the stream based on process memory (RSS) usage.
35
+ * When RSS exceeds `maxRSS` (in megabytes), the stream pauses
36
+ * and periodically re-checks until RSS drops below the threshold.
37
+ *
38
+ * Useful for pipelines that process large amounts of data and
39
+ * may cause memory pressure (e.g. database imports, file processing).
40
+ *
41
+ * @experimental
42
+ */
43
+ export declare function transformThrottleByRSS<T>(opt: TransformThrottleByRSSOptions): TransformTyped<T, T>;
@@ -0,0 +1,89 @@
1
+ import { Transform } from 'node:stream';
2
+ import { _mb } from '@naturalcycles/js-lib';
3
+ import { _ms, localTime } from '@naturalcycles/js-lib/datetime';
4
+ import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log';
5
+ import { pDefer } from '@naturalcycles/js-lib/promise/pDefer.js';
6
+ /**
7
+ * Throttles the stream based on process memory (RSS) usage.
8
+ * When RSS exceeds `maxRSS` (in megabytes), the stream pauses
9
+ * and periodically re-checks until RSS drops below the threshold.
10
+ *
11
+ * Useful for pipelines that process large amounts of data and
12
+ * may cause memory pressure (e.g. database imports, file processing).
13
+ *
14
+ * @experimental
15
+ */
16
+ export function transformThrottleByRSS(opt) {
17
+ const { maxRSS, pollInterval = 5000, pollTimeout = 30 * 60_000, // 30 min
18
+ onPollTimeout = 'open-the-floodgates', objectMode = true, highWaterMark, } = opt;
19
+ const maxRSSBytes = maxRSS * 1024 * 1024;
20
+ let lock;
21
+ let pollTimer;
22
+ let rssCheckTimer;
23
+ let lastRSS = 0;
24
+ let pausedSince = 0;
25
+ let disabled = false;
26
+ const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel);
27
+ return new Transform({
28
+ objectMode,
29
+ highWaterMark,
30
+ async transform(item, _, cb) {
31
+ if (lock) {
32
+ try {
33
+ await lock;
34
+ }
35
+ catch (err) {
36
+ cb(err);
37
+ return;
38
+ }
39
+ }
40
+ if (!disabled && lastRSS > maxRSSBytes && !lock) {
41
+ lock = pDefer();
42
+ pausedSince = Date.now();
43
+ logger.log(`${localTime.now().toPretty()} transformThrottleByRSS paused: RSS ${_mb(lastRSS)} > ${maxRSS} MB`);
44
+ pollTimer = setTimeout(() => pollRSS(), pollInterval);
45
+ }
46
+ cb(null, item);
47
+ },
48
+ construct(cb) {
49
+ // Start periodic RSS checking
50
+ checkRSS();
51
+ cb();
52
+ },
53
+ final(cb) {
54
+ clearTimeout(pollTimer);
55
+ clearTimeout(rssCheckTimer);
56
+ cb();
57
+ },
58
+ });
59
+ function checkRSS() {
60
+ lastRSS = process.memoryUsage.rss();
61
+ rssCheckTimer = setTimeout(() => checkRSS(), pollInterval);
62
+ }
63
+ function pollRSS() {
64
+ const rss = lastRSS;
65
+ if (rss <= maxRSSBytes) {
66
+ logger.log(`${localTime.now().toPretty()} transformThrottleByRSS resumed: RSS ${_mb(rss)} <= ${maxRSS} MB`);
67
+ lock.resolve();
68
+ lock = undefined;
69
+ }
70
+ else if (pollTimeout && Date.now() - pausedSince >= pollTimeout) {
71
+ clearTimeout(rssCheckTimer);
72
+ if (onPollTimeout === 'throw') {
73
+ lock.reject(new Error(`transformThrottleByRSS pollTimeout of ${_ms(pollTimeout)} reached, RSS ${_mb(rss)} still > ${maxRSS} MB`));
74
+ lock = undefined;
75
+ }
76
+ else {
77
+ // open-the-floodgates
78
+ logger.error(`${localTime.now().toPretty()} transformThrottleByRSS: pollTimeout of ${_ms(pollTimeout)} reached, RSS ${_mb(rss)} still > ${maxRSS} MB — DISABLING THROTTLE`);
79
+ disabled = true;
80
+ lock.resolve();
81
+ lock = undefined;
82
+ }
83
+ }
84
+ else {
85
+ logger.log(`${localTime.now().toPretty()} transformThrottleByRSS still paused: RSS ${_mb(rss)} > ${maxRSS} MB, rechecking in ${_ms(pollInterval)}`);
86
+ pollTimer = setTimeout(() => pollRSS(), pollInterval);
87
+ }
88
+ }
89
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.102.0",
4
+ "version": "15.103.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -23,6 +23,7 @@ export * from './transform/transformOffset.js'
23
23
  export * from './transform/transformSplit.js'
24
24
  export * from './transform/transformTap.js'
25
25
  export * from './transform/transformThrottle.js'
26
+ export * from './transform/transformThrottleByRSS.js'
26
27
  export * from './transform/transformWarmup.js'
27
28
  export * from './transform/worker/baseWorkerClass.js'
28
29
  export * from './transform/worker/transformMultiThreaded.js'
@@ -51,6 +51,8 @@ import { transformSplitOnNewline } from './transform/transformSplit.js'
51
51
  import { transformTap, transformTapSync } from './transform/transformTap.js'
52
52
  import { transformThrottle } from './transform/transformThrottle.js'
53
53
  import type { TransformThrottleOptions } from './transform/transformThrottle.js'
54
+ import { transformThrottleByRSS } from './transform/transformThrottleByRSS.js'
55
+ import type { TransformThrottleByRSSOptions } from './transform/transformThrottleByRSS.js'
54
56
  import { transformWarmup } from './transform/transformWarmup.js'
55
57
  import type { TransformWarmupOptions } from './transform/transformWarmup.js'
56
58
  import { writablePushToArray } from './writable/writablePushToArray.js'
@@ -249,6 +251,11 @@ export class Pipeline<T = unknown> {
249
251
  return this
250
252
  }
251
253
 
254
+ throttleByRSS(opt: TransformThrottleByRSSOptions): this {
255
+ this.transforms.push(transformThrottleByRSS(opt))
256
+ return this
257
+ }
258
+
252
259
  /**
253
260
  * @experimental to be removed after transformMap2 is stable
254
261
  */
@@ -0,0 +1,150 @@
1
+ import { Transform } from 'node:stream'
2
+ import { _mb } from '@naturalcycles/js-lib'
3
+ import { _ms, localTime } from '@naturalcycles/js-lib/datetime'
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 type { Integer, NumberOfMilliseconds } from '@naturalcycles/js-lib/types'
8
+ import type { TransformOptions, TransformTyped } from '../stream.model.js'
9
+
10
+ export interface TransformThrottleByRSSOptions extends TransformOptions {
11
+ /**
12
+ * Maximum RSS (Resident Set Size) in megabytes.
13
+ * When process RSS exceeds this value, the stream will pause
14
+ * until RSS drops below the threshold.
15
+ */
16
+ maxRSS: Integer
17
+
18
+ /**
19
+ * How often to re-check RSS (in milliseconds) while paused.
20
+ *
21
+ * @default 5000
22
+ */
23
+ pollInterval?: NumberOfMilliseconds
24
+
25
+ /**
26
+ * If this timeout is reached while RSS is above the limit -
27
+ * the transform will "give up", log the bold warning, and "open the gateways".
28
+ * Things will likely OOM after that, but at least it will not "hang forever".
29
+ *
30
+ * @default 30 minutes
31
+ */
32
+ pollTimeout?: NumberOfMilliseconds
33
+
34
+ /**
35
+ * What to do if pollTimeout is reached.
36
+ * 'open-the-floodgates' will disable this throttle completely (YOLO).
37
+ * 'throw' will throw an error, which will destroy the stream/Pipeline.
38
+ *
39
+ * @default 'open-the-floodgates'
40
+ */
41
+ onPollTimeout?: 'open-the-floodgates' | 'throw'
42
+ }
43
+
44
+ /**
45
+ * Throttles the stream based on process memory (RSS) usage.
46
+ * When RSS exceeds `maxRSS` (in megabytes), the stream pauses
47
+ * and periodically re-checks until RSS drops below the threshold.
48
+ *
49
+ * Useful for pipelines that process large amounts of data and
50
+ * may cause memory pressure (e.g. database imports, file processing).
51
+ *
52
+ * @experimental
53
+ */
54
+ export function transformThrottleByRSS<T>(
55
+ opt: TransformThrottleByRSSOptions,
56
+ ): TransformTyped<T, T> {
57
+ const {
58
+ maxRSS,
59
+ pollInterval = 5000,
60
+ pollTimeout = 30 * 60_000, // 30 min
61
+ onPollTimeout = 'open-the-floodgates',
62
+ objectMode = true,
63
+ highWaterMark,
64
+ } = opt
65
+
66
+ const maxRSSBytes = maxRSS * 1024 * 1024
67
+ let lock: DeferredPromise | undefined
68
+ let pollTimer: NodeJS.Timeout | undefined
69
+ let rssCheckTimer: NodeJS.Timeout | undefined
70
+ let lastRSS = 0
71
+ let pausedSince = 0
72
+ let disabled = false
73
+ const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel)
74
+
75
+ return new Transform({
76
+ objectMode,
77
+ highWaterMark,
78
+ async transform(item: T, _, cb) {
79
+ if (lock) {
80
+ try {
81
+ await lock
82
+ } catch (err) {
83
+ cb(err as Error)
84
+ return
85
+ }
86
+ }
87
+
88
+ if (!disabled && lastRSS > maxRSSBytes && !lock) {
89
+ lock = pDefer()
90
+ pausedSince = Date.now()
91
+ logger.log(
92
+ `${localTime.now().toPretty()} transformThrottleByRSS paused: RSS ${_mb(lastRSS)} > ${maxRSS} MB`,
93
+ )
94
+ pollTimer = setTimeout(() => pollRSS(), pollInterval)
95
+ }
96
+
97
+ cb(null, item)
98
+ },
99
+ construct(cb) {
100
+ // Start periodic RSS checking
101
+ checkRSS()
102
+ cb()
103
+ },
104
+ final(cb) {
105
+ clearTimeout(pollTimer)
106
+ clearTimeout(rssCheckTimer)
107
+ cb()
108
+ },
109
+ })
110
+
111
+ function checkRSS(): void {
112
+ lastRSS = process.memoryUsage.rss()
113
+ rssCheckTimer = setTimeout(() => checkRSS(), pollInterval)
114
+ }
115
+
116
+ function pollRSS(): void {
117
+ const rss = lastRSS
118
+
119
+ if (rss <= maxRSSBytes) {
120
+ logger.log(
121
+ `${localTime.now().toPretty()} transformThrottleByRSS resumed: RSS ${_mb(rss)} <= ${maxRSS} MB`,
122
+ )
123
+ lock!.resolve()
124
+ lock = undefined
125
+ } else if (pollTimeout && Date.now() - pausedSince >= pollTimeout) {
126
+ clearTimeout(rssCheckTimer)
127
+ if (onPollTimeout === 'throw') {
128
+ lock!.reject(
129
+ new Error(
130
+ `transformThrottleByRSS pollTimeout of ${_ms(pollTimeout)} reached, RSS ${_mb(rss)} still > ${maxRSS} MB`,
131
+ ),
132
+ )
133
+ lock = undefined
134
+ } else {
135
+ // open-the-floodgates
136
+ logger.error(
137
+ `${localTime.now().toPretty()} transformThrottleByRSS: pollTimeout of ${_ms(pollTimeout)} reached, RSS ${_mb(rss)} still > ${maxRSS} MB — DISABLING THROTTLE`,
138
+ )
139
+ disabled = true
140
+ lock!.resolve()
141
+ lock = undefined
142
+ }
143
+ } else {
144
+ logger.log(
145
+ `${localTime.now().toPretty()} transformThrottleByRSS still paused: RSS ${_mb(rss)} > ${maxRSS} MB, rechecking in ${_ms(pollInterval)}`,
146
+ )
147
+ pollTimer = setTimeout(() => pollRSS(), pollInterval)
148
+ }
149
+ }
150
+ }