bunsane 0.4.0 → 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 +26 -0
- package/core/Entity.ts +8 -0
- package/core/cache/RedisCache.ts +5 -4
- package/core/components/BaseComponent.ts +9 -2
- package/core/entity/cacheStrategies.ts +3 -3
- package/core/entity/componentAccess.ts +24 -5
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/saveEntity.ts +17 -19
- package/core/health.ts +93 -4
- package/core/remote/StreamConsumer.ts +535 -535
- package/core/validateEnv.ts +10 -0
- package/database/index.ts +14 -0
- package/database/sqlHelpers.ts +3 -1
- package/gql/schema/index.ts +15 -4
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -1
- package/query/OrNode.ts +2 -14
- package/query/Query.ts +51 -33
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to bunsane are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.5.0 — 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`/health` write probe** — the deep health check now exercises a real write
|
|
10
|
+
through the same `db.transaction()` path `Entity.save` uses (a temp-table
|
|
11
|
+
insert dropped on commit, no persistent side effect), instead of a read-only
|
|
12
|
+
`SELECT 1`. A wedged write pool — one where reads stay healthy but writes hang
|
|
13
|
+
— now fails liveness so orchestrators restart the container instead of it
|
|
14
|
+
serving timeouts indefinitely. Configurable via `HEALTH_DB_WRITE_PROBE`
|
|
15
|
+
(default on) and `DB_HEALTH_WRITE_TIMEOUT` (default 5000 ms). When the probe
|
|
16
|
+
fails or times out, `/health` returns 503.
|
|
17
|
+
- **`DB_DISABLE_PREPARE`** — set to `true` to disable Bun SQL's automatic
|
|
18
|
+
server-side prepared statements (`prepare: false`). **Required behind PgBouncer
|
|
19
|
+
in transaction pooling mode**, where per-connection prepared statements break
|
|
20
|
+
across pooled backends and can wedge the write path. Default behavior is
|
|
21
|
+
unchanged (prepared statements remain on).
|
|
22
|
+
- **`docs/CONFIGURATION.md`** — full environment-variable reference, including a
|
|
23
|
+
PgBouncer deployment section and the health-check/liveness guidance above.
|
|
24
|
+
|
|
25
|
+
### Behavior change
|
|
26
|
+
|
|
27
|
+
- `/health` now performs a database write by default. If you point a liveness
|
|
28
|
+
probe at `/health`, ensure the write path is reachable, or set
|
|
29
|
+
`HEALTH_DB_WRITE_PROBE=false` to keep the previous read-only behavior.
|
|
30
|
+
|
|
5
31
|
## 0.4.0 — 2026-06-11
|
|
6
32
|
|
|
7
33
|
### Performance (2026-06-10 overhaul)
|
package/core/Entity.ts
CHANGED
|
@@ -20,6 +20,14 @@ export class Entity implements IEntity {
|
|
|
20
20
|
// This persists after save() so resolvers can detect removed components
|
|
21
21
|
/** @internal Promoted from private for the core/entity/ submodule split (RFC §3.2). Not part of the public API. */
|
|
22
22
|
public savedRemovedComponents: Set<string> = new Set<string>();
|
|
23
|
+
/**
|
|
24
|
+
* @internal Type IDs confirmed absent from the DB during this entity's
|
|
25
|
+
* lifetime. Used as a negative cache in loadComponent() so repeated
|
|
26
|
+
* get() probes for optional components skip the SELECT. Invalidated
|
|
27
|
+
* whenever a component is added (addComponent) or the entity is
|
|
28
|
+
* reloaded. Not part of the public API.
|
|
29
|
+
*/
|
|
30
|
+
public _missingComponents: Set<string> = new Set<string>();
|
|
23
31
|
protected _dirty: boolean = false;
|
|
24
32
|
|
|
25
33
|
constructor(id?: string) {
|
package/core/cache/RedisCache.ts
CHANGED
|
@@ -270,18 +270,19 @@ export class RedisCache implements CacheProvider {
|
|
|
270
270
|
*/
|
|
271
271
|
async setMany<T>(entries: Array<{key: string, value: T, ttl?: number}>): Promise<void> {
|
|
272
272
|
try {
|
|
273
|
+
const compressed = await Promise.all(entries.map(e => CompressionUtils.compressForStorage(e.value)));
|
|
274
|
+
|
|
273
275
|
const pipeline = this.client.pipeline();
|
|
274
276
|
|
|
275
|
-
|
|
277
|
+
entries.forEach((entry, i) => {
|
|
276
278
|
const prefixedKey = this.prefixKey(entry.key);
|
|
277
|
-
const serializedValue =
|
|
278
|
-
|
|
279
|
+
const serializedValue = compressed[i] as string;
|
|
279
280
|
if (entry.ttl) {
|
|
280
281
|
pipeline.setex(prefixedKey, Math.floor(entry.ttl / 1000), serializedValue);
|
|
281
282
|
} else {
|
|
282
283
|
pipeline.set(prefixedKey, serializedValue);
|
|
283
284
|
}
|
|
284
|
-
}
|
|
285
|
+
});
|
|
285
286
|
|
|
286
287
|
await pipeline.exec();
|
|
287
288
|
} catch (error) {
|
|
@@ -7,6 +7,10 @@ import { uuidv7 } from '../../utils/uuid';
|
|
|
7
7
|
import { getMetadataStorage } from '../metadata';
|
|
8
8
|
const logger = MainLogger.child({ scope: "Components" });
|
|
9
9
|
|
|
10
|
+
// Cached property-name arrays keyed by typeId. Metadata is immutable after
|
|
11
|
+
// decorator registration, so allocating once per class is safe.
|
|
12
|
+
const _propNamesCache = new Map<string, string[]>();
|
|
13
|
+
|
|
10
14
|
export class BaseComponent {
|
|
11
15
|
public id: string = "";
|
|
12
16
|
protected _comp_name: string = "";
|
|
@@ -26,10 +30,13 @@ export class BaseComponent {
|
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
properties(): string[] {
|
|
33
|
+
const cached = _propNamesCache.get(this._typeId);
|
|
34
|
+
if (cached) return cached;
|
|
29
35
|
const storage = getMetadataStorage();
|
|
30
36
|
const props = storage.componentProperties.get(this._typeId);
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
const names = Object.freeze(props ? props.map(p => p.propertyKey) : []) as string[];
|
|
38
|
+
_propNamesCache.set(this._typeId, names);
|
|
39
|
+
return names;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/**
|
|
@@ -6,6 +6,7 @@ import { logger } from "../Logger";
|
|
|
6
6
|
import EntityHookManager from "../EntityHookManager";
|
|
7
7
|
import { EntityDeletedEvent } from "../events/EntityLifecycleEvents";
|
|
8
8
|
import type { SQL } from "bun";
|
|
9
|
+
import { getCacheManager } from "./getCacheManager";
|
|
9
10
|
import type { Entity } from "../Entity";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -15,8 +16,7 @@ import type { Entity } from "../Entity";
|
|
|
15
16
|
*/
|
|
16
17
|
export async function handleCacheAfterSave(entity: Entity, changedComponentTypeIds: string[], removedComponentTypeIds: string[], context?: { loaders?: { componentsByEntityType?: any }; trx?: SQL; signal?: AbortSignal }): Promise<void> {
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
-
const { CacheManager } = await import('../cache/CacheManager');
|
|
19
|
+
const CacheManager = getCacheManager();
|
|
20
20
|
const cacheManager = CacheManager.getInstance();
|
|
21
21
|
const config = cacheManager.getConfig();
|
|
22
22
|
|
|
@@ -81,7 +81,7 @@ export async function runPostDeleteSideEffects(entity: Entity, softDelete: boole
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
try {
|
|
84
|
-
const
|
|
84
|
+
const CacheManager = getCacheManager();
|
|
85
85
|
const cacheManager = CacheManager.getInstance();
|
|
86
86
|
const config = cacheManager.getConfig();
|
|
87
87
|
|
|
@@ -14,10 +14,15 @@ import { getMetadataStorage } from "../metadata";
|
|
|
14
14
|
import { ComponentAddedEvent, ComponentUpdatedEvent, ComponentRemovedEvent } from "../events/EntityLifecycleEvents";
|
|
15
15
|
import { getRequestScope } from "../requestScope";
|
|
16
16
|
import { trackCacheOp } from "./pendingOps";
|
|
17
|
+
import { getCacheManager } from "./getCacheManager";
|
|
17
18
|
import type { Entity } from "../Entity";
|
|
18
19
|
|
|
19
20
|
export function addComponent(entity: Entity, component: BaseComponent): Entity {
|
|
20
|
-
|
|
21
|
+
const typeId = component.getTypeID();
|
|
22
|
+
entity.components.set(typeId, component);
|
|
23
|
+
// A component that just arrived can never be "missing" — clear any
|
|
24
|
+
// previously recorded absence so future get() calls see the new data.
|
|
25
|
+
entity._missingComponents.delete(typeId);
|
|
21
26
|
return entity;
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -55,8 +60,6 @@ export function add<T extends BaseComponent>(entity: Entity, ctor: new (...args:
|
|
|
55
60
|
const instance = new ctor();
|
|
56
61
|
if (data) {
|
|
57
62
|
Object.assign(instance, data);
|
|
58
|
-
} else {
|
|
59
|
-
Object.assign(instance, {});
|
|
60
63
|
}
|
|
61
64
|
addComponent(entity, instance);
|
|
62
65
|
entity.setDirty(true);
|
|
@@ -109,7 +112,7 @@ export async function set<T extends BaseComponent>(entity: Entity, ctor: new (..
|
|
|
109
112
|
// App.shutdown can await it (H-CACHE-1).
|
|
110
113
|
trackCacheOp((async () => {
|
|
111
114
|
try {
|
|
112
|
-
const
|
|
115
|
+
const CacheManager = getCacheManager();
|
|
113
116
|
const cacheManager = CacheManager.getInstance();
|
|
114
117
|
const config = cacheManager.getConfig();
|
|
115
118
|
|
|
@@ -167,7 +170,7 @@ export function remove<T extends BaseComponent>(entity: Entity, ctor: new (...ar
|
|
|
167
170
|
// (H-CACHE-1).
|
|
168
171
|
trackCacheOp((async () => {
|
|
169
172
|
try {
|
|
170
|
-
const
|
|
173
|
+
const CacheManager = getCacheManager();
|
|
171
174
|
const cacheManager = CacheManager.getInstance();
|
|
172
175
|
const config = cacheManager.getConfig();
|
|
173
176
|
|
|
@@ -222,6 +225,7 @@ export async function reload(entity: Entity, opts?: { trx?: SQL; signal?: AbortS
|
|
|
222
225
|
entity.components.clear();
|
|
223
226
|
entity.removedComponents.clear();
|
|
224
227
|
entity.savedRemovedComponents.clear();
|
|
228
|
+
entity._missingComponents.clear();
|
|
225
229
|
|
|
226
230
|
const dbConn = opts?.trx ?? db;
|
|
227
231
|
const rows = await runWithSignal<any[]>(
|
|
@@ -291,6 +295,15 @@ async function loadComponent<T extends BaseComponent>(entity: Entity, ctor: new
|
|
|
291
295
|
// just to read the type id.
|
|
292
296
|
const typeId = typeIdOf(ctor);
|
|
293
297
|
|
|
298
|
+
// Negative-cache short-circuit: if we previously confirmed this component
|
|
299
|
+
// is absent from the DB (and no explicit transaction is in scope that
|
|
300
|
+
// could see a different snapshot), skip the SELECT entirely.
|
|
301
|
+
// Skipped when a trx is provided — within a transaction the visibility
|
|
302
|
+
// horizon may differ from the outer read (stale-read hazard).
|
|
303
|
+
if (!context?.trx && entity._missingComponents.has(typeId)) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
294
307
|
// Use transaction if provided, otherwise use default db
|
|
295
308
|
const dbConn = context?.trx ?? db;
|
|
296
309
|
|
|
@@ -353,6 +366,12 @@ async function loadComponent<T extends BaseComponent>(entity: Entity, ctor: new
|
|
|
353
366
|
addComponent(entity, comp);
|
|
354
367
|
return comp as T;
|
|
355
368
|
} else {
|
|
369
|
+
// Record the confirmed absence so repeated probes skip the DB.
|
|
370
|
+
// Only when no explicit trx — within a transaction the caller
|
|
371
|
+
// may insert the component and probe again in the same scope.
|
|
372
|
+
if (!context?.trx) {
|
|
373
|
+
entity._missingComponents.add(typeId);
|
|
374
|
+
}
|
|
356
375
|
return null;
|
|
357
376
|
}
|
|
358
377
|
} catch (error) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Static import of CacheManager for hot-path use in componentAccess and
|
|
2
|
+
// cacheStrategies. CacheManager's imports are all type-only references back
|
|
3
|
+
// to core/Entity, so there is no runtime circular dependency — static import
|
|
4
|
+
// is safe and avoids the microtask + Promise allocation of a dynamic import
|
|
5
|
+
// on every set/remove/save/delete call.
|
|
6
|
+
import { CacheManager } from '../cache/CacheManager';
|
|
7
|
+
|
|
8
|
+
export function getCacheManager(): typeof CacheManager {
|
|
9
|
+
return CacheManager;
|
|
10
|
+
}
|
|
@@ -268,9 +268,11 @@ export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Pr
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
// Perform updates. Validate all ids up front (synchronous, fails
|
|
271
|
-
// fast), then
|
|
272
|
-
//
|
|
273
|
-
//
|
|
271
|
+
// fast), then issue the UPDATEs sequentially. They were previously
|
|
272
|
+
// fired together via Promise.all to "pipeline" on the transaction
|
|
273
|
+
// connection, but multiple concurrent in-flight queries on one
|
|
274
|
+
// connection deadlock single-backend servers (PGlite test harness),
|
|
275
|
+
// and a single wire serializes them regardless — no real gain.
|
|
274
276
|
if (componentsToUpdate.length > 0) {
|
|
275
277
|
const traceEnabled = logger.isLevelEnabled?.('trace') === true;
|
|
276
278
|
for (const comp of componentsToUpdate) {
|
|
@@ -285,11 +287,9 @@ export async function doSave(entity: Entity, trx: SQL, signal?: AbortSignal): Pr
|
|
|
285
287
|
logger.trace({ componentId: comp.id, data: comp.data }, `[Entity.doSave] Updating component`);
|
|
286
288
|
}
|
|
287
289
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
)
|
|
292
|
-
);
|
|
290
|
+
for (const comp of componentsToUpdate) {
|
|
291
|
+
await run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`);
|
|
292
|
+
}
|
|
293
293
|
}
|
|
294
294
|
};
|
|
295
295
|
|
|
@@ -322,19 +322,17 @@ export async function doDelete(entity: Entity, force: boolean = false): Promise<
|
|
|
322
322
|
|
|
323
323
|
try {
|
|
324
324
|
await db.transaction(async (trx) => {
|
|
325
|
-
// Independent tables, no FK constraints
|
|
326
|
-
//
|
|
327
|
-
//
|
|
325
|
+
// Independent tables, no FK constraints. Issued sequentially:
|
|
326
|
+
// multiple concurrent in-flight queries on one connection
|
|
327
|
+
// deadlock single-backend servers (PGlite test harness), and a
|
|
328
|
+
// single wire serializes them anyway — Promise.all gave no real
|
|
329
|
+
// pipelining here.
|
|
328
330
|
if (force) {
|
|
329
|
-
await
|
|
330
|
-
|
|
331
|
-
run(trx`DELETE FROM entities WHERE id = ${entity.id}`),
|
|
332
|
-
]);
|
|
331
|
+
await run(trx`DELETE FROM components WHERE entity_id = ${entity.id}`);
|
|
332
|
+
await run(trx`DELETE FROM entities WHERE id = ${entity.id}`);
|
|
333
333
|
} else {
|
|
334
|
-
await
|
|
335
|
-
|
|
336
|
-
run(trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${entity.id} AND deleted_at IS NULL`),
|
|
337
|
-
]);
|
|
334
|
+
await run(trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${entity.id} AND deleted_at IS NULL`);
|
|
335
|
+
await run(trx`UPDATE components SET deleted_at = CURRENT_TIMESTAMP WHERE entity_id = ${entity.id} AND deleted_at IS NULL`);
|
|
338
336
|
}
|
|
339
337
|
});
|
|
340
338
|
clearTimeout(timeoutHandle);
|
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,
|