@steve31415/baselib 3.11.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.
- 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/sync-browser/client.d.ts +17 -0
- package/dist/sync-browser/client.js +115 -12
- 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
|
@@ -125,6 +125,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
125
125
|
private refetchInFlight;
|
|
126
126
|
private probeInFlight;
|
|
127
127
|
private resyncTimes;
|
|
128
|
+
/** Forward-gap events delivered while a resync is reconciling; drained
|
|
129
|
+
* after the bootstrap installs. Non-null exactly while reconciling. */
|
|
130
|
+
private gapBuffer;
|
|
131
|
+
private resyncBackoffTimer;
|
|
132
|
+
private loopEscalated;
|
|
128
133
|
private reconciliationTail;
|
|
129
134
|
private initialization;
|
|
130
135
|
private disposed;
|
|
@@ -169,6 +174,18 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
169
174
|
private onSyncEstablished;
|
|
170
175
|
private requestReload;
|
|
171
176
|
private resyncFromServer;
|
|
177
|
+
/** Applies the events delivered while the bootstrap was in flight. On a
|
|
178
|
+
* single socket they are contiguous with the install point, so this is
|
|
179
|
+
* where a resync under a live stream converges; a hole (socket replaced
|
|
180
|
+
* mid-fetch, buffer overflow) schedules a fresh resync via the normal gap
|
|
181
|
+
* path instead. */
|
|
182
|
+
private drainAfterResync;
|
|
183
|
+
/** A stall episode ends only on proven convergence: a resync whose drain
|
|
184
|
+
* scheduled nothing further, or a complete socket catch-up. A successful
|
|
185
|
+
* reconcile alone is not convergence — in a chronic diverge-reconverge
|
|
186
|
+
* cycle every reconcile succeeds. */
|
|
187
|
+
private endResyncEpisode;
|
|
188
|
+
private scheduleResyncRetry;
|
|
172
189
|
private sendConnect;
|
|
173
190
|
private sendRaw;
|
|
174
191
|
private trySend;
|
|
@@ -8,6 +8,11 @@ const FAILURE_TIMEOUT_MS = 60_000;
|
|
|
8
8
|
// blips (Wi-Fi roam, VPN re-auth — observed 30-40 s) must not page as ERROR
|
|
9
9
|
// (Steve, 2026-09-11; raised from 30 s, which flagged those blips).
|
|
10
10
|
const OUTAGE_ESCALATE_MS = 120_000;
|
|
11
|
+
const RESYNC_LOOP_WINDOW_MS = 60_000;
|
|
12
|
+
const RESYNC_LOOP_MAX = 3;
|
|
13
|
+
const RESYNC_RETRY_MS = 60_000;
|
|
14
|
+
const GAP_BUFFER_MAX = 1_000;
|
|
15
|
+
const RECONCILE_MOVED_MAX_REFETCHES = 3;
|
|
11
16
|
const CLOSE_NO_USER = 4403;
|
|
12
17
|
const PROBE_AFTER_ATTEMPTS = 2;
|
|
13
18
|
const SOCKET_OPEN = 1;
|
|
@@ -49,6 +54,11 @@ export class SyncClient {
|
|
|
49
54
|
refetchInFlight = false;
|
|
50
55
|
probeInFlight = false;
|
|
51
56
|
resyncTimes = [];
|
|
57
|
+
/** Forward-gap events delivered while a resync is reconciling; drained
|
|
58
|
+
* after the bootstrap installs. Non-null exactly while reconciling. */
|
|
59
|
+
gapBuffer = null;
|
|
60
|
+
resyncBackoffTimer = null;
|
|
61
|
+
loopEscalated = false;
|
|
52
62
|
reconciliationTail = Promise.resolve();
|
|
53
63
|
initialization = null;
|
|
54
64
|
disposed = false;
|
|
@@ -423,7 +433,22 @@ export class SyncClient {
|
|
|
423
433
|
return true;
|
|
424
434
|
}
|
|
425
435
|
if (event.seq > currentSeq + 1) {
|
|
426
|
-
|
|
436
|
+
// An own-write echo proves the commit even when the gap prevents
|
|
437
|
+
// applying it: settle the pending (without a settlement reconcile —
|
|
438
|
+
// the resync below installs fresh state anyway) so the 60 s failure
|
|
439
|
+
// timer cannot report a committed write as failed.
|
|
440
|
+
if (this.pending.has(event.eventGuid))
|
|
441
|
+
this.confirmPending(event.eventGuid, false);
|
|
442
|
+
if (this.gapBuffer !== null) {
|
|
443
|
+
if (this.gapBuffer.length < GAP_BUFFER_MAX)
|
|
444
|
+
this.gapBuffer.push(event);
|
|
445
|
+
// On overflow, drop the newest: the kept prefix stays contiguous
|
|
446
|
+
// with the install point; the drain then ends at a hole and
|
|
447
|
+
// schedules a fresh resync.
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
this.requestReload('sequence-gap');
|
|
451
|
+
}
|
|
427
452
|
return false;
|
|
428
453
|
}
|
|
429
454
|
this.options.store.applyServerEvent(event);
|
|
@@ -579,15 +604,24 @@ export class SyncClient {
|
|
|
579
604
|
});
|
|
580
605
|
await preceding;
|
|
581
606
|
try {
|
|
582
|
-
|
|
607
|
+
for (let attempt = 0;; attempt++) {
|
|
583
608
|
if (this.stopBeforeStoreMutation())
|
|
584
609
|
return false;
|
|
585
610
|
const startingSeq = this.options.store.currentSeq();
|
|
586
611
|
const bootstrap = await this.abortable((signal) => this.options.fetchBootstrap(signal));
|
|
587
612
|
if (this.stopBeforeStoreMutation())
|
|
588
613
|
return false;
|
|
589
|
-
if (this.options.store.currentSeq() !== startingSeq)
|
|
590
|
-
|
|
614
|
+
if (this.options.store.currentSeq() !== startingSeq) {
|
|
615
|
+
// The store advanced mid-fetch: live contiguous events are
|
|
616
|
+
// applying, so the client is converged-and-advancing. Refetch a
|
|
617
|
+
// bounded number of times for a quiescent snapshot; past that,
|
|
618
|
+
// keep the live state — installing the older bootstrap would
|
|
619
|
+
// rewind it. Any residual divergence surfaces as a normal gap
|
|
620
|
+
// and a fresh, detector-counted resync.
|
|
621
|
+
if (attempt < RECONCILE_MOVED_MAX_REFETCHES)
|
|
622
|
+
continue;
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
591
625
|
this.options.store.installBootstrap(bootstrap);
|
|
592
626
|
for (const [eventGuid, entry] of this.pending) {
|
|
593
627
|
if (this.settledPending.has(eventGuid))
|
|
@@ -641,9 +675,18 @@ export class SyncClient {
|
|
|
641
675
|
this.trySend(entry);
|
|
642
676
|
}
|
|
643
677
|
}
|
|
678
|
+
// A complete catch-up IS convergence: end any stall episode and cancel
|
|
679
|
+
// its pending backoff retry.
|
|
680
|
+
this.endResyncEpisode();
|
|
644
681
|
this.updateConnectedState();
|
|
645
682
|
}
|
|
646
683
|
requestReload(reason) {
|
|
684
|
+
// While a resync is reconciling or backing off, further triggers are
|
|
685
|
+
// absorbed: the in-flight/scheduled attempt already covers them, and
|
|
686
|
+
// per-event callbacks were the WARN/ERROR spam of the 2026-09-10
|
|
687
|
+
// incident. The callback fires once per resync attempt.
|
|
688
|
+
if (this.refetchInFlight || this.resyncBackoffTimer !== null)
|
|
689
|
+
return;
|
|
647
690
|
this.options.onReloadRequired(reason);
|
|
648
691
|
void this.resyncFromServer(reason === 'server' ? 'server requested reload' : 'sequence gap');
|
|
649
692
|
}
|
|
@@ -651,23 +694,35 @@ export class SyncClient {
|
|
|
651
694
|
if (this.refetchInFlight || this.halted || this.disposed)
|
|
652
695
|
return;
|
|
653
696
|
const now = this.options.runtime.now();
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
697
|
+
// `time <= now` drops entries stranded in the future by a backward
|
|
698
|
+
// clock jump, which would otherwise pin the detector tripped.
|
|
699
|
+
this.resyncTimes = [
|
|
700
|
+
...this.resyncTimes.filter((time) => time <= now && now - time < RESYNC_LOOP_WINDOW_MS),
|
|
701
|
+
now,
|
|
702
|
+
];
|
|
703
|
+
if (this.resyncTimes.length > RESYNC_LOOP_MAX) {
|
|
704
|
+
if (!this.loopEscalated) {
|
|
705
|
+
this.loopEscalated = true;
|
|
706
|
+
this.options.logger.error('resync loop detected', { reason });
|
|
707
|
+
}
|
|
657
708
|
this.publish({
|
|
658
709
|
kind: 'error',
|
|
659
710
|
pending: this.pendingCount(),
|
|
660
|
-
message: 'Repeated resyncs
|
|
711
|
+
message: 'Repeated resyncs; retrying automatically',
|
|
661
712
|
});
|
|
713
|
+
this.scheduleResyncRetry();
|
|
662
714
|
return;
|
|
663
715
|
}
|
|
664
716
|
this.refetchInFlight = true;
|
|
717
|
+
this.gapBuffer = [];
|
|
718
|
+
let reconciled = false;
|
|
665
719
|
try {
|
|
666
720
|
this.options.logger.info('resyncing from server', { reason });
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
721
|
+
reconciled = await this.reconcileStore();
|
|
722
|
+
if (reconciled) {
|
|
723
|
+
for (const entry of this.pending.values())
|
|
724
|
+
this.trySend(entry);
|
|
725
|
+
}
|
|
671
726
|
}
|
|
672
727
|
catch (error) {
|
|
673
728
|
if (!isLifecycleAbort(error) && !this.disposed && !this.halted) {
|
|
@@ -675,9 +730,54 @@ export class SyncClient {
|
|
|
675
730
|
}
|
|
676
731
|
}
|
|
677
732
|
finally {
|
|
733
|
+
// Ordering matters: take the buffer and release the flag before the
|
|
734
|
+
// drain, so a drain-triggered nested resync owns `refetchInFlight`
|
|
735
|
+
// (and a fresh buffer) alone — this finally has nothing left to clear.
|
|
736
|
+
const buffered = this.gapBuffer ?? [];
|
|
737
|
+
this.gapBuffer = null;
|
|
678
738
|
this.refetchInFlight = false;
|
|
739
|
+
if (reconciled)
|
|
740
|
+
this.drainAfterResync(buffered);
|
|
679
741
|
}
|
|
680
742
|
}
|
|
743
|
+
/** Applies the events delivered while the bootstrap was in flight. On a
|
|
744
|
+
* single socket they are contiguous with the install point, so this is
|
|
745
|
+
* where a resync under a live stream converges; a hole (socket replaced
|
|
746
|
+
* mid-fetch, buffer overflow) schedules a fresh resync via the normal gap
|
|
747
|
+
* path instead. */
|
|
748
|
+
drainAfterResync(buffered) {
|
|
749
|
+
for (const event of buffered) {
|
|
750
|
+
if (this.halted || this.disposed)
|
|
751
|
+
return;
|
|
752
|
+
if (!this.handleServerEvent(event))
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
this.endResyncEpisode();
|
|
756
|
+
}
|
|
757
|
+
/** A stall episode ends only on proven convergence: a resync whose drain
|
|
758
|
+
* scheduled nothing further, or a complete socket catch-up. A successful
|
|
759
|
+
* reconcile alone is not convergence — in a chronic diverge-reconverge
|
|
760
|
+
* cycle every reconcile succeeds. */
|
|
761
|
+
endResyncEpisode() {
|
|
762
|
+
if (this.resyncBackoffTimer !== null) {
|
|
763
|
+
this.options.runtime.clearTimeout(this.resyncBackoffTimer);
|
|
764
|
+
this.resyncBackoffTimer = null;
|
|
765
|
+
}
|
|
766
|
+
if (!this.loopEscalated)
|
|
767
|
+
return;
|
|
768
|
+
this.loopEscalated = false;
|
|
769
|
+
this.updateConnectedState();
|
|
770
|
+
}
|
|
771
|
+
scheduleResyncRetry() {
|
|
772
|
+
if (this.resyncBackoffTimer !== null || this.halted || this.disposed)
|
|
773
|
+
return;
|
|
774
|
+
// A hidden tab's timer throttling may stretch this well past the nominal
|
|
775
|
+
// delay; that only postpones recovery, it never breaks it.
|
|
776
|
+
this.resyncBackoffTimer = this.options.runtime.setTimeout(() => {
|
|
777
|
+
this.resyncBackoffTimer = null;
|
|
778
|
+
void this.resyncFromServer('retry after resync backoff');
|
|
779
|
+
}, RESYNC_RETRY_MS);
|
|
780
|
+
}
|
|
681
781
|
sendConnect() {
|
|
682
782
|
this.sendRaw({
|
|
683
783
|
type: 'connect',
|
|
@@ -800,11 +900,14 @@ export class SyncClient {
|
|
|
800
900
|
this.options.runtime.clearInterval(this.countdownTimer);
|
|
801
901
|
if (this.escalateTimer !== null)
|
|
802
902
|
this.options.runtime.clearTimeout(this.escalateTimer);
|
|
903
|
+
if (this.resyncBackoffTimer !== null)
|
|
904
|
+
this.options.runtime.clearTimeout(this.resyncBackoffTimer);
|
|
803
905
|
for (const timer of this.failureTimers.values())
|
|
804
906
|
this.options.runtime.clearTimeout(timer);
|
|
805
907
|
this.reconnectTimer = null;
|
|
806
908
|
this.countdownTimer = null;
|
|
807
909
|
this.escalateTimer = null;
|
|
910
|
+
this.resyncBackoffTimer = null;
|
|
808
911
|
this.failureTimers.clear();
|
|
809
912
|
}
|
|
810
913
|
detachSocket(socket) {
|
package/package.json
CHANGED