@naturalcycles/js-lib 15.57.0 → 15.58.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/error/index.d.ts +1 -1
- package/dist/error/index.js +0 -1
- package/dist/promise/index.d.ts +1 -0
- package/dist/promise/index.js +1 -0
- package/dist/promise/pGradualQueue.d.ts +61 -0
- package/dist/promise/pGradualQueue.js +115 -0
- package/dist/promise/pQueue.d.ts +12 -14
- package/dist/promise/pQueue.js +43 -33
- package/dist/types.d.ts +1 -1
- package/dist/types.js +0 -1
- package/package.json +2 -2
- package/src/error/index.ts +1 -1
- package/src/promise/index.ts +1 -0
- package/src/promise/pGradualQueue.ts +171 -0
- package/src/promise/pQueue.ts +62 -61
- package/src/types.ts +1 -1
package/dist/error/index.d.ts
CHANGED
package/dist/error/index.js
CHANGED
package/dist/promise/index.d.ts
CHANGED
package/dist/promise/index.js
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ErrorMode } from '../error/errorMode.js';
|
|
2
|
+
import { type CommonLogger, type CommonLogLevel } from '../log/commonLogger.js';
|
|
3
|
+
import type { AsyncFunction, PositiveInteger } from '../types.js';
|
|
4
|
+
export interface PGradualQueueCfg {
|
|
5
|
+
concurrency: PositiveInteger;
|
|
6
|
+
/**
|
|
7
|
+
* Time in seconds to gradually increase concurrency from 1 to cfg.concurrency.
|
|
8
|
+
* After this period, the queue operates at full concurrency.
|
|
9
|
+
*
|
|
10
|
+
* Set to 0 to disable warmup (behaves like regular PQueue).
|
|
11
|
+
*/
|
|
12
|
+
warmupSeconds: number;
|
|
13
|
+
/**
|
|
14
|
+
* Default: THROW_IMMEDIATELY
|
|
15
|
+
*
|
|
16
|
+
* THROW_AGGREGATED is not supported.
|
|
17
|
+
*
|
|
18
|
+
* SUPPRESS_ERRORS will still log errors via logger. It will resolve the `.push` promise with void.
|
|
19
|
+
*/
|
|
20
|
+
errorMode?: ErrorMode;
|
|
21
|
+
/**
|
|
22
|
+
* Default to `console`
|
|
23
|
+
*/
|
|
24
|
+
logger?: CommonLogger;
|
|
25
|
+
/**
|
|
26
|
+
* Default is 'log'.
|
|
27
|
+
*/
|
|
28
|
+
logLevel?: CommonLogLevel;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A queue similar to PQueue that gradually increases concurrency from 1 to the configured
|
|
32
|
+
* maximum over a warmup period. Useful for scenarios where you want to avoid overwhelming
|
|
33
|
+
* a system at startup (e.g., database connections, API rate limits).
|
|
34
|
+
*
|
|
35
|
+
* API is @experimental
|
|
36
|
+
*/
|
|
37
|
+
export declare class PGradualQueue {
|
|
38
|
+
constructor(cfg: PGradualQueueCfg);
|
|
39
|
+
private readonly cfg;
|
|
40
|
+
private readonly logger;
|
|
41
|
+
private readonly warmupMs;
|
|
42
|
+
private startTime;
|
|
43
|
+
private warmupComplete;
|
|
44
|
+
inFlight: number;
|
|
45
|
+
private queue;
|
|
46
|
+
/**
|
|
47
|
+
* Get current allowed concurrency based on warmup progress.
|
|
48
|
+
* Returns cfg.concurrency if warmup is complete (fast-path).
|
|
49
|
+
*/
|
|
50
|
+
private getCurrentConcurrency;
|
|
51
|
+
/**
|
|
52
|
+
* Push PromiseReturningFunction to the Queue.
|
|
53
|
+
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
54
|
+
*/
|
|
55
|
+
push<R>(fn_: AsyncFunction<R>): Promise<R>;
|
|
56
|
+
get queueSize(): number;
|
|
57
|
+
/**
|
|
58
|
+
* Current concurrency limit based on warmup progress.
|
|
59
|
+
*/
|
|
60
|
+
get currentConcurrency(): number;
|
|
61
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ErrorMode } from '../error/errorMode.js';
|
|
2
|
+
import { createCommonLoggerAtLevel, } from '../log/commonLogger.js';
|
|
3
|
+
import { pDefer } from './pDefer.js';
|
|
4
|
+
/**
|
|
5
|
+
* A queue similar to PQueue that gradually increases concurrency from 1 to the configured
|
|
6
|
+
* maximum 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
|
+
* API is @experimental
|
|
10
|
+
*/
|
|
11
|
+
export class PGradualQueue {
|
|
12
|
+
constructor(cfg) {
|
|
13
|
+
this.cfg = {
|
|
14
|
+
errorMode: ErrorMode.THROW_IMMEDIATELY,
|
|
15
|
+
...cfg,
|
|
16
|
+
};
|
|
17
|
+
this.logger = createCommonLoggerAtLevel(cfg.logger, cfg.logLevel);
|
|
18
|
+
this.warmupMs = cfg.warmupSeconds * 1000;
|
|
19
|
+
// Fast-path: if warmupSeconds is 0 or concurrency is 1, skip warmup entirely
|
|
20
|
+
this.warmupComplete = cfg.warmupSeconds <= 0 || cfg.concurrency <= 1;
|
|
21
|
+
}
|
|
22
|
+
cfg;
|
|
23
|
+
logger;
|
|
24
|
+
warmupMs;
|
|
25
|
+
startTime = 0;
|
|
26
|
+
warmupComplete;
|
|
27
|
+
inFlight = 0;
|
|
28
|
+
queue = [];
|
|
29
|
+
/**
|
|
30
|
+
* Get current allowed concurrency based on warmup progress.
|
|
31
|
+
* Returns cfg.concurrency if warmup is complete (fast-path).
|
|
32
|
+
*/
|
|
33
|
+
getCurrentConcurrency() {
|
|
34
|
+
// Fast-path: warmup complete
|
|
35
|
+
if (this.warmupComplete)
|
|
36
|
+
return this.cfg.concurrency;
|
|
37
|
+
const elapsed = Date.now() - this.startTime;
|
|
38
|
+
if (elapsed >= this.warmupMs) {
|
|
39
|
+
this.warmupComplete = true;
|
|
40
|
+
this.logger.debug('warmup complete');
|
|
41
|
+
return this.cfg.concurrency;
|
|
42
|
+
}
|
|
43
|
+
// Linear interpolation from 1 to concurrency
|
|
44
|
+
const progress = elapsed / this.warmupMs;
|
|
45
|
+
return Math.max(1, Math.floor(1 + (this.cfg.concurrency - 1) * progress));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Push PromiseReturningFunction to the Queue.
|
|
49
|
+
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
50
|
+
*/
|
|
51
|
+
async push(fn_) {
|
|
52
|
+
// Initialize start time on first push
|
|
53
|
+
if (this.startTime === 0) {
|
|
54
|
+
this.startTime = Date.now();
|
|
55
|
+
}
|
|
56
|
+
const { logger } = this;
|
|
57
|
+
const fn = fn_;
|
|
58
|
+
fn.defer ||= pDefer();
|
|
59
|
+
const concurrency = this.getCurrentConcurrency();
|
|
60
|
+
if (this.inFlight < concurrency) {
|
|
61
|
+
this.inFlight++;
|
|
62
|
+
logger.debug(`inFlight++ ${this.inFlight}/${concurrency}, queue ${this.queue.length}`);
|
|
63
|
+
runSafe(fn)
|
|
64
|
+
.then(result => {
|
|
65
|
+
fn.defer.resolve(result);
|
|
66
|
+
})
|
|
67
|
+
.catch((err) => {
|
|
68
|
+
if (this.cfg.errorMode === ErrorMode.SUPPRESS) {
|
|
69
|
+
logger.error(err);
|
|
70
|
+
fn.defer.resolve(); // resolve with `void`
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
fn.defer.reject(err);
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
this.inFlight--;
|
|
78
|
+
const currentConcurrency = this.getCurrentConcurrency();
|
|
79
|
+
logger.debug(`inFlight-- ${this.inFlight}/${currentConcurrency}, queue ${this.queue.length}`);
|
|
80
|
+
// Start queued jobs up to the current concurrency limit
|
|
81
|
+
// Use while loop since concurrency may have increased during warmup
|
|
82
|
+
while (this.queue.length && this.inFlight < this.getCurrentConcurrency()) {
|
|
83
|
+
const nextFn = this.queue.shift();
|
|
84
|
+
void this.push(nextFn);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.queue.push(fn);
|
|
90
|
+
logger.debug(`inFlight ${this.inFlight}/${concurrency}, queue++ ${this.queue.length}`);
|
|
91
|
+
}
|
|
92
|
+
return await fn.defer;
|
|
93
|
+
}
|
|
94
|
+
get queueSize() {
|
|
95
|
+
return this.queue.length;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Current concurrency limit based on warmup progress.
|
|
99
|
+
*/
|
|
100
|
+
get currentConcurrency() {
|
|
101
|
+
return this.getCurrentConcurrency();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Here we intentionally want it not async, as we don't want it to throw
|
|
105
|
+
// oxlint-disable-next-line typescript/promise-function-async
|
|
106
|
+
function runSafe(fn) {
|
|
107
|
+
try {
|
|
108
|
+
// Here we are intentionally not awaiting
|
|
109
|
+
return fn();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Handle synchronous throws
|
|
113
|
+
return Promise.reject(err);
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/promise/pQueue.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ErrorMode } from '../error/errorMode.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type CommonLogger, type CommonLogLevel } from '../log/commonLogger.js';
|
|
3
|
+
import type { AsyncFunction, PositiveInteger } from '../types.js';
|
|
3
4
|
export interface PQueueCfg {
|
|
4
|
-
concurrency:
|
|
5
|
+
concurrency: PositiveInteger;
|
|
5
6
|
/**
|
|
6
7
|
* Default: THROW_IMMEDIATELY
|
|
7
8
|
*
|
|
@@ -10,17 +11,14 @@ export interface PQueueCfg {
|
|
|
10
11
|
* SUPPRESS_ERRORS will still log errors via logger. It will resolve the `.push` promise with void.
|
|
11
12
|
*/
|
|
12
13
|
errorMode?: ErrorMode;
|
|
13
|
-
/**
|
|
14
|
-
* @default true
|
|
15
|
-
*/
|
|
16
14
|
/**
|
|
17
15
|
* Default to `console`
|
|
18
16
|
*/
|
|
19
17
|
logger?: CommonLogger;
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
19
|
+
* Default is 'log'.
|
|
22
20
|
*/
|
|
23
|
-
|
|
21
|
+
logLevel?: CommonLogLevel;
|
|
24
22
|
/**
|
|
25
23
|
* By default .push method resolves when the Promise is done (finished).
|
|
26
24
|
*
|
|
@@ -31,7 +29,6 @@ export interface PQueueCfg {
|
|
|
31
29
|
*/
|
|
32
30
|
resolveOn?: 'finish' | 'start';
|
|
33
31
|
}
|
|
34
|
-
export type PromiseReturningFunction<R> = () => Promise<R>;
|
|
35
32
|
/**
|
|
36
33
|
* Inspired by: https://github.com/sindresorhus/p-queue
|
|
37
34
|
*
|
|
@@ -43,10 +40,16 @@ export type PromiseReturningFunction<R> = () => Promise<R>;
|
|
|
43
40
|
export declare class PQueue {
|
|
44
41
|
constructor(cfg: PQueueCfg);
|
|
45
42
|
private readonly cfg;
|
|
46
|
-
private
|
|
43
|
+
private readonly resolveOnStart;
|
|
44
|
+
private readonly logger;
|
|
47
45
|
inFlight: number;
|
|
48
46
|
private queue;
|
|
49
47
|
private onIdleListeners;
|
|
48
|
+
/**
|
|
49
|
+
* Push PromiseReturningFunction to the Queue.
|
|
50
|
+
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
51
|
+
*/
|
|
52
|
+
push<R>(fn_: AsyncFunction<R>): Promise<R>;
|
|
50
53
|
get queueSize(): number;
|
|
51
54
|
/**
|
|
52
55
|
* Returns a Promise that resolves when the queue is Idle (next time, since the call).
|
|
@@ -54,9 +57,4 @@ export declare class PQueue {
|
|
|
54
57
|
* Idle means 0 queue and 0 inFlight.
|
|
55
58
|
*/
|
|
56
59
|
onIdle(): Promise<void>;
|
|
57
|
-
/**
|
|
58
|
-
* Push PromiseReturningFunction to the Queue.
|
|
59
|
-
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
60
|
-
*/
|
|
61
|
-
push<R>(fn_: PromiseReturningFunction<R>): Promise<R>;
|
|
62
60
|
}
|
package/dist/promise/pQueue.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ErrorMode } from '../error/errorMode.js';
|
|
2
|
+
import { createCommonLoggerAtLevel, } from '../log/commonLogger.js';
|
|
2
3
|
import { pDefer } from './pDefer.js';
|
|
3
4
|
/**
|
|
4
5
|
* Inspired by: https://github.com/sindresorhus/p-queue
|
|
@@ -11,74 +12,56 @@ import { pDefer } from './pDefer.js';
|
|
|
11
12
|
export class PQueue {
|
|
12
13
|
constructor(cfg) {
|
|
13
14
|
this.cfg = {
|
|
14
|
-
// concurrency: Number.MAX_SAFE_INTEGER,
|
|
15
15
|
errorMode: ErrorMode.THROW_IMMEDIATELY,
|
|
16
|
-
logger: console,
|
|
17
|
-
debug: false,
|
|
18
|
-
resolveOn: 'finish',
|
|
19
16
|
...cfg,
|
|
20
17
|
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
18
|
+
this.logger = createCommonLoggerAtLevel(cfg.logger, cfg.logLevel);
|
|
19
|
+
this.resolveOnStart = this.cfg.resolveOn === 'start';
|
|
24
20
|
}
|
|
25
21
|
cfg;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
22
|
+
resolveOnStart;
|
|
23
|
+
logger;
|
|
29
24
|
inFlight = 0;
|
|
30
25
|
queue = [];
|
|
31
26
|
onIdleListeners = [];
|
|
32
|
-
get queueSize() {
|
|
33
|
-
return this.queue.length;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Returns a Promise that resolves when the queue is Idle (next time, since the call).
|
|
37
|
-
* Resolves immediately in case the queue is Idle.
|
|
38
|
-
* Idle means 0 queue and 0 inFlight.
|
|
39
|
-
*/
|
|
40
|
-
async onIdle() {
|
|
41
|
-
if (this.queue.length === 0 && this.inFlight === 0)
|
|
42
|
-
return;
|
|
43
|
-
const listener = pDefer();
|
|
44
|
-
this.onIdleListeners.push(listener);
|
|
45
|
-
return await listener;
|
|
46
|
-
}
|
|
47
27
|
/**
|
|
48
28
|
* Push PromiseReturningFunction to the Queue.
|
|
49
29
|
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
50
30
|
*/
|
|
51
31
|
async push(fn_) {
|
|
52
32
|
const { concurrency } = this.cfg;
|
|
53
|
-
const resolveOnStart = this
|
|
33
|
+
const { resolveOnStart, logger } = this;
|
|
54
34
|
const fn = fn_;
|
|
55
35
|
fn.defer ||= pDefer();
|
|
56
36
|
if (this.inFlight < concurrency) {
|
|
57
37
|
// There is room for more jobs. Can start immediately
|
|
58
38
|
this.inFlight++;
|
|
59
|
-
|
|
39
|
+
logger.debug(`inFlight++ ${this.inFlight}/${concurrency}, queue ${this.queue.length}`);
|
|
60
40
|
if (resolveOnStart)
|
|
61
41
|
fn.defer.resolve();
|
|
62
|
-
fn
|
|
42
|
+
runSafe(fn)
|
|
63
43
|
.then(result => {
|
|
64
44
|
if (!resolveOnStart)
|
|
65
45
|
fn.defer.resolve(result);
|
|
66
46
|
})
|
|
67
47
|
.catch((err) => {
|
|
68
|
-
|
|
69
|
-
|
|
48
|
+
if (resolveOnStart) {
|
|
49
|
+
logger.error(err);
|
|
70
50
|
return;
|
|
51
|
+
}
|
|
71
52
|
if (this.cfg.errorMode === ErrorMode.SUPPRESS) {
|
|
53
|
+
logger.error(err);
|
|
72
54
|
fn.defer.resolve(); // resolve with `void`
|
|
73
55
|
}
|
|
74
56
|
else {
|
|
75
57
|
// Should be handled on the outside, otherwise it'll cause UnhandledRejection
|
|
58
|
+
// Not logging, because it's re-thrown upstream
|
|
76
59
|
fn.defer.reject(err);
|
|
77
60
|
}
|
|
78
61
|
})
|
|
79
62
|
.finally(() => {
|
|
80
63
|
this.inFlight--;
|
|
81
|
-
|
|
64
|
+
logger.debug(`inFlight-- ${this.inFlight}/${concurrency}, queue ${this.queue.length}`);
|
|
82
65
|
// check if there's room to start next job
|
|
83
66
|
if (this.queue.length && this.inFlight <= concurrency) {
|
|
84
67
|
const nextFn = this.queue.shift();
|
|
@@ -86,7 +69,7 @@ export class PQueue {
|
|
|
86
69
|
}
|
|
87
70
|
else {
|
|
88
71
|
if (this.inFlight === 0) {
|
|
89
|
-
|
|
72
|
+
logger.debug('onIdle');
|
|
90
73
|
this.onIdleListeners.forEach(defer => defer.resolve());
|
|
91
74
|
this.onIdleListeners.length = 0; // empty the array
|
|
92
75
|
}
|
|
@@ -95,8 +78,35 @@ export class PQueue {
|
|
|
95
78
|
}
|
|
96
79
|
else {
|
|
97
80
|
this.queue.push(fn);
|
|
98
|
-
|
|
81
|
+
logger.debug(`inFlight ${this.inFlight}/${concurrency}, queue++ ${this.queue.length}`);
|
|
99
82
|
}
|
|
100
83
|
return await fn.defer;
|
|
101
84
|
}
|
|
85
|
+
get queueSize() {
|
|
86
|
+
return this.queue.length;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Returns a Promise that resolves when the queue is Idle (next time, since the call).
|
|
90
|
+
* Resolves immediately in case the queue is Idle.
|
|
91
|
+
* Idle means 0 queue and 0 inFlight.
|
|
92
|
+
*/
|
|
93
|
+
async onIdle() {
|
|
94
|
+
if (this.queue.length === 0 && this.inFlight === 0)
|
|
95
|
+
return;
|
|
96
|
+
const listener = pDefer();
|
|
97
|
+
this.onIdleListeners.push(listener);
|
|
98
|
+
return await listener;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Here we intentionally want it not async, as we don't want it to throw
|
|
102
|
+
// oxlint-disable-next-line typescript/promise-function-async
|
|
103
|
+
function runSafe(fn) {
|
|
104
|
+
try {
|
|
105
|
+
// Here we are intentionally not awaiting
|
|
106
|
+
return fn();
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// Handle synchronous throws - ensure inFlight is decremented
|
|
110
|
+
return Promise.reject(err);
|
|
111
|
+
}
|
|
102
112
|
}
|
package/dist/types.d.ts
CHANGED
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/js-lib",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.58.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"tslib": "^2",
|
|
7
7
|
"undici": "^7",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@types/semver": "^7",
|
|
14
14
|
"crypto-js": "^4",
|
|
15
15
|
"dayjs": "^1",
|
|
16
|
-
"@naturalcycles/dev-lib": "
|
|
16
|
+
"@naturalcycles/dev-lib": "18.4.2"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./dist/index.js",
|
package/src/error/index.ts
CHANGED
package/src/promise/index.ts
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ErrorMode } from '../error/errorMode.js'
|
|
2
|
+
import {
|
|
3
|
+
type CommonLogger,
|
|
4
|
+
type CommonLogLevel,
|
|
5
|
+
createCommonLoggerAtLevel,
|
|
6
|
+
} from '../log/commonLogger.js'
|
|
7
|
+
import type { AsyncFunction, PositiveInteger } from '../types.js'
|
|
8
|
+
import type { DeferredPromise } from './pDefer.js'
|
|
9
|
+
import { pDefer } from './pDefer.js'
|
|
10
|
+
|
|
11
|
+
export interface PGradualQueueCfg {
|
|
12
|
+
concurrency: PositiveInteger
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Time in seconds to gradually increase concurrency from 1 to cfg.concurrency.
|
|
16
|
+
* After this period, the queue operates at full concurrency.
|
|
17
|
+
*
|
|
18
|
+
* Set to 0 to disable warmup (behaves like regular PQueue).
|
|
19
|
+
*/
|
|
20
|
+
warmupSeconds: number
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default: THROW_IMMEDIATELY
|
|
24
|
+
*
|
|
25
|
+
* THROW_AGGREGATED is not supported.
|
|
26
|
+
*
|
|
27
|
+
* SUPPRESS_ERRORS will still log errors via logger. It will resolve the `.push` promise with void.
|
|
28
|
+
*/
|
|
29
|
+
errorMode?: ErrorMode
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default to `console`
|
|
33
|
+
*/
|
|
34
|
+
logger?: CommonLogger
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default is 'log'.
|
|
38
|
+
*/
|
|
39
|
+
logLevel?: CommonLogLevel
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A queue similar to PQueue that gradually increases concurrency from 1 to the configured
|
|
44
|
+
* maximum over a warmup period. Useful for scenarios where you want to avoid overwhelming
|
|
45
|
+
* a system at startup (e.g., database connections, API rate limits).
|
|
46
|
+
*
|
|
47
|
+
* API is @experimental
|
|
48
|
+
*/
|
|
49
|
+
export class PGradualQueue {
|
|
50
|
+
constructor(cfg: PGradualQueueCfg) {
|
|
51
|
+
this.cfg = {
|
|
52
|
+
errorMode: ErrorMode.THROW_IMMEDIATELY,
|
|
53
|
+
...cfg,
|
|
54
|
+
}
|
|
55
|
+
this.logger = createCommonLoggerAtLevel(cfg.logger, cfg.logLevel)
|
|
56
|
+
this.warmupMs = cfg.warmupSeconds * 1000
|
|
57
|
+
// Fast-path: if warmupSeconds is 0 or concurrency is 1, skip warmup entirely
|
|
58
|
+
this.warmupComplete = cfg.warmupSeconds <= 0 || cfg.concurrency <= 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private readonly cfg: PGradualQueueCfg
|
|
62
|
+
private readonly logger: CommonLogger
|
|
63
|
+
private readonly warmupMs: number
|
|
64
|
+
|
|
65
|
+
private startTime = 0
|
|
66
|
+
private warmupComplete: boolean
|
|
67
|
+
|
|
68
|
+
inFlight = 0
|
|
69
|
+
private queue: AsyncFunction[] = []
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get current allowed concurrency based on warmup progress.
|
|
73
|
+
* Returns cfg.concurrency if warmup is complete (fast-path).
|
|
74
|
+
*/
|
|
75
|
+
private getCurrentConcurrency(): number {
|
|
76
|
+
// Fast-path: warmup complete
|
|
77
|
+
if (this.warmupComplete) return this.cfg.concurrency
|
|
78
|
+
|
|
79
|
+
const elapsed = Date.now() - this.startTime
|
|
80
|
+
if (elapsed >= this.warmupMs) {
|
|
81
|
+
this.warmupComplete = true
|
|
82
|
+
this.logger.debug('warmup complete')
|
|
83
|
+
return this.cfg.concurrency
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Linear interpolation from 1 to concurrency
|
|
87
|
+
const progress = elapsed / this.warmupMs
|
|
88
|
+
return Math.max(1, Math.floor(1 + (this.cfg.concurrency - 1) * progress))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Push PromiseReturningFunction to the Queue.
|
|
93
|
+
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
94
|
+
*/
|
|
95
|
+
async push<R>(fn_: AsyncFunction<R>): Promise<R> {
|
|
96
|
+
// Initialize start time on first push
|
|
97
|
+
if (this.startTime === 0) {
|
|
98
|
+
this.startTime = Date.now()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { logger } = this
|
|
102
|
+
const fn = fn_ as AsyncFunctionWithDefer<R>
|
|
103
|
+
fn.defer ||= pDefer<R>()
|
|
104
|
+
|
|
105
|
+
const concurrency = this.getCurrentConcurrency()
|
|
106
|
+
|
|
107
|
+
if (this.inFlight < concurrency) {
|
|
108
|
+
this.inFlight++
|
|
109
|
+
logger.debug(`inFlight++ ${this.inFlight}/${concurrency}, queue ${this.queue.length}`)
|
|
110
|
+
|
|
111
|
+
runSafe(fn)
|
|
112
|
+
.then(result => {
|
|
113
|
+
fn.defer.resolve(result)
|
|
114
|
+
})
|
|
115
|
+
.catch((err: Error) => {
|
|
116
|
+
if (this.cfg.errorMode === ErrorMode.SUPPRESS) {
|
|
117
|
+
logger.error(err)
|
|
118
|
+
fn.defer.resolve() // resolve with `void`
|
|
119
|
+
} else {
|
|
120
|
+
fn.defer.reject(err)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
this.inFlight--
|
|
125
|
+
const currentConcurrency = this.getCurrentConcurrency()
|
|
126
|
+
logger.debug(
|
|
127
|
+
`inFlight-- ${this.inFlight}/${currentConcurrency}, queue ${this.queue.length}`,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// Start queued jobs up to the current concurrency limit
|
|
131
|
+
// Use while loop since concurrency may have increased during warmup
|
|
132
|
+
while (this.queue.length && this.inFlight < this.getCurrentConcurrency()) {
|
|
133
|
+
const nextFn = this.queue.shift()!
|
|
134
|
+
void this.push(nextFn)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
} else {
|
|
138
|
+
this.queue.push(fn)
|
|
139
|
+
logger.debug(`inFlight ${this.inFlight}/${concurrency}, queue++ ${this.queue.length}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return await fn.defer
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get queueSize(): number {
|
|
146
|
+
return this.queue.length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Current concurrency limit based on warmup progress.
|
|
151
|
+
*/
|
|
152
|
+
get currentConcurrency(): number {
|
|
153
|
+
return this.getCurrentConcurrency()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Here we intentionally want it not async, as we don't want it to throw
|
|
158
|
+
// oxlint-disable-next-line typescript/promise-function-async
|
|
159
|
+
function runSafe<R>(fn: AsyncFunction<R>): Promise<R> {
|
|
160
|
+
try {
|
|
161
|
+
// Here we are intentionally not awaiting
|
|
162
|
+
return fn()
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Handle synchronous throws
|
|
165
|
+
return Promise.reject(err as Error)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface AsyncFunctionWithDefer<R = unknown> extends AsyncFunction<R> {
|
|
170
|
+
defer: DeferredPromise<R>
|
|
171
|
+
}
|
package/src/promise/pQueue.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { ErrorMode } from '../error/errorMode.js'
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
type CommonLogger,
|
|
4
|
+
type CommonLogLevel,
|
|
5
|
+
createCommonLoggerAtLevel,
|
|
6
|
+
} from '../log/commonLogger.js'
|
|
7
|
+
import type { AsyncFunction, PositiveInteger } from '../types.js'
|
|
3
8
|
import type { DeferredPromise } from './pDefer.js'
|
|
4
9
|
import { pDefer } from './pDefer.js'
|
|
5
10
|
|
|
6
11
|
export interface PQueueCfg {
|
|
7
|
-
concurrency:
|
|
12
|
+
concurrency: PositiveInteger
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Default: THROW_IMMEDIATELY
|
|
@@ -15,25 +20,15 @@ export interface PQueueCfg {
|
|
|
15
20
|
*/
|
|
16
21
|
errorMode?: ErrorMode
|
|
17
22
|
|
|
18
|
-
/**
|
|
19
|
-
* @default true
|
|
20
|
-
*/
|
|
21
|
-
// autoStart?: boolean
|
|
22
|
-
|
|
23
23
|
/**
|
|
24
24
|
* Default to `console`
|
|
25
25
|
*/
|
|
26
26
|
logger?: CommonLogger
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Default is 'log'.
|
|
30
30
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// logStatusChange?: boolean
|
|
34
|
-
// logSizeChange?: boolean
|
|
35
|
-
|
|
36
|
-
// timeout
|
|
31
|
+
logLevel?: CommonLogLevel
|
|
37
32
|
|
|
38
33
|
/**
|
|
39
34
|
* By default .push method resolves when the Promise is done (finished).
|
|
@@ -46,12 +41,6 @@ export interface PQueueCfg {
|
|
|
46
41
|
resolveOn?: 'finish' | 'start'
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
export type PromiseReturningFunction<R> = () => Promise<R>
|
|
50
|
-
|
|
51
|
-
interface PromiseReturningFunctionWithDefer<R> extends PromiseReturningFunction<R> {
|
|
52
|
-
defer: DeferredPromise<R>
|
|
53
|
-
}
|
|
54
|
-
|
|
55
44
|
/**
|
|
56
45
|
* Inspired by: https://github.com/sindresorhus/p-queue
|
|
57
46
|
*
|
|
@@ -63,81 +52,60 @@ interface PromiseReturningFunctionWithDefer<R> extends PromiseReturningFunction<
|
|
|
63
52
|
export class PQueue {
|
|
64
53
|
constructor(cfg: PQueueCfg) {
|
|
65
54
|
this.cfg = {
|
|
66
|
-
// concurrency: Number.MAX_SAFE_INTEGER,
|
|
67
55
|
errorMode: ErrorMode.THROW_IMMEDIATELY,
|
|
68
|
-
logger: console,
|
|
69
|
-
debug: false,
|
|
70
|
-
resolveOn: 'finish',
|
|
71
56
|
...cfg,
|
|
72
57
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.debug = () => {}
|
|
76
|
-
}
|
|
58
|
+
this.logger = createCommonLoggerAtLevel(cfg.logger, cfg.logLevel)
|
|
59
|
+
this.resolveOnStart = this.cfg.resolveOn === 'start'
|
|
77
60
|
}
|
|
78
61
|
|
|
79
|
-
private readonly cfg:
|
|
80
|
-
|
|
81
|
-
private
|
|
82
|
-
this.cfg.logger.log(...args)
|
|
83
|
-
}
|
|
62
|
+
private readonly cfg: PQueueCfg
|
|
63
|
+
private readonly resolveOnStart: boolean
|
|
64
|
+
private readonly logger: CommonLogger
|
|
84
65
|
|
|
85
66
|
inFlight = 0
|
|
86
|
-
private queue:
|
|
67
|
+
private queue: AsyncFunction[] = []
|
|
87
68
|
private onIdleListeners: DeferredPromise[] = []
|
|
88
69
|
|
|
89
|
-
get queueSize(): number {
|
|
90
|
-
return this.queue.length
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Returns a Promise that resolves when the queue is Idle (next time, since the call).
|
|
95
|
-
* Resolves immediately in case the queue is Idle.
|
|
96
|
-
* Idle means 0 queue and 0 inFlight.
|
|
97
|
-
*/
|
|
98
|
-
async onIdle(): Promise<void> {
|
|
99
|
-
if (this.queue.length === 0 && this.inFlight === 0) return
|
|
100
|
-
|
|
101
|
-
const listener = pDefer()
|
|
102
|
-
this.onIdleListeners.push(listener)
|
|
103
|
-
return await listener
|
|
104
|
-
}
|
|
105
|
-
|
|
106
70
|
/**
|
|
107
71
|
* Push PromiseReturningFunction to the Queue.
|
|
108
72
|
* Returns a Promise that resolves (or rejects) with the return value from the Promise.
|
|
109
73
|
*/
|
|
110
|
-
async push<R>(fn_:
|
|
74
|
+
async push<R>(fn_: AsyncFunction<R>): Promise<R> {
|
|
111
75
|
const { concurrency } = this.cfg
|
|
112
|
-
const resolveOnStart = this
|
|
76
|
+
const { resolveOnStart, logger } = this
|
|
113
77
|
|
|
114
|
-
const fn = fn_ as
|
|
78
|
+
const fn = fn_ as AsyncFunctionWithDefer<R>
|
|
115
79
|
fn.defer ||= pDefer<R>()
|
|
116
80
|
|
|
117
81
|
if (this.inFlight < concurrency) {
|
|
118
82
|
// There is room for more jobs. Can start immediately
|
|
119
83
|
this.inFlight++
|
|
120
|
-
|
|
84
|
+
logger.debug(`inFlight++ ${this.inFlight}/${concurrency}, queue ${this.queue.length}`)
|
|
121
85
|
if (resolveOnStart) fn.defer.resolve()
|
|
122
86
|
|
|
123
|
-
fn
|
|
87
|
+
runSafe(fn)
|
|
124
88
|
.then(result => {
|
|
125
89
|
if (!resolveOnStart) fn.defer.resolve(result)
|
|
126
90
|
})
|
|
127
91
|
.catch((err: Error) => {
|
|
128
|
-
|
|
129
|
-
|
|
92
|
+
if (resolveOnStart) {
|
|
93
|
+
logger.error(err)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
130
96
|
|
|
131
97
|
if (this.cfg.errorMode === ErrorMode.SUPPRESS) {
|
|
98
|
+
logger.error(err)
|
|
132
99
|
fn.defer.resolve() // resolve with `void`
|
|
133
100
|
} else {
|
|
134
101
|
// Should be handled on the outside, otherwise it'll cause UnhandledRejection
|
|
102
|
+
// Not logging, because it's re-thrown upstream
|
|
135
103
|
fn.defer.reject(err)
|
|
136
104
|
}
|
|
137
105
|
})
|
|
138
106
|
.finally(() => {
|
|
139
107
|
this.inFlight--
|
|
140
|
-
|
|
108
|
+
logger.debug(`inFlight-- ${this.inFlight}/${concurrency}, queue ${this.queue.length}`)
|
|
141
109
|
|
|
142
110
|
// check if there's room to start next job
|
|
143
111
|
if (this.queue.length && this.inFlight <= concurrency) {
|
|
@@ -145,7 +113,7 @@ export class PQueue {
|
|
|
145
113
|
void this.push(nextFn)
|
|
146
114
|
} else {
|
|
147
115
|
if (this.inFlight === 0) {
|
|
148
|
-
|
|
116
|
+
logger.debug('onIdle')
|
|
149
117
|
this.onIdleListeners.forEach(defer => defer.resolve())
|
|
150
118
|
this.onIdleListeners.length = 0 // empty the array
|
|
151
119
|
}
|
|
@@ -153,9 +121,42 @@ export class PQueue {
|
|
|
153
121
|
})
|
|
154
122
|
} else {
|
|
155
123
|
this.queue.push(fn)
|
|
156
|
-
|
|
124
|
+
logger.debug(`inFlight ${this.inFlight}/${concurrency}, queue++ ${this.queue.length}`)
|
|
157
125
|
}
|
|
158
126
|
|
|
159
127
|
return await fn.defer
|
|
160
128
|
}
|
|
129
|
+
|
|
130
|
+
get queueSize(): number {
|
|
131
|
+
return this.queue.length
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns a Promise that resolves when the queue is Idle (next time, since the call).
|
|
136
|
+
* Resolves immediately in case the queue is Idle.
|
|
137
|
+
* Idle means 0 queue and 0 inFlight.
|
|
138
|
+
*/
|
|
139
|
+
async onIdle(): Promise<void> {
|
|
140
|
+
if (this.queue.length === 0 && this.inFlight === 0) return
|
|
141
|
+
|
|
142
|
+
const listener = pDefer()
|
|
143
|
+
this.onIdleListeners.push(listener)
|
|
144
|
+
return await listener
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Here we intentionally want it not async, as we don't want it to throw
|
|
149
|
+
// oxlint-disable-next-line typescript/promise-function-async
|
|
150
|
+
function runSafe<R>(fn: AsyncFunction<R>): Promise<R> {
|
|
151
|
+
try {
|
|
152
|
+
// Here we are intentionally not awaiting
|
|
153
|
+
return fn()
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Handle synchronous throws - ensure inFlight is decremented
|
|
156
|
+
return Promise.reject(err as Error)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface AsyncFunctionWithDefer<R> extends AsyncFunction<R> {
|
|
161
|
+
defer: DeferredPromise<R>
|
|
161
162
|
}
|
package/src/types.ts
CHANGED