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 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) {
@@ -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
- for (const entry of entries) {
277
+ entries.forEach((entry, i) => {
276
278
  const prefixedKey = this.prefixKey(entry.key);
277
- const serializedValue = await CompressionUtils.compressForStorage(entry.value);
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
- if(!props) return [];
32
- return props.map(p => p.propertyKey);
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
- // Import CacheManager dynamically to avoid circular dependency
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 { CacheManager } = await import('../cache/CacheManager');
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
- entity.components.set(component.getTypeID(), component);
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 { CacheManager } = await import('../cache/CacheManager');
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 { CacheManager } = await import('../cache/CacheManager');
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 fire the UPDATEs together via Promise.all so they
272
- // pipeline on the transaction connection instead of paying one
273
- // serial round-trip per dirty component.
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
- await Promise.all(
289
- componentsToUpdate.map(comp =>
290
- run(saveTrx`UPDATE components SET data = ${comp.data} WHERE id = ${comp.id}`)
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 pipeline the
326
- // statements on the transaction connection instead of paying
327
- // serial round-trips while holding the connection.
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 Promise.all([
330
- run(trx`DELETE FROM components WHERE entity_id = ${entity.id}`),
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 Promise.all([
335
- run(trx`UPDATE entities SET deleted_at = CURRENT_TIMESTAMP WHERE id = ${entity.id} AND deleted_at IS NULL`),
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 [database, cache] = await Promise.all([
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: { database, cache },
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,