@steve31415/baselib 3.12.0 → 3.13.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.
@@ -0,0 +1,14 @@
1
+ export declare function requestStarted(): void;
2
+ export declare function requestEnded(): void;
3
+ /** The fields a diagnostic event carries: requests in flight right now and
4
+ * how long ago the last one ended (undefined before the first request). */
5
+ export declare function activitySnapshot(): {
6
+ inFlightRequests: number;
7
+ sinceLastRequestMs?: number;
8
+ };
9
+ /** Wrap a fetch-style handler so its requests count as activity. The
10
+ * request ends when the handler returns its Response; a streaming body
11
+ * that outlives that is not tracked. */
12
+ export declare function trackActivity<A extends unknown[]>(fetch: (...args: A) => Response | Promise<Response>): (...args: A) => Promise<Response>;
13
+ /** Test hook: forget everything. */
14
+ export declare function resetActivity(): void;
@@ -0,0 +1,49 @@
1
+ // Request activity of this process, for log events that need to know
2
+ // whether Cloud Run had CPU allocated at the time: with request-based
3
+ // billing (the fleet default) an instance's CPU is throttled to almost
4
+ // nothing between requests, and timers, connection handshakes and
5
+ // credential refreshes crawl. A connection failure logged with
6
+ // `inFlightRequests: 0, sinceLastRequestMs: 31000` happened in that
7
+ // throttled tail; one with `inFlightRequests: 1` did not.
8
+ //
9
+ // `startServer` tracks every HTTP request automatically. A custom server
10
+ // wraps its fetch handler with `trackActivity`. WebSocket sessions are not
11
+ // counted (they hold CPU on Cloud Run, but the upgrade bypasses fetch), so
12
+ // on a WebSocket-heavy app zero in-flight requests does not prove the
13
+ // instance was idle.
14
+ let inFlight = 0;
15
+ let lastRequestEndedAt = 0;
16
+ export function requestStarted() {
17
+ inFlight += 1;
18
+ }
19
+ export function requestEnded() {
20
+ inFlight = Math.max(0, inFlight - 1);
21
+ lastRequestEndedAt = Date.now();
22
+ }
23
+ /** The fields a diagnostic event carries: requests in flight right now and
24
+ * how long ago the last one ended (undefined before the first request). */
25
+ export function activitySnapshot() {
26
+ return {
27
+ inFlightRequests: inFlight,
28
+ ...(lastRequestEndedAt ? { sinceLastRequestMs: Date.now() - lastRequestEndedAt } : {}),
29
+ };
30
+ }
31
+ /** Wrap a fetch-style handler so its requests count as activity. The
32
+ * request ends when the handler returns its Response; a streaming body
33
+ * that outlives that is not tracked. */
34
+ export function trackActivity(fetch) {
35
+ return async (...args) => {
36
+ requestStarted();
37
+ try {
38
+ return await fetch(...args);
39
+ }
40
+ finally {
41
+ requestEnded();
42
+ }
43
+ };
44
+ }
45
+ /** Test hook: forget everything. */
46
+ export function resetActivity() {
47
+ inFlight = 0;
48
+ lastRequestEndedAt = 0;
49
+ }
package/dist/db.d.ts CHANGED
@@ -2,23 +2,46 @@ import type { ConnectorOptions } from '@google-cloud/cloud-sql-connector';
2
2
  import pg from 'pg';
3
3
  import { type Logger } from './log-core.js';
4
4
  export declare const dateAsText: (v: string) => string;
5
+ type Connector = import('@google-cloud/cloud-sql-connector').Connector;
6
+ type InstanceOptions = Parameters<Connector['getOptions']>[0];
5
7
  /** Connection options for a Cloud SQL instance the fleet way: the Node
6
8
  * connector, IAM authentication, public IP. `auth` replaces the connector's
7
9
  * default credential resolution (the agent's tooling needs that; deployed
8
10
  * code uses the default). The connector stays open for the connections'
9
- * lifetime; close it when they are done. */
11
+ * lifetime; close it when they are done. `credentialExpiry` reads the
12
+ * expiry of the certificate it currently holds (see cloudSqlCredentialExpiry). */
10
13
  export declare function cloudSqlClientOptions(instance: string, auth?: ConnectorOptions['auth']): Promise<{
11
14
  options: Awaited<ReturnType<Connector['getOptions']>>;
12
15
  connector: Connector;
16
+ credentialExpiry: () => number | undefined;
13
17
  }>;
14
- type Connector = import('@google-cloud/cloud-sql-connector').Connector;
18
+ /** Reads the expiry (epoch ms) of the ephemeral certificate the connector
19
+ * currently holds for an instance, or undefined when it cannot. The
20
+ * connector refreshes that certificate on a timer and never checks it on
21
+ * the connect path (1.11.3 `connector.js` `stream()`), so a connect that
22
+ * fails with the certificate already expired, or seconds from it, points
23
+ * at a refresh timer that fired late. This reads the connector's internal
24
+ * instance map (`instances.getInstance(opts).ephemeralCert.expirationTime`,
25
+ * an ISO string about an hour out; verified live against plasticine-pg on
26
+ * 2026-09-16). A connector version that moves it yields undefined, never a
27
+ * throw; the event then carries `credentialUnknown: true` so the gap is
28
+ * visible rather than read as "no credential involved". */
29
+ export declare function cloudSqlCredentialExpiry(connector: Connector, instanceOptions: InstanceOptions): () => number | undefined;
15
30
  export interface PoolOptions {
16
31
  /** Override the database name (default: env DB_NAME / DATABASE_URL path). */
17
32
  database?: string;
33
+ /** Override the role to connect as (default: env DB_IAM_USER on Cloud
34
+ * SQL, the DATABASE_URL user locally). */
35
+ user?: string;
36
+ /** Postgres command-line options sent in the startup packet, e.g.
37
+ * `-c role=app_runtime -c search_path=app` (pg's `options`): session
38
+ * settings that must hold before the first statement. */
39
+ sessionOptions?: string;
18
40
  max?: number;
19
- /** How long a checkout waits for a free connection before failing (pg's
20
- * default is to wait forever, which turns pool starvation into a silent
21
- * hang instead of an error). */
41
+ /** How long a checkout waits, for a free connection or for a new one to
42
+ * connect, before failing (default POOL_HYGIENE.connectionTimeoutMillis;
43
+ * pg's own default is to wait forever, which turns pool starvation into
44
+ * a silent hang instead of an error). */
22
45
  connectionTimeoutMillis?: number;
23
46
  /** Server-side cap on any single SQL statement, sent in each connection's
24
47
  * startup packet (default DEFAULT_STATEMENT_TIMEOUT_MS; 0 disables). */
@@ -41,43 +64,59 @@ export interface PoolOptions {
41
64
  * `set local statement_timeout = 0` — migrate() runs each file in its own
42
65
  * transaction, so the override scopes to that migration. */
43
66
  export declare const DEFAULT_STATEMENT_TIMEOUT_MS: number;
44
- /** Keep a pg connection's death from killing the process, on both paths a
45
- * client can die:
46
- *
47
- * - **Idle in the pool** — pg-pool re-emits the socket error as its own
48
- * 'error' event (in production "Connection terminated unexpectedly" when
49
- * Cloud SQL closes an idle connection). Unhandled, that event throws and
50
- * takes the process down (todo 2026-08-20, notes 2026-08-27 ×2). The
51
- * failed client has already been removed from the pool and the next
52
- * checkout opens a fresh connection, so logging it is the whole response.
53
- * - **Checked out** (`pool.connect()`) — pg-pool removes its listener at
54
- * checkout, so the client's own 'error' event is the borrower's problem;
55
- * with no listener it throws inside the socket callback and the process
56
- * dies (reader2's LISTEN bridge, 2026-09-10). Every checkout gets a WARN
57
- * listener here, attached on pg-pool's 'acquire' and removed on 'release'
58
- * (both fire while pg-pool's own listener is still attached, so there is
59
- * no listenerless instant). pg fails the in-flight and queued queries and
60
- * marks the client unqueryable *before* emitting, so a transaction on the
61
- * dead client still fails at its next await and rolls back server-side:
62
- * nothing is silently lost on the connection itself.
67
+ /** Options for the connection-outcome events `logPoolErrors` emits. */
68
+ export interface PoolLoggingOptions {
69
+ /** Epoch ms when the connector's cached Cloud SQL credential expires
70
+ * (`cloudSqlCredentialExpiry`); logged as `credentialRemainingMs`. */
71
+ credentialExpiry?: () => number | undefined;
72
+ }
73
+ /** Pool settings every fleet pool carries; `createPool` applies them and
74
+ * is the way to get them (a hand-built pool spreads this, but prefer
75
+ * `createPool` with `user` / `sessionOptions` over building one). Decided
76
+ * 2026-09-16 (`~/migration/research/db-connection-lifecycle-2026-09-16.md`):
77
+ * a bounded checkout wait so starvation and a stuck handshake fail loudly
78
+ * instead of hanging, and TCP keepalive so a half-open socket is noticed
79
+ * while the process has CPU. No max lifetime: reconnecting is the
80
+ * operation that fails on a throttled instance, so forcing more of it
81
+ * would be backwards. */
82
+ export declare const FLEET_POOL_DEFAULTS: {
83
+ readonly connectionTimeoutMillis: 15000;
84
+ readonly keepAlive: true;
85
+ readonly keepAliveInitialDelayMillis: 10000;
86
+ };
87
+ /** Keep a pg connection's death from killing the process, and log every
88
+ * way a pool loses or fails to get a connection as one WARN family:
89
+ * `kind=db-connection-loss`, `meta.phase`:
90
+ * - `connect`: a new connection failed to establish (handshake dropped,
91
+ * credential rejected, `connectionTimeoutMillis` hit mid-handshake).
92
+ * No statement ran, so the operation is safe to retry.
93
+ * - `wait`: the checkout timed out waiting for a free client. Pool
94
+ * starvation, not loss: the pool's `max` or a client held too long.
95
+ * - `idle`: a client idle in the pool died; pg-pool discards it and the
96
+ * next checkout opens a fresh one. Harmless by construction.
97
+ * - `checked-out`: a borrowed client died. Its next query fails (pg has
98
+ * already failed its queued queries and marked it unqueryable, so the
99
+ * borrower cannot mistake the dead transaction for a live one), and
100
+ * any transaction or advisory lock it held is gone.
101
+ * Each event carries `elapsedMs` (time in that phase: the connect
102
+ * attempt, the wait, the idle spell, the checkout), the pg `code`, the
103
+ * pool's counts, `inFlightRequests` + `sinceLastRequestMs` (`activity.ts`:
104
+ * was Cloud Run CPU allocated?) and, given a Cloud SQL credential reader,
105
+ * `credentialRemainingMs`. Query:
106
+ * `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
63
107
  *
64
- * **What this does NOT do — read before holding an advisory lock.** A
65
- * session-level advisory lock, or a transaction-scoped one on a client kept
66
- * idle in `begin` while the work runs elsewhere, evaporates the moment the
67
- * connection dies. The listener only logs; the work that believed itself
68
- * exclusive keeps running, unaware, until it next touches the dead client.
69
- * Another instance can take the lock and run the same work concurrently in
70
- * the meantime. Fleet policy (Steve, 2026-09-10; Plasticine Way
71
- * `docs/DATABASE.md`): our advisory locks limit *duplicated effort*, they
72
- * do not guarantee mutual exclusion, and code under one must be safe to
73
- * overlap. Long-running or expensive work under a lock should check the
74
- * lock connection periodically (a `select 1` on it) and stop on failure —
75
- * mirror2's ingestion loop is the reference. Do not rely on the crash that
76
- * used to happen here as a safety mechanism.
108
+ * The checked-out path is the one that used to crash the process: pg-pool
109
+ * listens for a client's `error` only while it is idle in the pool
110
+ * (`docs/IMPL.md` has the incident history). The survival comes with a
111
+ * rule: an advisory lock is not mutual exclusion. Our locks limit
112
+ * duplicated effort; code under one must be safe to overlap, and long
113
+ * work should probe the lock connection periodically (`select 1`) and
114
+ * stop on failure (Plasticine Way `docs/DATABASE.md`; mirror2's ingestion
115
+ * loop is the reference).
77
116
  *
78
- * createPool attaches all of this; exported for apps that build their own
79
- * pg.Pool. */
80
- export declare function logPoolErrors(pool: pg.Pool, logger?: Logger): void;
117
+ * `createPool` attaches all of this; exported for a pool an app built
118
+ * itself. Idempotent per pool. */
119
+ export declare function logPoolErrors(pool: pg.Pool, logger?: Logger, options?: PoolLoggingOptions): void;
81
120
  export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
82
121
  /** End a pool and resolve only once every client's connection has actually
83
122
  * closed. pg-pool's own `end()` resolves as soon as it has *asked* its idle
package/dist/db.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { readdir, readFile } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import pg from 'pg';
8
+ import { activitySnapshot } from './activity.js';
8
9
  import { requireEnv } from './config.js';
9
10
  import { serializeError } from './log-core.js';
10
11
  // DATE columns come back as 'YYYY-MM-DD' strings, not JS Dates: calendar
@@ -19,16 +20,41 @@ pg.types.setTypeParser(pg.types.builtins.DATE, dateAsText);
19
20
  * connector, IAM authentication, public IP. `auth` replaces the connector's
20
21
  * default credential resolution (the agent's tooling needs that; deployed
21
22
  * code uses the default). The connector stays open for the connections'
22
- * lifetime; close it when they are done. */
23
+ * lifetime; close it when they are done. `credentialExpiry` reads the
24
+ * expiry of the certificate it currently holds (see cloudSqlCredentialExpiry). */
23
25
  export async function cloudSqlClientOptions(instance, auth) {
24
26
  const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
25
27
  const connector = new Connector(auth ? { auth } : {});
26
- const options = await connector.getOptions({
28
+ const instanceOptions = {
27
29
  instanceConnectionName: instance,
28
30
  ipType: IpAddressTypes.PUBLIC,
29
31
  authType: AuthTypes.IAM,
30
- });
31
- return { options, connector };
32
+ };
33
+ const options = await connector.getOptions(instanceOptions);
34
+ return { options, connector, credentialExpiry: cloudSqlCredentialExpiry(connector, instanceOptions) };
35
+ }
36
+ /** Reads the expiry (epoch ms) of the ephemeral certificate the connector
37
+ * currently holds for an instance, or undefined when it cannot. The
38
+ * connector refreshes that certificate on a timer and never checks it on
39
+ * the connect path (1.11.3 `connector.js` `stream()`), so a connect that
40
+ * fails with the certificate already expired, or seconds from it, points
41
+ * at a refresh timer that fired late. This reads the connector's internal
42
+ * instance map (`instances.getInstance(opts).ephemeralCert.expirationTime`,
43
+ * an ISO string about an hour out; verified live against plasticine-pg on
44
+ * 2026-09-16). A connector version that moves it yields undefined, never a
45
+ * throw; the event then carries `credentialUnknown: true` so the gap is
46
+ * visible rather than read as "no credential involved". */
47
+ export function cloudSqlCredentialExpiry(connector, instanceOptions) {
48
+ return () => {
49
+ try {
50
+ const internals = connector;
51
+ const ms = new Date(internals.instances.getInstance(instanceOptions).ephemeralCert.expirationTime).getTime();
52
+ return Number.isFinite(ms) ? ms : undefined;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ };
32
58
  }
33
59
  /** Why a fleet-wide statement timeout (mirror, 2026-09-08): a transaction an
34
60
  * app deliberately holds open across an application await (mirror's job
@@ -44,103 +70,176 @@ export async function cloudSqlClientOptions(instance, auth) {
44
70
  * `set local statement_timeout = 0` — migrate() runs each file in its own
45
71
  * transaction, so the override scopes to that migration. */
46
72
  export const DEFAULT_STATEMENT_TIMEOUT_MS = 10 * 60_000;
47
- /** Keep a pg connection's death from killing the process, on both paths a
48
- * client can die:
73
+ /** Pool settings every fleet pool carries; `createPool` applies them and
74
+ * is the way to get them (a hand-built pool spreads this, but prefer
75
+ * `createPool` with `user` / `sessionOptions` over building one). Decided
76
+ * 2026-09-16 (`~/migration/research/db-connection-lifecycle-2026-09-16.md`):
77
+ * a bounded checkout wait so starvation and a stuck handshake fail loudly
78
+ * instead of hanging, and TCP keepalive so a half-open socket is noticed
79
+ * while the process has CPU. No max lifetime: reconnecting is the
80
+ * operation that fails on a throttled instance, so forcing more of it
81
+ * would be backwards. */
82
+ export const FLEET_POOL_DEFAULTS = {
83
+ connectionTimeoutMillis: 15_000,
84
+ keepAlive: true,
85
+ keepAliveInitialDelayMillis: 10_000,
86
+ };
87
+ const CONNECTION_LOSS_KIND = 'db-connection-loss';
88
+ const ACQUIRE_FAILED_MESSAGE = {
89
+ connect: 'database pool: new connection failed',
90
+ wait: 'database pool: checkout timed out waiting for a free client',
91
+ };
92
+ const instrumented = new WeakSet();
93
+ /** Keep a pg connection's death from killing the process, and log every
94
+ * way a pool loses or fails to get a connection as one WARN family:
95
+ * `kind=db-connection-loss`, `meta.phase`:
96
+ * - `connect`: a new connection failed to establish (handshake dropped,
97
+ * credential rejected, `connectionTimeoutMillis` hit mid-handshake).
98
+ * No statement ran, so the operation is safe to retry.
99
+ * - `wait`: the checkout timed out waiting for a free client. Pool
100
+ * starvation, not loss: the pool's `max` or a client held too long.
101
+ * - `idle`: a client idle in the pool died; pg-pool discards it and the
102
+ * next checkout opens a fresh one. Harmless by construction.
103
+ * - `checked-out`: a borrowed client died. Its next query fails (pg has
104
+ * already failed its queued queries and marked it unqueryable, so the
105
+ * borrower cannot mistake the dead transaction for a live one), and
106
+ * any transaction or advisory lock it held is gone.
107
+ * Each event carries `elapsedMs` (time in that phase: the connect
108
+ * attempt, the wait, the idle spell, the checkout), the pg `code`, the
109
+ * pool's counts, `inFlightRequests` + `sinceLastRequestMs` (`activity.ts`:
110
+ * was Cloud Run CPU allocated?) and, given a Cloud SQL credential reader,
111
+ * `credentialRemainingMs`. Query:
112
+ * `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
49
113
  *
50
- * - **Idle in the pool** — pg-pool re-emits the socket error as its own
51
- * 'error' event (in production "Connection terminated unexpectedly" when
52
- * Cloud SQL closes an idle connection). Unhandled, that event throws and
53
- * takes the process down (todo 2026-08-20, notes 2026-08-27 ×2). The
54
- * failed client has already been removed from the pool and the next
55
- * checkout opens a fresh connection, so logging it is the whole response.
56
- * - **Checked out** (`pool.connect()`) — pg-pool removes its listener at
57
- * checkout, so the client's own 'error' event is the borrower's problem;
58
- * with no listener it throws inside the socket callback and the process
59
- * dies (reader2's LISTEN bridge, 2026-09-10). Every checkout gets a WARN
60
- * listener here, attached on pg-pool's 'acquire' and removed on 'release'
61
- * (both fire while pg-pool's own listener is still attached, so there is
62
- * no listenerless instant). pg fails the in-flight and queued queries and
63
- * marks the client unqueryable *before* emitting, so a transaction on the
64
- * dead client still fails at its next await and rolls back server-side:
65
- * nothing is silently lost on the connection itself.
114
+ * The checked-out path is the one that used to crash the process: pg-pool
115
+ * listens for a client's `error` only while it is idle in the pool
116
+ * (`docs/IMPL.md` has the incident history). The survival comes with a
117
+ * rule: an advisory lock is not mutual exclusion. Our locks limit
118
+ * duplicated effort; code under one must be safe to overlap, and long
119
+ * work should probe the lock connection periodically (`select 1`) and
120
+ * stop on failure (Plasticine Way `docs/DATABASE.md`; mirror2's ingestion
121
+ * loop is the reference).
66
122
  *
67
- * **What this does NOT do — read before holding an advisory lock.** A
68
- * session-level advisory lock, or a transaction-scoped one on a client kept
69
- * idle in `begin` while the work runs elsewhere, evaporates the moment the
70
- * connection dies. The listener only logs; the work that believed itself
71
- * exclusive keeps running, unaware, until it next touches the dead client.
72
- * Another instance can take the lock and run the same work concurrently in
73
- * the meantime. Fleet policy (Steve, 2026-09-10; Plasticine Way
74
- * `docs/DATABASE.md`): our advisory locks limit *duplicated effort*, they
75
- * do not guarantee mutual exclusion, and code under one must be safe to
76
- * overlap. Long-running or expensive work under a lock should check the
77
- * lock connection periodically (a `select 1` on it) and stop on failure —
78
- * mirror2's ingestion loop is the reference. Do not rely on the crash that
79
- * used to happen here as a safety mechanism.
80
- *
81
- * createPool attaches all of this; exported for apps that build their own
82
- * pg.Pool. */
83
- export function logPoolErrors(pool, logger) {
84
- const warn = (message, err) => {
85
- if (logger)
86
- logger.warn(message, { error: err });
123
+ * `createPool` attaches all of this; exported for a pool an app built
124
+ * itself. Idempotent per pool. */
125
+ export function logPoolErrors(pool, logger, options = {}) {
126
+ if (instrumented.has(pool))
127
+ return;
128
+ 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
+ ...activitySnapshot(),
138
+ ...(options.credentialExpiry
139
+ ? expiry === undefined
140
+ ? { credentialUnknown: true }
141
+ : { credentialRemainingMs: expiry - Date.now() }
142
+ : {}),
143
+ error: err,
144
+ };
145
+ if (log)
146
+ log.warn(message, meta);
87
147
  else
88
- console.warn(message, serializeError(err));
148
+ console.warn(message, { kind: CONNECTION_LOSS_KIND, ...meta, error: serializeError(err) });
149
+ };
150
+ // Per-client bookkeeping across one checkout: the borrower-side error
151
+ // listener (pg-pool drops its own at checkout) and when the client was
152
+ // taken and given back.
153
+ const clients = new WeakMap();
154
+ const record = (client) => {
155
+ let entry = clients.get(client);
156
+ if (!entry)
157
+ clients.set(client, (entry = {}));
158
+ return entry;
89
159
  };
90
- pool.on('error', (err) => warn('database pool: idle client error', err));
91
- const listeners = new WeakMap();
160
+ pool.on('error', (err, client) => {
161
+ warn('idle', 'database pool: idle client error', err, client ? clients.get(client)?.releasedAt : undefined);
162
+ });
92
163
  pool.on('acquire', (client) => {
164
+ const entry = record(client);
165
+ entry.acquiredAt = Date.now();
93
166
  // pg emits twice for one death (the server's FATAL, then the socket
94
167
  // close); one line per checkout is enough.
95
168
  let logged = false;
96
- const listener = (err) => {
169
+ entry.listener = (err) => {
97
170
  if (logged)
98
171
  return;
99
172
  logged = true;
100
- warn('database pool: checked-out client lost its connection (any transaction or advisory lock it held is gone; its next query fails)', err);
173
+ 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);
101
174
  };
102
- listeners.set(client, listener);
103
- client.on('error', listener);
175
+ client.on('error', entry.listener);
104
176
  });
105
177
  pool.on('release', (_err, client) => {
106
- const listener = listeners.get(client);
107
- if (!listener)
178
+ const entry = record(client);
179
+ entry.releasedAt = Date.now();
180
+ if (entry.listener)
181
+ client.removeListener('error', entry.listener);
182
+ entry.listener = undefined;
183
+ });
184
+ // pg-pool has no event for a failed checkout: `pool.connect()` just
185
+ // rejects, and `pool.query()` calls `this.connect(cb)` in callback form.
186
+ // Wrap the instance method so both forms log before propagating.
187
+ const connect = pool.connect.bind(pool);
188
+ const onFailure = (err, started) => {
189
+ const message = String(err?.message ?? '');
190
+ // A checkout after end() is the caller's shutdown bug, not a lost connection.
191
+ if (message.startsWith('Cannot use a pool after calling end'))
108
192
  return;
109
- listeners.delete(client);
110
- client.removeListener('error', listener);
193
+ const phase = message === 'timeout exceeded when trying to connect' ? 'wait' : 'connect';
194
+ warn(phase, ACQUIRE_FAILED_MESSAGE[phase], err, started);
195
+ };
196
+ pool.connect = ((cb) => {
197
+ const started = Date.now();
198
+ if (typeof cb === 'function') {
199
+ return connect((err, ...rest) => {
200
+ if (err)
201
+ onFailure(err, started);
202
+ cb(err, ...rest);
203
+ });
204
+ }
205
+ return connect().catch((err) => {
206
+ onFailure(err, started);
207
+ throw err;
208
+ });
111
209
  });
112
210
  }
113
211
  export async function createPool(opts = {}) {
114
- const pool = await newPool(opts);
115
- logPoolErrors(pool, opts.logger);
116
- return pool;
117
- }
118
- async function newPool(opts) {
119
212
  // pg sends statement_timeout in the startup packet of every connection —
120
213
  // server-enforced, no per-connection SET. A 0 is falsy, so pg omits it and
121
214
  // the server default (no timeout) applies.
122
215
  const statement_timeout = opts.statementTimeoutMillis ?? DEFAULT_STATEMENT_TIMEOUT_MS;
216
+ const shared = {
217
+ ...FLEET_POOL_DEFAULTS,
218
+ connectionTimeoutMillis: opts.connectionTimeoutMillis ?? FLEET_POOL_DEFAULTS.connectionTimeoutMillis,
219
+ statement_timeout,
220
+ ...(opts.sessionOptions ? { options: opts.sessionOptions } : {}),
221
+ };
123
222
  const instance = process.env.CLOUD_SQL_INSTANCE;
124
223
  if (instance) {
125
- const { options: clientOpts } = await cloudSqlClientOptions(instance);
126
- return new pg.Pool({
224
+ const { options: clientOpts, credentialExpiry } = await cloudSqlClientOptions(instance);
225
+ const pool = new pg.Pool({
127
226
  ...clientOpts,
128
- user: requireEnv('DB_IAM_USER'),
227
+ ...shared,
228
+ user: opts.user ?? requireEnv('DB_IAM_USER'),
129
229
  database: opts.database ?? requireEnv('DB_NAME'),
130
230
  max: opts.max ?? 3, // shared instance: default max_connections is low
131
- connectionTimeoutMillis: opts.connectionTimeoutMillis,
132
- statement_timeout,
133
231
  });
232
+ logPoolErrors(pool, opts.logger, { credentialExpiry });
233
+ return pool;
134
234
  }
135
235
  const url = new URL(requireEnv('DATABASE_URL'));
136
236
  if (opts.database)
137
237
  url.pathname = `/${opts.database}`;
138
- return new pg.Pool({
139
- connectionString: url.toString(),
140
- max: opts.max ?? 5,
141
- connectionTimeoutMillis: opts.connectionTimeoutMillis,
142
- statement_timeout,
143
- });
238
+ if (opts.user)
239
+ url.username = opts.user;
240
+ const pool = new pg.Pool({ connectionString: url.toString(), ...shared, max: opts.max ?? 5 });
241
+ logPoolErrors(pool, opts.logger);
242
+ return pool;
144
243
  }
145
244
  /** End a pool and resolve only once every client's connection has actually
146
245
  * closed. pg-pool's own `end()` resolves as soon as it has *asked* its idle
package/dist/http.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // and the standard server entry point with graceful shutdown.
3
3
  import { createMiddleware } from 'hono/factory';
4
4
  import { serve } from '@hono/node-server';
5
+ import { trackActivity } from './activity.js';
5
6
  import { assertBootSafety } from './config.js';
6
7
  import { flushAllLoggers, logUncaughtErrors } from './log.js';
7
8
  import { truncate } from './log-core.js';
@@ -54,7 +55,9 @@ export function startServer(opts) {
54
55
  assertBootSafety();
55
56
  logUncaughtErrors(opts.logger);
56
57
  const port = opts.port ?? Number(process.env.PORT ?? 8080);
57
- const server = serve({ fetch: opts.app.fetch, port }, (info) => {
58
+ // Every request counts as activity (activity.ts) so connection-loss
59
+ // events can say whether Cloud Run had CPU allocated at the time.
60
+ const server = serve({ fetch: trackActivity(opts.app.fetch), port }, (info) => {
58
61
  opts.logger.info('server started', { port: info.port });
59
62
  });
60
63
  process.once('SIGTERM', () => {
package/dist/index.d.ts CHANGED
@@ -2,5 +2,6 @@ export * from './config.js';
2
2
  export * from './log.js';
3
3
  export * from './s2s.js';
4
4
  export * from './auth.js';
5
+ export { trackActivity } from './activity.js';
5
6
  export * from './db.js';
6
7
  export * from './http.js';
package/dist/index.js CHANGED
@@ -2,5 +2,6 @@ export * from './config.js';
2
2
  export * from './log.js';
3
3
  export * from './s2s.js';
4
4
  export * from './auth.js';
5
+ export { trackActivity } from './activity.js';
5
6
  export * from './db.js';
6
7
  export * from './http.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.12.0",
3
+ "version": "3.13.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",