bunsane 0.1.0 → 0.1.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/.github/workflows/deploy-docs.yml +57 -0
- package/LICENSE.md +1 -1
- package/README.md +2 -28
- package/TODO.md +8 -1
- package/bun.lock +3 -0
- package/config/upload.config.ts +135 -0
- package/core/App.ts +119 -4
- package/core/ArcheType.ts +122 -0
- package/core/BatchLoader.ts +100 -0
- package/core/ComponentRegistry.ts +4 -3
- package/core/Components.ts +2 -2
- package/core/Decorators.ts +15 -8
- package/core/Entity.ts +159 -12
- package/core/EntityCache.ts +15 -0
- package/core/EntityHookManager.ts +855 -0
- package/core/EntityManager.ts +12 -2
- package/core/ErrorHandler.ts +64 -7
- package/core/FileValidator.ts +284 -0
- package/core/Query.ts +453 -85
- package/core/RequestContext.ts +24 -0
- package/core/RequestLoaders.ts +65 -0
- package/core/SchedulerManager.ts +710 -0
- package/core/UploadManager.ts +261 -0
- package/core/components/UploadComponent.ts +206 -0
- package/core/decorators/EntityHooks.ts +190 -0
- package/core/decorators/ScheduledTask.ts +83 -0
- package/core/events/EntityLifecycleEvents.ts +177 -0
- package/core/processors/ImageProcessor.ts +423 -0
- package/core/storage/LocalStorageProvider.ts +290 -0
- package/core/storage/StorageProvider.ts +112 -0
- package/database/DatabaseHelper.ts +183 -58
- package/database/index.ts +1 -1
- package/database/sqlHelpers.ts +7 -0
- package/docs/README.md +149 -0
- package/docs/_coverpage.md +36 -0
- package/docs/_sidebar.md +23 -0
- package/docs/api/core.md +568 -0
- package/docs/api/hooks.md +554 -0
- package/docs/api/index.md +222 -0
- package/docs/api/query.md +678 -0
- package/docs/api/service.md +744 -0
- package/docs/core-concepts/archetypes.md +512 -0
- package/docs/core-concepts/components.md +498 -0
- package/docs/core-concepts/entity.md +314 -0
- package/docs/core-concepts/hooks.md +683 -0
- package/docs/core-concepts/query.md +588 -0
- package/docs/core-concepts/services.md +647 -0
- package/docs/examples/code-examples.md +425 -0
- package/docs/getting-started.md +337 -0
- package/docs/index.html +97 -0
- package/examples/hooks/README.md +228 -0
- package/examples/hooks/audit-logger.ts +495 -0
- package/gql/Generator.ts +56 -34
- package/gql/decorators/Upload.ts +176 -0
- package/gql/helpers.ts +67 -0
- package/gql/index.ts +55 -31
- package/gql/types.ts +1 -1
- package/index.ts +79 -11
- package/package.json +5 -4
- package/rest/Generator.ts +3 -0
- package/rest/index.ts +22 -0
- package/service/Service.ts +1 -1
- package/service/ServiceRegistry.ts +10 -6
- package/service/index.ts +12 -1
- package/tests/bench/insert.bench.ts +59 -0
- package/tests/bench/relations.bench.ts +269 -0
- package/tests/bench/sorting.bench.ts +415 -0
- package/tests/component-hooks.test.ts +1409 -0
- package/tests/component.test.ts +205 -0
- package/tests/errorHandling.test.ts +155 -0
- package/tests/hooks.test.ts +666 -0
- package/tests/query-sorting.test.ts +101 -0
- package/tests/relations.test.ts +169 -0
- package/tests/scheduler.test.ts +724 -0
- package/tsconfig.json +35 -34
- package/types/graphql.types.ts +87 -0
- package/types/hooks.types.ts +141 -0
- package/types/scheduler.types.ts +165 -0
- package/types/upload.types.ts +184 -0
- package/upload/index.ts +140 -0
- package/utils/UploadHelper.ts +305 -0
- package/utils/cronParser.ts +366 -0
- package/utils/errorMessages.ts +151 -0
- package/validate-docs.sh +90 -0
- package/core/Events.ts +0 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { logger } from "./Logger";
|
|
2
|
+
import ApplicationLifecycle, { ApplicationPhase } from "./ApplicationLifecycle";
|
|
3
|
+
import {
|
|
4
|
+
ScheduleInterval
|
|
5
|
+
} from "../types/scheduler.types";
|
|
6
|
+
import type {
|
|
7
|
+
ScheduledTaskInfo,
|
|
8
|
+
SchedulerMetrics,
|
|
9
|
+
TaskExecutionResult,
|
|
10
|
+
SchedulerEvent,
|
|
11
|
+
SchedulerEventCallback,
|
|
12
|
+
SchedulerConfig,
|
|
13
|
+
TaskMetrics
|
|
14
|
+
} from "../types/scheduler.types";
|
|
15
|
+
import Query from "./Query";
|
|
16
|
+
import { Entity } from "./Entity";
|
|
17
|
+
import { CronParser } from "../utils/cronParser";
|
|
18
|
+
import type { ComponentTargetConfig } from "./EntityHookManager";
|
|
19
|
+
import ArcheType from "./ArcheType";
|
|
20
|
+
import { BaseComponent } from "./Components";
|
|
21
|
+
|
|
22
|
+
const loggerInstance = logger.child({ scope: "SchedulerManager" });
|
|
23
|
+
|
|
24
|
+
export class SchedulerManager {
|
|
25
|
+
private static instance: SchedulerManager;
|
|
26
|
+
private tasks: Map<string, ScheduledTaskInfo> = new Map();
|
|
27
|
+
private intervals: Map<string, NodeJS.Timeout> = new Map();
|
|
28
|
+
private isRunning: boolean = false;
|
|
29
|
+
private eventListeners: SchedulerEventCallback[] = [];
|
|
30
|
+
private config: SchedulerConfig;
|
|
31
|
+
private metrics: SchedulerMetrics = {
|
|
32
|
+
totalTasks: 0,
|
|
33
|
+
runningTasks: 0,
|
|
34
|
+
completedExecutions: 0,
|
|
35
|
+
failedExecutions: 0,
|
|
36
|
+
averageExecutionTime: 0,
|
|
37
|
+
totalExecutionTime: 0,
|
|
38
|
+
timedOutTasks: 0,
|
|
39
|
+
retriedTasks: 0,
|
|
40
|
+
taskMetrics: {}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
private constructor() {
|
|
44
|
+
this.config = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
maxConcurrentTasks: 5,
|
|
47
|
+
defaultTimeout: 30000, // 30 seconds
|
|
48
|
+
enableLogging: true,
|
|
49
|
+
runOnStart: true
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
this.initializeLifecycleIntegration();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public static getInstance(): SchedulerManager {
|
|
56
|
+
if (!SchedulerManager.instance) {
|
|
57
|
+
SchedulerManager.instance = new SchedulerManager();
|
|
58
|
+
}
|
|
59
|
+
return SchedulerManager.instance;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private initializeLifecycleIntegration(): void {
|
|
63
|
+
ApplicationLifecycle.addPhaseListener((event) => {
|
|
64
|
+
const phase = event.detail;
|
|
65
|
+
if (phase === ApplicationPhase.APPLICATION_READY) {
|
|
66
|
+
logger.info("Scheduler initialized and ready");
|
|
67
|
+
if (this.config.runOnStart) {
|
|
68
|
+
this.start();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public registerTask(taskInfo: ScheduledTaskInfo): void {
|
|
75
|
+
if (this.tasks.has(taskInfo.id)) {
|
|
76
|
+
loggerInstance.warn(`Task ${taskInfo.id} is already registered. Skipping registration.`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate task info
|
|
81
|
+
if (!taskInfo.id || !taskInfo.name || (!taskInfo.componentTarget && !taskInfo.options?.componentTarget) || !taskInfo.interval) {
|
|
82
|
+
const error = new Error(`Invalid task info: missing required fields (id, name, componentTarget/componentTarget config, interval)`);
|
|
83
|
+
loggerInstance.error(`Failed to register task: ${error.message}`);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!taskInfo.service) {
|
|
88
|
+
const error = new Error(`Task ${taskInfo.id} has no service instance`);
|
|
89
|
+
loggerInstance.error(`Failed to register task: ${error.message}`);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof taskInfo.service[taskInfo.methodName] !== 'function') {
|
|
94
|
+
const error = new Error(`Method ${taskInfo.methodName} not found on service for task ${taskInfo.id}`);
|
|
95
|
+
loggerInstance.error(`Failed to register task: ${error.message}`);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Try to schedule the task - if scheduling fails, don't register it
|
|
100
|
+
try {
|
|
101
|
+
this.scheduleTask(taskInfo);
|
|
102
|
+
this.tasks.set(taskInfo.id, taskInfo);
|
|
103
|
+
this.metrics.totalTasks++;
|
|
104
|
+
|
|
105
|
+
if (this.config.enableLogging) {
|
|
106
|
+
loggerInstance.info(`Registered scheduled task: ${taskInfo.name} (${taskInfo.id})`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.emitEvent({
|
|
110
|
+
type: 'task.registered',
|
|
111
|
+
taskId: taskInfo.id,
|
|
112
|
+
timestamp: new Date(),
|
|
113
|
+
data: { taskName: taskInfo.name, interval: taskInfo.interval }
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
loggerInstance.error(`Failed to schedule task ${taskInfo.name}, not registering: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private scheduleTask(taskInfo: ScheduledTaskInfo): void {
|
|
122
|
+
try {
|
|
123
|
+
if (taskInfo.interval === ScheduleInterval.CRON) {
|
|
124
|
+
this.scheduleCronTask(taskInfo);
|
|
125
|
+
} else {
|
|
126
|
+
this.scheduleIntervalTask(taskInfo);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
loggerInstance.error(`Failed to schedule task ${taskInfo.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private scheduleIntervalTask(taskInfo: ScheduledTaskInfo): void {
|
|
135
|
+
const intervalMs = this.getIntervalMilliseconds(taskInfo.interval);
|
|
136
|
+
|
|
137
|
+
// For very long intervals (monthly), use a different approach
|
|
138
|
+
if (intervalMs > 24 * 60 * 60 * 1000) { // More than 24 hours
|
|
139
|
+
this.scheduleLongIntervalTask(taskInfo, intervalMs);
|
|
140
|
+
} else {
|
|
141
|
+
const intervalId = setInterval(async () => {
|
|
142
|
+
await this.executeTask(taskInfo.id);
|
|
143
|
+
}, intervalMs);
|
|
144
|
+
|
|
145
|
+
this.intervals.set(taskInfo.id, intervalId);
|
|
146
|
+
taskInfo.nextExecution = new Date(Date.now() + intervalMs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.config.enableLogging) {
|
|
150
|
+
loggerInstance.info(`Scheduled task ${taskInfo.name} to run every ${intervalMs}ms`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private scheduleLongIntervalTask(taskInfo: ScheduledTaskInfo, intervalMs: number): void {
|
|
155
|
+
// For very long intervals, use a shorter check interval to avoid timeout overflow
|
|
156
|
+
const checkInterval = Math.min(intervalMs, 24 * 60 * 60 * 1000); // Max 24 hours check interval
|
|
157
|
+
const nextExecution = new Date(Date.now() + intervalMs);
|
|
158
|
+
taskInfo.nextExecution = nextExecution;
|
|
159
|
+
|
|
160
|
+
const intervalId = setInterval(async () => {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
if (now >= nextExecution.getTime()) {
|
|
163
|
+
await this.executeTask(taskInfo.id);
|
|
164
|
+
// Reschedule for next execution
|
|
165
|
+
taskInfo.nextExecution = new Date(now + intervalMs);
|
|
166
|
+
}
|
|
167
|
+
}, checkInterval);
|
|
168
|
+
|
|
169
|
+
this.intervals.set(taskInfo.id, intervalId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private scheduleCronTask(taskInfo: ScheduledTaskInfo): void {
|
|
173
|
+
if (!taskInfo.cronExpression) {
|
|
174
|
+
throw new Error(`Cron expression is required for CRON interval tasks`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validate cron expression
|
|
178
|
+
const validation = CronParser.validate(taskInfo.cronExpression);
|
|
179
|
+
if (!validation.isValid) {
|
|
180
|
+
throw new Error(`Invalid cron expression: ${validation.error}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Calculate next execution time
|
|
184
|
+
const nextExecution = CronParser.getNextExecution(validation.fields!, new Date());
|
|
185
|
+
if (!nextExecution) {
|
|
186
|
+
throw new Error(`Unable to calculate next execution time for cron expression: ${taskInfo.cronExpression}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
taskInfo.nextExecution = nextExecution;
|
|
190
|
+
|
|
191
|
+
// Schedule the task to run at the calculated time
|
|
192
|
+
const timeoutId = setTimeout(async () => {
|
|
193
|
+
await this.executeTask(taskInfo.id);
|
|
194
|
+
// Reschedule for next execution
|
|
195
|
+
this.scheduleCronTask(taskInfo);
|
|
196
|
+
}, nextExecution.getTime() - Date.now());
|
|
197
|
+
|
|
198
|
+
this.intervals.set(taskInfo.id, timeoutId as any);
|
|
199
|
+
|
|
200
|
+
if (this.config.enableLogging) {
|
|
201
|
+
loggerInstance.info(`Scheduled cron task ${taskInfo.name} to run at ${nextExecution.toISOString()}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private getIntervalMilliseconds(interval: ScheduleInterval): number {
|
|
206
|
+
switch (interval) {
|
|
207
|
+
case ScheduleInterval.MINUTE:
|
|
208
|
+
return 60 * 1000; // 1 minute
|
|
209
|
+
case ScheduleInterval.HOUR:
|
|
210
|
+
return 60 * 60 * 1000; // 1 hour
|
|
211
|
+
case ScheduleInterval.DAILY:
|
|
212
|
+
return 24 * 60 * 60 * 1000; // 24 hours
|
|
213
|
+
case ScheduleInterval.WEEKLY:
|
|
214
|
+
return 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
215
|
+
case ScheduleInterval.MONTHLY:
|
|
216
|
+
return 30 * 24 * 60 * 60 * 1000; // 30 days (approximate)
|
|
217
|
+
default:
|
|
218
|
+
throw new Error(`Unsupported interval: ${interval}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async executeTask(taskId: string): Promise<void> {
|
|
223
|
+
const taskInfo = this.tasks.get(taskId);
|
|
224
|
+
if (!taskInfo || !taskInfo.enabled) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (this.metrics.runningTasks >= this.config.maxConcurrentTasks) {
|
|
229
|
+
if (this.config.enableLogging) {
|
|
230
|
+
loggerInstance.warn(`Maximum concurrent tasks reached. Skipping execution of ${taskInfo.name}`);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
taskInfo.isRunning = true;
|
|
236
|
+
taskInfo.lastExecution = new Date();
|
|
237
|
+
this.metrics.runningTasks++;
|
|
238
|
+
|
|
239
|
+
const startTime = Date.now();
|
|
240
|
+
const timeout = taskInfo.options?.timeout || this.config.defaultTimeout;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// Create query based on component targeting configuration
|
|
244
|
+
let query: Query;
|
|
245
|
+
|
|
246
|
+
if (taskInfo.options?.componentTarget) {
|
|
247
|
+
// Use new component targeting configuration
|
|
248
|
+
const componentTarget = taskInfo.options.componentTarget;
|
|
249
|
+
query = this.buildQueryFromComponentTarget(componentTarget);
|
|
250
|
+
} else if (taskInfo.componentTarget) {
|
|
251
|
+
// Use legacy single component targeting
|
|
252
|
+
query = new Query().with(taskInfo.componentTarget);
|
|
253
|
+
} else {
|
|
254
|
+
throw new Error('No component target specified');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Apply component filters if specified
|
|
258
|
+
if (taskInfo.options?.componentFilters && taskInfo.options.componentFilters.length > 0) {
|
|
259
|
+
// Group filters by component for the Query API
|
|
260
|
+
const filtersByComponent = new Map<string, any[]>();
|
|
261
|
+
|
|
262
|
+
for (const filter of taskInfo.options.componentFilters) {
|
|
263
|
+
// For now, we'll assume filters are for the main component
|
|
264
|
+
// In a more advanced implementation, we could support filters for different components
|
|
265
|
+
const mainComponent = taskInfo.componentTarget || taskInfo.options?.componentTarget?.includeComponents?.[0];
|
|
266
|
+
if (mainComponent) {
|
|
267
|
+
const componentName = typeof mainComponent === 'function' ? mainComponent.name : 'unknown';
|
|
268
|
+
if (!filtersByComponent.has(componentName)) {
|
|
269
|
+
filtersByComponent.set(componentName, []);
|
|
270
|
+
}
|
|
271
|
+
filtersByComponent.get(componentName)!.push(filter);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Apply filters to components
|
|
276
|
+
for (const [componentName, filters] of filtersByComponent.entries()) {
|
|
277
|
+
// This is a simplified implementation - in practice, you'd need to map component names to actual component classes
|
|
278
|
+
if (filters.length > 0) {
|
|
279
|
+
// For legacy compatibility, apply to the main component
|
|
280
|
+
if (taskInfo.componentTarget) {
|
|
281
|
+
query.with(taskInfo.componentTarget, Query.filters(...filters));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Apply entity limit if specified
|
|
288
|
+
if (taskInfo.options?.maxEntitiesPerExecution) {
|
|
289
|
+
query.take(taskInfo.options.maxEntitiesPerExecution);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const entities = await query.exec();
|
|
293
|
+
|
|
294
|
+
// Execute the scheduled method with the entities array
|
|
295
|
+
const method = taskInfo.service[taskInfo.methodName];
|
|
296
|
+
if (typeof method !== 'function') {
|
|
297
|
+
throw new Error(`Method ${taskInfo.methodName} not found on service`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Execute with timeout
|
|
301
|
+
const result = await this.executeWithTimeout(
|
|
302
|
+
method.call(taskInfo.service, entities),
|
|
303
|
+
timeout,
|
|
304
|
+
taskInfo
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const duration = Date.now() - startTime;
|
|
308
|
+
taskInfo.executionCount++;
|
|
309
|
+
this.metrics.completedExecutions++;
|
|
310
|
+
this.metrics.totalExecutionTime += duration;
|
|
311
|
+
this.metrics.averageExecutionTime = this.metrics.totalExecutionTime / this.metrics.completedExecutions;
|
|
312
|
+
|
|
313
|
+
// Update task-specific metrics
|
|
314
|
+
this.updateTaskMetrics(taskInfo.id, {
|
|
315
|
+
totalExecutions: taskInfo.executionCount,
|
|
316
|
+
successfulExecutions: (this.metrics.taskMetrics[taskInfo.id]?.successfulExecutions || 0) + 1,
|
|
317
|
+
averageExecutionTime: duration,
|
|
318
|
+
lastExecutionTime: new Date(),
|
|
319
|
+
totalEntitiesProcessed: entities.length
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (this.config.enableLogging) {
|
|
323
|
+
loggerInstance.info(`Task ${taskInfo.name} completed successfully in ${duration}ms (processed ${entities.length} entities)`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.emitEvent({
|
|
327
|
+
type: 'task.executed',
|
|
328
|
+
taskId: taskInfo.id,
|
|
329
|
+
timestamp: new Date(),
|
|
330
|
+
data: { duration, entitiesProcessed: entities.length, success: true }
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const duration = Date.now() - startTime;
|
|
335
|
+
this.metrics.failedExecutions++;
|
|
336
|
+
|
|
337
|
+
// Handle retry logic
|
|
338
|
+
await this.handleTaskFailure(taskInfo, error instanceof Error ? error : new Error(String(error)), duration);
|
|
339
|
+
|
|
340
|
+
if (this.config.enableLogging) {
|
|
341
|
+
loggerInstance.error(`Task ${taskInfo.name} failed after ${duration}ms: ${error instanceof Error ? error.message : String(error)}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
this.emitEvent({
|
|
345
|
+
type: 'task.failed',
|
|
346
|
+
taskId: taskInfo.id,
|
|
347
|
+
timestamp: new Date(),
|
|
348
|
+
data: { duration, error: error instanceof Error ? error.message : String(error) }
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
} finally {
|
|
352
|
+
taskInfo.isRunning = false;
|
|
353
|
+
this.metrics.runningTasks--;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
public start(): void {
|
|
358
|
+
if (this.isRunning) {
|
|
359
|
+
loggerInstance.warn("Scheduler is already running");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
this.isRunning = true;
|
|
364
|
+
|
|
365
|
+
// Sort tasks by priority before scheduling (higher priority first)
|
|
366
|
+
const sortedTasks = Array.from(this.tasks.values())
|
|
367
|
+
.filter(task => task.enabled)
|
|
368
|
+
.sort((a, b) => {
|
|
369
|
+
const priorityA = a.options?.priority ?? a.priority ?? 0;
|
|
370
|
+
const priorityB = b.options?.priority ?? b.priority ?? 0;
|
|
371
|
+
return priorityB - priorityA; // Higher priority first
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Schedule all registered tasks in priority order
|
|
375
|
+
for (const taskInfo of sortedTasks) {
|
|
376
|
+
this.scheduleTask(taskInfo);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (this.config.enableLogging) {
|
|
380
|
+
loggerInstance.info(`Scheduler started with ${this.tasks.size} tasks (sorted by priority)`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.emitEvent({
|
|
384
|
+
type: 'scheduler.started',
|
|
385
|
+
timestamp: new Date(),
|
|
386
|
+
data: { taskCount: this.tasks.size }
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
public stop(): void {
|
|
391
|
+
if (!this.isRunning) {
|
|
392
|
+
loggerInstance.warn("Scheduler is not running");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.isRunning = false;
|
|
397
|
+
|
|
398
|
+
// Clear all intervals and timeouts
|
|
399
|
+
for (const intervalId of this.intervals.values()) {
|
|
400
|
+
clearInterval(intervalId);
|
|
401
|
+
clearTimeout(intervalId as any);
|
|
402
|
+
}
|
|
403
|
+
this.intervals.clear();
|
|
404
|
+
|
|
405
|
+
if (this.config.enableLogging) {
|
|
406
|
+
loggerInstance.info("Scheduler stopped");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
this.emitEvent({
|
|
410
|
+
type: 'scheduler.stopped',
|
|
411
|
+
timestamp: new Date()
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
public getMetrics(): SchedulerMetrics {
|
|
416
|
+
return { ...this.metrics };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
public getTasks(): ScheduledTaskInfo[] {
|
|
420
|
+
return Array.from(this.tasks.values()).map(task => ({ ...task }));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
public enableTask(taskId: string): boolean {
|
|
424
|
+
const task = this.tasks.get(taskId);
|
|
425
|
+
if (!task) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
task.enabled = true;
|
|
430
|
+
if (this.isRunning) {
|
|
431
|
+
this.scheduleTask(task);
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
public disableTask(taskId: string): boolean {
|
|
437
|
+
const task = this.tasks.get(taskId);
|
|
438
|
+
if (!task) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
task.enabled = false;
|
|
443
|
+
const intervalId = this.intervals.get(taskId);
|
|
444
|
+
if (intervalId) {
|
|
445
|
+
clearInterval(intervalId);
|
|
446
|
+
clearTimeout(intervalId as any);
|
|
447
|
+
this.intervals.delete(taskId);
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
public addEventListener(callback: SchedulerEventCallback): void {
|
|
453
|
+
this.eventListeners.push(callback);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public removeEventListener(callback: SchedulerEventCallback): void {
|
|
457
|
+
const index = this.eventListeners.indexOf(callback);
|
|
458
|
+
if (index > -1) {
|
|
459
|
+
this.eventListeners.splice(index, 1);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private emitEvent(event: SchedulerEvent): void {
|
|
464
|
+
for (const listener of this.eventListeners) {
|
|
465
|
+
try {
|
|
466
|
+
listener(event);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
loggerInstance.error(`Error in scheduler event listener: ${error instanceof Error ? error.message : String(error)}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
public updateConfig(config: Partial<SchedulerConfig>): void {
|
|
474
|
+
this.config = { ...this.config, ...config };
|
|
475
|
+
if (this.config.enableLogging) {
|
|
476
|
+
loggerInstance.info(`Scheduler configuration updated: ${JSON.stringify(config)}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
public getConfig(): SchedulerConfig {
|
|
481
|
+
return { ...this.config };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Execute a task with timeout enforcement
|
|
486
|
+
*/
|
|
487
|
+
private async executeWithTimeout<T>(task: Promise<T>, timeoutMs: number, taskInfo: ScheduledTaskInfo): Promise<T> {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
const timeoutId = setTimeout(() => {
|
|
490
|
+
clearTimeout(timeoutId);
|
|
491
|
+
this.metrics.timedOutTasks++;
|
|
492
|
+
this.updateTaskMetrics(taskInfo.id, {
|
|
493
|
+
timeoutCount: (this.metrics.taskMetrics[taskInfo.id]?.timeoutCount || 0) + 1
|
|
494
|
+
});
|
|
495
|
+
const error = new Error(`Task ${taskInfo.name} timed out after ${timeoutMs}ms`);
|
|
496
|
+
this.emitEvent({
|
|
497
|
+
type: 'task.timeout',
|
|
498
|
+
taskId: taskInfo.id,
|
|
499
|
+
timestamp: new Date(),
|
|
500
|
+
data: { timeoutMs, taskName: taskInfo.name }
|
|
501
|
+
});
|
|
502
|
+
reject(error);
|
|
503
|
+
}, timeoutMs);
|
|
504
|
+
|
|
505
|
+
task
|
|
506
|
+
.then((result) => {
|
|
507
|
+
clearTimeout(timeoutId);
|
|
508
|
+
resolve(result);
|
|
509
|
+
})
|
|
510
|
+
.catch((error) => {
|
|
511
|
+
clearTimeout(timeoutId);
|
|
512
|
+
reject(error);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Handle task failure with retry logic
|
|
519
|
+
*/
|
|
520
|
+
private async handleTaskFailure(taskInfo: ScheduledTaskInfo, error: Error, duration: number): Promise<void> {
|
|
521
|
+
taskInfo.lastError = error.message;
|
|
522
|
+
|
|
523
|
+
const maxRetries = taskInfo.options?.maxRetries || taskInfo.maxRetries || 0;
|
|
524
|
+
const retryDelay = taskInfo.options?.retryDelay || 1000; // Default 1 second
|
|
525
|
+
|
|
526
|
+
if (taskInfo.retryCount === undefined) {
|
|
527
|
+
taskInfo.retryCount = 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (taskInfo.retryCount < maxRetries) {
|
|
531
|
+
taskInfo.retryCount++;
|
|
532
|
+
this.metrics.retriedTasks++;
|
|
533
|
+
|
|
534
|
+
this.updateTaskMetrics(taskInfo.id, {
|
|
535
|
+
retryCount: taskInfo.retryCount
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
if (this.config.enableLogging) {
|
|
539
|
+
loggerInstance.warn(`Task ${taskInfo.name} failed (attempt ${taskInfo.retryCount}/${maxRetries}), retrying in ${retryDelay}ms: ${error.message}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Schedule retry
|
|
543
|
+
setTimeout(async () => {
|
|
544
|
+
await this.executeTask(taskInfo.id);
|
|
545
|
+
}, retryDelay);
|
|
546
|
+
|
|
547
|
+
this.emitEvent({
|
|
548
|
+
type: 'task.retry',
|
|
549
|
+
taskId: taskInfo.id,
|
|
550
|
+
timestamp: new Date(),
|
|
551
|
+
data: {
|
|
552
|
+
attempt: taskInfo.retryCount,
|
|
553
|
+
maxRetries,
|
|
554
|
+
retryDelay,
|
|
555
|
+
error: error.message
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
} else {
|
|
559
|
+
// Max retries reached or no retries configured
|
|
560
|
+
this.updateTaskMetrics(taskInfo.id, {
|
|
561
|
+
failedExecutions: (this.metrics.taskMetrics[taskInfo.id]?.failedExecutions || 0) + 1
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (this.config.enableLogging) {
|
|
565
|
+
loggerInstance.error(`Task ${taskInfo.name} failed permanently after ${taskInfo.retryCount} attempts: ${error.message}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
this.emitEvent({
|
|
569
|
+
type: 'task.failed',
|
|
570
|
+
taskId: taskInfo.id,
|
|
571
|
+
timestamp: new Date(),
|
|
572
|
+
data: {
|
|
573
|
+
duration,
|
|
574
|
+
error: error.message,
|
|
575
|
+
attempts: taskInfo.retryCount,
|
|
576
|
+
maxRetries
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Update task-specific metrics
|
|
584
|
+
*/
|
|
585
|
+
private updateTaskMetrics(taskId: string, updates: Partial<TaskMetrics>): void {
|
|
586
|
+
if (!this.metrics.taskMetrics[taskId]) {
|
|
587
|
+
const taskInfo = this.tasks.get(taskId);
|
|
588
|
+
this.metrics.taskMetrics[taskId] = {
|
|
589
|
+
taskId,
|
|
590
|
+
taskName: taskInfo?.name || 'Unknown',
|
|
591
|
+
totalExecutions: 0,
|
|
592
|
+
successfulExecutions: 0,
|
|
593
|
+
failedExecutions: 0,
|
|
594
|
+
averageExecutionTime: 0,
|
|
595
|
+
totalEntitiesProcessed: 0,
|
|
596
|
+
retryCount: 0,
|
|
597
|
+
timeoutCount: 0
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const metrics = this.metrics.taskMetrics[taskId];
|
|
602
|
+
Object.assign(metrics, updates);
|
|
603
|
+
|
|
604
|
+
// Update rolling averages
|
|
605
|
+
if (updates.averageExecutionTime !== undefined) {
|
|
606
|
+
const currentAvg = metrics.averageExecutionTime;
|
|
607
|
+
const newCount = metrics.totalExecutions;
|
|
608
|
+
metrics.averageExecutionTime = ((currentAvg * (newCount - 1)) + updates.averageExecutionTime) / newCount;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Get detailed metrics for a specific task
|
|
614
|
+
*/
|
|
615
|
+
public getTaskMetrics(taskId: string): TaskMetrics | null {
|
|
616
|
+
return this.metrics.taskMetrics[taskId] || null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Get all task metrics
|
|
621
|
+
*/
|
|
622
|
+
public getAllTaskMetrics(): Record<string, TaskMetrics> {
|
|
623
|
+
return { ...this.metrics.taskMetrics };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Manually execute a task for testing purposes
|
|
628
|
+
* @param taskId The ID of the task to execute
|
|
629
|
+
*/
|
|
630
|
+
public async executeTaskNow(taskId: string): Promise<boolean> {
|
|
631
|
+
const taskInfo = this.tasks.get(taskId);
|
|
632
|
+
if (!taskInfo || !taskInfo.enabled) {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
await this.executeTask(taskId);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Build a Query object from ComponentTargetConfig
|
|
642
|
+
* @param componentTarget The component targeting configuration
|
|
643
|
+
* @returns A Query object configured with the component targeting
|
|
644
|
+
*/
|
|
645
|
+
private buildQueryFromComponentTarget(componentTarget: ComponentTargetConfig): Query {
|
|
646
|
+
let query = new Query();
|
|
647
|
+
|
|
648
|
+
// Handle archetype matching first (most specific)
|
|
649
|
+
if (componentTarget.archetype) {
|
|
650
|
+
// For archetype matching, we need to include all components from the archetype
|
|
651
|
+
const archetypeComponents = this.getArchetypeComponents(componentTarget.archetype);
|
|
652
|
+
for (const component of archetypeComponents) {
|
|
653
|
+
query = query.with(component);
|
|
654
|
+
}
|
|
655
|
+
} else if (componentTarget.archetypes && componentTarget.archetypes.length > 0) {
|
|
656
|
+
// Handle multiple archetypes - for simplicity, we'll use the first valid one
|
|
657
|
+
// In a more advanced implementation, you might want to handle OR logic
|
|
658
|
+
const firstArchetype = componentTarget.archetypes.find(archetype => archetype !== undefined);
|
|
659
|
+
if (firstArchetype) {
|
|
660
|
+
const archetypeComponents = this.getArchetypeComponents(firstArchetype);
|
|
661
|
+
for (const component of archetypeComponents) {
|
|
662
|
+
query = query.with(component);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Handle included components
|
|
668
|
+
if (componentTarget.includeComponents && componentTarget.includeComponents.length > 0) {
|
|
669
|
+
const requireAll = componentTarget.requireAllIncluded ?? true;
|
|
670
|
+
if (requireAll) {
|
|
671
|
+
// ALL included components must be present (AND logic)
|
|
672
|
+
for (const component of componentTarget.includeComponents) {
|
|
673
|
+
query = query.with(component);
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
// ANY included component must be present (OR logic)
|
|
677
|
+
// For OR logic with Query API, we need to use a different approach
|
|
678
|
+
// This is a simplified implementation - in practice, you might need custom query logic
|
|
679
|
+
for (const component of componentTarget.includeComponents) {
|
|
680
|
+
query = query.with(component);
|
|
681
|
+
break; // Just use the first one for simplicity
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Handle excluded components
|
|
687
|
+
if (componentTarget.excludeComponents && componentTarget.excludeComponents.length > 0) {
|
|
688
|
+
// Note: The current Query API might not directly support exclusion
|
|
689
|
+
// This would require extending the Query API or using post-filtering
|
|
690
|
+
// For now, we'll log a warning and continue
|
|
691
|
+
loggerInstance.warn('excludeComponents is not fully supported in scheduled tasks yet. Consider using post-query filtering.');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return query;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Extract component classes from an ArcheType
|
|
699
|
+
* @param archetype The archetype to extract components from
|
|
700
|
+
* @returns Array of component classes
|
|
701
|
+
*/
|
|
702
|
+
private getArchetypeComponents(archetype: ArcheType): (new () => BaseComponent)[] {
|
|
703
|
+
// Access the private componentMap from ArcheType
|
|
704
|
+
const componentMap = (archetype as any).componentMap as Record<string, new () => BaseComponent>;
|
|
705
|
+
if (!componentMap) {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
return Object.values(componentMap);
|
|
709
|
+
}
|
|
710
|
+
}
|