@arikajs/scheduler 0.0.5
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/Contracts/Task.d.ts +14 -0
- package/dist/Contracts/Task.js +3 -0
- package/dist/Contracts/Task.js.map +1 -0
- package/dist/Event.d.ts +44 -0
- package/dist/Event.js +140 -0
- package/dist/Event.js.map +1 -0
- package/dist/Schedule.d.ts +24 -0
- package/dist/Schedule.js +48 -0
- package/dist/Schedule.js.map +1 -0
- package/dist/Scheduler.d.ts +27 -0
- package/dist/Scheduler.js +170 -0
- package/dist/Scheduler.js.map +1 -0
- package/dist/Worker.d.ts +11 -0
- package/dist/Worker.js +41 -0
- package/dist/Worker.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/Contracts/Task.ts +17 -0
- package/src/Event.ts +151 -0
- package/src/Schedule.ts +50 -0
- package/src/Scheduler.ts +189 -0
- package/src/Worker.ts +44 -0
- package/src/index.ts +6 -0
- package/tests/Scheduler.test.ts +105 -0
- package/tsconfig.json +26 -0
package/src/Scheduler.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
|
|
2
|
+
import { Container } from '@arikajs/foundation';
|
|
3
|
+
import { Log } from '@arikajs/logging';
|
|
4
|
+
import { Schedule } from './Schedule';
|
|
5
|
+
import { Event } from './Event';
|
|
6
|
+
|
|
7
|
+
export class Scheduler {
|
|
8
|
+
protected schedule: Schedule;
|
|
9
|
+
|
|
10
|
+
constructor(protected container: Container) {
|
|
11
|
+
this.schedule = new Schedule();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Define the schedule.
|
|
16
|
+
*/
|
|
17
|
+
public define(callback: (schedule: Schedule) => void): this {
|
|
18
|
+
callback(this.schedule);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run the scheduled tasks.
|
|
24
|
+
*/
|
|
25
|
+
public async run(date: Date = new Date()): Promise<void> {
|
|
26
|
+
const config = this.container.make('config') as any;
|
|
27
|
+
const timezone = config.get('app.timezone', 'UTC');
|
|
28
|
+
const dueEvents = this.schedule.dueEvents(date, timezone);
|
|
29
|
+
|
|
30
|
+
if (dueEvents.length === 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Leader Election (Prevent multiple instances from running the same schedule)
|
|
35
|
+
if (await this.shouldSkipBecauseAnotherInstanceIsRunning()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Log.info(`Running ${dueEvents.length} scheduled tasks in parallel...`);
|
|
40
|
+
|
|
41
|
+
// Run events in parallel
|
|
42
|
+
await Promise.allSettled(dueEvents.map(event => this.runEvent(event)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
protected async shouldSkipBecauseAnotherInstanceIsRunning(): Promise<boolean> {
|
|
46
|
+
if (!this.container.has('cache')) return false;
|
|
47
|
+
const cache = this.container.make('cache') as any;
|
|
48
|
+
|
|
49
|
+
const lockKey = 'framework/scheduler-leader-lock';
|
|
50
|
+
const isLeader = await cache.add(lockKey, true, 55); // Lock for 55 seconds
|
|
51
|
+
|
|
52
|
+
if (!isLeader) {
|
|
53
|
+
Log.debug('Another instance is already running the scheduler. Skipping...');
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected async runEvent(event: Event): Promise<void> {
|
|
61
|
+
const name = this.getEventName(event);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await this.dispatchLifecycleEvent('TaskStarting', event);
|
|
65
|
+
|
|
66
|
+
// Check for overlapping
|
|
67
|
+
if (event.shouldSkipOverlapping()) {
|
|
68
|
+
const locked = await this.isLocked(event);
|
|
69
|
+
if (locked) {
|
|
70
|
+
Log.debug(`Skipping task [${name}] as it is still running.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await this.lock(event);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Log.info(`Running scheduled task: [${name}]`);
|
|
77
|
+
|
|
78
|
+
// Handle execution with Retries and Timeout
|
|
79
|
+
await this.executeWithRetries(event);
|
|
80
|
+
|
|
81
|
+
Log.info(`Task [${name}] completed successfully.`);
|
|
82
|
+
await this.dispatchLifecycleEvent('TaskFinished', event);
|
|
83
|
+
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
Log.error(`Task [${name}] failed: ${e.message}`);
|
|
86
|
+
await this.dispatchLifecycleEvent('TaskFailed', event, { error: e });
|
|
87
|
+
} finally {
|
|
88
|
+
if (event.shouldSkipOverlapping()) {
|
|
89
|
+
await this.unlock(event);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
protected async executeWithRetries(event: Event): Promise<void> {
|
|
95
|
+
const maxRetries = event.getRetries();
|
|
96
|
+
let attempt = 0;
|
|
97
|
+
|
|
98
|
+
while (attempt <= maxRetries) {
|
|
99
|
+
try {
|
|
100
|
+
await this.executeWithTimeout(event);
|
|
101
|
+
return;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
attempt++;
|
|
104
|
+
if (attempt > maxRetries) throw e;
|
|
105
|
+
|
|
106
|
+
const delay = event.getRetryDelay();
|
|
107
|
+
if (delay > 0) {
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, delay * 1000));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Log.warning(`Retrying task [${this.getEventName(event)}] (Attempt ${attempt}/${maxRetries})...`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
protected async executeWithTimeout(event: Event): Promise<void> {
|
|
117
|
+
const timeoutSeconds = event.getTimeout();
|
|
118
|
+
|
|
119
|
+
if (timeoutSeconds <= 0) {
|
|
120
|
+
return this.actuallyRun(event);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return new Promise(async (resolve, reject) => {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
reject(new Error(`Task timed out after ${timeoutSeconds} seconds.`));
|
|
126
|
+
}, timeoutSeconds * 1000);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await this.actuallyRun(event);
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
resolve();
|
|
132
|
+
} catch (e) {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
reject(e);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
protected async actuallyRun(event: Event): Promise<void> {
|
|
140
|
+
if (typeof event.command === 'string') {
|
|
141
|
+
const { CommandRegistry } = await import('@arikajs/console');
|
|
142
|
+
const registry = this.container.make(CommandRegistry) as any;
|
|
143
|
+
await registry.run([event.command]);
|
|
144
|
+
} else {
|
|
145
|
+
await event.run();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
protected async dispatchLifecycleEvent(type: string, event: Event, extra: any = {}): Promise<void> {
|
|
150
|
+
// Only if @arikajs/events is available
|
|
151
|
+
try {
|
|
152
|
+
const { Event: Emitter } = await import('@arikajs/events');
|
|
153
|
+
await Emitter.dispatch({
|
|
154
|
+
type: `scheduler.${type}`,
|
|
155
|
+
task: this.getEventName(event),
|
|
156
|
+
expression: event.expression(),
|
|
157
|
+
...extra
|
|
158
|
+
});
|
|
159
|
+
} catch (e) {
|
|
160
|
+
// Events package might not be installed, ignore silently
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected getEventName(event: Event): string {
|
|
165
|
+
return event.getDescription() || (typeof event.command === 'string' ? event.command : 'closure');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
protected async isLocked(event: Event): Promise<boolean> {
|
|
169
|
+
if (!this.container.has('cache')) return false;
|
|
170
|
+
const cache = this.container.make('cache') as any;
|
|
171
|
+
return await cache.has(this.getMutexName(event));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
protected async lock(event: Event): Promise<void> {
|
|
175
|
+
if (!this.container.has('cache')) return;
|
|
176
|
+
const cache = this.container.make('cache') as any;
|
|
177
|
+
await cache.put(this.getMutexName(event), true, event.mutexExpiration());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
protected async unlock(event: Event): Promise<void> {
|
|
181
|
+
if (!this.container.has('cache')) return;
|
|
182
|
+
const cache = this.container.make('cache') as any;
|
|
183
|
+
await cache.forget(this.getMutexName(event));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
protected getMutexName(event: Event): string {
|
|
187
|
+
return `framework/schedule-${Buffer.from(this.getEventName(event) + event.expression()).toString('base64')}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/Worker.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
|
|
2
|
+
import { Scheduler } from './Scheduler';
|
|
3
|
+
import { Log } from '@arikajs/logging';
|
|
4
|
+
|
|
5
|
+
export class Worker {
|
|
6
|
+
protected stopped: boolean = false;
|
|
7
|
+
protected running: boolean = false;
|
|
8
|
+
|
|
9
|
+
constructor(protected scheduler: Scheduler) { }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start the scheduler worker.
|
|
13
|
+
*/
|
|
14
|
+
public async start() {
|
|
15
|
+
Log.info('Scheduler worker started. Press Ctrl+C to stop.');
|
|
16
|
+
|
|
17
|
+
// Graceful shutdown
|
|
18
|
+
const shutdown = () => {
|
|
19
|
+
this.stopped = true;
|
|
20
|
+
Log.info('Stopping scheduler worker gracefully...');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
process.on('SIGINT', shutdown);
|
|
24
|
+
process.on('SIGTERM', shutdown);
|
|
25
|
+
|
|
26
|
+
while (!this.stopped) {
|
|
27
|
+
const now = new Date();
|
|
28
|
+
// Round down to the minute
|
|
29
|
+
now.setSeconds(0, 0);
|
|
30
|
+
|
|
31
|
+
this.running = true;
|
|
32
|
+
await this.scheduler.run(now);
|
|
33
|
+
this.running = false;
|
|
34
|
+
|
|
35
|
+
if (this.stopped) break;
|
|
36
|
+
|
|
37
|
+
// Wait until the next minute
|
|
38
|
+
const waitTime = 60000 - (Date.now() % 60000);
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Log.info('Scheduler worker stopped.');
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert';
|
|
4
|
+
import { Schedule } from '../src/Schedule';
|
|
5
|
+
import { Scheduler } from '../src/Scheduler';
|
|
6
|
+
import { Container } from '@arikajs/foundation';
|
|
7
|
+
import { Log, LogManager } from '@arikajs/logging';
|
|
8
|
+
|
|
9
|
+
// Setup basic logging for tests
|
|
10
|
+
Log.setManager(new LogManager({
|
|
11
|
+
default: 'console',
|
|
12
|
+
channels: {
|
|
13
|
+
console: { driver: 'console' }
|
|
14
|
+
}
|
|
15
|
+
} as any));
|
|
16
|
+
|
|
17
|
+
describe('Scheduler', () => {
|
|
18
|
+
it('can schedule a closure', () => {
|
|
19
|
+
const schedule = new Schedule();
|
|
20
|
+
let executed = false;
|
|
21
|
+
|
|
22
|
+
schedule.call(() => {
|
|
23
|
+
executed = true;
|
|
24
|
+
}).everyMinute();
|
|
25
|
+
|
|
26
|
+
const events = schedule.allEvents();
|
|
27
|
+
assert.strictEqual(events.length, 1);
|
|
28
|
+
assert.strictEqual(events[0].expression(), '* * * * *');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('identifies due events correctly', () => {
|
|
32
|
+
const schedule = new Schedule();
|
|
33
|
+
schedule.call(() => { }).cron('0 0 * * *'); // Daily at midnight
|
|
34
|
+
|
|
35
|
+
const midnight = new Date();
|
|
36
|
+
midnight.setHours(0, 0, 0, 0);
|
|
37
|
+
|
|
38
|
+
const noon = new Date();
|
|
39
|
+
noon.setHours(12, 0, 0, 0);
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(schedule.dueEvents(midnight).length, 1);
|
|
42
|
+
assert.strictEqual(schedule.dueEvents(noon).length, 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('can run due events', async () => {
|
|
46
|
+
const container = new Container();
|
|
47
|
+
container.instance('config', { get: () => 'UTC' });
|
|
48
|
+
|
|
49
|
+
const scheduler = new Scheduler(container);
|
|
50
|
+
let count = 0;
|
|
51
|
+
|
|
52
|
+
scheduler.define((schedule) => {
|
|
53
|
+
schedule.call(() => {
|
|
54
|
+
count++;
|
|
55
|
+
}).everyMinute();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const now = new Date();
|
|
59
|
+
now.setSeconds(0, 0);
|
|
60
|
+
|
|
61
|
+
await scheduler.run(now);
|
|
62
|
+
assert.strictEqual(count, 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('can retry failed tasks', async () => {
|
|
66
|
+
const container = new Container();
|
|
67
|
+
container.instance('config', { get: () => 'UTC' });
|
|
68
|
+
|
|
69
|
+
const scheduler = new Scheduler(container);
|
|
70
|
+
let attempts = 0;
|
|
71
|
+
|
|
72
|
+
scheduler.define((schedule) => {
|
|
73
|
+
schedule.call(() => {
|
|
74
|
+
attempts++;
|
|
75
|
+
throw new Error('Fail');
|
|
76
|
+
}).everyMinute().retry(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const now = new Date();
|
|
80
|
+
now.setSeconds(0, 0);
|
|
81
|
+
|
|
82
|
+
await scheduler.run(now);
|
|
83
|
+
// Initial run + 2 retries = 3 attempts
|
|
84
|
+
assert.strictEqual(attempts, 3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('handles task timeouts', async () => {
|
|
88
|
+
const container = new Container();
|
|
89
|
+
container.instance('config', { get: () => 'UTC' });
|
|
90
|
+
|
|
91
|
+
const scheduler = new Scheduler(container);
|
|
92
|
+
|
|
93
|
+
scheduler.define((schedule) => {
|
|
94
|
+
schedule.call(async () => {
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
96
|
+
}).everyMinute().timeout(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const now = new Date();
|
|
100
|
+
now.setSeconds(0, 0);
|
|
101
|
+
|
|
102
|
+
// This should log an error but not throw globally
|
|
103
|
+
await scheduler.run(now);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2022"
|
|
8
|
+
],
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"declaration": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"dist",
|
|
24
|
+
"tests"
|
|
25
|
+
]
|
|
26
|
+
}
|