@skelm/scheduler 0.4.1 → 0.4.3
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 +8 -11
- package/dist/builders.d.ts +1 -13
- package/dist/builders.js +0 -65
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/scheduler.d.ts +43 -17
- package/dist/scheduler.js +117 -157
- package/dist/types.d.ts +2 -19
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @skelm/scheduler
|
|
2
2
|
|
|
3
|
-
> Long-running trigger management for [skelm](https://github.com/scottgl9/skelm) pipelines — cron, interval,
|
|
3
|
+
> Long-running trigger management for [skelm](https://github.com/scottgl9/skelm) pipelines — cron, interval, and webhook triggers with deduplication and overlap policies.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@skelm/scheduler)
|
|
6
6
|
|
|
@@ -38,13 +38,11 @@ await scheduler.start()
|
|
|
38
38
|
|
|
39
39
|
## Trigger types
|
|
40
40
|
|
|
41
|
-
| Builder | When it fires
|
|
42
|
-
| ------------------------ |
|
|
43
|
-
| `createCronTrigger` | On a cron schedule (`'0 9 * * 1-5'`)
|
|
44
|
-
| `createIntervalTrigger` | Every N milliseconds
|
|
45
|
-
| `createWebhookTrigger` | When the gateway receives a request at the trigger's path
|
|
46
|
-
| `createPollTrigger` | When a polling function returns new items |
|
|
47
|
-
| `createQueueTrigger` | When a message arrives on a connected queue (in-memory or external broker) |
|
|
41
|
+
| Builder | When it fires |
|
|
42
|
+
| ------------------------ | --------------------------------------------------------- |
|
|
43
|
+
| `createCronTrigger` | On a cron schedule (`'0 9 * * 1-5'`) |
|
|
44
|
+
| `createIntervalTrigger` | Every N milliseconds |
|
|
45
|
+
| `createWebhookTrigger` | When the gateway receives a request at the trigger's path |
|
|
48
46
|
|
|
49
47
|
## Policies
|
|
50
48
|
|
|
@@ -57,13 +55,12 @@ await scheduler.start()
|
|
|
57
55
|
```ts
|
|
58
56
|
export { Scheduler } from './scheduler.js'
|
|
59
57
|
export {
|
|
60
|
-
createCronTrigger, createIntervalTrigger,
|
|
61
|
-
createWebhookTrigger, createPollTrigger, createQueueTrigger,
|
|
58
|
+
createCronTrigger, createIntervalTrigger, createWebhookTrigger,
|
|
62
59
|
} from './builders.js'
|
|
63
60
|
export type {
|
|
64
61
|
SchedulerConfig, Trigger, TriggerRegistration, TriggerContext, TriggerType,
|
|
65
62
|
DedupePolicy, OverlapPolicy, TriggerBase,
|
|
66
|
-
CronTrigger, IntervalTrigger, WebhookTrigger,
|
|
63
|
+
CronTrigger, IntervalTrigger, WebhookTrigger,
|
|
67
64
|
TriggerOptions,
|
|
68
65
|
} from './types.js'
|
|
69
66
|
```
|
package/dist/builders.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CronTrigger, DedupePolicy, IntervalTrigger, OverlapPolicy,
|
|
1
|
+
import type { CronTrigger, DedupePolicy, IntervalTrigger, OverlapPolicy, WebhookTrigger } from './types.js';
|
|
2
2
|
/** Base trigger options */
|
|
3
3
|
export interface TriggerOptions {
|
|
4
4
|
description?: string;
|
|
@@ -20,15 +20,3 @@ export declare function createWebhookTrigger(id: string, pipelineId: string, pat
|
|
|
20
20
|
secret?: string;
|
|
21
21
|
transformPayload?: (payload: unknown) => unknown;
|
|
22
22
|
}): WebhookTrigger;
|
|
23
|
-
/** Create a poll trigger */
|
|
24
|
-
export declare function createPollTrigger(id: string, pipelineId: string, url: string, intervalMs: number, options?: TriggerOptions & {
|
|
25
|
-
headers?: Record<string, string>;
|
|
26
|
-
detectNew?: (previous: unknown, current: unknown) => boolean;
|
|
27
|
-
extractInput?: (data: unknown) => unknown | null;
|
|
28
|
-
}): PollTrigger;
|
|
29
|
-
/** Create a queue trigger */
|
|
30
|
-
export declare function createQueueTrigger(id: string, pipelineId: string, queueName: string, options?: TriggerOptions & {
|
|
31
|
-
batchSize?: number;
|
|
32
|
-
visibilityTimeoutMs?: number;
|
|
33
|
-
extractInput?: (message: unknown) => unknown | null;
|
|
34
|
-
}): QueueTrigger;
|
package/dist/builders.js
CHANGED
|
@@ -87,68 +87,3 @@ export function createWebhookTrigger(id, pipelineId, path, options = {}) {
|
|
|
87
87
|
}
|
|
88
88
|
return result;
|
|
89
89
|
}
|
|
90
|
-
/** Create a poll trigger */
|
|
91
|
-
export function createPollTrigger(id, pipelineId, url, intervalMs, options = {}) {
|
|
92
|
-
const result = {
|
|
93
|
-
id,
|
|
94
|
-
type: 'poll',
|
|
95
|
-
url,
|
|
96
|
-
intervalMs,
|
|
97
|
-
pipelineId,
|
|
98
|
-
enabled: options.enabled ?? true,
|
|
99
|
-
dedupe: options.dedupe ?? 'skip',
|
|
100
|
-
overlap: options.overlap ?? 'wait',
|
|
101
|
-
};
|
|
102
|
-
if (options.description !== undefined) {
|
|
103
|
-
result.description = options.description;
|
|
104
|
-
}
|
|
105
|
-
if (options.maxConcurrent !== undefined) {
|
|
106
|
-
result.maxConcurrent = options.maxConcurrent;
|
|
107
|
-
}
|
|
108
|
-
if (options.headers !== undefined) {
|
|
109
|
-
result.headers = options.headers;
|
|
110
|
-
}
|
|
111
|
-
if (options.detectNew !== undefined) {
|
|
112
|
-
result.detectNew = options.detectNew;
|
|
113
|
-
}
|
|
114
|
-
if (options.extractInput !== undefined) {
|
|
115
|
-
result.extractInput = options.extractInput;
|
|
116
|
-
}
|
|
117
|
-
if (options.inputTemplate !== undefined) {
|
|
118
|
-
result.inputTemplate = options.inputTemplate;
|
|
119
|
-
}
|
|
120
|
-
if (options.metadata !== undefined) {
|
|
121
|
-
result.metadata = options.metadata;
|
|
122
|
-
}
|
|
123
|
-
return result;
|
|
124
|
-
}
|
|
125
|
-
/** Create a queue trigger */
|
|
126
|
-
export function createQueueTrigger(id, pipelineId, queueName, options = {}) {
|
|
127
|
-
const result = {
|
|
128
|
-
id,
|
|
129
|
-
type: 'queue',
|
|
130
|
-
queueName,
|
|
131
|
-
pipelineId,
|
|
132
|
-
enabled: options.enabled ?? true,
|
|
133
|
-
dedupe: options.dedupe ?? 'skip',
|
|
134
|
-
overlap: options.overlap ?? 'run-concurrent',
|
|
135
|
-
maxConcurrent: options.maxConcurrent ?? 5,
|
|
136
|
-
batchSize: options.batchSize ?? 1,
|
|
137
|
-
};
|
|
138
|
-
if (options.description !== undefined) {
|
|
139
|
-
result.description = options.description;
|
|
140
|
-
}
|
|
141
|
-
if (options.visibilityTimeoutMs !== undefined) {
|
|
142
|
-
result.visibilityTimeoutMs = options.visibilityTimeoutMs;
|
|
143
|
-
}
|
|
144
|
-
if (options.extractInput !== undefined) {
|
|
145
|
-
result.extractInput = options.extractInput;
|
|
146
|
-
}
|
|
147
|
-
if (options.inputTemplate !== undefined) {
|
|
148
|
-
result.inputTemplate = options.inputTemplate;
|
|
149
|
-
}
|
|
150
|
-
if (options.metadata !== undefined) {
|
|
151
|
-
result.metadata = options.metadata;
|
|
152
|
-
}
|
|
153
|
-
return result;
|
|
154
|
-
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* with deduplication and overlap policies.
|
|
6
6
|
*/
|
|
7
7
|
export { Scheduler } from './scheduler.js';
|
|
8
|
-
export {
|
|
9
|
-
export
|
|
8
|
+
export type { SchedulerRunStore, SchedulerPipelineLoader, SchedulerPipelineExecutor, } from './scheduler.js';
|
|
9
|
+
export { createCronTrigger, createIntervalTrigger, createWebhookTrigger, } from './builders.js';
|
|
10
|
+
export type { SchedulerConfig, Trigger, TriggerRegistration, TriggerContext, TriggerType, DedupePolicy, OverlapPolicy, TriggerBase, CronTrigger, IntervalTrigger, WebhookTrigger, } from './types.js';
|
|
10
11
|
export type { TriggerOptions } from './builders.js';
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* with deduplication and overlap policies.
|
|
6
6
|
*/
|
|
7
7
|
export { Scheduler } from './scheduler.js';
|
|
8
|
-
export { createCronTrigger, createIntervalTrigger, createWebhookTrigger,
|
|
8
|
+
export { createCronTrigger, createIntervalTrigger, createWebhookTrigger, } from './builders.js';
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Pipeline, Run, RunStore } from '@skelm/core';
|
|
2
|
+
import type { SchedulerConfig, Trigger, TriggerContext, TriggerRegistration } from './types.js';
|
|
3
|
+
/** The slice of `RunStore` the scheduler depends on for recording triggered runs. */
|
|
4
|
+
export type SchedulerRunStore = Pick<RunStore, 'putRun'>;
|
|
5
|
+
/** Resolves a registered pipeline ID to the executable pipeline, or `null` if unknown. */
|
|
6
|
+
export type SchedulerPipelineLoader = (pipelineId: string) => Promise<Pipeline | null>;
|
|
7
|
+
/**
|
|
8
|
+
* Executes a loaded pipeline for a trigger fire. Must return the terminal
|
|
9
|
+
* Run record (with status set to one of `completed`, `failed`, or
|
|
10
|
+
* `cancelled` and `completedAt` populated) so the scheduler can persist a
|
|
11
|
+
* complete entry. When omitted, fires register the trigger metadata but
|
|
12
|
+
* do not produce Run records — see Scheduler.executeTrigger.
|
|
13
|
+
*/
|
|
14
|
+
export type SchedulerPipelineExecutor = (pipeline: Pipeline, input: unknown, ctx: TriggerContext) => Promise<Run>;
|
|
2
15
|
/**
|
|
3
16
|
* Scheduler manages long-running triggers for pipelines.
|
|
4
|
-
* Handles cron, interval, webhook
|
|
5
|
-
* with deduplication and overlap policies.
|
|
17
|
+
* Handles cron, interval, and webhook triggers with overlap policies.
|
|
6
18
|
*/
|
|
7
19
|
export declare class Scheduler {
|
|
8
20
|
private readonly config;
|
|
@@ -10,16 +22,18 @@ export declare class Scheduler {
|
|
|
10
22
|
private readonly cronJobs;
|
|
11
23
|
private readonly intervalJobs;
|
|
12
24
|
private webhookServer;
|
|
13
|
-
private
|
|
14
|
-
private
|
|
25
|
+
private readonly inFlight;
|
|
26
|
+
private readonly runningCount;
|
|
27
|
+
private readonly lastRun;
|
|
15
28
|
private isRunning;
|
|
16
29
|
private readonly runStore;
|
|
17
30
|
private readonly pipelineLoader;
|
|
31
|
+
private readonly pipelineExecutor;
|
|
32
|
+
private readonly noExecutorWarned;
|
|
18
33
|
constructor(config: SchedulerConfig, deps: {
|
|
19
|
-
runStore:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
pipelineLoader: (pipelineId: string) => Promise<unknown>;
|
|
34
|
+
runStore: SchedulerRunStore;
|
|
35
|
+
pipelineLoader: SchedulerPipelineLoader;
|
|
36
|
+
pipelineExecutor?: SchedulerPipelineExecutor;
|
|
23
37
|
});
|
|
24
38
|
/** Register a new trigger */
|
|
25
39
|
register(trigger: Trigger): Promise<TriggerRegistration>;
|
|
@@ -39,18 +53,30 @@ export declare class Scheduler {
|
|
|
39
53
|
stopWebhookServer(): Promise<void>;
|
|
40
54
|
/** Start all triggers */
|
|
41
55
|
start(): Promise<void>;
|
|
42
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Stop all triggers. Clears the timers, then waits up to 30s for any
|
|
58
|
+
* in-flight executeTrigger callbacks to settle so a SIGTERM does not
|
|
59
|
+
* leave fire-and-forget executions racing the process exit.
|
|
60
|
+
*
|
|
61
|
+
* Runs unconditionally — `register()` arms timers immediately without
|
|
62
|
+
* setting isRunning, so a stop() that gated on isRunning would leak
|
|
63
|
+
* those timers when the scheduler is constructed without start().
|
|
64
|
+
*/
|
|
43
65
|
stop(): Promise<void>;
|
|
66
|
+
/** Track a fire-and-forget execution so stop() can drain it. */
|
|
67
|
+
private track;
|
|
68
|
+
private drainInFlight;
|
|
44
69
|
private startCronTrigger;
|
|
70
|
+
private scheduleNextCron;
|
|
45
71
|
private stopCronTrigger;
|
|
46
72
|
private startIntervalTrigger;
|
|
47
73
|
private stopIntervalTrigger;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Apply the overlap policy and schedule the execution.
|
|
76
|
+
* - fail-fast: skip if a previous run is in progress.
|
|
77
|
+
* - wait: chain after the previous run (next() resolves before we start).
|
|
78
|
+
* - run-concurrent: start immediately regardless.
|
|
79
|
+
*/
|
|
80
|
+
private fire;
|
|
52
81
|
private executeTrigger;
|
|
53
|
-
private executePollTrigger;
|
|
54
|
-
private executeQueueTrigger;
|
|
55
|
-
private parseCronToInterval;
|
|
56
82
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import parser from 'cron-parser';
|
|
1
2
|
/**
|
|
2
3
|
* Scheduler manages long-running triggers for pipelines.
|
|
3
|
-
* Handles cron, interval, webhook
|
|
4
|
-
* with deduplication and overlap policies.
|
|
4
|
+
* Handles cron, interval, and webhook triggers with overlap policies.
|
|
5
5
|
*/
|
|
6
6
|
export class Scheduler {
|
|
7
7
|
config;
|
|
@@ -9,11 +9,14 @@ export class Scheduler {
|
|
|
9
9
|
cronJobs = new Map();
|
|
10
10
|
intervalJobs = new Map();
|
|
11
11
|
webhookServer = null;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
inFlight = new Set();
|
|
13
|
+
runningCount = new Map();
|
|
14
|
+
lastRun = new Map();
|
|
14
15
|
isRunning = false;
|
|
15
16
|
runStore;
|
|
16
17
|
pipelineLoader;
|
|
18
|
+
pipelineExecutor;
|
|
19
|
+
noExecutorWarned = new Set();
|
|
17
20
|
constructor(config, deps) {
|
|
18
21
|
const cfg = {
|
|
19
22
|
webhookPort: config.webhookPort ?? 3001,
|
|
@@ -27,6 +30,7 @@ export class Scheduler {
|
|
|
27
30
|
this.config = cfg;
|
|
28
31
|
this.runStore = deps.runStore;
|
|
29
32
|
this.pipelineLoader = deps.pipelineLoader;
|
|
33
|
+
this.pipelineExecutor = deps.pipelineExecutor;
|
|
30
34
|
}
|
|
31
35
|
/** Register a new trigger */
|
|
32
36
|
async register(trigger) {
|
|
@@ -39,7 +43,6 @@ export class Scheduler {
|
|
|
39
43
|
status: 'active',
|
|
40
44
|
};
|
|
41
45
|
this.triggers.set(trigger.id, registration);
|
|
42
|
-
// Start the trigger based on type
|
|
43
46
|
switch (trigger.type) {
|
|
44
47
|
case 'cron':
|
|
45
48
|
this.startCronTrigger(trigger);
|
|
@@ -50,12 +53,6 @@ export class Scheduler {
|
|
|
50
53
|
case 'webhook':
|
|
51
54
|
// Webhook server starts separately via startWebhookServer()
|
|
52
55
|
break;
|
|
53
|
-
case 'poll':
|
|
54
|
-
this.startPollTrigger(trigger);
|
|
55
|
-
break;
|
|
56
|
-
case 'queue':
|
|
57
|
-
this.startQueueTrigger(trigger);
|
|
58
|
-
break;
|
|
59
56
|
}
|
|
60
57
|
return registration;
|
|
61
58
|
}
|
|
@@ -64,11 +61,10 @@ export class Scheduler {
|
|
|
64
61
|
const registration = this.triggers.get(triggerId);
|
|
65
62
|
if (!registration)
|
|
66
63
|
return;
|
|
67
|
-
// Stop any running jobs
|
|
68
64
|
this.stopCronTrigger(triggerId);
|
|
69
65
|
this.stopIntervalTrigger(triggerId);
|
|
70
|
-
this.
|
|
71
|
-
this.
|
|
66
|
+
this.lastRun.delete(triggerId);
|
|
67
|
+
this.runningCount.delete(triggerId);
|
|
72
68
|
this.triggers.delete(triggerId);
|
|
73
69
|
}
|
|
74
70
|
/** Pause a trigger */
|
|
@@ -78,11 +74,8 @@ export class Scheduler {
|
|
|
78
74
|
return;
|
|
79
75
|
registration.status = 'paused';
|
|
80
76
|
registration.updatedAt = Date.now();
|
|
81
|
-
// Stop jobs for time-based triggers
|
|
82
77
|
this.stopCronTrigger(triggerId);
|
|
83
78
|
this.stopIntervalTrigger(triggerId);
|
|
84
|
-
this.stopPollTrigger(triggerId);
|
|
85
|
-
this.stopQueueTrigger(triggerId);
|
|
86
79
|
}
|
|
87
80
|
/** Resume a paused trigger */
|
|
88
81
|
async resume(triggerId) {
|
|
@@ -94,7 +87,6 @@ export class Scheduler {
|
|
|
94
87
|
registration.status = 'active';
|
|
95
88
|
registration.updatedAt = Date.now();
|
|
96
89
|
const trigger = registration.trigger;
|
|
97
|
-
// Restart jobs based on type
|
|
98
90
|
switch (trigger.type) {
|
|
99
91
|
case 'cron':
|
|
100
92
|
this.startCronTrigger(trigger);
|
|
@@ -102,12 +94,6 @@ export class Scheduler {
|
|
|
102
94
|
case 'interval':
|
|
103
95
|
this.startIntervalTrigger(trigger);
|
|
104
96
|
break;
|
|
105
|
-
case 'poll':
|
|
106
|
-
this.startPollTrigger(trigger);
|
|
107
|
-
break;
|
|
108
|
-
case 'queue':
|
|
109
|
-
this.startQueueTrigger(trigger);
|
|
110
|
-
break;
|
|
111
97
|
}
|
|
112
98
|
}
|
|
113
99
|
/** List all registered triggers */
|
|
@@ -120,13 +106,10 @@ export class Scheduler {
|
|
|
120
106
|
}
|
|
121
107
|
/** Start the webhook server */
|
|
122
108
|
async startWebhookServer() {
|
|
123
|
-
// Webhook server implementation would go here
|
|
124
|
-
// Uses h3 or similar for HTTP handling
|
|
125
109
|
console.log(`Webhook server would start on ${this.config.webhookHost}:${this.config.webhookPort}`);
|
|
126
110
|
}
|
|
127
111
|
/** Stop the webhook server */
|
|
128
112
|
async stopWebhookServer() {
|
|
129
|
-
// Stop webhook server
|
|
130
113
|
this.webhookServer = null;
|
|
131
114
|
}
|
|
132
115
|
/** Start all triggers */
|
|
@@ -144,63 +127,90 @@ export class Scheduler {
|
|
|
144
127
|
case 'interval':
|
|
145
128
|
this.startIntervalTrigger(trigger);
|
|
146
129
|
break;
|
|
147
|
-
case 'poll':
|
|
148
|
-
this.startPollTrigger(trigger);
|
|
149
|
-
break;
|
|
150
|
-
case 'queue':
|
|
151
|
-
this.startQueueTrigger(trigger);
|
|
152
|
-
break;
|
|
153
130
|
}
|
|
154
131
|
}
|
|
155
132
|
}
|
|
156
133
|
}
|
|
157
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Stop all triggers. Clears the timers, then waits up to 30s for any
|
|
136
|
+
* in-flight executeTrigger callbacks to settle so a SIGTERM does not
|
|
137
|
+
* leave fire-and-forget executions racing the process exit.
|
|
138
|
+
*
|
|
139
|
+
* Runs unconditionally — `register()` arms timers immediately without
|
|
140
|
+
* setting isRunning, so a stop() that gated on isRunning would leak
|
|
141
|
+
* those timers when the scheduler is constructed without start().
|
|
142
|
+
*/
|
|
158
143
|
async stop() {
|
|
159
|
-
if (!this.isRunning)
|
|
160
|
-
return;
|
|
161
144
|
this.isRunning = false;
|
|
162
|
-
// Clear all jobs
|
|
163
145
|
for (const [id, job] of this.cronJobs) {
|
|
164
|
-
|
|
146
|
+
clearTimeout(job);
|
|
165
147
|
this.cronJobs.delete(id);
|
|
166
148
|
}
|
|
167
149
|
for (const [id, job] of this.intervalJobs) {
|
|
168
150
|
clearInterval(job);
|
|
169
151
|
this.intervalJobs.delete(id);
|
|
170
152
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
153
|
+
await this.drainInFlight(30_000);
|
|
154
|
+
}
|
|
155
|
+
/** Track a fire-and-forget execution so stop() can drain it. */
|
|
156
|
+
track(promise) {
|
|
157
|
+
this.inFlight.add(promise);
|
|
158
|
+
promise.finally(() => this.inFlight.delete(promise));
|
|
159
|
+
}
|
|
160
|
+
async drainInFlight(timeoutMs) {
|
|
161
|
+
if (this.inFlight.size === 0)
|
|
162
|
+
return;
|
|
163
|
+
await Promise.race([
|
|
164
|
+
Promise.allSettled([...this.inFlight]),
|
|
165
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.()),
|
|
166
|
+
]);
|
|
179
167
|
}
|
|
180
168
|
startCronTrigger(trigger) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
169
|
+
this.scheduleNextCron(trigger);
|
|
170
|
+
}
|
|
171
|
+
scheduleNextCron(trigger) {
|
|
172
|
+
let delay;
|
|
173
|
+
try {
|
|
174
|
+
const opts = { currentDate: new Date() };
|
|
175
|
+
if (trigger.timezone !== undefined)
|
|
176
|
+
opts.tz = trigger.timezone;
|
|
177
|
+
const next = parser.parseExpression(trigger.schedule, opts).next().getTime();
|
|
178
|
+
delay = Math.max(0, next - Date.now());
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
const registration = this.triggers.get(trigger.id);
|
|
182
|
+
if (registration) {
|
|
183
|
+
registration.status = 'error';
|
|
184
|
+
registration.lastError = `Invalid cron expression "${trigger.schedule}": ${err.message}`;
|
|
187
185
|
}
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
console.error(`Trigger ${trigger.id} disabled: invalid cron "${trigger.schedule}"`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const handle = setTimeout(() => {
|
|
190
|
+
const reg = this.triggers.get(trigger.id);
|
|
191
|
+
if (!reg || reg.status !== 'active' || !trigger.enabled)
|
|
192
|
+
return;
|
|
193
|
+
// Reschedule the next firing first so a slow run doesn't drift the schedule.
|
|
194
|
+
this.scheduleNextCron(trigger);
|
|
195
|
+
this.fire(trigger);
|
|
196
|
+
}, delay);
|
|
197
|
+
handle.unref?.();
|
|
198
|
+
this.cronJobs.set(trigger.id, handle);
|
|
190
199
|
}
|
|
191
200
|
stopCronTrigger(triggerId) {
|
|
192
201
|
const job = this.cronJobs.get(triggerId);
|
|
193
202
|
if (job) {
|
|
194
|
-
|
|
203
|
+
clearTimeout(job);
|
|
195
204
|
this.cronJobs.delete(triggerId);
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
207
|
startIntervalTrigger(trigger) {
|
|
199
|
-
const job = setInterval(
|
|
208
|
+
const job = setInterval(() => {
|
|
200
209
|
if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
|
|
201
|
-
|
|
210
|
+
this.fire(trigger);
|
|
202
211
|
}
|
|
203
212
|
}, trigger.intervalMs);
|
|
213
|
+
job.unref?.();
|
|
204
214
|
this.intervalJobs.set(trigger.id, job);
|
|
205
215
|
}
|
|
206
216
|
stopIntervalTrigger(triggerId) {
|
|
@@ -210,42 +220,29 @@ export class Scheduler {
|
|
|
210
220
|
this.intervalJobs.delete(triggerId);
|
|
211
221
|
}
|
|
212
222
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const job = setInterval(async () => {
|
|
230
|
-
if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
|
|
231
|
-
await this.executeQueueTrigger(trigger);
|
|
232
|
-
}
|
|
233
|
-
}, this.config.queuePollIntervalMs);
|
|
234
|
-
this.queueJobs.set(trigger.id, job);
|
|
235
|
-
}
|
|
236
|
-
stopQueueTrigger(triggerId) {
|
|
237
|
-
const job = this.queueJobs.get(triggerId);
|
|
238
|
-
if (job) {
|
|
239
|
-
clearInterval(job);
|
|
240
|
-
this.queueJobs.delete(triggerId);
|
|
241
|
-
}
|
|
223
|
+
/**
|
|
224
|
+
* Apply the overlap policy and schedule the execution.
|
|
225
|
+
* - fail-fast: skip if a previous run is in progress.
|
|
226
|
+
* - wait: chain after the previous run (next() resolves before we start).
|
|
227
|
+
* - run-concurrent: start immediately regardless.
|
|
228
|
+
*/
|
|
229
|
+
fire(trigger) {
|
|
230
|
+
const previous = this.lastRun.get(trigger.id);
|
|
231
|
+
const isRunning = (this.runningCount.get(trigger.id) ?? 0) > 0;
|
|
232
|
+
const policy = trigger.overlap ?? 'wait';
|
|
233
|
+
if (isRunning && policy === 'fail-fast')
|
|
234
|
+
return;
|
|
235
|
+
const start = isRunning && policy === 'wait' && previous ? previous : Promise.resolve();
|
|
236
|
+
const promise = start.then(() => this.executeTrigger(trigger));
|
|
237
|
+
this.lastRun.set(trigger.id, promise);
|
|
238
|
+
this.track(promise);
|
|
242
239
|
}
|
|
243
240
|
async executeTrigger(trigger) {
|
|
244
241
|
const registration = this.triggers.get(trigger.id);
|
|
245
242
|
if (!registration)
|
|
246
243
|
return;
|
|
244
|
+
this.runningCount.set(trigger.id, (this.runningCount.get(trigger.id) ?? 0) + 1);
|
|
247
245
|
try {
|
|
248
|
-
// Check deduplication
|
|
249
246
|
const ctx = {
|
|
250
247
|
triggerId: trigger.id,
|
|
251
248
|
runId: `trigger-${trigger.id}-${Date.now()}`,
|
|
@@ -254,47 +251,35 @@ export class Scheduler {
|
|
|
254
251
|
deduped: false,
|
|
255
252
|
overlapHandled: false,
|
|
256
253
|
};
|
|
257
|
-
// Handle overlap policy - check if already running
|
|
258
|
-
if (registration.status === 'active') {
|
|
259
|
-
// Check for concurrent run tracking (simplified)
|
|
260
|
-
const hasActiveRun = false; // Would track active runs in production
|
|
261
|
-
if (hasActiveRun) {
|
|
262
|
-
ctx.overlapHandled = true;
|
|
263
|
-
switch (trigger.overlap ?? 'wait') {
|
|
264
|
-
case 'fail-fast':
|
|
265
|
-
console.log(`Trigger ${trigger.id} skipped: overlap policy is fail-fast`);
|
|
266
|
-
return;
|
|
267
|
-
case 'run-concurrent':
|
|
268
|
-
// Allow concurrent runs
|
|
269
|
-
break;
|
|
270
|
-
default:
|
|
271
|
-
console.log(`Trigger ${trigger.id} waiting for previous run to complete`);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
// Execute the pipeline
|
|
277
254
|
const pipeline = await this.pipelineLoader(trigger.pipelineId);
|
|
278
255
|
if (!pipeline) {
|
|
279
256
|
throw new Error(`Pipeline ${trigger.pipelineId} not found`);
|
|
280
257
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
258
|
+
if (this.pipelineExecutor === undefined) {
|
|
259
|
+
// No executor wired: register the fire but do NOT persist a
|
|
260
|
+
// status='running' Run. Persisting one without anything to
|
|
261
|
+
// finalize it leaves an orphan that crash-recovery cannot
|
|
262
|
+
// distinguish from a real interrupted run. Production callers
|
|
263
|
+
// wire pipelineExecutor (typically the gateway TriggerCoordinator,
|
|
264
|
+
// not this class). Warn once per trigger so the misconfig is loud.
|
|
265
|
+
if (!this.noExecutorWarned.has(trigger.id)) {
|
|
266
|
+
this.noExecutorWarned.add(trigger.id);
|
|
267
|
+
console.warn(`Scheduler: trigger ${trigger.id} fired but no pipelineExecutor is configured; Run will not be persisted. Wire deps.pipelineExecutor to execute pipelines.`);
|
|
268
|
+
}
|
|
269
|
+
registration.runCount++;
|
|
270
|
+
registration.lastRunAt = Date.now();
|
|
271
|
+
registration.status = 'active';
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const input = trigger.inputTemplate ?? {};
|
|
275
|
+
const run = await this.pipelineExecutor(pipeline, input, ctx);
|
|
276
|
+
await this.runStore.putRun(run);
|
|
277
|
+
registration.runCount++;
|
|
278
|
+
registration.lastRunAt = Date.now();
|
|
279
|
+
registration.status = run.status === 'failed' ? 'error' : 'active';
|
|
280
|
+
if (run.error?.message)
|
|
281
|
+
registration.lastError = run.error.message;
|
|
282
|
+
}
|
|
298
283
|
}
|
|
299
284
|
catch (err) {
|
|
300
285
|
registration.errorCount++;
|
|
@@ -302,39 +287,14 @@ export class Scheduler {
|
|
|
302
287
|
registration.lastError = err.message;
|
|
303
288
|
console.error(`Trigger ${trigger.id} error:`, err);
|
|
304
289
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
console.log(`Checking queue ${trigger.queueName} for trigger ${trigger.id}`);
|
|
314
|
-
await this.executeTrigger(trigger);
|
|
315
|
-
}
|
|
316
|
-
parseCronToInterval(cron) {
|
|
317
|
-
// Simplified cron-to-interval conversion
|
|
318
|
-
// In production, use a proper cron library
|
|
319
|
-
if (!cron || typeof cron !== 'string') {
|
|
320
|
-
return 5 * 60 * 1000; // Default to 5 minutes
|
|
321
|
-
}
|
|
322
|
-
const parts = cron.split(' ');
|
|
323
|
-
if (parts.length !== 5) {
|
|
324
|
-
// Default to 5 minutes if invalid
|
|
325
|
-
return 5 * 60 * 1000;
|
|
326
|
-
}
|
|
327
|
-
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
328
|
-
// Simple heuristic for common patterns
|
|
329
|
-
if (minute === '*')
|
|
330
|
-
return 60 * 1000; // Every minute
|
|
331
|
-
if (hour === '*')
|
|
332
|
-
return 60 * 60 * 1000; // Every hour
|
|
333
|
-
if (dayOfMonth === '*' && dayOfWeek === '*') {
|
|
334
|
-
const minVal = Number.parseInt(minute || '0', 10) || 0;
|
|
335
|
-
return minVal * 60 * 1000;
|
|
290
|
+
finally {
|
|
291
|
+
const n = (this.runningCount.get(trigger.id) ?? 1) - 1;
|
|
292
|
+
if (n <= 0) {
|
|
293
|
+
this.runningCount.delete(trigger.id);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
this.runningCount.set(trigger.id, n);
|
|
297
|
+
}
|
|
336
298
|
}
|
|
337
|
-
// Default to 5 minutes
|
|
338
|
-
return 5 * 60 * 1000;
|
|
339
299
|
}
|
|
340
300
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* with dedupe and overlap policies.
|
|
6
6
|
*/
|
|
7
7
|
/** Trigger types */
|
|
8
|
-
export type TriggerType = 'cron' | 'interval' | 'webhook'
|
|
8
|
+
export type TriggerType = 'cron' | 'interval' | 'webhook';
|
|
9
9
|
/** Trigger deduplication strategies */
|
|
10
10
|
export type DedupePolicy = 'skip' | 'overwrite' | 'queue';
|
|
11
11
|
/** Trigger overlap strategies */
|
|
@@ -42,24 +42,7 @@ export interface WebhookTrigger extends TriggerBase {
|
|
|
42
42
|
secret?: string;
|
|
43
43
|
transformPayload?: (payload: unknown) => unknown;
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
export interface PollTrigger extends TriggerBase {
|
|
47
|
-
type: 'poll';
|
|
48
|
-
url: string;
|
|
49
|
-
intervalMs: number;
|
|
50
|
-
headers?: Record<string, string>;
|
|
51
|
-
detectNew?: (previous: unknown, current: unknown) => boolean;
|
|
52
|
-
extractInput?: (data: unknown) => unknown | null;
|
|
53
|
-
}
|
|
54
|
-
/** Queue trigger - message queue based */
|
|
55
|
-
export interface QueueTrigger extends TriggerBase {
|
|
56
|
-
type: 'queue';
|
|
57
|
-
queueName: string;
|
|
58
|
-
batchSize?: number;
|
|
59
|
-
visibilityTimeoutMs?: number;
|
|
60
|
-
extractInput?: (message: unknown) => unknown | null;
|
|
61
|
-
}
|
|
62
|
-
export type Trigger = CronTrigger | IntervalTrigger | WebhookTrigger | PollTrigger | QueueTrigger;
|
|
45
|
+
export type Trigger = CronTrigger | IntervalTrigger | WebhookTrigger;
|
|
63
46
|
/** Trigger registration */
|
|
64
47
|
export interface TriggerRegistration {
|
|
65
48
|
trigger: Trigger;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skelm/scheduler",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Scott Glover <scottgl@gmail.com>",
|
|
6
6
|
"homepage": "https://skelm.dev/",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"test": "vitest run"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@skelm/core": "^0.4.
|
|
47
|
+
"@skelm/core": "^0.4.3",
|
|
48
|
+
"cron-parser": "^4.9.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@types/node": "^22.15.0",
|