@emmvish/stable-request 2.5.0 → 2.6.1
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/README.md +19 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/stable-scheduler.d.ts +54 -0
- package/dist/core/stable-scheduler.d.ts.map +1 -0
- package/dist/core/stable-scheduler.js +536 -0
- package/dist/core/stable-scheduler.js.map +1 -0
- package/dist/enums/index.d.ts +6 -0
- package/dist/enums/index.d.ts.map +1 -1
- package/dist/enums/index.js +7 -0
- package/dist/enums/index.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/stable-runner/index.js +118 -0
- package/dist/stable-runner/index.js.map +1 -1
- package/dist/types/index.d.ts +90 -3
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ A stability-first production-grade TypeScript framework for resilient API integr
|
|
|
12
12
|
- [stableApiGateway](#stableapigateway)
|
|
13
13
|
- [stableWorkflow](#stableworkflow)
|
|
14
14
|
- [stableWorkflowGraph](#stableworkflowgraph)
|
|
15
|
+
- [StableScheduler](#stablescheduler)
|
|
16
|
+
- [Stable Runner](#stable-runner)
|
|
15
17
|
- [Resilience Mechanisms](#resilience-mechanisms)
|
|
16
18
|
- [Retry Strategies](#retry-strategies)
|
|
17
19
|
- [Circuit Breaker](#circuit-breaker)
|
|
@@ -53,8 +55,9 @@ A stability-first production-grade TypeScript framework for resilient API integr
|
|
|
53
55
|
2. **Phased workflows** via `stableWorkflow` for array-based multi-phase execution with dynamic control flow
|
|
54
56
|
3. **Graph-based workflows** via `stableWorkflowGraph` for DAG execution with higher parallelism
|
|
55
57
|
4. **Generic function execution** via `stableFunction`, inheriting all resilience guards
|
|
58
|
+
5. **Queue based scheduling** via `StableScheduler`, with option to preserve scheduler state and recover from saved state
|
|
56
59
|
|
|
57
|
-
All
|
|
60
|
+
All six core modules support the same resilience stack: retries, jitter, circuit breaking, caching, rate/concurrency limits, config cascading, shared buffers, trial mode, comprehensive hooks, and metrics. This uniformity makes it trivial to compose requests and functions in any topology. Finally, `Stable Runner` executes jobs from config.
|
|
58
61
|
|
|
59
62
|
---
|
|
60
63
|
|
|
@@ -484,6 +487,21 @@ console.log(`Graph workflow success: ${result.success}`);
|
|
|
484
487
|
- Provide deterministic execution order
|
|
485
488
|
- Offer higher parallelism than phased workflows for complex topologies
|
|
486
489
|
|
|
490
|
+
### StableScheduler
|
|
491
|
+
|
|
492
|
+
Queue-based scheduler for cron/interval/timestamp execution with concurrency limits and recoverable state via custom persistence handlers.
|
|
493
|
+
|
|
494
|
+
**Key responsibilities:**
|
|
495
|
+
- Enforce max-parallel job execution
|
|
496
|
+
- Schedule jobs with cron, interval, or timestamp(s)
|
|
497
|
+
- Persist and restore scheduler state via user-provided handlers
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## Stable Runner
|
|
502
|
+
|
|
503
|
+
Config-driven runner that executes core module jobs from JSON/ESM configs and can use StableScheduler for scheduled jobs.
|
|
504
|
+
|
|
487
505
|
---
|
|
488
506
|
|
|
489
507
|
## Resilience Mechanisms
|
package/dist/core/index.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ export { stableFunction } from './stable-function.js';
|
|
|
3
3
|
export { stableApiGateway } from './stable-api-gateway.js';
|
|
4
4
|
export { stableWorkflow } from './stable-workflow.js';
|
|
5
5
|
export { stableWorkflowGraph } from './stable-workflow-graph.js';
|
|
6
|
+
export { StableScheduler } from './stable-scheduler.js';
|
|
6
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/core/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/core/index.js
CHANGED
|
@@ -3,4 +3,5 @@ export { stableFunction } from './stable-function.js';
|
|
|
3
3
|
export { stableApiGateway } from './stable-api-gateway.js';
|
|
4
4
|
export { stableWorkflow } from './stable-workflow.js';
|
|
5
5
|
export { stableWorkflowGraph } from './stable-workflow-graph.js';
|
|
6
|
+
export { StableScheduler } from './stable-scheduler.js';
|
|
6
7
|
//# sourceMappingURL=index.js.map
|
package/dist/core/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SchedulerConfig, SchedulerRetryConfig, SchedulerSchedule, SchedulerState, SchedulerJobHandler } from '../types/index.js';
|
|
2
|
+
export declare class StableScheduler<TJob extends {
|
|
3
|
+
id?: string;
|
|
4
|
+
schedule?: SchedulerSchedule;
|
|
5
|
+
retry?: SchedulerRetryConfig;
|
|
6
|
+
executionTimeoutMs?: number;
|
|
7
|
+
}> {
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly handler;
|
|
10
|
+
private readonly jobs;
|
|
11
|
+
private readonly queue;
|
|
12
|
+
private readonly queued;
|
|
13
|
+
private timer;
|
|
14
|
+
private persistTimer;
|
|
15
|
+
private persistQueued;
|
|
16
|
+
private runningCount;
|
|
17
|
+
private completed;
|
|
18
|
+
private failed;
|
|
19
|
+
private dropped;
|
|
20
|
+
private sequence;
|
|
21
|
+
constructor(config: SchedulerConfig, handler: SchedulerJobHandler<TJob>);
|
|
22
|
+
addJobs(jobs: TJob[]): void;
|
|
23
|
+
setJobs(jobs: TJob[]): void;
|
|
24
|
+
addJob(job: TJob): string;
|
|
25
|
+
start(): void;
|
|
26
|
+
stop(): void;
|
|
27
|
+
tick(): void;
|
|
28
|
+
getStats(): {
|
|
29
|
+
queued: number;
|
|
30
|
+
running: number;
|
|
31
|
+
completed: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
dropped: number;
|
|
34
|
+
totalJobs: number;
|
|
35
|
+
};
|
|
36
|
+
getState(): SchedulerState<TJob>;
|
|
37
|
+
restoreState(state?: SchedulerState<TJob>): Promise<boolean>;
|
|
38
|
+
private dispatch;
|
|
39
|
+
private getRetryConfig;
|
|
40
|
+
private getExecutionTimeoutMs;
|
|
41
|
+
private scheduleRetryIfEnabled;
|
|
42
|
+
private withTimeout;
|
|
43
|
+
private initializeSchedule;
|
|
44
|
+
private updateNextRun;
|
|
45
|
+
private parseTimestamp;
|
|
46
|
+
private getNextCronTime;
|
|
47
|
+
private parseCronField;
|
|
48
|
+
private isValidInteger;
|
|
49
|
+
private getCronDateParts;
|
|
50
|
+
private createId;
|
|
51
|
+
private generateUuid;
|
|
52
|
+
private persistStateIfEnabled;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=stable-scheduler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stable-scheduler.d.ts","sourceRoot":"","sources":["../../src/core/stable-scheduler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,eAAe,EACf,oBAAoB,EAEpB,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EAEpB,MAAM,mBAAmB,CAAC;AAE3B,qBAAa,eAAe,CAC1B,IAAI,SAAS;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAAC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAAC,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAE;IAErH,OAAO,CAAC,QAAQ,CAAC,MAAM,CAarB;IACF,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyC;IAC9D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,YAAY,CAA+B;IACnD,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,QAAQ,CAAK;gBAET,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,mBAAmB,CAAC,IAAI,CAAC;IAkBvE,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI;IAK3B,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI;IAc3B,MAAM,CAAC,GAAG,EAAE,IAAI,GAAG,MAAM;IAqBzB,KAAK,IAAI,IAAI;IAQb,IAAI,IAAI,IAAI;IAOZ,IAAI,IAAI,IAAI;IAiCZ,QAAQ;;;;;;;;IAWR,QAAQ,IAAI,cAAc,CAAC,IAAI,CAAC;IAuB1B,YAAY,CAAC,KAAK,CAAC,EAAE,cAAc,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IA+ClE,OAAO,CAAC,QAAQ;IA8ChB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,sBAAsB;IAwB9B,OAAO,CAAC,WAAW;IAsBnB,OAAO,CAAC,kBAAkB;IAoC1B,OAAO,CAAC,aAAa;IA8BrB,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,eAAe;IA+CvB,OAAO,CAAC,cAAc;IA6DtB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,gBAAgB;IAuExB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,YAAY;YAuBN,qBAAqB;CA0BpC"}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { ScheduleTypes } from '../enums/index.js';
|
|
2
|
+
export class StableScheduler {
|
|
3
|
+
config;
|
|
4
|
+
handler;
|
|
5
|
+
jobs = new Map();
|
|
6
|
+
queue = [];
|
|
7
|
+
queued = new Set();
|
|
8
|
+
timer = null;
|
|
9
|
+
persistTimer = null;
|
|
10
|
+
persistQueued = false;
|
|
11
|
+
runningCount = 0;
|
|
12
|
+
completed = 0;
|
|
13
|
+
failed = 0;
|
|
14
|
+
dropped = 0;
|
|
15
|
+
sequence = 0;
|
|
16
|
+
constructor(config, handler) {
|
|
17
|
+
this.config = {
|
|
18
|
+
maxParallel: config.maxParallel ?? 2,
|
|
19
|
+
tickIntervalMs: config.tickIntervalMs ?? 500,
|
|
20
|
+
queueLimit: config.queueLimit ?? 1000,
|
|
21
|
+
timezone: config.timezone,
|
|
22
|
+
persistence: {
|
|
23
|
+
enabled: config.persistence?.enabled ?? false,
|
|
24
|
+
saveState: config.persistence?.saveState,
|
|
25
|
+
loadState: config.persistence?.loadState
|
|
26
|
+
},
|
|
27
|
+
retry: config.retry,
|
|
28
|
+
executionTimeoutMs: config.executionTimeoutMs ?? 86400000,
|
|
29
|
+
persistenceDebounceMs: config.persistenceDebounceMs ?? 1000
|
|
30
|
+
};
|
|
31
|
+
this.handler = handler;
|
|
32
|
+
}
|
|
33
|
+
addJobs(jobs) {
|
|
34
|
+
jobs.forEach((job) => this.addJob(job));
|
|
35
|
+
void this.persistStateIfEnabled();
|
|
36
|
+
}
|
|
37
|
+
setJobs(jobs) {
|
|
38
|
+
this.stop();
|
|
39
|
+
this.jobs.clear();
|
|
40
|
+
this.queue.length = 0;
|
|
41
|
+
this.queued.clear();
|
|
42
|
+
this.runningCount = 0;
|
|
43
|
+
this.completed = 0;
|
|
44
|
+
this.failed = 0;
|
|
45
|
+
this.dropped = 0;
|
|
46
|
+
this.addJobs(jobs);
|
|
47
|
+
this.start();
|
|
48
|
+
void this.persistStateIfEnabled();
|
|
49
|
+
}
|
|
50
|
+
addJob(job) {
|
|
51
|
+
const id = job.id ?? this.createId('job');
|
|
52
|
+
const schedule = job.schedule;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const { nextRunAt, runOnce, remainingTimestamps } = this.initializeSchedule(schedule, now);
|
|
55
|
+
const scheduledJob = {
|
|
56
|
+
id,
|
|
57
|
+
job: { ...job, id },
|
|
58
|
+
schedule,
|
|
59
|
+
nextRunAt,
|
|
60
|
+
lastRunAt: null,
|
|
61
|
+
remainingTimestamps,
|
|
62
|
+
runOnce,
|
|
63
|
+
isRunning: false,
|
|
64
|
+
retryAttempts: 0
|
|
65
|
+
};
|
|
66
|
+
this.jobs.set(id, scheduledJob);
|
|
67
|
+
void this.persistStateIfEnabled();
|
|
68
|
+
return id;
|
|
69
|
+
}
|
|
70
|
+
start() {
|
|
71
|
+
if (this.timer) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.timer = setInterval(() => this.tick(), Math.max(50, this.config.tickIntervalMs));
|
|
75
|
+
this.tick();
|
|
76
|
+
}
|
|
77
|
+
stop() {
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearInterval(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
tick() {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
let stateChanged = false;
|
|
86
|
+
for (const [id, job] of this.jobs.entries()) {
|
|
87
|
+
if (job.isRunning || this.queued.has(id) || job.nextRunAt === null) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (job.nextRunAt <= now) {
|
|
91
|
+
if (this.queue.length >= this.config.queueLimit) {
|
|
92
|
+
this.dropped += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
this.queue.push(id);
|
|
96
|
+
this.queued.add(id);
|
|
97
|
+
stateChanged = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
while (this.runningCount < this.config.maxParallel && this.queue.length > 0) {
|
|
101
|
+
const id = this.queue.shift();
|
|
102
|
+
if (!id)
|
|
103
|
+
break;
|
|
104
|
+
this.queued.delete(id);
|
|
105
|
+
const job = this.jobs.get(id);
|
|
106
|
+
if (!job)
|
|
107
|
+
continue;
|
|
108
|
+
this.dispatch(job);
|
|
109
|
+
stateChanged = true;
|
|
110
|
+
}
|
|
111
|
+
if (stateChanged) {
|
|
112
|
+
void this.persistStateIfEnabled();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
getStats() {
|
|
116
|
+
return {
|
|
117
|
+
queued: this.queue.length,
|
|
118
|
+
running: this.runningCount,
|
|
119
|
+
completed: this.completed,
|
|
120
|
+
failed: this.failed,
|
|
121
|
+
dropped: this.dropped,
|
|
122
|
+
totalJobs: this.jobs.size
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
getState() {
|
|
126
|
+
return {
|
|
127
|
+
jobs: Array.from(this.jobs.values()).map((job) => ({
|
|
128
|
+
id: job.id,
|
|
129
|
+
job: job.job,
|
|
130
|
+
schedule: job.schedule,
|
|
131
|
+
nextRunAt: job.nextRunAt,
|
|
132
|
+
lastRunAt: job.lastRunAt,
|
|
133
|
+
remainingTimestamps: job.remainingTimestamps ? [...job.remainingTimestamps] : null,
|
|
134
|
+
runOnce: job.runOnce,
|
|
135
|
+
isRunning: job.isRunning,
|
|
136
|
+
retryAttempts: job.retryAttempts
|
|
137
|
+
})),
|
|
138
|
+
queue: [...this.queue],
|
|
139
|
+
stats: {
|
|
140
|
+
completed: this.completed,
|
|
141
|
+
failed: this.failed,
|
|
142
|
+
dropped: this.dropped,
|
|
143
|
+
sequence: this.sequence
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async restoreState(state) {
|
|
148
|
+
let resolvedState = state;
|
|
149
|
+
if (!resolvedState && this.config.persistence.loadState) {
|
|
150
|
+
resolvedState = await this.config.persistence.loadState();
|
|
151
|
+
}
|
|
152
|
+
if (!resolvedState) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
this.stop();
|
|
156
|
+
this.jobs.clear();
|
|
157
|
+
this.queue.length = 0;
|
|
158
|
+
this.queued.clear();
|
|
159
|
+
this.runningCount = 0;
|
|
160
|
+
this.completed = resolvedState.stats.completed;
|
|
161
|
+
this.failed = resolvedState.stats.failed;
|
|
162
|
+
this.dropped = resolvedState.stats.dropped;
|
|
163
|
+
this.sequence = resolvedState.stats.sequence;
|
|
164
|
+
resolvedState.jobs.forEach((jobState) => {
|
|
165
|
+
const restored = {
|
|
166
|
+
id: jobState.id,
|
|
167
|
+
job: jobState.job,
|
|
168
|
+
schedule: jobState.schedule,
|
|
169
|
+
nextRunAt: jobState.nextRunAt,
|
|
170
|
+
lastRunAt: jobState.lastRunAt,
|
|
171
|
+
remainingTimestamps: jobState.remainingTimestamps ? [...jobState.remainingTimestamps] : null,
|
|
172
|
+
runOnce: jobState.runOnce,
|
|
173
|
+
isRunning: false,
|
|
174
|
+
retryAttempts: jobState.retryAttempts ?? 0
|
|
175
|
+
};
|
|
176
|
+
this.jobs.set(jobState.id, restored);
|
|
177
|
+
});
|
|
178
|
+
resolvedState.queue.forEach((id) => {
|
|
179
|
+
if (this.jobs.has(id)) {
|
|
180
|
+
this.queue.push(id);
|
|
181
|
+
this.queued.add(id);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
this.tick();
|
|
185
|
+
void this.persistStateIfEnabled();
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
dispatch(job) {
|
|
189
|
+
this.runningCount += 1;
|
|
190
|
+
job.isRunning = true;
|
|
191
|
+
void this.persistStateIfEnabled();
|
|
192
|
+
const startedAt = Date.now();
|
|
193
|
+
const context = {
|
|
194
|
+
runId: this.createId('run'),
|
|
195
|
+
jobId: job.id,
|
|
196
|
+
scheduledAt: new Date(job.nextRunAt ?? startedAt).toISOString(),
|
|
197
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
198
|
+
schedule: job.schedule
|
|
199
|
+
};
|
|
200
|
+
const retryConfig = this.getRetryConfig(job);
|
|
201
|
+
if (retryConfig) {
|
|
202
|
+
job.retryAttempts += 1;
|
|
203
|
+
}
|
|
204
|
+
let jobError = null;
|
|
205
|
+
const handlerPromise = this.handler(job.job, context);
|
|
206
|
+
const executionPromise = this.withTimeout(handlerPromise, this.getExecutionTimeoutMs(job));
|
|
207
|
+
void executionPromise
|
|
208
|
+
.then(() => {
|
|
209
|
+
this.completed += 1;
|
|
210
|
+
job.retryAttempts = 0;
|
|
211
|
+
})
|
|
212
|
+
.catch((error) => {
|
|
213
|
+
this.failed += 1;
|
|
214
|
+
jobError = error;
|
|
215
|
+
})
|
|
216
|
+
.finally(() => {
|
|
217
|
+
const scheduledRetry = this.scheduleRetryIfEnabled(job, startedAt, jobError);
|
|
218
|
+
job.isRunning = false;
|
|
219
|
+
job.lastRunAt = startedAt;
|
|
220
|
+
this.runningCount -= 1;
|
|
221
|
+
if (!scheduledRetry) {
|
|
222
|
+
job.retryAttempts = 0;
|
|
223
|
+
this.updateNextRun(job, startedAt);
|
|
224
|
+
}
|
|
225
|
+
this.tick();
|
|
226
|
+
void this.persistStateIfEnabled();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
getRetryConfig(job) {
|
|
230
|
+
return job.job.retry ?? this.config.retry;
|
|
231
|
+
}
|
|
232
|
+
getExecutionTimeoutMs(job) {
|
|
233
|
+
return job.job.executionTimeoutMs ?? this.config.executionTimeoutMs;
|
|
234
|
+
}
|
|
235
|
+
scheduleRetryIfEnabled(job, startedAt, error) {
|
|
236
|
+
if (!error) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const retryConfig = this.getRetryConfig(job);
|
|
240
|
+
if (!retryConfig) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const maxAttempts = retryConfig.maxAttempts ?? 1;
|
|
244
|
+
if (maxAttempts <= 1 || job.retryAttempts >= maxAttempts) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const baseDelay = retryConfig.delayMs ?? 1000;
|
|
248
|
+
const backoff = retryConfig.backoffMultiplier ?? 1;
|
|
249
|
+
const calculatedDelay = baseDelay * Math.pow(backoff, Math.max(job.retryAttempts - 1, 0));
|
|
250
|
+
const delay = retryConfig.maxDelayMs ? Math.min(calculatedDelay, retryConfig.maxDelayMs) : calculatedDelay;
|
|
251
|
+
job.nextRunAt = startedAt + Math.max(0, delay);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
withTimeout(promise, timeoutMs) {
|
|
255
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
256
|
+
return promise;
|
|
257
|
+
}
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
const timeoutId = setTimeout(() => {
|
|
260
|
+
reject(new Error(`Scheduler job timed out after ${timeoutMs}ms`));
|
|
261
|
+
}, timeoutMs);
|
|
262
|
+
promise
|
|
263
|
+
.then((value) => {
|
|
264
|
+
clearTimeout(timeoutId);
|
|
265
|
+
resolve(value);
|
|
266
|
+
})
|
|
267
|
+
.catch((error) => {
|
|
268
|
+
clearTimeout(timeoutId);
|
|
269
|
+
reject(error);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
initializeSchedule(schedule, now) {
|
|
274
|
+
if (!schedule) {
|
|
275
|
+
return { nextRunAt: now, runOnce: true, remainingTimestamps: null };
|
|
276
|
+
}
|
|
277
|
+
if (schedule.type === ScheduleTypes.INTERVAL) {
|
|
278
|
+
const startAt = this.parseTimestamp(schedule.startAt);
|
|
279
|
+
if (startAt !== null && startAt > now) {
|
|
280
|
+
return { nextRunAt: startAt, runOnce: false, remainingTimestamps: null };
|
|
281
|
+
}
|
|
282
|
+
return { nextRunAt: now, runOnce: false, remainingTimestamps: null };
|
|
283
|
+
}
|
|
284
|
+
if (schedule.type === ScheduleTypes.CRON) {
|
|
285
|
+
const nextRunAt = this.getNextCronTime(schedule.expression, now, schedule.timezone);
|
|
286
|
+
return { nextRunAt, runOnce: false, remainingTimestamps: null };
|
|
287
|
+
}
|
|
288
|
+
if (schedule.type === ScheduleTypes.TIMESTAMP) {
|
|
289
|
+
const at = this.parseTimestamp(schedule.at);
|
|
290
|
+
return { nextRunAt: at, runOnce: true, remainingTimestamps: null };
|
|
291
|
+
}
|
|
292
|
+
const timestamps = schedule.at
|
|
293
|
+
.map((value) => this.parseTimestamp(value))
|
|
294
|
+
.filter((value) => value !== null)
|
|
295
|
+
.sort((a, b) => a - b);
|
|
296
|
+
const nextRunAt = timestamps.length > 0 ? timestamps[0] : null;
|
|
297
|
+
return { nextRunAt, runOnce: false, remainingTimestamps: timestamps };
|
|
298
|
+
}
|
|
299
|
+
updateNextRun(job, lastRunAt) {
|
|
300
|
+
const schedule = job.schedule;
|
|
301
|
+
if (!schedule) {
|
|
302
|
+
job.nextRunAt = job.runOnce ? null : lastRunAt;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (schedule.type === ScheduleTypes.INTERVAL) {
|
|
306
|
+
job.nextRunAt = lastRunAt + schedule.everyMs;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (schedule.type === ScheduleTypes.CRON) {
|
|
310
|
+
job.nextRunAt = this.getNextCronTime(schedule.expression, lastRunAt, schedule.timezone);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (schedule.type === ScheduleTypes.TIMESTAMP) {
|
|
314
|
+
job.nextRunAt = null;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const remaining = job.remainingTimestamps ?? [];
|
|
318
|
+
while (remaining.length > 0 && remaining[0] <= lastRunAt) {
|
|
319
|
+
remaining.shift();
|
|
320
|
+
}
|
|
321
|
+
job.remainingTimestamps = remaining;
|
|
322
|
+
job.nextRunAt = remaining.length > 0 ? remaining[0] : null;
|
|
323
|
+
}
|
|
324
|
+
parseTimestamp(value) {
|
|
325
|
+
if (typeof value === 'number') {
|
|
326
|
+
return Number.isFinite(value) ? value : null;
|
|
327
|
+
}
|
|
328
|
+
if (typeof value === 'string') {
|
|
329
|
+
const parsed = Date.parse(value);
|
|
330
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
getNextCronTime(expression, fromMs, timezone) {
|
|
335
|
+
const fields = expression.trim().split(/\s+/);
|
|
336
|
+
if (fields.length < 5 || fields.length > 6) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const hasSeconds = fields.length === 6;
|
|
340
|
+
const [secField, minField, hourField, dayField, monthField, dowField] = hasSeconds
|
|
341
|
+
? fields
|
|
342
|
+
: ['0', ...fields];
|
|
343
|
+
const seconds = this.parseCronField(secField, 0, 59, true);
|
|
344
|
+
const minutes = this.parseCronField(minField, 0, 59, true);
|
|
345
|
+
const hours = this.parseCronField(hourField, 0, 23, true);
|
|
346
|
+
const days = this.parseCronField(dayField, 1, 31, true);
|
|
347
|
+
const months = this.parseCronField(monthField, 1, 12, true);
|
|
348
|
+
const dows = this.parseCronField(dowField, 0, 6, true);
|
|
349
|
+
if (!seconds || !minutes || !hours || !days || !months || !dows) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const maxIterations = 366 * 24 * 60 * 60;
|
|
353
|
+
let candidate = new Date(fromMs + 1000);
|
|
354
|
+
for (let i = 0; i < maxIterations; i += 1) {
|
|
355
|
+
const candidateDate = candidate;
|
|
356
|
+
const parts = this.getCronDateParts(candidateDate, timezone);
|
|
357
|
+
if (!parts) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const match = seconds.has(parts.second) &&
|
|
361
|
+
minutes.has(parts.minute) &&
|
|
362
|
+
hours.has(parts.hour) &&
|
|
363
|
+
days.has(parts.day) &&
|
|
364
|
+
months.has(parts.month) &&
|
|
365
|
+
dows.has(parts.dow);
|
|
366
|
+
if (match) {
|
|
367
|
+
return candidateDate.getTime();
|
|
368
|
+
}
|
|
369
|
+
candidate = new Date(candidateDate.getTime() + 1000);
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
parseCronField(field, min, max, strict) {
|
|
374
|
+
const values = new Set();
|
|
375
|
+
const segments = field.split(',');
|
|
376
|
+
let hasValidSegment = false;
|
|
377
|
+
segments.forEach((segment) => {
|
|
378
|
+
const trimmed = segment.trim();
|
|
379
|
+
if (!trimmed)
|
|
380
|
+
return;
|
|
381
|
+
const [rangePart, stepPart] = trimmed.split('/');
|
|
382
|
+
if (stepPart !== undefined && !this.isValidInteger(stepPart)) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const step = stepPart ? Number(stepPart) : 1;
|
|
386
|
+
const safeStep = Number.isFinite(step) && step > 0 ? step : null;
|
|
387
|
+
if (!safeStep) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
let rangeStart;
|
|
391
|
+
let rangeEnd;
|
|
392
|
+
if (rangePart === '*') {
|
|
393
|
+
rangeStart = min;
|
|
394
|
+
rangeEnd = max;
|
|
395
|
+
}
|
|
396
|
+
else if (rangePart.includes('-')) {
|
|
397
|
+
const [startRaw, endRaw] = rangePart.split('-');
|
|
398
|
+
if (!this.isValidInteger(startRaw) || !this.isValidInteger(endRaw)) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
rangeStart = Number(startRaw);
|
|
402
|
+
rangeEnd = Number(endRaw);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
if (!this.isValidInteger(rangePart)) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
rangeStart = Number(rangePart);
|
|
409
|
+
rangeEnd = rangeStart;
|
|
410
|
+
}
|
|
411
|
+
if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
for (let value = rangeStart; value <= rangeEnd; value += safeStep) {
|
|
415
|
+
values.add(value);
|
|
416
|
+
}
|
|
417
|
+
hasValidSegment = true;
|
|
418
|
+
});
|
|
419
|
+
if (values.size === 0) {
|
|
420
|
+
if (strict) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
for (let value = min; value <= max; value += 1) {
|
|
424
|
+
values.add(value);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return values;
|
|
428
|
+
}
|
|
429
|
+
isValidInteger(value) {
|
|
430
|
+
return /^\d+$/.test(value);
|
|
431
|
+
}
|
|
432
|
+
getCronDateParts(date, timezone) {
|
|
433
|
+
if (!timezone) {
|
|
434
|
+
return {
|
|
435
|
+
second: date.getSeconds(),
|
|
436
|
+
minute: date.getMinutes(),
|
|
437
|
+
hour: date.getHours(),
|
|
438
|
+
day: date.getDate(),
|
|
439
|
+
month: date.getMonth() + 1,
|
|
440
|
+
dow: date.getDay()
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
445
|
+
timeZone: timezone,
|
|
446
|
+
hour12: false,
|
|
447
|
+
weekday: 'short',
|
|
448
|
+
year: 'numeric',
|
|
449
|
+
month: '2-digit',
|
|
450
|
+
day: '2-digit',
|
|
451
|
+
hour: '2-digit',
|
|
452
|
+
minute: '2-digit',
|
|
453
|
+
second: '2-digit'
|
|
454
|
+
});
|
|
455
|
+
const parts = formatter.formatToParts(date);
|
|
456
|
+
const partMap = new Map(parts.map((part) => [part.type, part.value]));
|
|
457
|
+
const month = Number(partMap.get('month'));
|
|
458
|
+
const day = Number(partMap.get('day'));
|
|
459
|
+
const hour = Number(partMap.get('hour'));
|
|
460
|
+
const minute = Number(partMap.get('minute'));
|
|
461
|
+
const second = Number(partMap.get('second'));
|
|
462
|
+
const weekday = partMap.get('weekday');
|
|
463
|
+
if (Number.isNaN(month) ||
|
|
464
|
+
Number.isNaN(day) ||
|
|
465
|
+
Number.isNaN(hour) ||
|
|
466
|
+
Number.isNaN(minute) ||
|
|
467
|
+
Number.isNaN(second) ||
|
|
468
|
+
!weekday) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
const weekdayIndex = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday);
|
|
472
|
+
if (weekdayIndex === -1) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
second,
|
|
477
|
+
minute,
|
|
478
|
+
hour,
|
|
479
|
+
day,
|
|
480
|
+
month,
|
|
481
|
+
dow: weekdayIndex
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
createId(prefix) {
|
|
489
|
+
return `${prefix}-${this.generateUuid()}-${Date.now()}`;
|
|
490
|
+
}
|
|
491
|
+
generateUuid() {
|
|
492
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
493
|
+
return globalThis.crypto.randomUUID();
|
|
494
|
+
}
|
|
495
|
+
const bytes = new Uint8Array(16);
|
|
496
|
+
if (typeof globalThis.crypto?.getRandomValues === 'function') {
|
|
497
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
501
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
505
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
506
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
|
|
507
|
+
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
|
|
508
|
+
.slice(6, 8)
|
|
509
|
+
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
|
|
510
|
+
}
|
|
511
|
+
async persistStateIfEnabled() {
|
|
512
|
+
if (!this.config.persistence.enabled || !this.config.persistence.saveState) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const debounceMs = this.config.persistenceDebounceMs ?? 0;
|
|
516
|
+
if (debounceMs > 0) {
|
|
517
|
+
if (this.persistTimer) {
|
|
518
|
+
this.persistQueued = true;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
this.persistQueued = false;
|
|
522
|
+
this.persistTimer = setTimeout(async () => {
|
|
523
|
+
this.persistTimer = null;
|
|
524
|
+
const state = this.getState();
|
|
525
|
+
await this.config.persistence.saveState?.(state);
|
|
526
|
+
if (this.persistQueued) {
|
|
527
|
+
void this.persistStateIfEnabled();
|
|
528
|
+
}
|
|
529
|
+
}, debounceMs);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const state = this.getState();
|
|
533
|
+
await this.config.persistence.saveState(state);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
//# sourceMappingURL=stable-scheduler.js.map
|