@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.
@@ -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,6 @@
1
+
2
+ export * from './Scheduler';
3
+ export * from './Schedule';
4
+ export * from './Event';
5
+ export * from './Worker';
6
+ export * from './Contracts/Task';
@@ -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
+ }