@steve31415/baselib 3.41.0 → 3.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/db.d.ts +47 -2
  2. package/dist/db.js +129 -24
  3. package/package.json +1 -1
package/dist/db.d.ts CHANGED
@@ -49,6 +49,9 @@ export interface PoolOptions {
49
49
  /** Receives idle-client errors (see logPoolErrors). Without one they go to
50
50
  * stderr — on the Cloud Logging backstop, but not in Axiom. */
51
51
  logger?: Logger;
52
+ /** Tuning for `probeIdleCheckouts` (defaults IDLE_PROBE_AFTER_MS and
53
+ * IDLE_PROBE_TIMEOUT_MS); tests shorten them. */
54
+ idleProbe?: IdleProbeOptions;
52
55
  }
53
56
  /** Why a fleet-wide statement timeout (mirror, 2026-09-08): a transaction an
54
57
  * app deliberately holds open across an application await (mirror's job
@@ -78,12 +81,34 @@ export interface PoolLoggingOptions {
78
81
  * instead of hanging, and TCP keepalive so a half-open socket is noticed
79
82
  * while the process has CPU. No max lifetime: reconnecting is the
80
83
  * operation that fails on a throttled instance, so forcing more of it
81
- * would be backwards. */
84
+ * would be backwards.
85
+ *
86
+ * One warm connection (2026-10-30, `~/migration/prompts/baselib-pool-idle-timeout.md`):
87
+ * pg-pool's default 10 s idle timeout emptied every pool between a single
88
+ * user's requests, so nearly every request paid a ~77 ms Cloud SQL connect.
89
+ * pg-pool 3.14's `min` does not open connections; it exempts the first
90
+ * `min` connections from the idle timeout, so the pool keeps one once it
91
+ * has made one. Connections above it close after `idleTimeoutMillis` (a
92
+ * bound, never unlimited). A held connection can die while idle (the
93
+ * weekly Thursday bursts), unnoticed until a checkout, so `createPool`
94
+ * also probes a client that sat idle (`probeIdleCheckouts`). Budget: one
95
+ * connection per pool per instance at rest, about +12 fleet-wide. A
96
+ * process that forgets `pool.end()` no longer exits by itself: end pools
97
+ * in scripts and jobs. */
82
98
  export declare const FLEET_POOL_DEFAULTS: {
83
99
  readonly connectionTimeoutMillis: 15000;
84
100
  readonly keepAlive: true;
85
101
  readonly keepAliveInitialDelayMillis: 10000;
102
+ readonly min: 1;
103
+ readonly idleTimeoutMillis: 60000;
86
104
  };
105
+ /** A client idle at least this long is probed before it is handed out.
106
+ * Below it the pool reused connections without a probe before 2026-10-30
107
+ * too (they lived 10 s), so nothing is less safe than it was. */
108
+ export declare const IDLE_PROBE_AFTER_MS = 10000;
109
+ /** How long the probe waits. A healthy `select 1` takes a millisecond or
110
+ * two; a half-open socket would otherwise wait for TCP to give up. */
111
+ export declare const IDLE_PROBE_TIMEOUT_MS = 3000;
87
112
  /** Keep a pg connection's death from killing the process, and log every
88
113
  * way a pool loses or fails to get a connection as one WARN family:
89
114
  * `kind=db-connection-loss`, `meta.phase`:
@@ -93,7 +118,9 @@ export declare const FLEET_POOL_DEFAULTS: {
93
118
  * - `wait`: the checkout timed out waiting for a free client. Pool
94
119
  * starvation, not loss: the pool's `max` or a client held too long.
95
120
  * - `idle`: a client idle in the pool died; pg-pool discards it and the
96
- * next checkout opens a fresh one. Harmless by construction.
121
+ * next checkout opens a fresh one. Harmless by construction. With
122
+ * `meta.detectedAt: 'checkout'` the death went unnoticed until a
123
+ * checkout's probe found it (`probeIdleCheckouts`); also harmless.
97
124
  * - `checked-out`: a borrowed client died. Its next query fails (pg has
98
125
  * already failed its queued queries and marked it unqueryable, so the
99
126
  * borrower cannot mistake the dead transaction for a live one), and
@@ -117,6 +144,24 @@ export declare const FLEET_POOL_DEFAULTS: {
117
144
  * `createPool` attaches all of this; exported for a pool an app built
118
145
  * itself. Idempotent per pool. */
119
146
  export declare function logPoolErrors(pool: pg.Pool, logger?: Logger, options?: PoolLoggingOptions): void;
147
+ export interface IdleProbeOptions {
148
+ afterIdleMs?: number;
149
+ timeoutMs?: number;
150
+ }
151
+ /** Hand out no pooled client that died while idle. A client idle for
152
+ * `afterIdleMs` or more runs `select 1` (bounded by `timeoutMs`) before
153
+ * the borrower gets it; if that fails the client is discarded, one WARN
154
+ * (`kind=db-connection-loss`, `phase=idle`, `detectedAt=checkout`) is
155
+ * logged, and the checkout is retried, ending at a fresh connection at
156
+ * worst. Retrying is safe because the probe is the only statement that
157
+ * ran on the dead client. What stays unretried, as before: a connection
158
+ * that dies after the probe, mid-statement or mid-transaction (the
159
+ * borrower sees the error; whether a lone statement took effect is
160
+ * unknowable), and a failed new connection (`phase=connect`). New clients
161
+ * and recently used ones are not probed. Both connect forms are covered
162
+ * (`pool.query` checks out in callback form). `createPool` attaches this;
163
+ * exported for a pool an app built itself. Idempotent per pool. */
164
+ export declare function probeIdleCheckouts(pool: pg.Pool, logger?: Logger, options?: IdleProbeOptions): void;
120
165
  export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
121
166
  /** End a pool and resolve only once every client's connection has actually
122
167
  * closed. pg-pool's own `end()` resolves as soon as it has *asked* its idle
package/dist/db.js CHANGED
@@ -78,18 +78,69 @@ export const DEFAULT_STATEMENT_TIMEOUT_MS = 10 * 60_000;
78
78
  * instead of hanging, and TCP keepalive so a half-open socket is noticed
79
79
  * while the process has CPU. No max lifetime: reconnecting is the
80
80
  * operation that fails on a throttled instance, so forcing more of it
81
- * would be backwards. */
81
+ * would be backwards.
82
+ *
83
+ * One warm connection (2026-10-30, `~/migration/prompts/baselib-pool-idle-timeout.md`):
84
+ * pg-pool's default 10 s idle timeout emptied every pool between a single
85
+ * user's requests, so nearly every request paid a ~77 ms Cloud SQL connect.
86
+ * pg-pool 3.14's `min` does not open connections; it exempts the first
87
+ * `min` connections from the idle timeout, so the pool keeps one once it
88
+ * has made one. Connections above it close after `idleTimeoutMillis` (a
89
+ * bound, never unlimited). A held connection can die while idle (the
90
+ * weekly Thursday bursts), unnoticed until a checkout, so `createPool`
91
+ * also probes a client that sat idle (`probeIdleCheckouts`). Budget: one
92
+ * connection per pool per instance at rest, about +12 fleet-wide. A
93
+ * process that forgets `pool.end()` no longer exits by itself: end pools
94
+ * in scripts and jobs. */
82
95
  export const FLEET_POOL_DEFAULTS = {
83
96
  connectionTimeoutMillis: 15_000,
84
97
  keepAlive: true,
85
98
  keepAliveInitialDelayMillis: 10_000,
99
+ min: 1,
100
+ idleTimeoutMillis: 60_000,
86
101
  };
102
+ /** A client idle at least this long is probed before it is handed out.
103
+ * Below it the pool reused connections without a probe before 2026-10-30
104
+ * too (they lived 10 s), so nothing is less safe than it was. */
105
+ export const IDLE_PROBE_AFTER_MS = 10_000;
106
+ /** How long the probe waits. A healthy `select 1` takes a millisecond or
107
+ * two; a half-open socket would otherwise wait for TCP to give up. */
108
+ export const IDLE_PROBE_TIMEOUT_MS = 3_000;
109
+ const IDLE_PROBE_SQL = 'select 1 /* baselib idle probe */';
87
110
  const CONNECTION_LOSS_KIND = 'db-connection-loss';
88
111
  const ACQUIRE_FAILED_MESSAGE = {
89
112
  connect: 'database pool: new connection failed',
90
113
  wait: 'database pool: checkout timed out waiting for a free client',
91
114
  };
92
115
  const instrumented = new WeakSet();
116
+ // Clients under an idle probe: their death is reported once, by the probe,
117
+ // as an idle loss rather than a checked-out one.
118
+ const probing = new WeakSet();
119
+ /** One `kind=db-connection-loss` WARN, the shape every pool loss shares. */
120
+ function connectionLossWarner(pool, logger, credentialExpiry) {
121
+ const log = logger?.with({ kind: CONNECTION_LOSS_KIND });
122
+ return (phase, message, err, since, extra = {}) => {
123
+ const expiry = credentialExpiry?.();
124
+ const meta = {
125
+ phase,
126
+ ...extra,
127
+ ...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
128
+ code: err.code,
129
+ pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
130
+ ...(credentialExpiry
131
+ ? expiry === undefined
132
+ ? { credentialUnknown: true }
133
+ : { credentialRemainingMs: expiry - Date.now() }
134
+ : {}),
135
+ error: err,
136
+ };
137
+ if (log)
138
+ log.warn(message, meta);
139
+ // No logger to add the activity fields, so add them here.
140
+ else
141
+ console.warn(message, { kind: CONNECTION_LOSS_KIND, ...processMeta(), ...meta, error: serializeError(err) });
142
+ };
143
+ }
93
144
  /** Keep a pg connection's death from killing the process, and log every
94
145
  * way a pool loses or fails to get a connection as one WARN family:
95
146
  * `kind=db-connection-loss`, `meta.phase`:
@@ -99,7 +150,9 @@ const instrumented = new WeakSet();
99
150
  * - `wait`: the checkout timed out waiting for a free client. Pool
100
151
  * starvation, not loss: the pool's `max` or a client held too long.
101
152
  * - `idle`: a client idle in the pool died; pg-pool discards it and the
102
- * next checkout opens a fresh one. Harmless by construction.
153
+ * next checkout opens a fresh one. Harmless by construction. With
154
+ * `meta.detectedAt: 'checkout'` the death went unnoticed until a
155
+ * checkout's probe found it (`probeIdleCheckouts`); also harmless.
103
156
  * - `checked-out`: a borrowed client died. Its next query fails (pg has
104
157
  * already failed its queued queries and marked it unqueryable, so the
105
158
  * borrower cannot mistake the dead transaction for a live one), and
@@ -126,27 +179,7 @@ export function logPoolErrors(pool, logger, options = {}) {
126
179
  if (instrumented.has(pool))
127
180
  return;
128
181
  instrumented.add(pool);
129
- const log = logger?.with({ kind: CONNECTION_LOSS_KIND });
130
- const warn = (phase, message, err, since) => {
131
- const expiry = options.credentialExpiry?.();
132
- const meta = {
133
- phase,
134
- ...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
135
- code: err.code,
136
- pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
137
- ...(options.credentialExpiry
138
- ? expiry === undefined
139
- ? { credentialUnknown: true }
140
- : { credentialRemainingMs: expiry - Date.now() }
141
- : {}),
142
- error: err,
143
- };
144
- if (log)
145
- log.warn(message, meta);
146
- // No logger to add the activity fields, so add them here.
147
- else
148
- console.warn(message, { kind: CONNECTION_LOSS_KIND, ...processMeta(), ...meta, error: serializeError(err) });
149
- };
182
+ const warn = connectionLossWarner(pool, logger, options.credentialExpiry);
150
183
  // Per-client bookkeeping across one checkout: the borrower-side error
151
184
  // listener (pg-pool drops its own at checkout) and when the client was
152
185
  // taken and given back.
@@ -167,7 +200,7 @@ export function logPoolErrors(pool, logger, options = {}) {
167
200
  // close); one line per checkout is enough.
168
201
  let logged = false;
169
202
  entry.listener = (err) => {
170
- if (logged)
203
+ if (logged || probing.has(client))
171
204
  return;
172
205
  logged = true;
173
206
  warn('checked-out', 'database pool: checked-out client lost its connection (any transaction or advisory lock it held is gone; its next query fails)', err, entry.acquiredAt);
@@ -208,6 +241,76 @@ export function logPoolErrors(pool, logger, options = {}) {
208
241
  });
209
242
  });
210
243
  }
244
+ const probed = new WeakSet();
245
+ /** Hand out no pooled client that died while idle. A client idle for
246
+ * `afterIdleMs` or more runs `select 1` (bounded by `timeoutMs`) before
247
+ * the borrower gets it; if that fails the client is discarded, one WARN
248
+ * (`kind=db-connection-loss`, `phase=idle`, `detectedAt=checkout`) is
249
+ * logged, and the checkout is retried, ending at a fresh connection at
250
+ * worst. Retrying is safe because the probe is the only statement that
251
+ * ran on the dead client. What stays unretried, as before: a connection
252
+ * that dies after the probe, mid-statement or mid-transaction (the
253
+ * borrower sees the error; whether a lone statement took effect is
254
+ * unknowable), and a failed new connection (`phase=connect`). New clients
255
+ * and recently used ones are not probed. Both connect forms are covered
256
+ * (`pool.query` checks out in callback form). `createPool` attaches this;
257
+ * exported for a pool an app built itself. Idempotent per pool. */
258
+ export function probeIdleCheckouts(pool, logger, options = {}) {
259
+ if (probed.has(pool))
260
+ return;
261
+ probed.add(pool);
262
+ const afterIdleMs = options.afterIdleMs ?? IDLE_PROBE_AFTER_MS;
263
+ const timeoutMs = options.timeoutMs ?? IDLE_PROBE_TIMEOUT_MS;
264
+ const releasedAt = new WeakMap();
265
+ pool.on('release', (_err, client) => releasedAt.set(client, Date.now()));
266
+ const warn = connectionLossWarner(pool, logger);
267
+ const connect = pool.connect.bind(pool);
268
+ const checkout = async () => {
269
+ // Each failed probe removes a client, so this ends at a client that
270
+ // passes or a fresh one (or a connect error, which propagates).
271
+ for (;;) {
272
+ const client = await connect();
273
+ const idleSince = releasedAt.get(client);
274
+ if (idleSince === undefined || Date.now() - idleSince < afterIdleMs)
275
+ return client;
276
+ probing.add(client);
277
+ // The death also arrives as a client 'error', possibly after the
278
+ // discard below; the probe reports it, and a discarded client keeps
279
+ // this listener so a late socket error cannot go unhandled.
280
+ const ignore = () => { };
281
+ client.on('error', ignore);
282
+ let timer;
283
+ try {
284
+ const probe = client.query(IDLE_PROBE_SQL);
285
+ // On a timeout the release below destroys the socket (pg's end()
286
+ // with a query active), which rejects the probe; nothing awaits it.
287
+ probe.catch(() => { });
288
+ await Promise.race([
289
+ probe,
290
+ new Promise((_, reject) => {
291
+ timer = setTimeout(() => reject(new Error(`idle probe timed out after ${timeoutMs} ms`)), timeoutMs);
292
+ }),
293
+ ]);
294
+ client.removeListener('error', ignore);
295
+ return client;
296
+ }
297
+ catch (err) {
298
+ const error = err instanceof Error ? err : new Error(String(err));
299
+ warn('idle', 'database pool: idle client found dead at checkout; replaced', error, idleSince, { detectedAt: 'checkout' });
300
+ client.release(error);
301
+ }
302
+ finally {
303
+ clearTimeout(timer);
304
+ probing.delete(client);
305
+ }
306
+ }
307
+ };
308
+ pool.connect = ((cb) => {
309
+ if (typeof cb !== 'function')
310
+ return checkout();
311
+ checkout().then((client) => cb(undefined, client, (release) => client.release(release)), (err) => cb(err));
312
+ });
313
+ }
211
314
  export async function createPool(opts = {}) {
212
315
  // pg sends statement_timeout in the startup packet of every connection —
213
316
  // server-enforced, no per-connection SET. A 0 is falsy, so pg omits it and
@@ -230,6 +333,7 @@ export async function createPool(opts = {}) {
230
333
  max: opts.max ?? 3, // shared instance: default max_connections is low
231
334
  });
232
335
  logPoolErrors(pool, opts.logger, { credentialExpiry });
336
+ probeIdleCheckouts(pool, opts.logger, opts.idleProbe);
233
337
  return pool;
234
338
  }
235
339
  const url = new URL(requireEnv('DATABASE_URL'));
@@ -239,6 +343,7 @@ export async function createPool(opts = {}) {
239
343
  url.username = opts.user;
240
344
  const pool = new pg.Pool({ connectionString: url.toString(), ...shared, max: opts.max ?? 5 });
241
345
  logPoolErrors(pool, opts.logger);
346
+ probeIdleCheckouts(pool, opts.logger, opts.idleProbe);
242
347
  return pool;
243
348
  }
244
349
  /** End a pool and resolve only once every client's connection has actually
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.41.0",
3
+ "version": "3.42.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",