bunsane 0.3.1 → 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 (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -0,0 +1,420 @@
1
+ import { logger } from "../Logger";
2
+ import { Query } from "../../query/Query";
3
+ import ArcheType from "../ArcheType";
4
+ import { BaseComponent } from "../components";
5
+ import type { ScheduledTaskInfo, SchedulerMetrics, TaskMetrics } from "../../types/scheduler.types";
6
+ import type { ComponentTargetConfig } from "../EntityHookManager";
7
+ import type { SchedulerManager } from "../SchedulerManager";
8
+
9
+ const loggerInstance = logger.child({ scope: "SchedulerManager" });
10
+
11
+ export function updateTaskMetrics(manager: SchedulerManager, taskId: string, updates: Partial<TaskMetrics>): void {
12
+ if (!manager.metrics.taskMetrics[taskId]) {
13
+ const taskInfo = manager.tasks.get(taskId);
14
+ manager.metrics.taskMetrics[taskId] = {
15
+ taskId,
16
+ taskName: taskInfo?.name || 'Unknown',
17
+ totalExecutions: 0,
18
+ successfulExecutions: 0,
19
+ failedExecutions: 0,
20
+ averageExecutionTime: 0,
21
+ totalEntitiesProcessed: 0,
22
+ retryCount: 0,
23
+ timeoutCount: 0
24
+ };
25
+ }
26
+
27
+ const metrics = manager.metrics.taskMetrics[taskId];
28
+ Object.assign(metrics, updates);
29
+
30
+ // Update rolling averages
31
+ if (updates.averageExecutionTime !== undefined) {
32
+ const currentAvg = metrics.averageExecutionTime;
33
+ const newCount = metrics.totalExecutions;
34
+ metrics.averageExecutionTime = ((currentAvg * (newCount - 1)) + updates.averageExecutionTime) / newCount;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Execute a task with timeout enforcement.
40
+ *
41
+ * Note: JS has no way to cancel an arbitrary Promise — on timeout the
42
+ * wrapper rejects but the underlying task continues. The second .catch
43
+ * below captures a late rejection after the wrapper already rejected,
44
+ * preventing an unhandled-rejection process crash (H-SCHED-5). The
45
+ * `settled` flag guards against double-settle.
46
+ */
47
+ export async function executeWithTimeout<T>(
48
+ manager: SchedulerManager,
49
+ task: Promise<T>,
50
+ timeoutMs: number,
51
+ taskInfo: ScheduledTaskInfo
52
+ ): Promise<T> {
53
+ return new Promise((resolve, reject) => {
54
+ let settled = false;
55
+ const timeoutId = setTimeout(() => {
56
+ if (settled) return;
57
+ settled = true;
58
+ manager.metrics.timedOutTasks++;
59
+ updateTaskMetrics(manager, taskInfo.id, {
60
+ timeoutCount: (manager.metrics.taskMetrics[taskInfo.id]?.timeoutCount || 0) + 1
61
+ });
62
+ const error = new Error(`Task ${taskInfo.name} timed out after ${timeoutMs}ms`);
63
+ manager.emitEvent({
64
+ type: 'task.timeout',
65
+ taskId: taskInfo.id,
66
+ timestamp: new Date(),
67
+ data: { timeoutMs, taskName: taskInfo.name }
68
+ });
69
+ reject(error);
70
+ }, timeoutMs);
71
+
72
+ task
73
+ .then((result) => {
74
+ if (settled) return;
75
+ settled = true;
76
+ clearTimeout(timeoutId);
77
+ resolve(result);
78
+ })
79
+ .catch((error) => {
80
+ if (settled) {
81
+ // Late rejection after timeout. Log only — the wrapper
82
+ // promise is already settled, so re-rejecting would be
83
+ // a no-op but the rejection would escape as unhandled.
84
+ loggerInstance.warn({ taskId: taskInfo.id, err: error }, 'Late rejection from scheduled task after timeout');
85
+ return;
86
+ }
87
+ settled = true;
88
+ clearTimeout(timeoutId);
89
+ reject(error);
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Handle task failure with retry logic
96
+ */
97
+ export async function handleTaskFailure(
98
+ manager: SchedulerManager,
99
+ taskInfo: ScheduledTaskInfo,
100
+ error: Error,
101
+ duration: number
102
+ ): Promise<void> {
103
+ taskInfo.lastError = error.message;
104
+
105
+ const maxRetries = taskInfo.options?.maxRetries || taskInfo.maxRetries || 0;
106
+ const retryDelay = taskInfo.options?.retryDelay || 1000; // Default 1 second
107
+
108
+ if (taskInfo.retryCount === undefined) {
109
+ taskInfo.retryCount = 0;
110
+ }
111
+
112
+ if (taskInfo.retryCount < maxRetries) {
113
+ taskInfo.retryCount++;
114
+ manager.metrics.retriedTasks++;
115
+
116
+ updateTaskMetrics(manager, taskInfo.id, {
117
+ retryCount: taskInfo.retryCount
118
+ });
119
+
120
+ if (manager.config.enableLogging) {
121
+ loggerInstance.warn(`Task ${taskInfo.name} failed (attempt ${taskInfo.retryCount}/${maxRetries}), retrying in ${retryDelay}ms: ${error.message}`);
122
+ }
123
+
124
+ // Schedule retry. Track the timer handle in `intervals` under a
125
+ // unique key so `stop()` can clear it (H-SCHED-3); without this
126
+ // the retry fires post-shutdown against a closed DB pool. Also
127
+ // skip the retry if the scheduler is no longer running by the
128
+ // time the timer fires, and rely on the new isRunning guard in
129
+ // doExecuteTask (H-SCHED-1) to prevent retry/tick overlap.
130
+ const retryKey = `${taskInfo.id}:retry:${taskInfo.retryCount}`;
131
+ const retryHandle = setTimeout(async () => {
132
+ manager.intervals.delete(retryKey);
133
+ if (!manager.isRunning) return;
134
+ await (manager as any).executeTask(taskInfo.id);
135
+ }, retryDelay);
136
+ manager.intervals.set(retryKey, retryHandle as any);
137
+
138
+ manager.emitEvent({
139
+ type: 'task.retry',
140
+ taskId: taskInfo.id,
141
+ timestamp: new Date(),
142
+ data: {
143
+ attempt: taskInfo.retryCount,
144
+ maxRetries,
145
+ retryDelay,
146
+ error: error.message
147
+ }
148
+ });
149
+ } else {
150
+ // Max retries reached or no retries configured
151
+ updateTaskMetrics(manager, taskInfo.id, {
152
+ failedExecutions: (manager.metrics.taskMetrics[taskInfo.id]?.failedExecutions || 0) + 1
153
+ });
154
+
155
+ if (manager.config.enableLogging) {
156
+ loggerInstance.error(`Task ${taskInfo.name} failed permanently after ${taskInfo.retryCount} attempts: ${error.message}`);
157
+ }
158
+
159
+ manager.emitEvent({
160
+ type: 'task.failed',
161
+ taskId: taskInfo.id,
162
+ timestamp: new Date(),
163
+ data: {
164
+ duration,
165
+ error: error.message,
166
+ attempts: taskInfo.retryCount,
167
+ maxRetries
168
+ }
169
+ });
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Build a Query object from ComponentTargetConfig
175
+ * @param componentTarget The component targeting configuration
176
+ * @returns A Query object configured with the component targeting
177
+ */
178
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
+ export function buildQueryFromComponentTarget(componentTarget: ComponentTargetConfig): Query<any> {
180
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
+ let query: Query<any> = new Query();
182
+
183
+ // Handle archetype matching first (most specific)
184
+ if (componentTarget.archetype) {
185
+ // For archetype matching, we need to include all components from the archetype
186
+ const archetypeComponents = getArchetypeComponents(componentTarget.archetype);
187
+ for (const component of archetypeComponents) {
188
+ query = query.with(component);
189
+ }
190
+ } else if (componentTarget.archetypes && componentTarget.archetypes.length > 0) {
191
+ // Handle multiple archetypes - for simplicity, we'll use the first valid one
192
+ // In a more advanced implementation, you might want to handle OR logic
193
+ const firstArchetype = componentTarget.archetypes.find(archetype => archetype !== undefined);
194
+ if (firstArchetype) {
195
+ const archetypeComponents = getArchetypeComponents(firstArchetype);
196
+ for (const component of archetypeComponents) {
197
+ query = query.with(component);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Handle included components
203
+ if (componentTarget.includeComponents && componentTarget.includeComponents.length > 0) {
204
+ const requireAll = componentTarget.requireAllIncluded ?? true;
205
+ if (requireAll) {
206
+ // ALL included components must be present (AND logic)
207
+ for (const component of componentTarget.includeComponents) {
208
+ query = query.with(component);
209
+ }
210
+ } else {
211
+ // ANY included component must be present (OR logic)
212
+ // For OR logic with Query API, we need to use a different approach
213
+ // This is a simplified implementation - in practice, you might need custom query logic
214
+ for (const component of componentTarget.includeComponents) {
215
+ query = query.with(component);
216
+ break; // Just use the first one for simplicity
217
+ }
218
+ }
219
+ }
220
+
221
+ // Handle excluded components
222
+ if (componentTarget.excludeComponents && componentTarget.excludeComponents.length > 0) {
223
+ for(const component of componentTarget.excludeComponents){
224
+ query = query.without(component);
225
+ }
226
+ }
227
+
228
+ return query;
229
+ }
230
+
231
+ /**
232
+ * Extract component classes from an ArcheType
233
+ * @param archetype The archetype to extract components from
234
+ * @returns Array of component classes
235
+ */
236
+ export function getArchetypeComponents(archetype: ArcheType): (new () => BaseComponent)[] {
237
+ // Access the private componentMap from ArcheType
238
+ const componentMap = (archetype as any).componentMap as Record<string, new () => BaseComponent>;
239
+ if (!componentMap) {
240
+ return [];
241
+ }
242
+ return Object.values(componentMap);
243
+ }
244
+
245
+ export async function doExecuteTask(manager: SchedulerManager, taskId: string): Promise<void> {
246
+ const taskInfo = manager.tasks.get(taskId);
247
+ if (!taskInfo || !taskInfo.enabled) {
248
+ return;
249
+ }
250
+
251
+ // Skip if the previous tick is still executing. Without this guard
252
+ // a slow task with interval < execution-time burns a lock-acquire
253
+ // round-trip every tick and floods the skipped-executions metric
254
+ // (H-SCHED-1). Cheap in-process check before reaching out to PG.
255
+ if (taskInfo.isRunning) {
256
+ manager.metrics.skippedExecutions++;
257
+ if (manager.config.enableLogging) {
258
+ loggerInstance.debug(`Task ${taskInfo.name} skipped - previous execution still running`);
259
+ }
260
+ return;
261
+ }
262
+
263
+ if (manager.metrics.runningTasks >= manager.config.maxConcurrentTasks) {
264
+ if (manager.config.enableLogging) {
265
+ loggerInstance.warn(`Maximum concurrent tasks reached. Skipping execution of ${taskInfo.name}`);
266
+ }
267
+ return;
268
+ }
269
+
270
+ // Try to acquire distributed lock before executing
271
+ manager.metrics.lockAttempts++;
272
+ const lockResult = await manager.distributedLock.tryAcquire(taskId);
273
+
274
+ if (!lockResult.acquired) {
275
+ // Another instance is executing this task
276
+ manager.metrics.skippedExecutions++;
277
+
278
+ if (manager.config.enableLogging) {
279
+ loggerInstance.debug(`Task ${taskInfo.name} skipped - another instance is executing (lock key: ${lockResult.lockKey})`);
280
+ }
281
+
282
+ manager.emitEvent({
283
+ type: 'task.skipped',
284
+ taskId: taskInfo.id,
285
+ timestamp: new Date(),
286
+ data: { reason: 'lock_unavailable', lockKey: lockResult.lockKey.toString() }
287
+ });
288
+
289
+ return;
290
+ }
291
+
292
+ // Lock acquired successfully
293
+ manager.metrics.locksAcquired++;
294
+
295
+ manager.emitEvent({
296
+ type: 'task.lock.acquired',
297
+ taskId: taskInfo.id,
298
+ timestamp: new Date(),
299
+ data: { lockKey: lockResult.lockKey.toString() }
300
+ });
301
+
302
+ taskInfo.isRunning = true;
303
+ taskInfo.lastExecution = new Date();
304
+ manager.metrics.runningTasks++;
305
+
306
+ const startTime = Date.now();
307
+ const timeout = taskInfo.options?.timeout || manager.config.defaultTimeout;
308
+
309
+ try {
310
+ // Create query based on targeting configuration
311
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
312
+ let query: Query<any> | null = null;
313
+
314
+ if (taskInfo.options?.query) {
315
+ // Use custom query function (preferred approach)
316
+ query = taskInfo.options.query();
317
+ } else if (taskInfo.options?.componentTarget) {
318
+ // Use component targeting configuration (deprecated - use query instead)
319
+ const componentTarget = taskInfo.options.componentTarget;
320
+ query = buildQueryFromComponentTarget(componentTarget);
321
+ } else if (taskInfo.componentTarget) {
322
+ // Use legacy single component targeting (deprecated - use query instead)
323
+ query = new Query().with(taskInfo.componentTarget);
324
+ }
325
+ // else: time-based task — no entity selection. Handler invoked
326
+ // with no arguments on each tick.
327
+
328
+ // Apply entity limit if specified (can be used with query function)
329
+ if (query && taskInfo.options?.maxEntitiesPerExecution) {
330
+ query.take(taskInfo.options.maxEntitiesPerExecution);
331
+ }
332
+
333
+ const entities = query ? await query.exec() : [];
334
+
335
+ // Execute the scheduled method with the entities array
336
+ const method = taskInfo.service[taskInfo.methodName];
337
+ if (typeof method !== 'function') {
338
+ throw new Error(`Method ${taskInfo.methodName} not found on service`);
339
+ }
340
+
341
+ // Execute with timeout. Time-based tasks receive no entity arg.
342
+ const result = await executeWithTimeout(
343
+ manager,
344
+ query
345
+ ? method.call(taskInfo.service, entities)
346
+ : method.call(taskInfo.service),
347
+ timeout,
348
+ taskInfo
349
+ );
350
+
351
+ const duration = Date.now() - startTime;
352
+ taskInfo.executionCount++;
353
+ manager.metrics.completedExecutions++;
354
+ manager.metrics.totalExecutionTime += duration;
355
+ manager.metrics.averageExecutionTime = manager.metrics.totalExecutionTime / manager.metrics.completedExecutions;
356
+
357
+ // Update task-specific metrics
358
+ updateTaskMetrics(manager, taskInfo.id, {
359
+ totalExecutions: taskInfo.executionCount,
360
+ successfulExecutions: (manager.metrics.taskMetrics[taskInfo.id]?.successfulExecutions || 0) + 1,
361
+ averageExecutionTime: duration,
362
+ lastExecutionTime: new Date(),
363
+ totalEntitiesProcessed: entities.length
364
+ });
365
+
366
+ if (manager.config.enableLogging) {
367
+ loggerInstance.info(`Task ${taskInfo.name} completed successfully in ${duration}ms (processed ${entities.length} entities)`);
368
+ }
369
+
370
+ manager.emitEvent({
371
+ type: 'task.executed',
372
+ taskId: taskInfo.id,
373
+ timestamp: new Date(),
374
+ data: { duration, entitiesProcessed: entities.length, success: true }
375
+ });
376
+
377
+ } catch (error) {
378
+ const duration = Date.now() - startTime;
379
+ manager.metrics.failedExecutions++;
380
+
381
+ // Handle retry logic
382
+ await handleTaskFailure(manager, taskInfo, error instanceof Error ? error : new Error(String(error)), duration);
383
+
384
+ if (manager.config.enableLogging) {
385
+ loggerInstance.error(`Task ${taskInfo.name} failed after ${duration}ms: ${error instanceof Error ? error.message : String(error)}`);
386
+ }
387
+
388
+ manager.emitEvent({
389
+ type: 'task.failed',
390
+ taskId: taskInfo.id,
391
+ timestamp: new Date(),
392
+ data: { duration, error: error instanceof Error ? error.message : String(error) }
393
+ });
394
+
395
+ } finally {
396
+ taskInfo.isRunning = false;
397
+ manager.metrics.runningTasks--;
398
+
399
+ // Release the distributed lock
400
+ await manager.distributedLock.release(taskId);
401
+
402
+ manager.emitEvent({
403
+ type: 'task.lock.released',
404
+ taskId: taskInfo.id,
405
+ timestamp: new Date(),
406
+ data: { lockKey: lockResult.lockKey.toString() }
407
+ });
408
+ }
409
+ }
410
+
411
+ export async function executeTask(manager: SchedulerManager, taskId: string): Promise<void> {
412
+ // Track this execution so stop() can await in-flight work before
413
+ // resources (DB pool, cache) are torn down. Without this, a task mid-
414
+ // write during SIGTERM hits a closed DB pool and silently corrupts
415
+ // or loses data.
416
+ const p = doExecuteTask(manager, taskId);
417
+ manager.inflightTasks.add(p);
418
+ p.finally(() => manager.inflightTasks.delete(p));
419
+ return p;
420
+ }