@steve31415/baselib 3.12.0 → 3.14.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/activity.d.ts +14 -0
- package/dist/activity.js +49 -0
- package/dist/db.d.ts +79 -40
- package/dist/db.js +168 -69
- package/dist/http.js +4 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/log-core.d.ts +4 -0
- package/dist/log-core.js +27 -0
- package/dist/log.d.ts +2 -2
- package/dist/log.js +4 -1
- package/package.json +1 -1
|
@@ -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;
|
package/dist/activity.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
28
|
+
const instanceOptions = {
|
|
27
29
|
instanceConnectionName: instance,
|
|
28
30
|
ipType: IpAddressTypes.PUBLIC,
|
|
29
31
|
authType: AuthTypes.IAM,
|
|
30
|
-
}
|
|
31
|
-
|
|
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
|
-
/**
|
|
48
|
-
*
|
|
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
|
-
* -
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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) =>
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
client.on('error', listener);
|
|
175
|
+
client.on('error', entry.listener);
|
|
104
176
|
});
|
|
105
177
|
pool.on('release', (_err, client) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
224
|
+
const { options: clientOpts, credentialExpiry } = await cloudSqlClientOptions(instance);
|
|
225
|
+
const pool = new pg.Pool({
|
|
127
226
|
...clientOpts,
|
|
128
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
package/dist/log-core.d.ts
CHANGED
|
@@ -152,5 +152,9 @@ export declare function shapeMeta(meta: Record<string, unknown>): {
|
|
|
152
152
|
issues: ShapeIssue[];
|
|
153
153
|
leaves: number;
|
|
154
154
|
};
|
|
155
|
+
/** Toggle strict shape checking — for tests that exercise the rewrite/WARN
|
|
156
|
+
* behavior itself. Restore with setLogShapeStrict(true) (or leave it: vitest
|
|
157
|
+
* isolates module state per file). */
|
|
158
|
+
export declare function setLogShapeStrict(on: boolean): void;
|
|
155
159
|
/** Forget which sites have been flagged (tests). */
|
|
156
160
|
export declare function resetLogShapeWarnings(): void;
|
package/dist/log-core.js
CHANGED
|
@@ -377,6 +377,12 @@ function buildEvent(opts, level, message, meta) {
|
|
|
377
377
|
* the shape rules in this process, a WARN `kind=log-shape` naming the site. */
|
|
378
378
|
export function makeEvents(opts, level, message, meta) {
|
|
379
379
|
const { event, issues } = buildEvent(opts, level, message, meta);
|
|
380
|
+
if (issues.length > 0 && strict) {
|
|
381
|
+
throw new Error(`log-shape (strict): "${message}" — ${issues.map(describeIssue).join('; ')}. ` +
|
|
382
|
+
'Fix the logging site: keys are columns (OPERATIONS.md). ' +
|
|
383
|
+
'(Strict mode is on under vitest so shape bugs fail tests instead of ' +
|
|
384
|
+
'shipping as rewrites; setLogShapeStrict(false) to test the rewrite itself.)');
|
|
385
|
+
}
|
|
380
386
|
const fresh = issues.filter((i) => {
|
|
381
387
|
const key = `${i.kind}\n${message}`;
|
|
382
388
|
if (flagged.has(key))
|
|
@@ -457,6 +463,27 @@ export function shapeMeta(meta) {
|
|
|
457
463
|
// Sites already flagged in this process, by issue kind and message: the
|
|
458
464
|
// first offending event is worth a WARN, the ten-thousandth is not.
|
|
459
465
|
const flagged = new Set();
|
|
466
|
+
// Strict mode: makeEvents throws on a shape issue instead of rewriting.
|
|
467
|
+
// On by default under vitest so a shape bug fails the suite that exercises
|
|
468
|
+
// the logging site rather than reaching production as a rewritten event
|
|
469
|
+
// with a once-per-process WARN. LOG_SHAPE_STRICT=1/0 overrides the default
|
|
470
|
+
// either way (production stays permissive: a logger must never throw there).
|
|
471
|
+
function strictDefault() {
|
|
472
|
+
// log-core is shared with the browser build; no bare `process` access.
|
|
473
|
+
if (typeof process === 'undefined' || !process.env)
|
|
474
|
+
return false;
|
|
475
|
+
const explicit = process.env.LOG_SHAPE_STRICT;
|
|
476
|
+
if (explicit !== undefined && explicit !== '')
|
|
477
|
+
return explicit !== '0';
|
|
478
|
+
return process.env.VITEST !== undefined;
|
|
479
|
+
}
|
|
480
|
+
let strict = strictDefault();
|
|
481
|
+
/** Toggle strict shape checking — for tests that exercise the rewrite/WARN
|
|
482
|
+
* behavior itself. Restore with setLogShapeStrict(true) (or leave it: vitest
|
|
483
|
+
* isolates module state per file). */
|
|
484
|
+
export function setLogShapeStrict(on) {
|
|
485
|
+
strict = on;
|
|
486
|
+
}
|
|
460
487
|
/** Forget which sites have been flagged (tests). */
|
|
461
488
|
export function resetLogShapeWarnings() {
|
|
462
489
|
flagged.clear();
|
package/dist/log.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Logger, LogShipper, ShipperOptions } from './log-core.js';
|
|
2
|
-
export { LogShipper, serializeError, truncate } from './log-core.js';
|
|
3
|
-
export type { Logger, LogEvent, LogLevel, ShipperOptions, ShippedBatch, ShipFailureReason, } from './log-core.js';
|
|
2
|
+
export { LogShipper, makeEvent, makeEvents, serializeError, setLogShapeStrict, truncate } from './log-core.js';
|
|
3
|
+
export type { EventFactoryOptions, Logger, LogEvent, LogLevel, ShipperOptions, ShippedBatch, ShipFailureReason, } from './log-core.js';
|
|
4
4
|
export interface LoggerOptions {
|
|
5
5
|
/** App name, e.g. 'auth'. Becomes the `app` field queried in Axiom. */
|
|
6
6
|
app: string;
|
package/dist/log.js
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
// log-core's key-shape guard rewrites offenders and WARNs once per site.
|
|
10
10
|
import { writeSync } from 'node:fs';
|
|
11
11
|
import { LogShipper, makeEvents, truncate, } from './log-core.js';
|
|
12
|
-
|
|
12
|
+
// makeEvent/makeEvents are exported for app test fakes: routing a recording
|
|
13
|
+
// logger's calls through makeEvents applies the key-shape guard (strict under
|
|
14
|
+
// vitest) to fake-logged sites too.
|
|
15
|
+
export { LogShipper, makeEvent, makeEvents, serializeError, setLogShapeStrict, truncate } from './log-core.js';
|
|
13
16
|
// Registry of every shipper created here (plus any registered explicitly),
|
|
14
17
|
// used by flushAllLoggers and graceful shutdown. Shipper-level on purpose:
|
|
15
18
|
// with() children share their root's shipper, so registering loggers leaked
|
package/package.json
CHANGED