@skelm/scheduler 0.4.2 → 0.4.4
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 +33 -19
- package/dist/scheduler.js +95 -156
- 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,17 +22,18 @@ export declare class Scheduler {
|
|
|
10
22
|
private readonly cronJobs;
|
|
11
23
|
private readonly intervalJobs;
|
|
12
24
|
private webhookServer;
|
|
13
|
-
private pollJobs;
|
|
14
|
-
private queueJobs;
|
|
15
25
|
private readonly inFlight;
|
|
26
|
+
private readonly runningCount;
|
|
27
|
+
private readonly lastRun;
|
|
16
28
|
private isRunning;
|
|
17
29
|
private readonly runStore;
|
|
18
30
|
private readonly pipelineLoader;
|
|
31
|
+
private readonly pipelineExecutor;
|
|
32
|
+
private readonly noExecutorWarned;
|
|
19
33
|
constructor(config: SchedulerConfig, deps: {
|
|
20
|
-
runStore:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
pipelineLoader: (pipelineId: string) => Promise<unknown>;
|
|
34
|
+
runStore: SchedulerRunStore;
|
|
35
|
+
pipelineLoader: SchedulerPipelineLoader;
|
|
36
|
+
pipelineExecutor?: SchedulerPipelineExecutor;
|
|
24
37
|
});
|
|
25
38
|
/** Register a new trigger */
|
|
26
39
|
register(trigger: Trigger): Promise<TriggerRegistration>;
|
|
@@ -41,9 +54,9 @@ export declare class Scheduler {
|
|
|
41
54
|
/** Start all triggers */
|
|
42
55
|
start(): Promise<void>;
|
|
43
56
|
/**
|
|
44
|
-
* Stop all triggers. Clears the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
|
47
60
|
*
|
|
48
61
|
* Runs unconditionally — `register()` arms timers immediately without
|
|
49
62
|
* setting isRunning, so a stop() that gated on isRunning would leak
|
|
@@ -54,15 +67,16 @@ export declare class Scheduler {
|
|
|
54
67
|
private track;
|
|
55
68
|
private drainInFlight;
|
|
56
69
|
private startCronTrigger;
|
|
70
|
+
private scheduleNextCron;
|
|
57
71
|
private stopCronTrigger;
|
|
58
72
|
private startIntervalTrigger;
|
|
59
73
|
private stopIntervalTrigger;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
64
81
|
private executeTrigger;
|
|
65
|
-
private executePollTrigger;
|
|
66
|
-
private executeQueueTrigger;
|
|
67
|
-
private parseCronToInterval;
|
|
68
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,12 +9,14 @@ export class Scheduler {
|
|
|
9
9
|
cronJobs = new Map();
|
|
10
10
|
intervalJobs = new Map();
|
|
11
11
|
webhookServer = null;
|
|
12
|
-
pollJobs = new Map();
|
|
13
|
-
queueJobs = new Map();
|
|
14
12
|
inFlight = new Set();
|
|
13
|
+
runningCount = new Map();
|
|
14
|
+
lastRun = new Map();
|
|
15
15
|
isRunning = false;
|
|
16
16
|
runStore;
|
|
17
17
|
pipelineLoader;
|
|
18
|
+
pipelineExecutor;
|
|
19
|
+
noExecutorWarned = new Set();
|
|
18
20
|
constructor(config, deps) {
|
|
19
21
|
const cfg = {
|
|
20
22
|
webhookPort: config.webhookPort ?? 3001,
|
|
@@ -28,6 +30,7 @@ export class Scheduler {
|
|
|
28
30
|
this.config = cfg;
|
|
29
31
|
this.runStore = deps.runStore;
|
|
30
32
|
this.pipelineLoader = deps.pipelineLoader;
|
|
33
|
+
this.pipelineExecutor = deps.pipelineExecutor;
|
|
31
34
|
}
|
|
32
35
|
/** Register a new trigger */
|
|
33
36
|
async register(trigger) {
|
|
@@ -40,7 +43,6 @@ export class Scheduler {
|
|
|
40
43
|
status: 'active',
|
|
41
44
|
};
|
|
42
45
|
this.triggers.set(trigger.id, registration);
|
|
43
|
-
// Start the trigger based on type
|
|
44
46
|
switch (trigger.type) {
|
|
45
47
|
case 'cron':
|
|
46
48
|
this.startCronTrigger(trigger);
|
|
@@ -51,12 +53,6 @@ export class Scheduler {
|
|
|
51
53
|
case 'webhook':
|
|
52
54
|
// Webhook server starts separately via startWebhookServer()
|
|
53
55
|
break;
|
|
54
|
-
case 'poll':
|
|
55
|
-
this.startPollTrigger(trigger);
|
|
56
|
-
break;
|
|
57
|
-
case 'queue':
|
|
58
|
-
this.startQueueTrigger(trigger);
|
|
59
|
-
break;
|
|
60
56
|
}
|
|
61
57
|
return registration;
|
|
62
58
|
}
|
|
@@ -65,11 +61,10 @@ export class Scheduler {
|
|
|
65
61
|
const registration = this.triggers.get(triggerId);
|
|
66
62
|
if (!registration)
|
|
67
63
|
return;
|
|
68
|
-
// Stop any running jobs
|
|
69
64
|
this.stopCronTrigger(triggerId);
|
|
70
65
|
this.stopIntervalTrigger(triggerId);
|
|
71
|
-
this.
|
|
72
|
-
this.
|
|
66
|
+
this.lastRun.delete(triggerId);
|
|
67
|
+
this.runningCount.delete(triggerId);
|
|
73
68
|
this.triggers.delete(triggerId);
|
|
74
69
|
}
|
|
75
70
|
/** Pause a trigger */
|
|
@@ -79,11 +74,8 @@ export class Scheduler {
|
|
|
79
74
|
return;
|
|
80
75
|
registration.status = 'paused';
|
|
81
76
|
registration.updatedAt = Date.now();
|
|
82
|
-
// Stop jobs for time-based triggers
|
|
83
77
|
this.stopCronTrigger(triggerId);
|
|
84
78
|
this.stopIntervalTrigger(triggerId);
|
|
85
|
-
this.stopPollTrigger(triggerId);
|
|
86
|
-
this.stopQueueTrigger(triggerId);
|
|
87
79
|
}
|
|
88
80
|
/** Resume a paused trigger */
|
|
89
81
|
async resume(triggerId) {
|
|
@@ -95,7 +87,6 @@ export class Scheduler {
|
|
|
95
87
|
registration.status = 'active';
|
|
96
88
|
registration.updatedAt = Date.now();
|
|
97
89
|
const trigger = registration.trigger;
|
|
98
|
-
// Restart jobs based on type
|
|
99
90
|
switch (trigger.type) {
|
|
100
91
|
case 'cron':
|
|
101
92
|
this.startCronTrigger(trigger);
|
|
@@ -103,12 +94,6 @@ export class Scheduler {
|
|
|
103
94
|
case 'interval':
|
|
104
95
|
this.startIntervalTrigger(trigger);
|
|
105
96
|
break;
|
|
106
|
-
case 'poll':
|
|
107
|
-
this.startPollTrigger(trigger);
|
|
108
|
-
break;
|
|
109
|
-
case 'queue':
|
|
110
|
-
this.startQueueTrigger(trigger);
|
|
111
|
-
break;
|
|
112
97
|
}
|
|
113
98
|
}
|
|
114
99
|
/** List all registered triggers */
|
|
@@ -121,13 +106,10 @@ export class Scheduler {
|
|
|
121
106
|
}
|
|
122
107
|
/** Start the webhook server */
|
|
123
108
|
async startWebhookServer() {
|
|
124
|
-
// Webhook server implementation would go here
|
|
125
|
-
// Uses h3 or similar for HTTP handling
|
|
126
109
|
console.log(`Webhook server would start on ${this.config.webhookHost}:${this.config.webhookPort}`);
|
|
127
110
|
}
|
|
128
111
|
/** Stop the webhook server */
|
|
129
112
|
async stopWebhookServer() {
|
|
130
|
-
// Stop webhook server
|
|
131
113
|
this.webhookServer = null;
|
|
132
114
|
}
|
|
133
115
|
/** Start all triggers */
|
|
@@ -145,20 +127,14 @@ export class Scheduler {
|
|
|
145
127
|
case 'interval':
|
|
146
128
|
this.startIntervalTrigger(trigger);
|
|
147
129
|
break;
|
|
148
|
-
case 'poll':
|
|
149
|
-
this.startPollTrigger(trigger);
|
|
150
|
-
break;
|
|
151
|
-
case 'queue':
|
|
152
|
-
this.startQueueTrigger(trigger);
|
|
153
|
-
break;
|
|
154
130
|
}
|
|
155
131
|
}
|
|
156
132
|
}
|
|
157
133
|
}
|
|
158
134
|
/**
|
|
159
|
-
* Stop all triggers. Clears the
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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.
|
|
162
138
|
*
|
|
163
139
|
* Runs unconditionally — `register()` arms timers immediately without
|
|
164
140
|
* setting isRunning, so a stop() that gated on isRunning would leak
|
|
@@ -166,23 +142,14 @@ export class Scheduler {
|
|
|
166
142
|
*/
|
|
167
143
|
async stop() {
|
|
168
144
|
this.isRunning = false;
|
|
169
|
-
// Clear all jobs
|
|
170
145
|
for (const [id, job] of this.cronJobs) {
|
|
171
|
-
|
|
146
|
+
clearTimeout(job);
|
|
172
147
|
this.cronJobs.delete(id);
|
|
173
148
|
}
|
|
174
149
|
for (const [id, job] of this.intervalJobs) {
|
|
175
150
|
clearInterval(job);
|
|
176
151
|
this.intervalJobs.delete(id);
|
|
177
152
|
}
|
|
178
|
-
for (const [id, job] of this.pollJobs) {
|
|
179
|
-
clearInterval(job);
|
|
180
|
-
this.pollJobs.delete(id);
|
|
181
|
-
}
|
|
182
|
-
for (const [id, job] of this.queueJobs) {
|
|
183
|
-
clearInterval(job);
|
|
184
|
-
this.queueJobs.delete(id);
|
|
185
|
-
}
|
|
186
153
|
await this.drainInFlight(30_000);
|
|
187
154
|
}
|
|
188
155
|
/** Track a fire-and-forget execution so stop() can drain it. */
|
|
@@ -199,29 +166,51 @@ export class Scheduler {
|
|
|
199
166
|
]);
|
|
200
167
|
}
|
|
201
168
|
startCronTrigger(trigger) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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}`;
|
|
208
185
|
}
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
211
199
|
}
|
|
212
200
|
stopCronTrigger(triggerId) {
|
|
213
201
|
const job = this.cronJobs.get(triggerId);
|
|
214
202
|
if (job) {
|
|
215
|
-
|
|
203
|
+
clearTimeout(job);
|
|
216
204
|
this.cronJobs.delete(triggerId);
|
|
217
205
|
}
|
|
218
206
|
}
|
|
219
207
|
startIntervalTrigger(trigger) {
|
|
220
208
|
const job = setInterval(() => {
|
|
221
209
|
if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
|
|
222
|
-
this.
|
|
210
|
+
this.fire(trigger);
|
|
223
211
|
}
|
|
224
212
|
}, trigger.intervalMs);
|
|
213
|
+
job.unref?.();
|
|
225
214
|
this.intervalJobs.set(trigger.id, job);
|
|
226
215
|
}
|
|
227
216
|
stopIntervalTrigger(triggerId) {
|
|
@@ -231,42 +220,29 @@ export class Scheduler {
|
|
|
231
220
|
this.intervalJobs.delete(triggerId);
|
|
232
221
|
}
|
|
233
222
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const job = setInterval(() => {
|
|
251
|
-
if (trigger.enabled && this.triggers.get(trigger.id)?.status === 'active') {
|
|
252
|
-
this.track(this.executeQueueTrigger(trigger));
|
|
253
|
-
}
|
|
254
|
-
}, this.config.queuePollIntervalMs);
|
|
255
|
-
this.queueJobs.set(trigger.id, job);
|
|
256
|
-
}
|
|
257
|
-
stopQueueTrigger(triggerId) {
|
|
258
|
-
const job = this.queueJobs.get(triggerId);
|
|
259
|
-
if (job) {
|
|
260
|
-
clearInterval(job);
|
|
261
|
-
this.queueJobs.delete(triggerId);
|
|
262
|
-
}
|
|
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);
|
|
263
239
|
}
|
|
264
240
|
async executeTrigger(trigger) {
|
|
265
241
|
const registration = this.triggers.get(trigger.id);
|
|
266
242
|
if (!registration)
|
|
267
243
|
return;
|
|
244
|
+
this.runningCount.set(trigger.id, (this.runningCount.get(trigger.id) ?? 0) + 1);
|
|
268
245
|
try {
|
|
269
|
-
// Check deduplication
|
|
270
246
|
const ctx = {
|
|
271
247
|
triggerId: trigger.id,
|
|
272
248
|
runId: `trigger-${trigger.id}-${Date.now()}`,
|
|
@@ -275,47 +251,35 @@ export class Scheduler {
|
|
|
275
251
|
deduped: false,
|
|
276
252
|
overlapHandled: false,
|
|
277
253
|
};
|
|
278
|
-
// Handle overlap policy - check if already running
|
|
279
|
-
if (registration.status === 'active') {
|
|
280
|
-
// Check for concurrent run tracking (simplified)
|
|
281
|
-
const hasActiveRun = false; // Would track active runs in production
|
|
282
|
-
if (hasActiveRun) {
|
|
283
|
-
ctx.overlapHandled = true;
|
|
284
|
-
switch (trigger.overlap ?? 'wait') {
|
|
285
|
-
case 'fail-fast':
|
|
286
|
-
console.log(`Trigger ${trigger.id} skipped: overlap policy is fail-fast`);
|
|
287
|
-
return;
|
|
288
|
-
case 'run-concurrent':
|
|
289
|
-
// Allow concurrent runs
|
|
290
|
-
break;
|
|
291
|
-
default:
|
|
292
|
-
console.log(`Trigger ${trigger.id} waiting for previous run to complete`);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
// Execute the pipeline
|
|
298
254
|
const pipeline = await this.pipelineLoader(trigger.pipelineId);
|
|
299
255
|
if (!pipeline) {
|
|
300
256
|
throw new Error(`Pipeline ${trigger.pipelineId} not found`);
|
|
301
257
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
319
283
|
}
|
|
320
284
|
catch (err) {
|
|
321
285
|
registration.errorCount++;
|
|
@@ -323,39 +287,14 @@ export class Scheduler {
|
|
|
323
287
|
registration.lastError = err.message;
|
|
324
288
|
console.error(`Trigger ${trigger.id} error:`, err);
|
|
325
289
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
console.log(`Checking queue ${trigger.queueName} for trigger ${trigger.id}`);
|
|
335
|
-
await this.executeTrigger(trigger);
|
|
336
|
-
}
|
|
337
|
-
parseCronToInterval(cron) {
|
|
338
|
-
// Simplified cron-to-interval conversion
|
|
339
|
-
// In production, use a proper cron library
|
|
340
|
-
if (!cron || typeof cron !== 'string') {
|
|
341
|
-
return 5 * 60 * 1000; // Default to 5 minutes
|
|
342
|
-
}
|
|
343
|
-
const parts = cron.split(' ');
|
|
344
|
-
if (parts.length !== 5) {
|
|
345
|
-
// Default to 5 minutes if invalid
|
|
346
|
-
return 5 * 60 * 1000;
|
|
347
|
-
}
|
|
348
|
-
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
349
|
-
// Simple heuristic for common patterns
|
|
350
|
-
if (minute === '*')
|
|
351
|
-
return 60 * 1000; // Every minute
|
|
352
|
-
if (hour === '*')
|
|
353
|
-
return 60 * 60 * 1000; // Every hour
|
|
354
|
-
if (dayOfMonth === '*' && dayOfWeek === '*') {
|
|
355
|
-
const minVal = Number.parseInt(minute || '0', 10) || 0;
|
|
356
|
-
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
|
+
}
|
|
357
298
|
}
|
|
358
|
-
// Default to 5 minutes
|
|
359
|
-
return 5 * 60 * 1000;
|
|
360
299
|
}
|
|
361
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.4",
|
|
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.4",
|
|
48
|
+
"cron-parser": "^4.9.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@types/node": "^22.15.0",
|