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.
- package/CHANGELOG.md +471 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +93 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +8 -7
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +25 -10
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +383 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +375 -0
- package/core/health.ts +93 -4
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/StreamConsumer.ts +535 -535
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +29 -3
- package/database/instrumentedDb.ts +141 -141
- package/database/sqlHelpers.ts +3 -1
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/schema/index.ts +15 -4
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +245 -14
- package/query/OrNode.ts +8 -19
- package/query/Query.ts +208 -79
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
package/core/health.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import db from "../database";
|
|
2
|
+
import { runWithSignal } from "../database/cancellable";
|
|
2
3
|
import { CacheManager } from "./cache/CacheManager";
|
|
3
4
|
|
|
4
5
|
export interface CheckResult {
|
|
@@ -13,6 +14,14 @@ export interface HealthResponse {
|
|
|
13
14
|
checks: {
|
|
14
15
|
database: CheckResult;
|
|
15
16
|
cache: CheckResult;
|
|
17
|
+
/**
|
|
18
|
+
* Present only when the DB write probe is enabled (default on).
|
|
19
|
+
* Exercises the real `db.transaction()` write path so a wedged write
|
|
20
|
+
* pool — a stuck pooled client or exhausted pool that leaves reads
|
|
21
|
+
* (`SELECT 1`) healthy — fails the liveness check and the orchestrator
|
|
22
|
+
* restarts the container instead of it serving 504s indefinitely.
|
|
23
|
+
*/
|
|
24
|
+
database_write?: CheckResult;
|
|
16
25
|
};
|
|
17
26
|
}
|
|
18
27
|
|
|
@@ -24,6 +33,71 @@ export interface HealthResult {
|
|
|
24
33
|
export interface HealthDeps {
|
|
25
34
|
pingDb: () => Promise<boolean>;
|
|
26
35
|
pingCache: () => Promise<boolean>;
|
|
36
|
+
/**
|
|
37
|
+
* Write-path probe. Optional: when omitted (e.g. tests passing custom
|
|
38
|
+
* deps) the write check is skipped and behavior matches the read-only
|
|
39
|
+
* health check. `defaultDeps` supplies the real probe.
|
|
40
|
+
*/
|
|
41
|
+
pingDbWrite?: () => Promise<boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Independent, short timeout for the write probe so a wedged write path is
|
|
45
|
+
// caught fast (and the container restarted) rather than blocking on the 30s
|
|
46
|
+
// request/save timeout. Configurable via DB_HEALTH_WRITE_TIMEOUT.
|
|
47
|
+
const WRITE_PROBE_TIMEOUT_MS = parseInt(process.env.DB_HEALTH_WRITE_TIMEOUT ?? "5000", 10);
|
|
48
|
+
|
|
49
|
+
function writeProbeDisabled(): boolean {
|
|
50
|
+
return process.env.HEALTH_DB_WRITE_PROBE === "false";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exercises a genuine write through the same `db.transaction()` acquisition
|
|
55
|
+
* path `Entity.save` uses. A wedged write pool (stuck pooled client, pool
|
|
56
|
+
* exhausted by leaked transactions) hangs here while `SELECT 1` stays healthy
|
|
57
|
+
* on any idle read connection — exactly the false-healthy scenario that kept a
|
|
58
|
+
* timed-out container "healthy" and unrestarted.
|
|
59
|
+
*
|
|
60
|
+
* The whole transaction is raced against an independent timeout so even a hang
|
|
61
|
+
* during connection *acquisition* (which runWithSignal alone cannot interrupt,
|
|
62
|
+
* since it only wraps in-flight queries) is caught. The temp table is dropped
|
|
63
|
+
* at COMMIT, so the probe has no persistent side effect.
|
|
64
|
+
*/
|
|
65
|
+
async function probeDbWrite(): Promise<boolean> {
|
|
66
|
+
const timeoutMs = WRITE_PROBE_TIMEOUT_MS;
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
70
|
+
handle = setTimeout(() => {
|
|
71
|
+
const err = new Error(`DB write health probe timeout after ${timeoutMs}ms`);
|
|
72
|
+
controller.abort(err);
|
|
73
|
+
reject(err);
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
(handle as any).unref?.();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const txn = db.transaction(async (trx) => {
|
|
79
|
+
await runWithSignal(
|
|
80
|
+
trx`CREATE TEMP TABLE IF NOT EXISTS _bunsane_health_write (probed_at timestamptz NOT NULL) ON COMMIT DROP`,
|
|
81
|
+
controller.signal,
|
|
82
|
+
);
|
|
83
|
+
await runWithSignal(
|
|
84
|
+
trx`INSERT INTO _bunsane_health_write (probed_at) VALUES (now())`,
|
|
85
|
+
controller.signal,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await Promise.race([txn, timeoutPromise]);
|
|
91
|
+
return true;
|
|
92
|
+
} finally {
|
|
93
|
+
if (handle) clearTimeout(handle);
|
|
94
|
+
// Abort any in-flight query so the transaction rolls back and the
|
|
95
|
+
// pooled connection is released even when the timeout won the race.
|
|
96
|
+
if (!controller.signal.aborted) controller.abort();
|
|
97
|
+
// Swallow a late transaction settle after a lost race so it cannot
|
|
98
|
+
// surface as an unhandled rejection.
|
|
99
|
+
Promise.resolve(txn).catch(() => { /* ignore post-timeout settle */ });
|
|
100
|
+
}
|
|
27
101
|
}
|
|
28
102
|
|
|
29
103
|
const defaultDeps: HealthDeps = {
|
|
@@ -32,6 +106,7 @@ const defaultDeps: HealthDeps = {
|
|
|
32
106
|
return true;
|
|
33
107
|
},
|
|
34
108
|
pingCache: () => CacheManager.getInstance().ping(),
|
|
109
|
+
pingDbWrite: probeDbWrite,
|
|
35
110
|
};
|
|
36
111
|
|
|
37
112
|
async function checkDatabase(pingDb: () => Promise<boolean>): Promise<CheckResult> {
|
|
@@ -55,24 +130,30 @@ async function checkCache(pingCache: () => Promise<boolean>): Promise<CheckResul
|
|
|
55
130
|
}
|
|
56
131
|
|
|
57
132
|
export async function deepHealthCheck(deps: HealthDeps = defaultDeps): Promise<HealthResult> {
|
|
58
|
-
const
|
|
133
|
+
const runWrite = !!deps.pingDbWrite && !writeProbeDisabled();
|
|
134
|
+
|
|
135
|
+
const [database, cache, databaseWrite] = await Promise.all([
|
|
59
136
|
checkDatabase(deps.pingDb),
|
|
60
137
|
checkCache(deps.pingCache),
|
|
138
|
+
runWrite ? checkDatabase(deps.pingDbWrite!) : Promise.resolve(undefined),
|
|
61
139
|
]);
|
|
62
140
|
|
|
63
141
|
const dbUp = database.status === "up";
|
|
142
|
+
const writeUp = !databaseWrite || databaseWrite.status === "up";
|
|
64
143
|
const cacheUp = cache.status === "up";
|
|
65
144
|
|
|
66
145
|
let status: HealthResponse["status"];
|
|
67
146
|
let httpStatus: number;
|
|
68
147
|
|
|
69
|
-
if (dbUp && cacheUp) {
|
|
148
|
+
if (dbUp && writeUp && cacheUp) {
|
|
70
149
|
status = "ok";
|
|
71
150
|
httpStatus = 200;
|
|
72
|
-
} else if (dbUp && !cacheUp) {
|
|
151
|
+
} else if (dbUp && writeUp && !cacheUp) {
|
|
73
152
|
status = "degraded";
|
|
74
153
|
httpStatus = 200;
|
|
75
154
|
} else {
|
|
155
|
+
// DB read OR write down → unavailable. A wedged write path (reads fine,
|
|
156
|
+
// writes hang) lands here so liveness fails and the container restarts.
|
|
76
157
|
status = "unavailable";
|
|
77
158
|
httpStatus = 503;
|
|
78
159
|
}
|
|
@@ -82,7 +163,11 @@ export async function deepHealthCheck(deps: HealthDeps = defaultDeps): Promise<H
|
|
|
82
163
|
status,
|
|
83
164
|
timestamp: new Date().toISOString(),
|
|
84
165
|
uptime: process.uptime(),
|
|
85
|
-
checks: {
|
|
166
|
+
checks: {
|
|
167
|
+
database,
|
|
168
|
+
cache,
|
|
169
|
+
...(databaseWrite ? { database_write: databaseWrite } : {}),
|
|
170
|
+
},
|
|
86
171
|
},
|
|
87
172
|
httpStatus,
|
|
88
173
|
};
|
|
@@ -94,6 +179,7 @@ export async function readinessCheck(
|
|
|
94
179
|
deps: HealthDeps = defaultDeps,
|
|
95
180
|
): Promise<HealthResult> {
|
|
96
181
|
if (!isReady || isShuttingDown) {
|
|
182
|
+
const includeWrite = !!deps.pingDbWrite && !writeProbeDisabled();
|
|
97
183
|
return {
|
|
98
184
|
result: {
|
|
99
185
|
status: "unavailable",
|
|
@@ -102,6 +188,9 @@ export async function readinessCheck(
|
|
|
102
188
|
checks: {
|
|
103
189
|
database: { status: "unknown", latency_ms: 0 },
|
|
104
190
|
cache: { status: "unknown", latency_ms: 0 },
|
|
191
|
+
...(includeWrite
|
|
192
|
+
? { database_write: { status: "unknown", latency_ms: 0 } }
|
|
193
|
+
: {}),
|
|
105
194
|
},
|
|
106
195
|
},
|
|
107
196
|
httpStatus: 503,
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import type { LifecycleEvent } from "../events/EntityLifecycleEvents";
|
|
2
|
+
import { logger as MainLogger } from "../Logger";
|
|
3
|
+
import type { RegisteredHook, HookMetrics, RegistryState } from "./registry";
|
|
4
|
+
import { matchesComponentTarget } from "./guards";
|
|
5
|
+
|
|
6
|
+
const logger = MainLogger.child({ scope: "EntityHookManager" });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Dispatcher state owned by the manager instance
|
|
10
|
+
*/
|
|
11
|
+
export interface DispatcherState {
|
|
12
|
+
metrics: Map<string, HookMetrics>;
|
|
13
|
+
globalMetrics: HookMetrics;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create initial dispatcher state
|
|
18
|
+
*/
|
|
19
|
+
export function createDispatcherState(): DispatcherState {
|
|
20
|
+
return {
|
|
21
|
+
metrics: new Map(),
|
|
22
|
+
globalMetrics: {
|
|
23
|
+
totalExecutions: 0,
|
|
24
|
+
totalExecutionTime: 0,
|
|
25
|
+
averageExecutionTime: 0,
|
|
26
|
+
errorCount: 0,
|
|
27
|
+
lastExecutionTime: 0
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record hook execution metrics
|
|
34
|
+
*/
|
|
35
|
+
export function recordMetrics(state: DispatcherState, eventType: string, executionTime: number, hadErrors: boolean): void {
|
|
36
|
+
// Update event-specific metrics
|
|
37
|
+
let eventMetrics = state.metrics.get(eventType);
|
|
38
|
+
if (!eventMetrics) {
|
|
39
|
+
eventMetrics = {
|
|
40
|
+
totalExecutions: 0,
|
|
41
|
+
totalExecutionTime: 0,
|
|
42
|
+
averageExecutionTime: 0,
|
|
43
|
+
errorCount: 0,
|
|
44
|
+
lastExecutionTime: 0
|
|
45
|
+
};
|
|
46
|
+
state.metrics.set(eventType, eventMetrics);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
eventMetrics.totalExecutions++;
|
|
50
|
+
eventMetrics.totalExecutionTime += executionTime;
|
|
51
|
+
eventMetrics.averageExecutionTime = eventMetrics.totalExecutionTime / eventMetrics.totalExecutions;
|
|
52
|
+
eventMetrics.lastExecutionTime = executionTime;
|
|
53
|
+
if (hadErrors) {
|
|
54
|
+
eventMetrics.errorCount++;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Update global metrics
|
|
58
|
+
state.globalMetrics.totalExecutions++;
|
|
59
|
+
state.globalMetrics.totalExecutionTime += executionTime;
|
|
60
|
+
state.globalMetrics.averageExecutionTime = state.globalMetrics.totalExecutionTime / state.globalMetrics.totalExecutions;
|
|
61
|
+
state.globalMetrics.lastExecutionTime = executionTime;
|
|
62
|
+
if (hadErrors) {
|
|
63
|
+
state.globalMetrics.errorCount++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get performance metrics for hook execution
|
|
69
|
+
*/
|
|
70
|
+
export function getMetrics(state: DispatcherState, eventType?: string): HookMetrics {
|
|
71
|
+
if (eventType) {
|
|
72
|
+
return state.metrics.get(eventType) || {
|
|
73
|
+
totalExecutions: 0,
|
|
74
|
+
totalExecutionTime: 0,
|
|
75
|
+
averageExecutionTime: 0,
|
|
76
|
+
errorCount: 0,
|
|
77
|
+
lastExecutionTime: 0
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ...state.globalMetrics };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reset performance metrics
|
|
85
|
+
*/
|
|
86
|
+
export function resetMetrics(state: DispatcherState, eventType?: string): void {
|
|
87
|
+
if (eventType) {
|
|
88
|
+
state.metrics.delete(eventType);
|
|
89
|
+
} else {
|
|
90
|
+
state.metrics.clear();
|
|
91
|
+
state.globalMetrics = {
|
|
92
|
+
totalExecutions: 0,
|
|
93
|
+
totalExecutionTime: 0,
|
|
94
|
+
averageExecutionTime: 0,
|
|
95
|
+
errorCount: 0,
|
|
96
|
+
lastExecutionTime: 0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
logger.trace(`Reset metrics${eventType ? ` for ${eventType}` : ''}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Execute hooks for a specific event
|
|
104
|
+
*/
|
|
105
|
+
export async function executeHooks(registryState: RegistryState, dispatcherState: DispatcherState, event: LifecycleEvent): Promise<void> {
|
|
106
|
+
const eventType = event.getEventType();
|
|
107
|
+
const hooks = registryState.hooks.get(eventType) || [];
|
|
108
|
+
const startTime = performance.now();
|
|
109
|
+
let hadErrors = false;
|
|
110
|
+
|
|
111
|
+
if (hooks.length === 0) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
logger.trace(`Executing ${hooks.length} hooks for event: ${eventType}`);
|
|
116
|
+
|
|
117
|
+
// Separate sync and async hooks
|
|
118
|
+
const syncHooks = hooks.filter(hook => !hook.options.async);
|
|
119
|
+
const asyncHooks = hooks.filter(hook => hook.options.async);
|
|
120
|
+
|
|
121
|
+
// Execute sync hooks immediately
|
|
122
|
+
for (const hook of syncHooks) {
|
|
123
|
+
// Check component targeting first
|
|
124
|
+
if (!matchesComponentTarget(event, hook.options.componentTarget)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check filter condition
|
|
129
|
+
if (hook.options.filter && !hook.options.filter(event)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
if (hook.options.timeout && hook.options.timeout > 0) {
|
|
135
|
+
// Execute with timeout. Timer handle is stored so the
|
|
136
|
+
// normal-completion path clears it (no leaked pending
|
|
137
|
+
// timers per successful hook). The underlying callback
|
|
138
|
+
// promise is attached with a detached .catch so a late
|
|
139
|
+
// rejection after timeout does not escape as unhandled
|
|
140
|
+
// (H-HOOK-2 / H-MEM-2).
|
|
141
|
+
let timerHandle: ReturnType<typeof setTimeout> | null = null;
|
|
142
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
143
|
+
timerHandle = setTimeout(
|
|
144
|
+
() => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
|
|
145
|
+
hook.options.timeout
|
|
146
|
+
);
|
|
147
|
+
(timerHandle as unknown as { unref?: () => void }).unref?.();
|
|
148
|
+
});
|
|
149
|
+
const hookPromise = Promise.resolve().then(() => hook.callback(event));
|
|
150
|
+
hookPromise.catch((err) => {
|
|
151
|
+
logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
await Promise.race([hookPromise, timeoutPromise]);
|
|
155
|
+
} finally {
|
|
156
|
+
if (timerHandle) clearTimeout(timerHandle);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
// Always await — callback may be an async function declared
|
|
160
|
+
// with async:false by mistake. Without await, a rejection
|
|
161
|
+
// from such a callback escapes as an unhandled rejection
|
|
162
|
+
// and crashes the process under strict mode (C13).
|
|
163
|
+
await hook.callback(event);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.error(`Error executing sync hook ${hook.id} for event ${eventType}: ${error}`);
|
|
167
|
+
hadErrors = true;
|
|
168
|
+
// Continue executing other hooks even if one fails
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Execute async hooks in parallel
|
|
173
|
+
if (asyncHooks.length > 0) {
|
|
174
|
+
const asyncPromises = asyncHooks.map(async (hook) => {
|
|
175
|
+
// Check component targeting first
|
|
176
|
+
if (!matchesComponentTarget(event, hook.options.componentTarget)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check filter condition
|
|
181
|
+
if (hook.options.filter && !hook.options.filter(event)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
if (hook.options.timeout && hook.options.timeout > 0) {
|
|
187
|
+
// Execute with timeout. See sync path for rationale —
|
|
188
|
+
// clear the timer on normal completion and detach a
|
|
189
|
+
// .catch on the hook promise so late rejections do
|
|
190
|
+
// not escape (H-HOOK-2 / H-MEM-2).
|
|
191
|
+
let timerHandle: ReturnType<typeof setTimeout> | null = null;
|
|
192
|
+
const hookPromise = Promise.resolve().then(() => hook.callback(event));
|
|
193
|
+
hookPromise.catch((err) => {
|
|
194
|
+
logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
|
|
195
|
+
});
|
|
196
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
197
|
+
timerHandle = setTimeout(
|
|
198
|
+
() => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
|
|
199
|
+
hook.options.timeout
|
|
200
|
+
);
|
|
201
|
+
(timerHandle as unknown as { unref?: () => void }).unref?.();
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
await Promise.race([hookPromise, timeoutPromise]);
|
|
205
|
+
} finally {
|
|
206
|
+
if (timerHandle) clearTimeout(timerHandle);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// Execute normally
|
|
210
|
+
await hook.callback(event);
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
logger.error(`Error executing async hook ${hook.id} for event ${eventType}: ${error}`);
|
|
214
|
+
hadErrors = true;
|
|
215
|
+
// Continue executing other hooks even if one fails
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await Promise.allSettled(asyncPromises);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Record performance metrics
|
|
223
|
+
const executionTime = performance.now() - startTime;
|
|
224
|
+
recordMetrics(dispatcherState, eventType, executionTime, hadErrors);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Execute hooks for multiple events in batch
|
|
229
|
+
*/
|
|
230
|
+
export async function executeHooksBatch(registryState: RegistryState, dispatcherState: DispatcherState, events: LifecycleEvent[]): Promise<void> {
|
|
231
|
+
if (events.length === 0) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
logger.trace(`Executing hooks for ${events.length} events in batch`);
|
|
236
|
+
|
|
237
|
+
// Group events by type for efficient processing
|
|
238
|
+
const eventsByType = new Map<string, LifecycleEvent[]>();
|
|
239
|
+
for (const event of events) {
|
|
240
|
+
const eventType = event.getEventType();
|
|
241
|
+
if (!eventsByType.has(eventType)) {
|
|
242
|
+
eventsByType.set(eventType, []);
|
|
243
|
+
}
|
|
244
|
+
eventsByType.get(eventType)!.push(event);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Process each event type
|
|
248
|
+
const promises: Promise<void>[] = [];
|
|
249
|
+
for (const [eventType, typeEvents] of eventsByType.entries()) {
|
|
250
|
+
promises.push(executeHooksForType(registryState, dispatcherState, eventType, typeEvents));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await Promise.allSettled(promises);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Execute hooks for a specific event type with multiple events
|
|
258
|
+
*/
|
|
259
|
+
async function executeHooksForType(registryState: RegistryState, dispatcherState: DispatcherState, eventType: string, events: LifecycleEvent[]): Promise<void> {
|
|
260
|
+
const hooks = registryState.hooks.get(eventType) || [];
|
|
261
|
+
|
|
262
|
+
if (hooks.length === 0 || events.length === 0) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
logger.trace(`Executing ${hooks.length} hooks for ${events.length} ${eventType} events`);
|
|
267
|
+
|
|
268
|
+
// Pre-filter hooks by component targeting to avoid repeated checks
|
|
269
|
+
const preFilteredHooks = preFilterHooksByComponentTargeting(hooks, events);
|
|
270
|
+
|
|
271
|
+
if (preFilteredHooks.length === 0) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Separate sync and async hooks
|
|
276
|
+
const syncHooks = preFilteredHooks.filter(hook => !hook.options.async);
|
|
277
|
+
const asyncHooks = preFilteredHooks.filter(hook => hook.options.async);
|
|
278
|
+
|
|
279
|
+
// Execute sync hooks for all events with batch optimization
|
|
280
|
+
if (syncHooks.length > 0) {
|
|
281
|
+
await executeSyncHooksBatch(dispatcherState, syncHooks, events, eventType);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Execute async hooks in parallel for all events with batch optimization
|
|
285
|
+
if (asyncHooks.length > 0) {
|
|
286
|
+
await executeAsyncHooksBatch(dispatcherState, asyncHooks, events, eventType);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Pre-filter hooks based on component targeting to optimize batch processing
|
|
292
|
+
*/
|
|
293
|
+
function preFilterHooksByComponentTargeting(hooks: RegisteredHook[], events: LifecycleEvent[]): RegisteredHook[] {
|
|
294
|
+
// If no hooks have component targeting, return all hooks (preserving order)
|
|
295
|
+
const hasComponentTargeting = hooks.some(hook => hook.options.componentTarget);
|
|
296
|
+
if (!hasComponentTargeting) {
|
|
297
|
+
return [...hooks]; // Return a copy to avoid modifying the original
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// For hooks with component targeting, check if they could match any event
|
|
301
|
+
// This is a broad pre-filter to avoid checking every hook against every event
|
|
302
|
+
const filteredHooks = hooks.filter(hook => {
|
|
303
|
+
if (!hook.options.componentTarget) {
|
|
304
|
+
return true; // No targeting means it matches all
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check if this hook could potentially match any of the events
|
|
308
|
+
return events.some(event => matchesComponentTarget(event, hook.options.componentTarget));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Return filtered hooks in their original order (priority should already be sorted)
|
|
312
|
+
return filteredHooks;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Execute sync hooks for multiple events with batch optimizations
|
|
317
|
+
*/
|
|
318
|
+
async function executeSyncHooksBatch(dispatcherState: DispatcherState, syncHooks: RegisteredHook[], events: LifecycleEvent[], eventType: string): Promise<void> {
|
|
319
|
+
const startTime = performance.now();
|
|
320
|
+
let hadErrors = false;
|
|
321
|
+
|
|
322
|
+
// Execute hooks in priority order across all events to maintain deterministic execution
|
|
323
|
+
for (const hook of syncHooks) {
|
|
324
|
+
// Process all events for this hook
|
|
325
|
+
for (const event of events) {
|
|
326
|
+
// Double-check component targeting (pre-filter may have false positives)
|
|
327
|
+
if (!matchesComponentTarget(event, hook.options.componentTarget)) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Check filter condition
|
|
332
|
+
if (hook.options.filter && !hook.options.filter(event)) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
if (hook.options.timeout && hook.options.timeout > 0) {
|
|
338
|
+
// Same cleanup pattern as single-event path (H-HOOK-2 / H-MEM-2).
|
|
339
|
+
let timerHandle: ReturnType<typeof setTimeout> | null = null;
|
|
340
|
+
const hookPromise = Promise.resolve().then(() => hook.callback(event));
|
|
341
|
+
hookPromise.catch((err) => {
|
|
342
|
+
logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
|
|
343
|
+
});
|
|
344
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
345
|
+
timerHandle = setTimeout(
|
|
346
|
+
() => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
|
|
347
|
+
hook.options.timeout
|
|
348
|
+
);
|
|
349
|
+
(timerHandle as unknown as { unref?: () => void }).unref?.();
|
|
350
|
+
});
|
|
351
|
+
try {
|
|
352
|
+
await Promise.race([hookPromise, timeoutPromise]);
|
|
353
|
+
} finally {
|
|
354
|
+
if (timerHandle) clearTimeout(timerHandle);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
// Await so async callbacks do not escape as unhandled
|
|
358
|
+
// rejections (C13 parity).
|
|
359
|
+
await hook.callback(event);
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger.error(`Error executing sync hook ${hook.id} for event ${eventType}: ${error}`);
|
|
363
|
+
hadErrors = true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Record performance metrics
|
|
369
|
+
const executionTime = performance.now() - startTime;
|
|
370
|
+
recordMetrics(dispatcherState, eventType, executionTime, hadErrors);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Execute async hooks for multiple events with batch optimizations
|
|
375
|
+
*/
|
|
376
|
+
async function executeAsyncHooksBatch(dispatcherState: DispatcherState, asyncHooks: RegisteredHook[], events: LifecycleEvent[], eventType: string): Promise<void> {
|
|
377
|
+
const startTime = performance.now();
|
|
378
|
+
let hadErrors = false;
|
|
379
|
+
|
|
380
|
+
// Collect all async hook executions
|
|
381
|
+
const asyncPromises: Promise<void>[] = [];
|
|
382
|
+
|
|
383
|
+
// Use a more efficient batching strategy for async hooks
|
|
384
|
+
for (const event of events) {
|
|
385
|
+
for (const hook of asyncHooks) {
|
|
386
|
+
// Double-check component targeting
|
|
387
|
+
if (!matchesComponentTarget(event, hook.options.componentTarget)) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Check filter condition
|
|
392
|
+
if (hook.options.filter && !hook.options.filter(event)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
asyncPromises.push(
|
|
397
|
+
(async () => {
|
|
398
|
+
try {
|
|
399
|
+
if (hook.options.timeout && hook.options.timeout > 0) {
|
|
400
|
+
// Same cleanup pattern (H-HOOK-2 / H-MEM-2).
|
|
401
|
+
let timerHandle: ReturnType<typeof setTimeout> | null = null;
|
|
402
|
+
const hookPromise = Promise.resolve().then(() => hook.callback(event));
|
|
403
|
+
hookPromise.catch((err) => {
|
|
404
|
+
logger.warn({ hookId: hook.id, err }, `Late rejection from hook after timeout`);
|
|
405
|
+
});
|
|
406
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
407
|
+
timerHandle = setTimeout(
|
|
408
|
+
() => reject(new Error(`Hook ${hook.id} timed out after ${hook.options.timeout}ms`)),
|
|
409
|
+
hook.options.timeout
|
|
410
|
+
);
|
|
411
|
+
(timerHandle as unknown as { unref?: () => void }).unref?.();
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
await Promise.race([hookPromise, timeoutPromise]);
|
|
415
|
+
} finally {
|
|
416
|
+
if (timerHandle) clearTimeout(timerHandle);
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
// Execute normally
|
|
420
|
+
await hook.callback(event);
|
|
421
|
+
}
|
|
422
|
+
} catch (error) {
|
|
423
|
+
logger.error(`Error executing async hook ${hook.id} for event ${eventType}: ${error}`);
|
|
424
|
+
hadErrors = true;
|
|
425
|
+
}
|
|
426
|
+
})()
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Execute all async hooks in parallel with controlled concurrency
|
|
432
|
+
if (asyncPromises.length > 0) {
|
|
433
|
+
await Promise.allSettled(asyncPromises);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Record performance metrics
|
|
437
|
+
const executionTime = performance.now() - startTime;
|
|
438
|
+
recordMetrics(dispatcherState, eventType, executionTime, hadErrors);
|
|
439
|
+
}
|