bunsane 0.3.2 → 0.4.0

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.
Files changed (214) hide show
  1. package/CHANGELOG.md +445 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +85 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +4 -4
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +16 -8
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +364 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/pendingOps.ts +72 -0
  30. package/core/entity/saveEntity.ts +377 -0
  31. package/core/hooks/dispatcher.ts +439 -0
  32. package/core/hooks/guards.ts +155 -0
  33. package/core/hooks/registry.ts +247 -0
  34. package/core/metadata/definitions/Component.ts +1 -1
  35. package/core/metadata/index.ts +15 -4
  36. package/core/middleware/RateLimit.ts +102 -105
  37. package/core/middleware/RequestId.ts +2 -9
  38. package/core/middleware/SecurityHeaders.ts +2 -11
  39. package/core/middleware/headers.ts +28 -0
  40. package/core/remote/OutboxWorker.ts +213 -183
  41. package/core/remote/RemoteManager.ts +401 -400
  42. package/core/remote/types.ts +153 -151
  43. package/core/requestScope.ts +34 -0
  44. package/core/scheduler/cronEvaluator.ts +174 -0
  45. package/core/scheduler/lifecycleHooks.ts +21 -0
  46. package/core/scheduler/lockCoordinator.ts +27 -0
  47. package/core/scheduler/metrics.ts +14 -0
  48. package/core/scheduler/taskRunner.ts +420 -0
  49. package/database/DatabaseHelper.ts +128 -101
  50. package/database/IndexingStrategy.ts +72 -2
  51. package/database/PreparedStatementCache.ts +8 -2
  52. package/database/cancellable.ts +35 -22
  53. package/database/index.ts +15 -3
  54. package/database/instrumentedDb.ts +141 -141
  55. package/endpoints/archetypes.ts +2 -8
  56. package/endpoints/tables.ts +6 -1
  57. package/gql/index.ts +1 -1
  58. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  59. package/package.json +22 -1
  60. package/query/CTENode.ts +5 -3
  61. package/query/ComponentInclusionNode.ts +240 -13
  62. package/query/OrNode.ts +6 -5
  63. package/query/Query.ts +157 -46
  64. package/query/QueryContext.ts +6 -0
  65. package/query/QueryDAG.ts +7 -2
  66. package/query/membershipSource.ts +66 -0
  67. package/storage/LocalStorageProvider.ts +8 -3
  68. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  69. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  70. package/studio/{index.html → dist/index.html} +3 -2
  71. package/swagger/generator.ts +11 -1
  72. package/upload/UploadManager.ts +8 -6
  73. package/utils/uuid.ts +40 -10
  74. package/.claude/scheduled_tasks.lock +0 -1
  75. package/.claude/settings.local.json +0 -47
  76. package/.prettierrc +0 -4
  77. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  78. package/.serena/memories/architecture.md +0 -154
  79. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  80. package/.serena/memories/code_style_and_conventions.md +0 -76
  81. package/.serena/memories/project_overview.md +0 -43
  82. package/.serena/memories/schema-dsl-plan.md +0 -107
  83. package/.serena/memories/suggested_commands.md +0 -80
  84. package/.serena/memories/typescript-compilation-status.md +0 -54
  85. package/.serena/project.yml +0 -114
  86. package/BunSane.jpg +0 -0
  87. package/CLAUDE.md +0 -198
  88. package/TODO.md +0 -2
  89. package/bun.lock +0 -302
  90. package/bunfig.toml +0 -10
  91. package/docs/RFC_APP_REFACTOR.md +0 -248
  92. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  93. package/docs/SCALABILITY_PLAN.md +0 -175
  94. package/studio/bun.lock +0 -482
  95. package/studio/package.json +0 -39
  96. package/studio/postcss.config.js +0 -6
  97. package/studio/src/components/DataTable.tsx +0 -211
  98. package/studio/src/components/Layout.tsx +0 -13
  99. package/studio/src/components/PageContainer.tsx +0 -9
  100. package/studio/src/components/PageHeader.tsx +0 -13
  101. package/studio/src/components/SearchBar.tsx +0 -57
  102. package/studio/src/components/Sidebar.tsx +0 -294
  103. package/studio/src/components/ui/button.tsx +0 -56
  104. package/studio/src/components/ui/checkbox.tsx +0 -26
  105. package/studio/src/components/ui/input.tsx +0 -25
  106. package/studio/src/hooks/useDataTable.ts +0 -131
  107. package/studio/src/index.css +0 -36
  108. package/studio/src/lib/api.ts +0 -186
  109. package/studio/src/lib/utils.ts +0 -13
  110. package/studio/src/main.tsx +0 -17
  111. package/studio/src/pages/ArcheType.tsx +0 -239
  112. package/studio/src/pages/Components.tsx +0 -124
  113. package/studio/src/pages/EntityInspector.tsx +0 -302
  114. package/studio/src/pages/QueryRunner.tsx +0 -246
  115. package/studio/src/pages/Table.tsx +0 -94
  116. package/studio/src/pages/Welcome.tsx +0 -241
  117. package/studio/src/routes.tsx +0 -45
  118. package/studio/src/store/archeTypeSettings.ts +0 -30
  119. package/studio/src/store/studio.ts +0 -65
  120. package/studio/src/utils/columnHelpers.tsx +0 -114
  121. package/studio/studio-instructions.md +0 -81
  122. package/studio/tailwind.config.js +0 -77
  123. package/studio/utils.ts +0 -54
  124. package/studio/vite.config.js +0 -19
  125. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  126. package/tests/benchmark/bunfig.toml +0 -9
  127. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  128. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  129. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  130. package/tests/benchmark/fixtures/index.ts +0 -6
  131. package/tests/benchmark/index.ts +0 -22
  132. package/tests/benchmark/noop-preload.ts +0 -3
  133. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  134. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  135. package/tests/benchmark/runners/index.ts +0 -4
  136. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  137. package/tests/benchmark/scripts/generate-db.ts +0 -344
  138. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  139. package/tests/e2e/http.test.ts +0 -130
  140. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  141. package/tests/fixtures/components/TestOrder.ts +0 -23
  142. package/tests/fixtures/components/TestProduct.ts +0 -23
  143. package/tests/fixtures/components/TestUser.ts +0 -20
  144. package/tests/fixtures/components/index.ts +0 -6
  145. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  146. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  147. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  148. package/tests/helpers/MockRedisClient.ts +0 -113
  149. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  150. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  151. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  152. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  153. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  154. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  155. package/tests/integration/query/Query.abort.test.ts +0 -66
  156. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  157. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  158. package/tests/integration/query/Query.exec.test.ts +0 -576
  159. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  160. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  161. package/tests/integration/remote/dlq.test.ts +0 -175
  162. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  163. package/tests/integration/remote/outbox.test.ts +0 -130
  164. package/tests/integration/remote/rpc.test.ts +0 -177
  165. package/tests/pglite-setup.ts +0 -62
  166. package/tests/setup.ts +0 -164
  167. package/tests/stress/BenchmarkRunner.ts +0 -203
  168. package/tests/stress/DataSeeder.ts +0 -190
  169. package/tests/stress/StressTestReporter.ts +0 -229
  170. package/tests/stress/cursor-perf-test.ts +0 -171
  171. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  172. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  173. package/tests/stress/index.ts +0 -7
  174. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  175. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  176. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  177. package/tests/unit/BatchLoader.test.ts +0 -196
  178. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  179. package/tests/unit/cache/CacheManager.test.ts +0 -498
  180. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  181. package/tests/unit/cache/RedisCache.test.ts +0 -411
  182. package/tests/unit/database/cancellable.test.ts +0 -81
  183. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  184. package/tests/unit/entity/Entity.components.test.ts +0 -317
  185. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  186. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  187. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  188. package/tests/unit/entity/Entity.test.ts +0 -345
  189. package/tests/unit/gql/depthLimit.test.ts +0 -203
  190. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  191. package/tests/unit/health/Health.test.ts +0 -129
  192. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  193. package/tests/unit/middleware/Middleware.test.ts +0 -98
  194. package/tests/unit/middleware/RequestId.test.ts +0 -54
  195. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  196. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  197. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  198. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  199. package/tests/unit/query/Query.test.ts +0 -310
  200. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  201. package/tests/unit/remote/RemoteError.test.ts +0 -55
  202. package/tests/unit/remote/decorators.test.ts +0 -195
  203. package/tests/unit/remote/metrics.test.ts +0 -115
  204. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  205. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  206. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  207. package/tests/unit/schema/schema-integration.test.ts +0 -426
  208. package/tests/unit/schema/schema.test.ts +0 -580
  209. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  210. package/tests/unit/upload/RestUpload.test.ts +0 -267
  211. package/tests/unit/validateEnv.test.ts +0 -82
  212. package/tests/utils/entity-tracker.ts +0 -57
  213. package/tests/utils/index.ts +0 -13
  214. package/tests/utils/test-context.ts +0 -149
@@ -19,20 +19,25 @@ import type { ComponentTargetConfig } from "./EntityHookManager";
19
19
  import ArcheType from "./ArcheType";
20
20
  import { BaseComponent } from "./components";
21
21
  import { DistributedLock, type DistributedLockConfig } from "./scheduler/DistributedLock";
22
+ import { scheduleTask, scheduleJob } from "./scheduler/cronEvaluator";
23
+ import { executeTask, doExecuteTask, updateTaskMetrics } from "./scheduler/taskRunner";
24
+ import { getDistributedLockInfo, isDistributedLockingEnabled, syncLockConfig } from "./scheduler/lockCoordinator";
25
+ import { initializeLifecycleIntegration, disposeLifecycleIntegration as _disposeLifecycleIntegration } from "./scheduler/lifecycleHooks";
26
+ import { getMetrics, getTaskMetrics, getAllTaskMetrics } from "./scheduler/metrics";
22
27
 
23
28
  const loggerInstance = logger.child({ scope: "SchedulerManager" });
24
29
 
25
30
  export class SchedulerManager {
26
31
  private static instance: SchedulerManager;
27
- private tasks: Map<string, ScheduledTaskInfo> = new Map();
28
- private intervals: Map<string, NodeJS.Timeout> = new Map();
29
- private isRunning: boolean = false;
32
+ public tasks: Map<string, ScheduledTaskInfo> = new Map();
33
+ public intervals: Map<string, NodeJS.Timeout> = new Map();
34
+ public isRunning: boolean = false;
30
35
  private eventListeners: SchedulerEventCallback[] = [];
31
36
  public config: SchedulerConfig;
32
- private distributedLock: DistributedLock;
33
- private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
34
- private inflightTasks: Set<Promise<any>> = new Set();
35
- private metrics: SchedulerMetrics = {
37
+ public distributedLock: DistributedLock;
38
+ public phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
39
+ public inflightTasks: Set<Promise<any>> = new Set();
40
+ public metrics: SchedulerMetrics = {
36
41
  totalTasks: 0,
37
42
  runningTasks: 0,
38
43
  completedExecutions: 0,
@@ -67,7 +72,7 @@ export class SchedulerManager {
67
72
  retryInterval: this.config.lockRetryInterval ?? 100,
68
73
  });
69
74
 
70
- this.initializeLifecycleIntegration();
75
+ initializeLifecycleIntegration(this);
71
76
  }
72
77
 
73
78
  public static getInstance(): SchedulerManager {
@@ -77,23 +82,8 @@ export class SchedulerManager {
77
82
  return SchedulerManager.instance;
78
83
  }
79
84
 
80
- private initializeLifecycleIntegration(): void {
81
- this.phaseListener = (event) => {
82
- const phase = event.detail;
83
- if (phase === ApplicationPhase.APPLICATION_READY) {
84
- if (this.config.runOnStart) {
85
- this.start();
86
- }
87
- }
88
- };
89
- ApplicationLifecycle.addPhaseListener(this.phaseListener);
90
- }
91
-
92
85
  public disposeLifecycleIntegration(): void {
93
- if (this.phaseListener) {
94
- ApplicationLifecycle.removePhaseListener(this.phaseListener);
95
- this.phaseListener = null;
96
- }
86
+ _disposeLifecycleIntegration(this);
97
87
  }
98
88
 
99
89
  public registerTask(taskInfo: ScheduledTaskInfo): void {
@@ -128,7 +118,7 @@ export class SchedulerManager {
128
118
 
129
119
  // Try to schedule the task - if scheduling fails, don't register it
130
120
  try {
131
- this.scheduleTask(taskInfo);
121
+ scheduleTask(this, taskInfo);
132
122
  this.tasks.set(taskInfo.id, taskInfo);
133
123
  this.metrics.totalTasks++;
134
124
 
@@ -154,341 +144,15 @@ export class SchedulerManager {
154
144
  * entity-component system integration.
155
145
  */
156
146
  public scheduleJob(name: string, cronExpression: string, callback: () => Promise<void> | void): { cancel: () => void } {
157
- const jobId = `job_${name}_${Date.now()}`;
158
-
159
- // Validate cron expression
160
- const validation = CronParser.validate(cronExpression);
161
- if (!validation.isValid) {
162
- throw new Error(`Invalid cron expression for job "${name}": ${validation.error}`);
163
- }
164
-
165
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
166
- let cancelled = false;
167
-
168
- const scheduleNextExecution = () => {
169
- if (cancelled) return;
170
-
171
- const nextExecution = CronParser.getNextExecution(validation.fields!, new Date());
172
- if (!nextExecution) {
173
- loggerInstance.warn(`Unable to calculate next execution for job "${name}"`);
174
- return;
175
- }
176
-
177
- const delay = nextExecution.getTime() - Date.now();
178
- timeoutId = setTimeout(async () => {
179
- if (cancelled) return;
180
- try {
181
- await callback();
182
- } catch (error) {
183
- loggerInstance.error(`Job "${name}" failed: ${error instanceof Error ? error.message : String(error)}`);
184
- }
185
- scheduleNextExecution();
186
- }, delay);
187
-
188
- this.intervals.set(jobId, timeoutId as any);
189
- };
190
-
191
- scheduleNextExecution();
192
-
193
- return {
194
- cancel: () => {
195
- cancelled = true;
196
- if (timeoutId) {
197
- clearTimeout(timeoutId);
198
- this.intervals.delete(jobId);
199
- }
200
- }
201
- };
202
- }
203
-
204
- private scheduleTask(taskInfo: ScheduledTaskInfo): void {
205
- try {
206
- if (taskInfo.interval === ScheduleInterval.CRON) {
207
- this.scheduleCronTask(taskInfo);
208
- } else {
209
- this.scheduleIntervalTask(taskInfo);
210
- }
211
- } catch (error) {
212
- loggerInstance.error(`Failed to schedule task ${taskInfo.name}: ${error instanceof Error ? error.message : String(error)}`);
213
- throw error;
214
- }
215
- }
216
-
217
- private scheduleIntervalTask(taskInfo: ScheduledTaskInfo): void {
218
- const intervalMs = this.getIntervalMilliseconds(taskInfo.interval);
219
-
220
- // Clear any existing interval for this task before creating a new one
221
- const existingInterval = this.intervals.get(taskInfo.id);
222
- if (existingInterval) {
223
- clearInterval(existingInterval);
224
- this.intervals.delete(taskInfo.id);
225
- }
226
-
227
- // For very long intervals (monthly), use a different approach
228
- if (intervalMs > 24 * 60 * 60 * 1000) { // More than 24 hours
229
- this.scheduleLongIntervalTask(taskInfo, intervalMs);
230
- } else {
231
- const intervalId = setInterval(async () => {
232
- await this.executeTask(taskInfo.id);
233
- }, intervalMs);
234
-
235
- this.intervals.set(taskInfo.id, intervalId);
236
- taskInfo.nextExecution = new Date(Date.now() + intervalMs);
237
- }
238
-
239
- if (this.config.enableLogging) {
240
- loggerInstance.info(`Scheduled task ${taskInfo.name} to run every ${intervalMs}ms`);
241
- }
242
- }
243
-
244
- private scheduleLongIntervalTask(taskInfo: ScheduledTaskInfo, intervalMs: number): void {
245
- // For very long intervals, use a shorter check interval to avoid timeout overflow
246
- const checkInterval = Math.min(intervalMs, 24 * 60 * 60 * 1000); // Max 24 hours check interval
247
- const nextExecution = new Date(Date.now() + intervalMs);
248
- taskInfo.nextExecution = nextExecution;
249
-
250
- const intervalId = setInterval(async () => {
251
- const now = Date.now();
252
- if (now >= nextExecution.getTime()) {
253
- await this.executeTask(taskInfo.id);
254
- // Reschedule for next execution
255
- taskInfo.nextExecution = new Date(now + intervalMs);
256
- }
257
- }, checkInterval);
258
-
259
- this.intervals.set(taskInfo.id, intervalId);
260
- }
261
-
262
- private scheduleCronTask(taskInfo: ScheduledTaskInfo): void {
263
- if (!taskInfo.cronExpression) {
264
- throw new Error(`Cron expression is required for CRON interval tasks`);
265
- }
266
-
267
- // Validate cron expression
268
- const validation = CronParser.validate(taskInfo.cronExpression);
269
- if (!validation.isValid) {
270
- throw new Error(`Invalid cron expression: ${validation.error}`);
271
- }
272
-
273
- // Calculate next execution time
274
- const nextExecution = CronParser.getNextExecution(validation.fields!, new Date());
275
- if (!nextExecution) {
276
- throw new Error(`Unable to calculate next execution time for cron expression: ${taskInfo.cronExpression}`);
277
- }
278
-
279
- taskInfo.nextExecution = nextExecution;
280
-
281
- // Clear any existing timeout for this task before creating a new one
282
- const existingTimeout = this.intervals.get(taskInfo.id);
283
- if (existingTimeout) {
284
- clearTimeout(existingTimeout as any);
285
- }
286
-
287
- // Schedule the task to run at the calculated time
288
- const timeoutId = setTimeout(async () => {
289
- await this.executeTask(taskInfo.id);
290
- // Reschedule for next execution
291
- this.scheduleCronTask(taskInfo);
292
- }, nextExecution.getTime() - Date.now());
293
-
294
- this.intervals.set(taskInfo.id, timeoutId as any);
295
-
296
- if (this.config.enableLogging) {
297
- loggerInstance.info(`Scheduled cron task ${taskInfo.name} to run at ${nextExecution.toISOString()}`);
298
- }
299
- }
300
-
301
- private getIntervalMilliseconds(interval: ScheduleInterval): number {
302
- switch (interval) {
303
- case ScheduleInterval.MINUTE:
304
- return 60 * 1000; // 1 minute
305
- case ScheduleInterval.HOUR:
306
- return 60 * 60 * 1000; // 1 hour
307
- case ScheduleInterval.DAILY:
308
- return 24 * 60 * 60 * 1000; // 24 hours
309
- case ScheduleInterval.WEEKLY:
310
- return 7 * 24 * 60 * 60 * 1000; // 7 days
311
- case ScheduleInterval.MONTHLY:
312
- return 30 * 24 * 60 * 60 * 1000; // 30 days (approximate)
313
- default:
314
- throw new Error(`Unsupported interval: ${interval}`);
315
- }
147
+ return scheduleJob(this, name, cronExpression, callback);
316
148
  }
317
149
 
318
150
  private async executeTask(taskId: string): Promise<void> {
319
- // Track this execution so stop() can await in-flight work before
320
- // resources (DB pool, cache) are torn down. Without this, a task mid-
321
- // write during SIGTERM hits a closed DB pool and silently corrupts
322
- // or loses data.
323
- const p = this.doExecuteTask(taskId);
324
- this.inflightTasks.add(p);
325
- p.finally(() => this.inflightTasks.delete(p));
326
- return p;
151
+ return executeTask(this, taskId);
327
152
  }
328
153
 
329
154
  private async doExecuteTask(taskId: string): Promise<void> {
330
- const taskInfo = this.tasks.get(taskId);
331
- if (!taskInfo || !taskInfo.enabled) {
332
- return;
333
- }
334
-
335
- // Skip if the previous tick is still executing. Without this guard
336
- // a slow task with interval < execution-time burns a lock-acquire
337
- // round-trip every tick and floods the skipped-executions metric
338
- // (H-SCHED-1). Cheap in-process check before reaching out to PG.
339
- if (taskInfo.isRunning) {
340
- this.metrics.skippedExecutions++;
341
- if (this.config.enableLogging) {
342
- loggerInstance.debug(`Task ${taskInfo.name} skipped - previous execution still running`);
343
- }
344
- return;
345
- }
346
-
347
- if (this.metrics.runningTasks >= this.config.maxConcurrentTasks) {
348
- if (this.config.enableLogging) {
349
- loggerInstance.warn(`Maximum concurrent tasks reached. Skipping execution of ${taskInfo.name}`);
350
- }
351
- return;
352
- }
353
-
354
- // Try to acquire distributed lock before executing
355
- this.metrics.lockAttempts++;
356
- const lockResult = await this.distributedLock.tryAcquire(taskId);
357
-
358
- if (!lockResult.acquired) {
359
- // Another instance is executing this task
360
- this.metrics.skippedExecutions++;
361
-
362
- if (this.config.enableLogging) {
363
- loggerInstance.debug(`Task ${taskInfo.name} skipped - another instance is executing (lock key: ${lockResult.lockKey})`);
364
- }
365
-
366
- this.emitEvent({
367
- type: 'task.skipped',
368
- taskId: taskInfo.id,
369
- timestamp: new Date(),
370
- data: { reason: 'lock_unavailable', lockKey: lockResult.lockKey.toString() }
371
- });
372
-
373
- return;
374
- }
375
-
376
- // Lock acquired successfully
377
- this.metrics.locksAcquired++;
378
-
379
- this.emitEvent({
380
- type: 'task.lock.acquired',
381
- taskId: taskInfo.id,
382
- timestamp: new Date(),
383
- data: { lockKey: lockResult.lockKey.toString() }
384
- });
385
-
386
- taskInfo.isRunning = true;
387
- taskInfo.lastExecution = new Date();
388
- this.metrics.runningTasks++;
389
-
390
- const startTime = Date.now();
391
- const timeout = taskInfo.options?.timeout || this.config.defaultTimeout;
392
-
393
- try {
394
- // Create query based on targeting configuration
395
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
396
- let query: Query<any> | null = null;
397
-
398
- if (taskInfo.options?.query) {
399
- // Use custom query function (preferred approach)
400
- query = taskInfo.options.query();
401
- } else if (taskInfo.options?.componentTarget) {
402
- // Use component targeting configuration (deprecated - use query instead)
403
- const componentTarget = taskInfo.options.componentTarget;
404
- query = this.buildQueryFromComponentTarget(componentTarget);
405
- } else if (taskInfo.componentTarget) {
406
- // Use legacy single component targeting (deprecated - use query instead)
407
- query = new Query().with(taskInfo.componentTarget);
408
- }
409
- // else: time-based task — no entity selection. Handler invoked
410
- // with no arguments on each tick.
411
-
412
- // Apply entity limit if specified (can be used with query function)
413
- if (query && taskInfo.options?.maxEntitiesPerExecution) {
414
- query.take(taskInfo.options.maxEntitiesPerExecution);
415
- }
416
-
417
- const entities = query ? await query.exec() : [];
418
-
419
- // Execute the scheduled method with the entities array
420
- const method = taskInfo.service[taskInfo.methodName];
421
- if (typeof method !== 'function') {
422
- throw new Error(`Method ${taskInfo.methodName} not found on service`);
423
- }
424
-
425
- // Execute with timeout. Time-based tasks receive no entity arg.
426
- const result = await this.executeWithTimeout(
427
- query
428
- ? method.call(taskInfo.service, entities)
429
- : method.call(taskInfo.service),
430
- timeout,
431
- taskInfo
432
- );
433
-
434
- const duration = Date.now() - startTime;
435
- taskInfo.executionCount++;
436
- this.metrics.completedExecutions++;
437
- this.metrics.totalExecutionTime += duration;
438
- this.metrics.averageExecutionTime = this.metrics.totalExecutionTime / this.metrics.completedExecutions;
439
-
440
- // Update task-specific metrics
441
- this.updateTaskMetrics(taskInfo.id, {
442
- totalExecutions: taskInfo.executionCount,
443
- successfulExecutions: (this.metrics.taskMetrics[taskInfo.id]?.successfulExecutions || 0) + 1,
444
- averageExecutionTime: duration,
445
- lastExecutionTime: new Date(),
446
- totalEntitiesProcessed: entities.length
447
- });
448
-
449
- if (this.config.enableLogging) {
450
- loggerInstance.info(`Task ${taskInfo.name} completed successfully in ${duration}ms (processed ${entities.length} entities)`);
451
- }
452
-
453
- this.emitEvent({
454
- type: 'task.executed',
455
- taskId: taskInfo.id,
456
- timestamp: new Date(),
457
- data: { duration, entitiesProcessed: entities.length, success: true }
458
- });
459
-
460
- } catch (error) {
461
- const duration = Date.now() - startTime;
462
- this.metrics.failedExecutions++;
463
-
464
- // Handle retry logic
465
- await this.handleTaskFailure(taskInfo, error instanceof Error ? error : new Error(String(error)), duration);
466
-
467
- if (this.config.enableLogging) {
468
- loggerInstance.error(`Task ${taskInfo.name} failed after ${duration}ms: ${error instanceof Error ? error.message : String(error)}`);
469
- }
470
-
471
- this.emitEvent({
472
- type: 'task.failed',
473
- taskId: taskInfo.id,
474
- timestamp: new Date(),
475
- data: { duration, error: error instanceof Error ? error.message : String(error) }
476
- });
477
-
478
- } finally {
479
- taskInfo.isRunning = false;
480
- this.metrics.runningTasks--;
481
-
482
- // Release the distributed lock
483
- await this.distributedLock.release(taskId);
484
-
485
- this.emitEvent({
486
- type: 'task.lock.released',
487
- taskId: taskInfo.id,
488
- timestamp: new Date(),
489
- data: { lockKey: lockResult.lockKey.toString() }
490
- });
491
- }
155
+ return doExecuteTask(this, taskId);
492
156
  }
493
157
 
494
158
  public start(): void {
@@ -510,7 +174,7 @@ export class SchedulerManager {
510
174
 
511
175
  // Schedule all registered tasks in priority order
512
176
  for (const taskInfo of sortedTasks) {
513
- this.scheduleTask(taskInfo);
177
+ scheduleTask(this, taskInfo);
514
178
  }
515
179
 
516
180
  const lockStatus = this.config.distributedLocking !== false ? 'enabled' : 'disabled';
@@ -577,7 +241,7 @@ export class SchedulerManager {
577
241
  }
578
242
 
579
243
  public getMetrics(): SchedulerMetrics {
580
- return { ...this.metrics };
244
+ return getMetrics(this);
581
245
  }
582
246
 
583
247
  public getTasks(): ScheduledTaskInfo[] {
@@ -592,7 +256,7 @@ export class SchedulerManager {
592
256
 
593
257
  task.enabled = true;
594
258
  if (this.isRunning) {
595
- this.scheduleTask(task);
259
+ scheduleTask(this, task);
596
260
  }
597
261
  return true;
598
262
  }
@@ -624,7 +288,7 @@ export class SchedulerManager {
624
288
  }
625
289
  }
626
290
 
627
- private emitEvent(event: SchedulerEvent): void {
291
+ public emitEvent(event: SchedulerEvent): void {
628
292
  for (const listener of this.eventListeners) {
629
293
  try {
630
294
  listener(event);
@@ -638,12 +302,7 @@ export class SchedulerManager {
638
302
  this.config = { ...this.config, ...config };
639
303
 
640
304
  // Sync distributed lock configuration
641
- this.distributedLock.updateConfig({
642
- enabled: this.config.distributedLocking ?? true,
643
- enableLogging: this.config.enableLogging,
644
- lockTimeout: this.config.lockTimeout ?? 0,
645
- retryInterval: this.config.lockRetryInterval ?? 100,
646
- });
305
+ syncLockConfig(this);
647
306
 
648
307
  if (this.config.enableLogging) {
649
308
  loggerInstance.info(`Scheduler configuration updated: ${JSON.stringify(config)}`);
@@ -662,187 +321,28 @@ export class SchedulerManager {
662
321
  heldLocks: number;
663
322
  config: DistributedLockConfig;
664
323
  } {
665
- return {
666
- enabled: this.config.distributedLocking !== false,
667
- heldLocks: this.distributedLock.getHeldLockCount(),
668
- config: this.distributedLock.getConfig(),
669
- };
324
+ return getDistributedLockInfo(this);
670
325
  }
671
326
 
672
327
  /**
673
328
  * Check if distributed locking is enabled
674
329
  */
675
330
  public isDistributedLockingEnabled(): boolean {
676
- return this.config.distributedLocking !== false;
677
- }
678
-
679
- /**
680
- * Execute a task with timeout enforcement.
681
- *
682
- * Note: JS has no way to cancel an arbitrary Promise — on timeout the
683
- * wrapper rejects but the underlying task continues. The second .catch
684
- * below captures a late rejection after the wrapper already rejected,
685
- * preventing an unhandled-rejection process crash (H-SCHED-5). The
686
- * `settled` flag guards against double-settle.
687
- */
688
- private async executeWithTimeout<T>(task: Promise<T>, timeoutMs: number, taskInfo: ScheduledTaskInfo): Promise<T> {
689
- return new Promise((resolve, reject) => {
690
- let settled = false;
691
- const timeoutId = setTimeout(() => {
692
- if (settled) return;
693
- settled = true;
694
- this.metrics.timedOutTasks++;
695
- this.updateTaskMetrics(taskInfo.id, {
696
- timeoutCount: (this.metrics.taskMetrics[taskInfo.id]?.timeoutCount || 0) + 1
697
- });
698
- const error = new Error(`Task ${taskInfo.name} timed out after ${timeoutMs}ms`);
699
- this.emitEvent({
700
- type: 'task.timeout',
701
- taskId: taskInfo.id,
702
- timestamp: new Date(),
703
- data: { timeoutMs, taskName: taskInfo.name }
704
- });
705
- reject(error);
706
- }, timeoutMs);
707
-
708
- task
709
- .then((result) => {
710
- if (settled) return;
711
- settled = true;
712
- clearTimeout(timeoutId);
713
- resolve(result);
714
- })
715
- .catch((error) => {
716
- if (settled) {
717
- // Late rejection after timeout. Log only — the wrapper
718
- // promise is already settled, so re-rejecting would be
719
- // a no-op but the rejection would escape as unhandled.
720
- loggerInstance.warn({ taskId: taskInfo.id, err: error }, 'Late rejection from scheduled task after timeout');
721
- return;
722
- }
723
- settled = true;
724
- clearTimeout(timeoutId);
725
- reject(error);
726
- });
727
- });
728
- }
729
-
730
- /**
731
- * Handle task failure with retry logic
732
- */
733
- private async handleTaskFailure(taskInfo: ScheduledTaskInfo, error: Error, duration: number): Promise<void> {
734
- taskInfo.lastError = error.message;
735
-
736
- const maxRetries = taskInfo.options?.maxRetries || taskInfo.maxRetries || 0;
737
- const retryDelay = taskInfo.options?.retryDelay || 1000; // Default 1 second
738
-
739
- if (taskInfo.retryCount === undefined) {
740
- taskInfo.retryCount = 0;
741
- }
742
-
743
- if (taskInfo.retryCount < maxRetries) {
744
- taskInfo.retryCount++;
745
- this.metrics.retriedTasks++;
746
-
747
- this.updateTaskMetrics(taskInfo.id, {
748
- retryCount: taskInfo.retryCount
749
- });
750
-
751
- if (this.config.enableLogging) {
752
- loggerInstance.warn(`Task ${taskInfo.name} failed (attempt ${taskInfo.retryCount}/${maxRetries}), retrying in ${retryDelay}ms: ${error.message}`);
753
- }
754
-
755
- // Schedule retry. Track the timer handle in `intervals` under a
756
- // unique key so `stop()` can clear it (H-SCHED-3); without this
757
- // the retry fires post-shutdown against a closed DB pool. Also
758
- // skip the retry if the scheduler is no longer running by the
759
- // time the timer fires, and rely on the new isRunning guard in
760
- // doExecuteTask (H-SCHED-1) to prevent retry/tick overlap.
761
- const retryKey = `${taskInfo.id}:retry:${taskInfo.retryCount}`;
762
- const retryHandle = setTimeout(async () => {
763
- this.intervals.delete(retryKey);
764
- if (!this.isRunning) return;
765
- await this.executeTask(taskInfo.id);
766
- }, retryDelay);
767
- this.intervals.set(retryKey, retryHandle as any);
768
-
769
- this.emitEvent({
770
- type: 'task.retry',
771
- taskId: taskInfo.id,
772
- timestamp: new Date(),
773
- data: {
774
- attempt: taskInfo.retryCount,
775
- maxRetries,
776
- retryDelay,
777
- error: error.message
778
- }
779
- });
780
- } else {
781
- // Max retries reached or no retries configured
782
- this.updateTaskMetrics(taskInfo.id, {
783
- failedExecutions: (this.metrics.taskMetrics[taskInfo.id]?.failedExecutions || 0) + 1
784
- });
785
-
786
- if (this.config.enableLogging) {
787
- loggerInstance.error(`Task ${taskInfo.name} failed permanently after ${taskInfo.retryCount} attempts: ${error.message}`);
788
- }
789
-
790
- this.emitEvent({
791
- type: 'task.failed',
792
- taskId: taskInfo.id,
793
- timestamp: new Date(),
794
- data: {
795
- duration,
796
- error: error.message,
797
- attempts: taskInfo.retryCount,
798
- maxRetries
799
- }
800
- });
801
- }
802
- }
803
-
804
- /**
805
- * Update task-specific metrics
806
- */
807
- private updateTaskMetrics(taskId: string, updates: Partial<TaskMetrics>): void {
808
- if (!this.metrics.taskMetrics[taskId]) {
809
- const taskInfo = this.tasks.get(taskId);
810
- this.metrics.taskMetrics[taskId] = {
811
- taskId,
812
- taskName: taskInfo?.name || 'Unknown',
813
- totalExecutions: 0,
814
- successfulExecutions: 0,
815
- failedExecutions: 0,
816
- averageExecutionTime: 0,
817
- totalEntitiesProcessed: 0,
818
- retryCount: 0,
819
- timeoutCount: 0
820
- };
821
- }
822
-
823
- const metrics = this.metrics.taskMetrics[taskId];
824
- Object.assign(metrics, updates);
825
-
826
- // Update rolling averages
827
- if (updates.averageExecutionTime !== undefined) {
828
- const currentAvg = metrics.averageExecutionTime;
829
- const newCount = metrics.totalExecutions;
830
- metrics.averageExecutionTime = ((currentAvg * (newCount - 1)) + updates.averageExecutionTime) / newCount;
831
- }
331
+ return isDistributedLockingEnabled(this);
832
332
  }
833
333
 
834
334
  /**
835
335
  * Get detailed metrics for a specific task
836
336
  */
837
337
  public getTaskMetrics(taskId: string): TaskMetrics | null {
838
- return this.metrics.taskMetrics[taskId] || null;
338
+ return getTaskMetrics(this, taskId);
839
339
  }
840
340
 
841
341
  /**
842
342
  * Get all task metrics
843
343
  */
844
344
  public getAllTaskMetrics(): Record<string, TaskMetrics> {
845
- return { ...this.metrics.taskMetrics };
345
+ return getAllTaskMetrics(this);
846
346
  }
847
347
 
848
348
  /**
@@ -858,76 +358,4 @@ export class SchedulerManager {
858
358
  await this.executeTask(taskId);
859
359
  return true;
860
360
  }
861
-
862
- /**
863
- * Build a Query object from ComponentTargetConfig
864
- * @param componentTarget The component targeting configuration
865
- * @returns A Query object configured with the component targeting
866
- */
867
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
868
- private buildQueryFromComponentTarget(componentTarget: ComponentTargetConfig): Query<any> {
869
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
870
- let query: Query<any> = new Query();
871
-
872
- // Handle archetype matching first (most specific)
873
- if (componentTarget.archetype) {
874
- // For archetype matching, we need to include all components from the archetype
875
- const archetypeComponents = this.getArchetypeComponents(componentTarget.archetype);
876
- for (const component of archetypeComponents) {
877
- query = query.with(component);
878
- }
879
- } else if (componentTarget.archetypes && componentTarget.archetypes.length > 0) {
880
- // Handle multiple archetypes - for simplicity, we'll use the first valid one
881
- // In a more advanced implementation, you might want to handle OR logic
882
- const firstArchetype = componentTarget.archetypes.find(archetype => archetype !== undefined);
883
- if (firstArchetype) {
884
- const archetypeComponents = this.getArchetypeComponents(firstArchetype);
885
- for (const component of archetypeComponents) {
886
- query = query.with(component);
887
- }
888
- }
889
- }
890
-
891
- // Handle included components
892
- if (componentTarget.includeComponents && componentTarget.includeComponents.length > 0) {
893
- const requireAll = componentTarget.requireAllIncluded ?? true;
894
- if (requireAll) {
895
- // ALL included components must be present (AND logic)
896
- for (const component of componentTarget.includeComponents) {
897
- query = query.with(component);
898
- }
899
- } else {
900
- // ANY included component must be present (OR logic)
901
- // For OR logic with Query API, we need to use a different approach
902
- // This is a simplified implementation - in practice, you might need custom query logic
903
- for (const component of componentTarget.includeComponents) {
904
- query = query.with(component);
905
- break; // Just use the first one for simplicity
906
- }
907
- }
908
- }
909
-
910
- // Handle excluded components
911
- if (componentTarget.excludeComponents && componentTarget.excludeComponents.length > 0) {
912
- for(const component of componentTarget.excludeComponents){
913
- query = query.without(component);
914
- }
915
- }
916
-
917
- return query;
918
- }
919
-
920
- /**
921
- * Extract component classes from an ArcheType
922
- * @param archetype The archetype to extract components from
923
- * @returns Array of component classes
924
- */
925
- private getArchetypeComponents(archetype: ArcheType): (new () => BaseComponent)[] {
926
- // Access the private componentMap from ArcheType
927
- const componentMap = (archetype as any).componentMap as Record<string, new () => BaseComponent>;
928
- if (!componentMap) {
929
- return [];
930
- }
931
- return Object.values(componentMap);
932
- }
933
- }
361
+ }