bunsane 0.2.10 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +318 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +300 -69
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +525 -256
- package/core/EntityHookManager.ts +88 -21
- package/core/EntityManager.ts +12 -3
- package/core/Logger.ts +4 -0
- package/core/RequestContext.ts +4 -1
- package/core/SchedulerManager.ts +105 -22
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +72 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/components/BaseComponent.ts +12 -2
- package/core/decorators/EntityHooks.ts +24 -12
- package/core/middleware/RateLimit.ts +105 -0
- package/core/middleware/index.ts +1 -0
- package/core/remote/OutboxWorker.ts +42 -35
- package/core/scheduler/DistributedLock.ts +22 -7
- package/database/PreparedStatementCache.ts +5 -13
- package/gql/builders/ResolverBuilder.ts +4 -4
- package/gql/complexityLimit.ts +95 -0
- package/gql/index.ts +15 -3
- package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +18 -11
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +42 -31
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +28 -9
- package/service/index.ts +4 -2
- package/storage/LocalStorageProvider.ts +12 -3
- package/storage/S3StorageProvider.ts +6 -6
- package/tests/e2e/http.test.ts +6 -2
- package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
- package/tests/unit/cache/CacheManager.test.ts +20 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
package/core/App.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ApplicationLifecycle, {
|
|
2
2
|
ApplicationPhase,
|
|
3
|
+
type PhaseChangeEvent,
|
|
3
4
|
} from "./ApplicationLifecycle";
|
|
4
5
|
import {
|
|
5
6
|
GenerateTableName,
|
|
@@ -32,6 +33,8 @@ import {
|
|
|
32
33
|
setRemoteManager,
|
|
33
34
|
} from "./remote";
|
|
34
35
|
import type { RemoteManagerConfig } from "./remote";
|
|
36
|
+
import type { CacheConfig } from "../config/cache.config";
|
|
37
|
+
import { createRequestContextPlugin } from "./RequestContext";
|
|
35
38
|
|
|
36
39
|
export type CorsConfig = {
|
|
37
40
|
origin?: string | string[] | ((origin: string) => boolean);
|
|
@@ -81,7 +84,17 @@ export default class App {
|
|
|
81
84
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
82
85
|
private isShuttingDown = false;
|
|
83
86
|
private isReady = false;
|
|
87
|
+
private cacheConfig: Partial<CacheConfig> | null = null;
|
|
88
|
+
private requestContextPluginEnabled = true;
|
|
89
|
+
private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
|
|
90
|
+
private signalHandlersRegistered = false;
|
|
91
|
+
private processHandlersRegistered = false;
|
|
92
|
+
private sigTermHandler: (() => void) | null = null;
|
|
93
|
+
private sigIntHandler: (() => void) | null = null;
|
|
94
|
+
private unhandledRejectionHandler: ((reason: unknown, promise: Promise<unknown>) => void) | null = null;
|
|
95
|
+
private uncaughtExceptionHandler: ((error: Error) => void) | null = null;
|
|
84
96
|
private graphqlMaxDepth: number = 10;
|
|
97
|
+
private graphqlMaxComplexity: number = 1000;
|
|
85
98
|
private shutdownGracePeriod = 10_000;
|
|
86
99
|
private maxRequestBodySize = 50 * 1024 * 1024; // 50MB default
|
|
87
100
|
|
|
@@ -125,6 +138,9 @@ export default class App {
|
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
public setCors(cors: CorsConfig) {
|
|
141
|
+
if (cors.origin === undefined) {
|
|
142
|
+
throw new Error('[CORS] `origin` is required. Pass an explicit string, array, function, or "*" if you truly want to allow everyone.');
|
|
143
|
+
}
|
|
128
144
|
this.config.cors = cors;
|
|
129
145
|
// Warn about invalid configuration
|
|
130
146
|
if (cors.credentials && cors.origin === '*') {
|
|
@@ -133,19 +149,29 @@ export default class App {
|
|
|
133
149
|
}
|
|
134
150
|
|
|
135
151
|
async init() {
|
|
152
|
+
// Register process-level error handlers FIRST so failures during init
|
|
153
|
+
// (DB prep, component registration, schema build) are observable. If
|
|
154
|
+
// registration happens later (e.g. in start()) any boot-sequence
|
|
155
|
+
// unhandled rejection is silently discarded by the runtime.
|
|
156
|
+
this.registerProcessHandlers();
|
|
157
|
+
|
|
136
158
|
validateEnv();
|
|
137
159
|
logger.trace(`Initializing App`);
|
|
138
160
|
ComponentRegistry.init();
|
|
139
161
|
ServiceRegistry.init();
|
|
140
162
|
|
|
141
|
-
// Initialize CacheManager
|
|
163
|
+
// Initialize CacheManager with merged config. MUST await — initialize()
|
|
164
|
+
// is async and sets up pub/sub for cross-instance invalidation. Previously
|
|
165
|
+
// only getInstance() was called, silently skipping pub/sub setup and
|
|
166
|
+
// ignoring any app-supplied config (C04).
|
|
142
167
|
try {
|
|
143
168
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
144
169
|
const cacheManager = CacheManager.getInstance();
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
await cacheManager.initialize(this.cacheConfig ?? {});
|
|
171
|
+
const config = cacheManager.getConfig();
|
|
172
|
+
logger.info({ scope: 'cache', component: 'App', msg: 'CacheManager initialized', provider: config.provider, enabled: config.enabled, strategy: config.strategy });
|
|
147
173
|
} catch (error) {
|
|
148
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', error });
|
|
174
|
+
logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', err: error });
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
// Plugin initialization
|
|
@@ -155,7 +181,12 @@ export default class App {
|
|
|
155
181
|
}
|
|
156
182
|
}
|
|
157
183
|
|
|
158
|
-
|
|
184
|
+
// Remove any previous listener so repeated init() calls (tests) don't
|
|
185
|
+
// stack handlers on the lifecycle singleton.
|
|
186
|
+
if (this.phaseListener) {
|
|
187
|
+
ApplicationLifecycle.removePhaseListener(this.phaseListener);
|
|
188
|
+
}
|
|
189
|
+
this.phaseListener = async (event: PhaseChangeEvent) => {
|
|
159
190
|
const phase = event.detail;
|
|
160
191
|
logger.info(`Application phase changed to: ${phase}`);
|
|
161
192
|
// Notify plugins of phase change
|
|
@@ -217,23 +248,39 @@ export default class App {
|
|
|
217
248
|
if (envDepth) {
|
|
218
249
|
this.graphqlMaxDepth = parseInt(envDepth, 10);
|
|
219
250
|
}
|
|
251
|
+
const envComplexity = process.env.GRAPHQL_MAX_COMPLEXITY;
|
|
252
|
+
if (envComplexity) {
|
|
253
|
+
const parsed = parseInt(envComplexity, 10);
|
|
254
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
255
|
+
this.graphqlMaxComplexity = parsed;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
220
258
|
|
|
221
259
|
const yogaOptions = {
|
|
222
260
|
cors: this.config.cors,
|
|
223
261
|
maxDepth: this.graphqlMaxDepth || undefined,
|
|
262
|
+
maxComplexity: this.graphqlMaxComplexity,
|
|
224
263
|
};
|
|
225
264
|
|
|
265
|
+
// Auto-apply RequestContext plugin by default so
|
|
266
|
+
// apps using @BelongsTo / @HasMany get DataLoader
|
|
267
|
+
// batching without opt-in. Prevents N+1 query
|
|
268
|
+
// explosion. Opt out via disableRequestContextPlugin().
|
|
269
|
+
const effectivePlugins: Plugin[] = this.requestContextPluginEnabled
|
|
270
|
+
? [createRequestContextPlugin(), ...this.yogaPlugins]
|
|
271
|
+
: [...this.yogaPlugins];
|
|
272
|
+
|
|
226
273
|
if (schema) {
|
|
227
274
|
this.yoga = createYogaInstance(
|
|
228
275
|
schema,
|
|
229
|
-
|
|
276
|
+
effectivePlugins,
|
|
230
277
|
wrappedContextFactory,
|
|
231
278
|
yogaOptions
|
|
232
279
|
);
|
|
233
280
|
} else {
|
|
234
281
|
this.yoga = createYogaInstance(
|
|
235
282
|
undefined,
|
|
236
|
-
|
|
283
|
+
effectivePlugins,
|
|
237
284
|
wrappedContextFactory,
|
|
238
285
|
yogaOptions
|
|
239
286
|
);
|
|
@@ -402,8 +449,21 @@ export default class App {
|
|
|
402
449
|
ApplicationPhase.APPLICATION_READY
|
|
403
450
|
);
|
|
404
451
|
} catch (error) {
|
|
405
|
-
|
|
406
|
-
|
|
452
|
+
// SYSTEM_READY failures must not be swallowed silently.
|
|
453
|
+
// Without this, the app stays forever in SYSTEM_READY
|
|
454
|
+
// (isReady=false, /health/ready → 503 forever) and k8s
|
|
455
|
+
// rollout hangs with no observable cause. Surface the
|
|
456
|
+
// failure so the readiness probe reports it and the
|
|
457
|
+
// orchestrator can restart.
|
|
458
|
+
this.isReady = false;
|
|
459
|
+
logger.fatal({ scope: 'app', component: 'App', err: error }, 'Fatal error during SYSTEM_READY phase — marking app unready');
|
|
460
|
+
// In production, exit so k8s can restart the pod.
|
|
461
|
+
// In tests, rethrow so the test sees the failure.
|
|
462
|
+
if (process.env.NODE_ENV === 'test') {
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
// Give the logger a chance to flush, then exit.
|
|
466
|
+
setTimeout(() => process.exit(1), 100).unref?.();
|
|
407
467
|
}
|
|
408
468
|
break;
|
|
409
469
|
}
|
|
@@ -414,7 +474,8 @@ export default class App {
|
|
|
414
474
|
break;
|
|
415
475
|
}
|
|
416
476
|
}
|
|
417
|
-
}
|
|
477
|
+
};
|
|
478
|
+
ApplicationLifecycle.addPhaseListener(this.phaseListener);
|
|
418
479
|
|
|
419
480
|
if (
|
|
420
481
|
ApplicationLifecycle.getCurrentPhase() ===
|
|
@@ -433,17 +494,31 @@ export default class App {
|
|
|
433
494
|
}
|
|
434
495
|
}
|
|
435
496
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Resolve once the application has reached APPLICATION_READY. Previously
|
|
499
|
+
* polled every 100ms with no exit condition — a boot failure would keep
|
|
500
|
+
* the interval timer alive forever (H-MEM-1). Now attaches a one-shot
|
|
501
|
+
* phase listener and self-cleans on first match. Bounded by `timeoutMs`
|
|
502
|
+
* so callers cannot hang indefinitely; default matches waitForPhase.
|
|
503
|
+
*/
|
|
504
|
+
waitForAppReady(timeoutMs = 60_000): Promise<void> {
|
|
505
|
+
if (ApplicationLifecycle.getCurrentPhase() >= ApplicationPhase.APPLICATION_READY) {
|
|
506
|
+
return Promise.resolve();
|
|
507
|
+
}
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const timer = setTimeout(() => {
|
|
510
|
+
ApplicationLifecycle.removePhaseListener(onPhase);
|
|
511
|
+
reject(new Error(`waitForAppReady timed out after ${timeoutMs}ms; current phase=${ApplicationLifecycle.getCurrentPhase()}`));
|
|
512
|
+
}, timeoutMs);
|
|
513
|
+
timer.unref?.();
|
|
514
|
+
const onPhase = (event: PhaseChangeEvent) => {
|
|
515
|
+
if (event.detail === ApplicationPhase.APPLICATION_READY) {
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
ApplicationLifecycle.removePhaseListener(onPhase);
|
|
444
518
|
resolve();
|
|
445
519
|
}
|
|
446
|
-
}
|
|
520
|
+
};
|
|
521
|
+
ApplicationLifecycle.addPhaseListener(onPhase);
|
|
447
522
|
});
|
|
448
523
|
}
|
|
449
524
|
|
|
@@ -485,8 +560,12 @@ export default class App {
|
|
|
485
560
|
|
|
486
561
|
const configOrigin = this.config.cors.origin;
|
|
487
562
|
|
|
488
|
-
//
|
|
489
|
-
|
|
563
|
+
// origin is required by setCors; undefined here would indicate a
|
|
564
|
+
// programming error. Treat as deny rather than silent wildcard.
|
|
565
|
+
if (configOrigin === undefined) return null;
|
|
566
|
+
|
|
567
|
+
// Explicit wildcard allows all
|
|
568
|
+
if (configOrigin === '*') {
|
|
490
569
|
// If credentials enabled, cannot use wildcard - return actual origin
|
|
491
570
|
return this.config.cors.credentials ? requestOrigin : '*';
|
|
492
571
|
}
|
|
@@ -518,12 +597,16 @@ export default class App {
|
|
|
518
597
|
// If origin not allowed, return empty (no CORS headers)
|
|
519
598
|
if (requestOrigin && !allowedOrigin) return {};
|
|
520
599
|
|
|
600
|
+
// No allowedOrigin means request carried no Origin header. Skip
|
|
601
|
+
// Access-Control-Allow-Origin rather than falling back to '*'.
|
|
521
602
|
const headers: Record<string, string> = {
|
|
522
|
-
'Access-Control-Allow-Origin': allowedOrigin || '*',
|
|
523
603
|
'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
|
|
524
604
|
'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
|
|
525
605
|
'Vary': 'Origin',
|
|
526
606
|
};
|
|
607
|
+
if (allowedOrigin) {
|
|
608
|
+
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
609
|
+
}
|
|
527
610
|
|
|
528
611
|
if (this.config.cors.credentials) {
|
|
529
612
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
@@ -540,6 +623,27 @@ export default class App {
|
|
|
540
623
|
return headers;
|
|
541
624
|
}
|
|
542
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Combine multiple AbortSignals into one that aborts when any input
|
|
628
|
+
* aborts. Uses `AbortSignal.any` when available (Node 20+/current Bun),
|
|
629
|
+
* falls back to a manual combiner for older runtimes.
|
|
630
|
+
*/
|
|
631
|
+
private combineSignals(signals: AbortSignal[]): AbortSignal {
|
|
632
|
+
const anyFn = (AbortSignal as any).any;
|
|
633
|
+
if (typeof anyFn === 'function') {
|
|
634
|
+
return anyFn.call(AbortSignal, signals);
|
|
635
|
+
}
|
|
636
|
+
const controller = new AbortController();
|
|
637
|
+
for (const s of signals) {
|
|
638
|
+
if (s.aborted) {
|
|
639
|
+
controller.abort((s as any).reason);
|
|
640
|
+
return controller.signal;
|
|
641
|
+
}
|
|
642
|
+
s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
|
|
643
|
+
}
|
|
644
|
+
return controller.signal;
|
|
645
|
+
}
|
|
646
|
+
|
|
543
647
|
private addCorsHeaders(response: Response, req?: Request): Response {
|
|
544
648
|
const corsHeaders = this.getCorsHeaders(req);
|
|
545
649
|
if (Object.keys(corsHeaders).length === 0) return response;
|
|
@@ -569,18 +673,28 @@ export default class App {
|
|
|
569
673
|
});
|
|
570
674
|
}
|
|
571
675
|
|
|
572
|
-
//
|
|
676
|
+
// Request timeout — combine the framework wall-clock with the client's
|
|
677
|
+
// abort signal. The combined signal is attached to a cloned request
|
|
678
|
+
// that is passed to Yoga / REST handlers so downstream work (DB
|
|
679
|
+
// queries, resolvers) actually gets cancelled on timeout or client
|
|
680
|
+
// disconnect. Previously the signal was created but never propagated,
|
|
681
|
+
// so the timer only logged a warning while the request continued
|
|
682
|
+
// consuming resources (C05).
|
|
573
683
|
const controller = new AbortController();
|
|
574
684
|
const timeoutId = setTimeout(() => {
|
|
575
|
-
controller.abort();
|
|
685
|
+
controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
|
|
576
686
|
logger.warn(`Request timeout: ${method} ${url.pathname}`);
|
|
577
|
-
}, 30000);
|
|
687
|
+
}, 30000);
|
|
688
|
+
const combinedSignal = this.combineSignals([req.signal, controller.signal]);
|
|
689
|
+
// Rebind the request with the combined signal so handlers (Yoga, REST)
|
|
690
|
+
// see it via req.signal and can propagate cancellation.
|
|
691
|
+
req = new Request(req, { signal: combinedSignal });
|
|
578
692
|
|
|
579
693
|
try {
|
|
580
694
|
// Health check endpoint
|
|
581
695
|
if (url.pathname === "/health") {
|
|
582
|
-
clearTimeout(timeoutId);
|
|
583
696
|
const health = await deepHealthCheck();
|
|
697
|
+
clearTimeout(timeoutId);
|
|
584
698
|
return this.addCorsHeaders(new Response(
|
|
585
699
|
JSON.stringify(health.result),
|
|
586
700
|
{
|
|
@@ -592,8 +706,8 @@ export default class App {
|
|
|
592
706
|
|
|
593
707
|
// Metrics endpoint
|
|
594
708
|
if (url.pathname === "/metrics") {
|
|
595
|
-
clearTimeout(timeoutId);
|
|
596
709
|
const metrics = await this.collectMetrics();
|
|
710
|
+
clearTimeout(timeoutId);
|
|
597
711
|
return this.addCorsHeaders(new Response(
|
|
598
712
|
JSON.stringify(metrics),
|
|
599
713
|
{
|
|
@@ -605,8 +719,8 @@ export default class App {
|
|
|
605
719
|
|
|
606
720
|
// Remote health check
|
|
607
721
|
if (url.pathname === "/health/remote") {
|
|
608
|
-
clearTimeout(timeoutId);
|
|
609
722
|
if (!this.remote) {
|
|
723
|
+
clearTimeout(timeoutId);
|
|
610
724
|
return this.addCorsHeaders(new Response(
|
|
611
725
|
JSON.stringify({
|
|
612
726
|
healthy: false,
|
|
@@ -619,6 +733,7 @@ export default class App {
|
|
|
619
733
|
), req);
|
|
620
734
|
}
|
|
621
735
|
const health = await this.remote.health();
|
|
736
|
+
clearTimeout(timeoutId);
|
|
622
737
|
return this.addCorsHeaders(new Response(
|
|
623
738
|
JSON.stringify(health),
|
|
624
739
|
{
|
|
@@ -630,8 +745,8 @@ export default class App {
|
|
|
630
745
|
|
|
631
746
|
// Readiness probe
|
|
632
747
|
if (url.pathname === "/health/ready") {
|
|
633
|
-
clearTimeout(timeoutId);
|
|
634
748
|
const ready = await readinessCheck(this.isReady, this.isShuttingDown);
|
|
749
|
+
clearTimeout(timeoutId);
|
|
635
750
|
return this.addCorsHeaders(new Response(
|
|
636
751
|
JSON.stringify(ready.result),
|
|
637
752
|
{
|
|
@@ -1019,6 +1134,34 @@ export default class App {
|
|
|
1019
1134
|
this.graphqlMaxDepth = depth;
|
|
1020
1135
|
}
|
|
1021
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* Set the maximum GraphQL query complexity. 0 disables.
|
|
1139
|
+
* Complexity = sum of per-field costs, multiplied by `first`/`limit`/`take`
|
|
1140
|
+
* arguments when present on each field.
|
|
1141
|
+
*/
|
|
1142
|
+
public setGraphQLMaxComplexity(complexity: number) {
|
|
1143
|
+
this.graphqlMaxComplexity = complexity;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Supply a cache configuration that will be merged with `defaultCacheConfig`
|
|
1148
|
+
* and passed to `CacheManager.initialize()` during `init()`. Must be called
|
|
1149
|
+
* before `init()`.
|
|
1150
|
+
*/
|
|
1151
|
+
public setCacheConfig(config: Partial<CacheConfig>) {
|
|
1152
|
+
this.cacheConfig = config;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Disable the auto-applied RequestContext plugin. Only do this if your
|
|
1157
|
+
* app does not use `@BelongsTo` / `@HasMany` relations OR you are
|
|
1158
|
+
* supplying your own DataLoader plugin. Without it, nested relation
|
|
1159
|
+
* resolvers issue one DB query per row (N+1).
|
|
1160
|
+
*/
|
|
1161
|
+
public disableRequestContextPlugin() {
|
|
1162
|
+
this.requestContextPluginEnabled = false;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1022
1165
|
/**
|
|
1023
1166
|
* Set the grace period for draining connections during shutdown (ms).
|
|
1024
1167
|
*/
|
|
@@ -1098,7 +1241,9 @@ export default class App {
|
|
|
1098
1241
|
try {
|
|
1099
1242
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
1100
1243
|
cacheStats = await CacheManager.getInstance().getStats();
|
|
1101
|
-
} catch {
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
logger.warn({ err }, 'metrics: cache stats unavailable');
|
|
1246
|
+
}
|
|
1102
1247
|
|
|
1103
1248
|
return {
|
|
1104
1249
|
timestamp: new Date().toISOString(),
|
|
@@ -1151,68 +1296,107 @@ export default class App {
|
|
|
1151
1296
|
)}`
|
|
1152
1297
|
);
|
|
1153
1298
|
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1299
|
+
// Signal handlers now registered in init() via registerProcessHandlers()
|
|
1300
|
+
// so they cover the boot sequence (before start() runs).
|
|
1301
|
+
|
|
1302
|
+
this.isReady = true;
|
|
1303
|
+
this.appReadyCallbacks.forEach((cb) => cb());
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Register process-level signal and error handlers. Called at the top of
|
|
1308
|
+
* `init()` so that failures during boot (DB prep, component registration,
|
|
1309
|
+
* schema build) are logged and don't silently crash the runtime.
|
|
1310
|
+
*
|
|
1311
|
+
* Uses `process.once` for signals so a double SIGTERM can't fire two
|
|
1312
|
+
* concurrent shutdown paths racing each other to `process.exit`. Also
|
|
1313
|
+
* idempotent — safe to call multiple times (e.g. in tests).
|
|
1314
|
+
*/
|
|
1315
|
+
private registerProcessHandlers(): void {
|
|
1316
|
+
if (this.processHandlersRegistered) return;
|
|
1160
1317
|
|
|
1161
|
-
|
|
1318
|
+
// Use arrow-bound handlers so `once` works cleanly.
|
|
1319
|
+
this.sigTermHandler = () => {
|
|
1320
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGTERM' });
|
|
1321
|
+
this.shutdown().finally(() => process.exit(0));
|
|
1322
|
+
};
|
|
1323
|
+
this.sigIntHandler = () => {
|
|
1162
1324
|
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1325
|
+
this.shutdown().finally(() => process.exit(0));
|
|
1326
|
+
};
|
|
1327
|
+
process.once('SIGTERM', this.sigTermHandler);
|
|
1328
|
+
process.once('SIGINT', this.sigIntHandler);
|
|
1166
1329
|
|
|
1167
|
-
// Global error handlers to prevent silent crashes
|
|
1168
|
-
|
|
1330
|
+
// Global error handlers to prevent silent crashes during init AND runtime.
|
|
1331
|
+
this.unhandledRejectionHandler = (reason, promise) => {
|
|
1169
1332
|
logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
logger.fatal({ scope: 'app', component: 'App', error, msg: 'Uncaught exception — shutting down' });
|
|
1333
|
+
};
|
|
1334
|
+
this.uncaughtExceptionHandler = (error) => {
|
|
1335
|
+
logger.fatal({ scope: 'app', component: 'App', err: error, msg: 'Uncaught exception — shutting down' });
|
|
1174
1336
|
this.shutdown().finally(() => process.exit(1));
|
|
1175
|
-
}
|
|
1337
|
+
};
|
|
1338
|
+
process.on('unhandledRejection', this.unhandledRejectionHandler);
|
|
1339
|
+
process.on('uncaughtException', this.uncaughtExceptionHandler);
|
|
1176
1340
|
|
|
1177
|
-
this.
|
|
1178
|
-
|
|
1341
|
+
this.processHandlersRegistered = true;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private unregisterProcessHandlers(): void {
|
|
1345
|
+
if (!this.processHandlersRegistered) return;
|
|
1346
|
+
if (this.sigTermHandler) process.removeListener('SIGTERM', this.sigTermHandler);
|
|
1347
|
+
if (this.sigIntHandler) process.removeListener('SIGINT', this.sigIntHandler);
|
|
1348
|
+
if (this.unhandledRejectionHandler) process.removeListener('unhandledRejection', this.unhandledRejectionHandler);
|
|
1349
|
+
if (this.uncaughtExceptionHandler) process.removeListener('uncaughtException', this.uncaughtExceptionHandler);
|
|
1350
|
+
this.sigTermHandler = null;
|
|
1351
|
+
this.sigIntHandler = null;
|
|
1352
|
+
this.unhandledRejectionHandler = null;
|
|
1353
|
+
this.uncaughtExceptionHandler = null;
|
|
1354
|
+
this.processHandlersRegistered = false;
|
|
1179
1355
|
}
|
|
1180
1356
|
|
|
1181
1357
|
/**
|
|
1182
|
-
* Gracefully shutdown the application
|
|
1358
|
+
* Gracefully shutdown the application.
|
|
1359
|
+
*
|
|
1360
|
+
* Ordered drain: HTTP → scheduler → remote → cache → database. Each step
|
|
1361
|
+
* awaits completion before the next begins so in-flight work always sees
|
|
1362
|
+
* its dependencies still available. Total budget bounded by
|
|
1363
|
+
* `shutdownGracePeriod`; per-step budgets fall back to reasonable defaults.
|
|
1183
1364
|
*/
|
|
1184
1365
|
async shutdown(): Promise<void> {
|
|
1185
1366
|
if (this.isShuttingDown) return;
|
|
1186
1367
|
this.isShuttingDown = true;
|
|
1187
1368
|
this.isReady = false;
|
|
1188
1369
|
|
|
1189
|
-
|
|
1370
|
+
const shutdownStart = Date.now();
|
|
1371
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Shutting down application', gracePeriodMs: this.shutdownGracePeriod });
|
|
1190
1372
|
|
|
1191
|
-
|
|
1373
|
+
const budgetRemaining = () => Math.max(500, this.shutdownGracePeriod - (Date.now() - shutdownStart));
|
|
1374
|
+
|
|
1375
|
+
// 1. Stop HTTP server: stop accepting new connections, wait for in-flight
|
|
1376
|
+
// requests to finish. Bun's server.stop(false) initiates graceful
|
|
1377
|
+
// drain but does not return a promise — we poll the server's
|
|
1378
|
+
// pendingRequests count, then force-close on deadline.
|
|
1192
1379
|
if (this.server) {
|
|
1193
1380
|
try {
|
|
1194
|
-
logger.info({ scope: 'app', component: 'App', msg: 'Draining connections' });
|
|
1381
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
|
|
1195
1382
|
this.server.stop(false);
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
try { this.server?.stop(true); } catch {}
|
|
1199
|
-
}, this.shutdownGracePeriod);
|
|
1200
|
-
forceTimer.unref?.();
|
|
1383
|
+
await this.waitForHttpDrain(budgetRemaining());
|
|
1384
|
+
try { this.server.stop(true); } catch {}
|
|
1201
1385
|
logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
|
|
1202
1386
|
} catch (error) {
|
|
1203
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', error });
|
|
1387
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', err: error });
|
|
1204
1388
|
}
|
|
1205
1389
|
}
|
|
1206
1390
|
|
|
1207
|
-
// Stop scheduler
|
|
1391
|
+
// 2. Stop scheduler (awaits in-flight tasks internally, see C14).
|
|
1208
1392
|
try {
|
|
1209
|
-
await SchedulerManager.getInstance().stop();
|
|
1393
|
+
await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
|
|
1210
1394
|
logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
|
|
1211
1395
|
} catch (error) {
|
|
1212
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', error });
|
|
1396
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', err: error });
|
|
1213
1397
|
}
|
|
1214
1398
|
|
|
1215
|
-
// Shutdown RemoteManager (after scheduler, before cache — DB still available)
|
|
1399
|
+
// 3. Shutdown RemoteManager (after scheduler, before cache — DB still available).
|
|
1216
1400
|
if (this.remote) {
|
|
1217
1401
|
try {
|
|
1218
1402
|
await this.remote.shutdown();
|
|
@@ -1220,27 +1404,74 @@ export default class App {
|
|
|
1220
1404
|
this.remote = null;
|
|
1221
1405
|
logger.info({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown' });
|
|
1222
1406
|
} catch (error) {
|
|
1223
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', error });
|
|
1407
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', err: error });
|
|
1224
1408
|
}
|
|
1225
1409
|
}
|
|
1226
1410
|
|
|
1227
|
-
//
|
|
1411
|
+
// 4. Drain any fire-and-forget cache ops triggered by entity.set /
|
|
1412
|
+
// entity.remove before we disconnect the cache (H-CACHE-1).
|
|
1413
|
+
// Also drain post-commit side effects (cache + hooks scheduled
|
|
1414
|
+
// via queueMicrotask from save()) so hook-triggered DB work
|
|
1415
|
+
// doesn't hit a closed pool.
|
|
1416
|
+
try {
|
|
1417
|
+
const { Entity } = await import('./Entity');
|
|
1418
|
+
await Entity.drainPendingCacheOps(Math.min(budgetRemaining(), 5_000));
|
|
1419
|
+
await Entity.drainPendingSideEffects(Math.min(budgetRemaining(), 5_000));
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
logger.warn({ scope: 'cache', component: 'App', msg: 'Entity cache op drain error', err: error });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// 5. Shutdown cache (flush pending writes, unsubscribe pub/sub, disconnect).
|
|
1228
1425
|
try {
|
|
1229
1426
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
1230
1427
|
await CacheManager.getInstance().shutdown();
|
|
1231
1428
|
logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
|
|
1232
1429
|
} catch (error) {
|
|
1233
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', error });
|
|
1430
|
+
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
|
|
1234
1431
|
}
|
|
1235
1432
|
|
|
1236
|
-
// Close database pool (last
|
|
1433
|
+
// 5. Close database pool (last — after all consumers done).
|
|
1237
1434
|
try {
|
|
1238
1435
|
db.close();
|
|
1239
1436
|
logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
|
|
1240
1437
|
} catch (error) {
|
|
1241
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', error });
|
|
1438
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', err: error });
|
|
1242
1439
|
}
|
|
1243
1440
|
|
|
1244
|
-
|
|
1441
|
+
// 6. Dispose lifecycle listeners so a subsequent init() (tests) doesn't
|
|
1442
|
+
// stack handlers on the singleton.
|
|
1443
|
+
try {
|
|
1444
|
+
if (this.phaseListener) {
|
|
1445
|
+
ApplicationLifecycle.removePhaseListener(this.phaseListener);
|
|
1446
|
+
this.phaseListener = null;
|
|
1447
|
+
}
|
|
1448
|
+
SchedulerManager.getInstance().disposeLifecycleIntegration();
|
|
1449
|
+
} catch { /* ignore */ }
|
|
1450
|
+
|
|
1451
|
+
// 7. Unregister process handlers (signals + error handlers) last so
|
|
1452
|
+
// shutdown errors still surface via them above.
|
|
1453
|
+
this.unregisterProcessHandlers();
|
|
1454
|
+
|
|
1455
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Application shutdown completed', durationMs: Date.now() - shutdownStart });
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Wait for pending HTTP requests to drain, bounded by `timeoutMs`.
|
|
1460
|
+
* Bun's `Server` exposes `pendingRequests` for this poll. If the field is
|
|
1461
|
+
* unavailable (older Bun), fall back to a fixed sleep.
|
|
1462
|
+
*/
|
|
1463
|
+
private async waitForHttpDrain(timeoutMs: number): Promise<void> {
|
|
1464
|
+
if (!this.server) return;
|
|
1465
|
+
const deadline = Date.now() + timeoutMs;
|
|
1466
|
+
// Poll pending request count. Bun exposes this on the Server object.
|
|
1467
|
+
while (Date.now() < deadline) {
|
|
1468
|
+
const pending = (this.server as any).pendingRequests ?? 0;
|
|
1469
|
+
if (pending === 0) return;
|
|
1470
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1471
|
+
}
|
|
1472
|
+
const leftover = (this.server as any).pendingRequests ?? -1;
|
|
1473
|
+
if (leftover > 0) {
|
|
1474
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP drain timeout, pending requests remaining', pendingRequests: leftover });
|
|
1475
|
+
}
|
|
1245
1476
|
}
|
|
1246
1477
|
}
|