@steve31415/baselib 3.48.3 → 3.50.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 +19 -4
- package/dist/db.js +93 -23
- package/package.json +1 -1
package/dist/db.d.ts
CHANGED
|
@@ -115,8 +115,16 @@ export declare const IDLE_PROBE_TIMEOUT_MS = 3000;
|
|
|
115
115
|
* - `connect`: a new connection failed to establish (handshake dropped,
|
|
116
116
|
* credential rejected, `connectionTimeoutMillis` hit mid-handshake).
|
|
117
117
|
* No statement ran, so the operation is safe to retry.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
118
|
+
* Also a checkout that timed out while the pool was still opening a
|
|
119
|
+
* connection (`pool.connecting` > 0, message "a new connection did not
|
|
120
|
+
* finish within the checkout timeout"): pg-pool queues a checkout that
|
|
121
|
+
* finds an idle client, and if another checkout takes that client
|
|
122
|
+
* first it hands the queued one a new connection, so a stalled
|
|
123
|
+
* handshake surfaces with pg-pool's starvation error (coder
|
|
124
|
+
* 2026-11-02: total 4 of 6, 3 idle, a 15 s Cloud SQL handshake).
|
|
125
|
+
* - `wait`: the checkout timed out waiting for a free client with no
|
|
126
|
+
* connection being opened. Pool starvation, not loss: the pool's
|
|
127
|
+
* `max` or a client held too long.
|
|
120
128
|
* - `idle`: a client idle in the pool died; pg-pool discards it and the
|
|
121
129
|
* next checkout opens a fresh one. Harmless by construction. With
|
|
122
130
|
* `meta.detectedAt: 'checkout'` the death went unnoticed until a
|
|
@@ -127,7 +135,8 @@ export declare const IDLE_PROBE_TIMEOUT_MS = 3000;
|
|
|
127
135
|
* any transaction or advisory lock it held is gone.
|
|
128
136
|
* Each event carries `elapsedMs` (time in that phase: the connect
|
|
129
137
|
* attempt, the wait, the idle spell, the checkout), the pg `code`, the
|
|
130
|
-
* pool's counts
|
|
138
|
+
* pool's counts (`total`, `idle`, `waiting`, `connecting`, `max`) and,
|
|
139
|
+
* given a Cloud SQL credential reader,
|
|
131
140
|
* `credentialRemainingMs`; the logger adds request activity, as on every
|
|
132
141
|
* server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
|
|
133
142
|
* `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
|
|
@@ -169,7 +178,13 @@ export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
|
|
|
169
178
|
* drops the database `with (force)` can kill a backend mid-goodbye, and the
|
|
170
179
|
* FATAL it sends back surfaces as an unhandled pool 'error' (it took down a
|
|
171
180
|
* todo2 deploy with every test green, 2026-08-20). pg-pool emits 'remove' for
|
|
172
|
-
* each client once its socket has closed; wait for all of them.
|
|
181
|
+
* each client once its socket has closed; wait for all of them.
|
|
182
|
+
*
|
|
183
|
+
* Only a client that finished connecting gets a 'remove': one still being
|
|
184
|
+
* opened that then fails leaves the pool silently, so counting it (as
|
|
185
|
+
* `totalCount` does) waited forever for an event that never came (found
|
|
186
|
+
* 2026-11-02 with a stalled handshake). Count the connected clients, and
|
|
187
|
+
* any that finish connecting while the pool ends. */
|
|
173
188
|
export declare function endPool(pool: pg.Pool): Promise<void>;
|
|
174
189
|
export declare function migrate(pool: pg.Pool, migrationsDir: string, logger?: Logger): Promise<{
|
|
175
190
|
applied: string[];
|
package/dist/db.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (per-checkout instance — see TESTING.md). Cloud Run: the Cloud SQL Node
|
|
3
3
|
// connector with IAM authentication (CLOUD_SQL_INSTANCE + DB_IAM_USER +
|
|
4
4
|
// DB_NAME env vars) — no database passwords anywhere in the serving path.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import { readdir, readFile } from 'node:fs/promises';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import pg from 'pg';
|
|
@@ -111,7 +112,20 @@ const CONNECTION_LOSS_KIND = 'db-connection-loss';
|
|
|
111
112
|
const ACQUIRE_FAILED_MESSAGE = {
|
|
112
113
|
connect: 'database pool: new connection failed',
|
|
113
114
|
wait: 'database pool: checkout timed out waiting for a free client',
|
|
115
|
+
stalled: 'database pool: a new connection did not finish within the checkout timeout',
|
|
114
116
|
};
|
|
117
|
+
/** pg-pool's client list, read for diagnostics only (pg-pool 3.x
|
|
118
|
+
* `_clients`, pg 8.x `_connecting` / `_connected`). Undefined when a
|
|
119
|
+
* version moves them; callers then fall back to the public counts. */
|
|
120
|
+
function poolClients(pool) {
|
|
121
|
+
const clients = pool._clients;
|
|
122
|
+
return Array.isArray(clients) ? clients : undefined;
|
|
123
|
+
}
|
|
124
|
+
/** Clients the pool is still opening: counted in `totalCount`, neither idle
|
|
125
|
+
* nor lent out. */
|
|
126
|
+
function connectingCount(pool) {
|
|
127
|
+
return poolClients(pool)?.filter((c) => c._connecting === true).length;
|
|
128
|
+
}
|
|
115
129
|
const instrumented = new WeakSet();
|
|
116
130
|
// Clients under an idle probe: their death is reported once, by the probe,
|
|
117
131
|
// as an idle loss rather than a checked-out one.
|
|
@@ -126,7 +140,7 @@ function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
|
126
140
|
...extra,
|
|
127
141
|
...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
|
|
128
142
|
code: err.code,
|
|
129
|
-
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
|
|
143
|
+
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount, connecting: connectingCount(pool), max: pool.options.max },
|
|
130
144
|
...(credentialExpiry
|
|
131
145
|
? expiry === undefined
|
|
132
146
|
? { credentialUnknown: true }
|
|
@@ -147,8 +161,16 @@ function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
|
147
161
|
* - `connect`: a new connection failed to establish (handshake dropped,
|
|
148
162
|
* credential rejected, `connectionTimeoutMillis` hit mid-handshake).
|
|
149
163
|
* No statement ran, so the operation is safe to retry.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
164
|
+
* Also a checkout that timed out while the pool was still opening a
|
|
165
|
+
* connection (`pool.connecting` > 0, message "a new connection did not
|
|
166
|
+
* finish within the checkout timeout"): pg-pool queues a checkout that
|
|
167
|
+
* finds an idle client, and if another checkout takes that client
|
|
168
|
+
* first it hands the queued one a new connection, so a stalled
|
|
169
|
+
* handshake surfaces with pg-pool's starvation error (coder
|
|
170
|
+
* 2026-11-02: total 4 of 6, 3 idle, a 15 s Cloud SQL handshake).
|
|
171
|
+
* - `wait`: the checkout timed out waiting for a free client with no
|
|
172
|
+
* connection being opened. Pool starvation, not loss: the pool's
|
|
173
|
+
* `max` or a client held too long.
|
|
152
174
|
* - `idle`: a client idle in the pool died; pg-pool discards it and the
|
|
153
175
|
* next checkout opens a fresh one. Harmless by construction. With
|
|
154
176
|
* `meta.detectedAt: 'checkout'` the death went unnoticed until a
|
|
@@ -159,7 +181,8 @@ function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
|
159
181
|
* any transaction or advisory lock it held is gone.
|
|
160
182
|
* Each event carries `elapsedMs` (time in that phase: the connect
|
|
161
183
|
* attempt, the wait, the idle spell, the checkout), the pg `code`, the
|
|
162
|
-
* pool's counts
|
|
184
|
+
* pool's counts (`total`, `idle`, `waiting`, `connecting`, `max`) and,
|
|
185
|
+
* given a Cloud SQL credential reader,
|
|
163
186
|
* `credentialRemainingMs`; the logger adds request activity, as on every
|
|
164
187
|
* server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
|
|
165
188
|
* `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
|
|
@@ -223,8 +246,11 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
223
246
|
// A checkout after end() is the caller's shutdown bug, not a lost connection.
|
|
224
247
|
if (message.startsWith('Cannot use a pool after calling end'))
|
|
225
248
|
return;
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
if (message !== 'timeout exceeded when trying to connect')
|
|
250
|
+
return warn('connect', ACQUIRE_FAILED_MESSAGE.connect, err, started);
|
|
251
|
+
// The pool's own timeout: starvation only if nothing was being opened.
|
|
252
|
+
const opening = (connectingCount(pool) ?? 0) > 0;
|
|
253
|
+
warn(opening ? 'connect' : 'wait', opening ? ACQUIRE_FAILED_MESSAGE.stalled : ACQUIRE_FAILED_MESSAGE.wait, err, started);
|
|
228
254
|
};
|
|
229
255
|
pool.connect = ((cb) => {
|
|
230
256
|
const started = Date.now();
|
|
@@ -352,23 +378,37 @@ export async function createPool(opts = {}) {
|
|
|
352
378
|
* drops the database `with (force)` can kill a backend mid-goodbye, and the
|
|
353
379
|
* FATAL it sends back surfaces as an unhandled pool 'error' (it took down a
|
|
354
380
|
* todo2 deploy with every test green, 2026-08-20). pg-pool emits 'remove' for
|
|
355
|
-
* each client once its socket has closed; wait for all of them.
|
|
381
|
+
* each client once its socket has closed; wait for all of them.
|
|
382
|
+
*
|
|
383
|
+
* Only a client that finished connecting gets a 'remove': one still being
|
|
384
|
+
* opened that then fails leaves the pool silently, so counting it (as
|
|
385
|
+
* `totalCount` does) waited forever for an event that never came (found
|
|
386
|
+
* 2026-11-02 with a stalled handshake). Count the connected clients, and
|
|
387
|
+
* any that finish connecting while the pool ends. */
|
|
356
388
|
export async function endPool(pool) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
389
|
+
const clients = poolClients(pool);
|
|
390
|
+
let open = clients ? clients.filter((c) => c._connected === true).length : pool.totalCount;
|
|
391
|
+
let resolveAll;
|
|
392
|
+
const allClosed = new Promise((resolve) => (resolveAll = resolve));
|
|
393
|
+
const settle = () => {
|
|
394
|
+
if (open <= 0)
|
|
395
|
+
resolveAll();
|
|
396
|
+
};
|
|
397
|
+
pool.on('connect', () => open++);
|
|
398
|
+
pool.on('remove', () => {
|
|
399
|
+
open--;
|
|
400
|
+
settle();
|
|
365
401
|
});
|
|
366
402
|
await pool.end();
|
|
403
|
+
// No client can start connecting once end() has resolved.
|
|
404
|
+
settle();
|
|
367
405
|
await allClosed;
|
|
368
406
|
}
|
|
369
407
|
// Plain-SQL migrations: numbered files (001-*.sql, 002-*.sql, …) applied in
|
|
370
|
-
// order, recorded in schema_migrations
|
|
371
|
-
// an advisory lock so concurrent boots can't
|
|
408
|
+
// order, recorded in schema_migrations with their content's sha256, each
|
|
409
|
+
// inside a transaction, guarded by an advisory lock so concurrent boots can't
|
|
410
|
+
// race. Applied at service boot; an applied file whose content has since
|
|
411
|
+
// changed fails the boot.
|
|
372
412
|
// Lock loss (see logPoolErrors): all the work runs on the lock's own client,
|
|
373
413
|
// so a dead connection fails the next statement and the boot aborts — no
|
|
374
414
|
// window in which a migration runs unlocked.
|
|
@@ -380,18 +420,48 @@ export async function migrate(pool, migrationsDir, logger) {
|
|
|
380
420
|
await client.query('select pg_advisory_lock(hashtext($1))', ['baselib_migrate']);
|
|
381
421
|
await client.query(`create table if not exists schema_migrations (
|
|
382
422
|
name text primary key,
|
|
383
|
-
applied_at timestamptz not null default now()
|
|
423
|
+
applied_at timestamptz not null default now(),
|
|
424
|
+
sha256 text
|
|
384
425
|
)`);
|
|
385
|
-
|
|
386
|
-
const
|
|
426
|
+
await client.query('alter table schema_migrations add column if not exists sha256 text');
|
|
427
|
+
const { rows } = await client.query('select name, sha256 from schema_migrations');
|
|
428
|
+
const recorded = new Map(rows.map((r) => [r.name, r.sha256]));
|
|
429
|
+
const contents = new Map();
|
|
387
430
|
for (const file of files) {
|
|
388
|
-
if (done.has(file))
|
|
389
|
-
continue;
|
|
390
431
|
const sql = await readFile(join(migrationsDir, file), 'utf8');
|
|
432
|
+
contents.set(file, { sql, sha256: createHash('sha256').update(sql).digest('hex') });
|
|
433
|
+
}
|
|
434
|
+
// A shipped migration file is never edited (plasticine-way docs/DATABASE.md): the
|
|
435
|
+
// runner skips a recorded name, so an edit would never reach the database while the
|
|
436
|
+
// code relying on it ships. Refuse to boot instead, before applying anything, so the
|
|
437
|
+
// new revision fails its start and the old one keeps serving (people, 2026-11-02).
|
|
438
|
+
// Rows recorded before hashes were kept get the file's current hash, once.
|
|
439
|
+
const edited = [];
|
|
440
|
+
for (const file of files) {
|
|
441
|
+
if (!recorded.has(file))
|
|
442
|
+
continue;
|
|
443
|
+
const { sha256 } = contents.get(file);
|
|
444
|
+
const was = recorded.get(file);
|
|
445
|
+
if (was == null) {
|
|
446
|
+
await client.query('update schema_migrations set sha256 = $2 where name = $1', [file, sha256]);
|
|
447
|
+
}
|
|
448
|
+
else if (was !== sha256) {
|
|
449
|
+
edited.push(file);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (edited.length > 0) {
|
|
453
|
+
throw new Error(`migration ${edited.join(', ')} was edited after it was applied; a shipped migration is never ` +
|
|
454
|
+
'edited: restore the file and put the change in a new migration (a local database holding an ' +
|
|
455
|
+
'unshipped draft: drop it and re-run)');
|
|
456
|
+
}
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
if (recorded.has(file))
|
|
459
|
+
continue;
|
|
460
|
+
const { sql, sha256 } = contents.get(file);
|
|
391
461
|
try {
|
|
392
462
|
await client.query('begin');
|
|
393
463
|
await client.query(sql);
|
|
394
|
-
await client.query('insert into schema_migrations (name) values ($1)', [file]);
|
|
464
|
+
await client.query('insert into schema_migrations (name, sha256) values ($1, $2)', [file, sha256]);
|
|
395
465
|
await client.query('commit');
|
|
396
466
|
}
|
|
397
467
|
catch (err) {
|
package/package.json
CHANGED