bunsane 0.3.2 → 0.5.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 (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -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 +8 -7
  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 +25 -10
  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 +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. 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
+ }
@@ -60,6 +60,16 @@ const envSchema = z
60
60
  .string()
61
61
  .regex(/^\d+$/, "DB_CONNECTION_TIMEOUT must be numeric (seconds)")
62
62
  .optional(),
63
+ DB_HEALTH_WRITE_TIMEOUT: z
64
+ .string()
65
+ .regex(/^\d+$/, "DB_HEALTH_WRITE_TIMEOUT must be numeric (milliseconds)")
66
+ .optional(),
67
+ HEALTH_DB_WRITE_PROBE: z
68
+ .enum(["true", "false"])
69
+ .optional(),
70
+ DB_DISABLE_PREPARE: z
71
+ .enum(["true", "false"])
72
+ .optional(),
63
73
  })
64
74
  .refine(
65
75
  (env) => {