@steve31415/baselib 3.41.0 → 3.43.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/dist/db.d.ts +47 -2
- package/dist/db.js +129 -24
- package/dist/log-core.d.ts +7 -1
- package/dist/log.js +8 -0
- 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
|
|
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/dist/log-core.d.ts
CHANGED
|
@@ -65,7 +65,13 @@ export interface ShipperOptions {
|
|
|
65
65
|
* (uncounted) attempt (default 40 s). */
|
|
66
66
|
offlineRetryMs?: number;
|
|
67
67
|
/** Per-attempt fetch timeout (default 10s; 3s aborted most of the fleet's
|
|
68
|
-
* attempts in the two weeks to 2026-08-28).
|
|
68
|
+
* attempts in the two weeks to 2026-08-28). Headroom judgment 2026-10-30
|
|
69
|
+
* (watchdog task f4ec3337): steady-state ingest takes ~0.2–2 s (≤ 0.2 of
|
|
70
|
+
* the limit). What still times out is a fresh instance's startup line
|
|
71
|
+
* shipped while Cloud Run throttles its CPU (no request in flight), which
|
|
72
|
+
* a longer timeout would not cure — it waits for CPU, not for Axiom — and
|
|
73
|
+
* flush() callers wait up to this + 500 ms, so raising it lengthens the
|
|
74
|
+
* worst-case request. Kept at 10 s; the retry delivers those batches. */
|
|
69
75
|
attemptTimeoutMs?: number;
|
|
70
76
|
/** Caps across ALL undelivered batches; overflow drops oldest with an
|
|
71
77
|
* onGiveUp('buffer-overflow'). Defaults 5000 events / 5 MB. */
|
package/dist/log.js
CHANGED
|
@@ -88,6 +88,14 @@ export function stdoutShipMarkers(app, dataset) {
|
|
|
88
88
|
attempt: batch.attempt,
|
|
89
89
|
max_attempts: batch.maxAttempts,
|
|
90
90
|
error: String(error),
|
|
91
|
+
// Whether Cloud Run had CPU allocated: request-based billing throttles
|
|
92
|
+
// an idle instance to almost nothing, and a fresh instance's startup
|
|
93
|
+
// line ships just after its startup probe passes — with no request in
|
|
94
|
+
// flight when a deploy or replacement starts it ahead of traffic. Its
|
|
95
|
+
// 10 s attempt then times out (watchdog task f4ec3337, 2026-10-30:
|
|
96
|
+
// all 12 batches that failed on 10-29 13:33–16:25Z were such startup lines).
|
|
97
|
+
uptime_ms: Math.round(process.uptime() * 1000),
|
|
98
|
+
...processMeta(),
|
|
91
99
|
message: `axiom ship attempt ${batch.attempt}/${batch.maxAttempts} failed: ${String(error)} ` +
|
|
92
100
|
`(${batch.events.length} events, ${batch.bytes} bytes; will retry)`,
|
|
93
101
|
}),
|
package/package.json
CHANGED