@ultimat3/db 29.4.0 → 30.1.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/CLAUDE.md +74 -110
- package/README.md +163 -15
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +20 -0
- package/src/concurrent-plan.ts +135 -17
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +17 -3
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +67 -0
- package/src/errors.ts +77 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +6 -1
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +105 -79
- package/src/index.ts +129 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +12 -4
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +45 -4
- package/src/migration-errors.ts +27 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +50 -22
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
package/src/pooler.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Single responsibility: a connection pooler in the path — the mode the environment names, how long
|
|
2
|
+
// a statement may queue in it, the two bounds as the session reads them, and the two errors a pooler
|
|
3
|
+
// in the path is reported with. The fix text is `pooler-fix.ts`', which `errors.ts` reads too.
|
|
4
|
+
|
|
5
|
+
import { describeValue, finiteCount } from '@ultimat3/core';
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import { DbError } from './errors';
|
|
8
|
+
import type { PoolProfile } from './pool-profile';
|
|
9
|
+
import { POOLER_SESSION_REQUIRED_FIX, STATEMENT_BOUND_LOST_FIX } from './pooler-fix';
|
|
10
|
+
import { sql } from './sql';
|
|
11
|
+
|
|
12
|
+
/** The variable naming a connection pooler in this process's path. */
|
|
13
|
+
export const POOLER_ENV = 'DATABASE_POOLER';
|
|
14
|
+
|
|
15
|
+
/** What the pooler in the path multiplexes on: a whole client session, or one transaction. */
|
|
16
|
+
export type PoolerMode = 'session' | 'transaction';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long a statement may queue in the pooler for a backend, on top of the server's own bound.
|
|
20
|
+
* `statement_timeout` starts only once the pooler assigns a backend, so a saturated pooler is not
|
|
21
|
+
* a dead path; PgBouncer's default `query_wait_timeout` is 120 s, and past it the pooler answers
|
|
22
|
+
* itself. Reproduced 2026-11-03: 20 busy server connections, `select 1` from `web` was retired at
|
|
23
|
+
* 7005 ms on a healthy server without it.
|
|
24
|
+
*/
|
|
25
|
+
export const POOLER_WAIT_MS = 120_000;
|
|
26
|
+
|
|
27
|
+
const poolerInvalid = (received: string): DbError =>
|
|
28
|
+
new DbError({
|
|
29
|
+
code: 'X_ENV_MISSING',
|
|
30
|
+
// `describeValue`, never the value, in the cause AND meta: a pooler URL pasted here carries its
|
|
31
|
+
// password into the boot log and the `--json` payload, where no key is left to redact it by.
|
|
32
|
+
cause: `DATABASE_POOLER is ${describeValue(received)}, which is not session or transaction`,
|
|
33
|
+
fix: 'DATABASE_POOLER=session # or transaction, or unset it',
|
|
34
|
+
meta: { received: describeValue(received) },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `DATABASE_POOLER`, or nothing — and nothing keeps today's bytes. A switch and never a detection:
|
|
39
|
+
* one pooler accepts the libpq `options` and drops them, so there is no failure to retry on. Env,
|
|
40
|
+
* for `POOL_MAX_ENV`'s reason: one image, and the URL changes per environment. An unknown value
|
|
41
|
+
* **refuses**, for that one's reason too: a typo here silently re-sends what the pooler refuses,
|
|
42
|
+
* or strips the bounds off a connection that has no pooler at all. `postgresClient({ env })` is its
|
|
43
|
+
* one reader in a process; everything else asks `client.pooler`.
|
|
44
|
+
*/
|
|
45
|
+
export function poolerFromEnv(
|
|
46
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
47
|
+
): PoolerMode | undefined {
|
|
48
|
+
const raw = env[POOLER_ENV];
|
|
49
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
50
|
+
if (raw === 'session' || raw === 'transaction') return raw;
|
|
51
|
+
throw poolerInvalid(raw);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The deadline extension a pool gets: `0` with no pooler, the budget with one. */
|
|
55
|
+
export function poolerWaitFor(pooler: PoolerMode | undefined, waitMs: number | undefined): number {
|
|
56
|
+
if (pooler === undefined) return 0;
|
|
57
|
+
return waitMs === undefined
|
|
58
|
+
? POOLER_WAIT_MS
|
|
59
|
+
: finiteCount('postgresClient', 'poolerWaitMs', waitMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `subject` is who was refused: `ROLE=migrate`, or the step that takes the migration lock. */
|
|
63
|
+
export const poolerSessionRequired = (subject: string): DbError =>
|
|
64
|
+
new DbError({
|
|
65
|
+
code: 'X_DB_POOLER_SESSION_REQUIRED',
|
|
66
|
+
cause: `${subject} under DATABASE_POOLER=transaction: session advisory locks and replication need one backend, and through this pooler two migrators would both read true from pg_try_advisory_lock`,
|
|
67
|
+
fix: POOLER_SESSION_REQUIRED_FIX,
|
|
68
|
+
meta: { subject },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** One setting as the session reads it, beside what the role was sized around. Milliseconds. */
|
|
72
|
+
interface LostBound {
|
|
73
|
+
readonly setting: 'statement_timeout' | 'idle_in_transaction_session_timeout';
|
|
74
|
+
readonly appliedMs: number;
|
|
75
|
+
readonly expectedMs: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A report, logged and never thrown: a fleet already running this must not stop on upgrade. The
|
|
80
|
+
* cause states the second cost, because it is not visible as a bound: the unanswered-statement
|
|
81
|
+
* deadline still runs on the profile's number, so a long healthy statement reads as a dead path.
|
|
82
|
+
*/
|
|
83
|
+
export const statementBoundLost = (role: string, lost: readonly LostBound[]): DbError =>
|
|
84
|
+
new DbError({
|
|
85
|
+
code: 'X_DB_STATEMENT_BOUND_LOST',
|
|
86
|
+
cause:
|
|
87
|
+
lost
|
|
88
|
+
.map(
|
|
89
|
+
(bound) =>
|
|
90
|
+
`${bound.setting} is ${String(bound.appliedMs)}ms, the ${role} role expects ${String(bound.expectedMs)}ms`,
|
|
91
|
+
)
|
|
92
|
+
.join('; ') +
|
|
93
|
+
'. Nothing on the server ends a long statement now, and the client still judges one by the role bound: past statementTimeoutMs plus 5 s (plus poolerWaitMs behind a pooler) it answers X_DB_UNAVAILABLE and the pool is replaced, failing its siblings with it',
|
|
94
|
+
fix: STATEMENT_BOUND_LOST_FIX,
|
|
95
|
+
meta: { role, lost },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** One setting: what the session reads, and what the role's profile asks for. Milliseconds both. */
|
|
99
|
+
interface BoundReading {
|
|
100
|
+
readonly applied: number;
|
|
101
|
+
readonly expected: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface StatementBound {
|
|
105
|
+
readonly statementTimeout: BoundReading;
|
|
106
|
+
readonly idleInTransaction: BoundReading;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The two bounds as the SESSION reads them. A pooler can accept the libpq `options` and drop them,
|
|
111
|
+
* so what was sent says nothing: this is the only reading of what is in force. `pg_settings`, not
|
|
112
|
+
* `show`: both are `ms`-unit settings, so `setting` is a number where `show` answers `10s`.
|
|
113
|
+
*/
|
|
114
|
+
export async function statementBound(
|
|
115
|
+
client: DbClient & { readonly profile: PoolProfile },
|
|
116
|
+
): Promise<StatementBound> {
|
|
117
|
+
const rows = await client.query<{ readonly name: string; readonly setting: string }>(
|
|
118
|
+
sql`select name, setting from pg_settings where name in ('statement_timeout', 'idle_in_transaction_session_timeout')`,
|
|
119
|
+
);
|
|
120
|
+
const applied = (name: string): number =>
|
|
121
|
+
Number(rows.find((row) => row.name === name)?.setting ?? 0);
|
|
122
|
+
return {
|
|
123
|
+
statementTimeout: {
|
|
124
|
+
applied: applied('statement_timeout'),
|
|
125
|
+
expected: client.profile.statementTimeoutMs,
|
|
126
|
+
},
|
|
127
|
+
idleInTransaction: {
|
|
128
|
+
applied: applied('idle_in_transaction_session_timeout'),
|
|
129
|
+
expected: client.profile.idleInTransactionMs,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The verdict on a reading, or nothing. With no pooler named the framework sent the value, so any
|
|
136
|
+
* difference is something in the path. With one named it did not, and one database user may serve
|
|
137
|
+
* two roles, so only `0` — no bound at all, where the role has one — is a lost bound.
|
|
138
|
+
*/
|
|
139
|
+
export function lostStatementBound(
|
|
140
|
+
bound: StatementBound,
|
|
141
|
+
role: string,
|
|
142
|
+
pooler: PoolerMode | undefined,
|
|
143
|
+
): DbError | undefined {
|
|
144
|
+
const judged: readonly LostBound[] = (
|
|
145
|
+
[
|
|
146
|
+
['statement_timeout', bound.statementTimeout],
|
|
147
|
+
['idle_in_transaction_session_timeout', bound.idleInTransaction],
|
|
148
|
+
] as const
|
|
149
|
+
)
|
|
150
|
+
.filter(([, reading]) =>
|
|
151
|
+
pooler === undefined
|
|
152
|
+
? reading.applied !== reading.expected
|
|
153
|
+
: reading.applied === 0 && reading.expected !== 0,
|
|
154
|
+
)
|
|
155
|
+
.map(([setting, reading]) => ({
|
|
156
|
+
setting,
|
|
157
|
+
appliedMs: reading.applied,
|
|
158
|
+
expectedMs: reading.expected,
|
|
159
|
+
}));
|
|
160
|
+
return judged.length === 0 ? undefined : statementBoundLost(role, judged);
|
|
161
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Single responsibility: a read inside its own read-only transaction, with transaction-local
|
|
2
|
+
// settings applied first. Row security's tenant and a read's statement bound both ride this one
|
|
3
|
+
// helper, so a setting meant for one read can never outlive it on a pooled connection.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import { type LocalSettings, readLocal, setLocal } from './local-settings';
|
|
8
|
+
import { currentTx, withTransaction } from './transaction';
|
|
9
|
+
import type { DbTx } from './transaction-options';
|
|
10
|
+
|
|
11
|
+
export interface ReadTransactionOptions {
|
|
12
|
+
/** Applied through `setLocal`, before `fn` runs. */
|
|
13
|
+
readonly settings?: LocalSettings | undefined;
|
|
14
|
+
/** The client to open on — row security's standby read passes the replica. Default the pool. */
|
|
15
|
+
readonly client?: DbClient | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `withTransaction(fn, { readOnly: true, client })`, then `setLocal`. The server refuses a write
|
|
20
|
+
* inside it (`25006`), and `readOnly` keeps a `withReplicaReads` scope from being marked as written.
|
|
21
|
+
*
|
|
22
|
+
* Inside an open transaction the access mode is the outer `BEGIN`'s and cannot change, so this
|
|
23
|
+
* joins it: the settings are applied, `fn` runs, and the values read before are put back — the
|
|
24
|
+
* rest of the outer transaction sees what it saw. No savepoint: nothing here needs rolling back.
|
|
25
|
+
*/
|
|
26
|
+
export async function withReadTransaction<T>(
|
|
27
|
+
fn: (tx: DbTx) => Promise<T>,
|
|
28
|
+
options: ReadTransactionOptions = {},
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
const settings = options.settings ?? {};
|
|
31
|
+
const open = currentTx();
|
|
32
|
+
if (open === undefined) {
|
|
33
|
+
return withTransaction(
|
|
34
|
+
async (tx) => {
|
|
35
|
+
await setLocal(tx, settings);
|
|
36
|
+
return fn(tx);
|
|
37
|
+
},
|
|
38
|
+
{ readOnly: true, client: options.client },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
assert(
|
|
42
|
+
options.client === undefined || options.client === open.origin,
|
|
43
|
+
'withReadTransaction({ client }) inside a transaction opened on a different client: it joins the open transaction, so nothing would run on the client named here',
|
|
44
|
+
'run the read outside the open transaction — await withReadTransaction(fn, { client }) after it returns — or drop { client } to read inside it',
|
|
45
|
+
);
|
|
46
|
+
const prior = await readLocal(open, Object.keys(settings));
|
|
47
|
+
await setLocal(open, settings);
|
|
48
|
+
let result: T;
|
|
49
|
+
try {
|
|
50
|
+
result = await fn(open);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// The outer transaction may be aborted by `fn`'s failure; a restore would then fail too, and
|
|
53
|
+
// the caller needs `fn`'s error. The abort discards the local values with everything else.
|
|
54
|
+
await setLocal(open, prior).catch(() => undefined);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
await setLocal(open, prior);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
package/src/readonly-query.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { finiteCount } from '@ultimat3/core';
|
|
7
7
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
8
8
|
import { multipleStatements } from './errors';
|
|
9
|
+
import { setLocal } from './local-settings';
|
|
9
10
|
import { identifier, raw, sql } from './sql';
|
|
10
11
|
import { stripSqlNoise } from './sql-noise';
|
|
11
12
|
import { statementsOf } from './statement-split';
|
|
@@ -96,9 +97,8 @@ export async function readOnlyQuery<T>(
|
|
|
96
97
|
// against an exhausted pool answered with the pool's error — or waited for a connection it was
|
|
97
98
|
// never going to use — in place of the `X_INVARIANT` naming the option that was wrong.
|
|
98
99
|
//
|
|
99
|
-
// Clamped
|
|
100
|
-
//
|
|
101
|
-
// parameters, which is why this can't go through `sql` the normal, parameterised way.
|
|
100
|
+
// Clamped to a whole number, then sent through `setLocal`, the one parameter-bound writer of a
|
|
101
|
+
// transaction-local setting — never as spliced `SET LOCAL` text, a second path to the same bound.
|
|
102
102
|
// REFUSED rather than normalised: `NaN` used to take the default silently, so a config typo
|
|
103
103
|
// ran under a timeout nobody wrote. Only an explicit 0 disables the layer, which is why the
|
|
104
104
|
// floor is 0 and not 1; the hour ceiling is a clamp on a number that IS one.
|
|
@@ -130,7 +130,7 @@ export async function readOnlyQuery<T>(
|
|
|
130
130
|
// every request the pool serves afterwards. Caveat: embedded PGlite applies the setting
|
|
131
131
|
// but is single-threaded WASM, so it cannot interrupt a running scan; on a real server
|
|
132
132
|
// the timeout fires. Layers 1 and 3 do not depend on it.
|
|
133
|
-
await connection
|
|
133
|
+
await setLocal(connection, { statement_timeout: String(ms) });
|
|
134
134
|
guards.push(`timeout:${ms}ms`);
|
|
135
135
|
}
|
|
136
136
|
|
package/src/replica-client.ts
CHANGED
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
// handle a statement is sent on, what happens when the replica will not answer, and the counters
|
|
4
4
|
// that make both visible to a test that cannot scrape a metrics endpoint.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type BreakerTicket,
|
|
8
|
+
breaker,
|
|
9
|
+
type Clock,
|
|
10
|
+
finiteCount,
|
|
11
|
+
logger,
|
|
12
|
+
renderThrowable,
|
|
13
|
+
systemClock,
|
|
14
|
+
} from '@ultimat3/core';
|
|
7
15
|
import { type DbClient, type DbConnection, isReservable, type ReservableClient } from './client';
|
|
16
|
+
import { POSITION_CACHE_MS, primaryPosition, replayReader } from './replica-position';
|
|
8
17
|
import { isPlainRead } from './replica-route';
|
|
9
18
|
import { markScopeWrote, replicaScope } from './replica-scope';
|
|
10
19
|
import type { SqlFragment } from './sql';
|
|
@@ -25,12 +34,19 @@ export interface ReplicatedClientOptions {
|
|
|
25
34
|
/** Consecutive replica failures before it is parked. */
|
|
26
35
|
readonly breakerFailures?: number | undefined;
|
|
27
36
|
readonly breakerCooldownMs?: number | undefined;
|
|
37
|
+
/** How long one reading of the replica's replay position is reused. Default 50 ms. */
|
|
38
|
+
readonly positionCacheMs?: number | undefined;
|
|
28
39
|
/** Injection seam; production passes neither. */
|
|
29
40
|
readonly clock?: Clock | undefined;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
export interface ReplicatedClient extends DbClient {
|
|
33
44
|
readonly stats: ReplicaStats;
|
|
45
|
+
/**
|
|
46
|
+
* The primary's WAL position NOW, read on the primary and never routed. Asked once, after a
|
|
47
|
+
* request that wrote has committed, so the caller can carry it to the next request.
|
|
48
|
+
*/
|
|
49
|
+
primaryPosition(): Promise<bigint | undefined>;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
/**
|
|
@@ -75,9 +91,8 @@ export function replicatedClient(
|
|
|
75
91
|
options: ReplicatedClientOptions = {},
|
|
76
92
|
): ReplicatedClient {
|
|
77
93
|
const clock = options.clock ?? systemClock;
|
|
78
|
-
//
|
|
79
|
-
// `
|
|
80
|
-
// and never parks, with every read still going to the replica that is failing.
|
|
94
|
+
// Screened HERE as well as by `breaker()`, so a refusal names this function's own option: a
|
|
95
|
+
// `NaN` in either is a breaker that never trips and never parks.
|
|
81
96
|
const limit = finiteCount(
|
|
82
97
|
'replicatedClient',
|
|
83
98
|
'breakerFailures',
|
|
@@ -93,58 +108,73 @@ export function replicatedClient(
|
|
|
93
108
|
let replicaCount = 0;
|
|
94
109
|
let primaryCount = 0;
|
|
95
110
|
let fallbackCount = 0;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
// Core's one breaker machine: monotonic time, so an NTP step never un-parks it. After the
|
|
112
|
+
// cooldown ONE read probes the replica and the rest take the primary until it answers.
|
|
113
|
+
const gate = breaker({ name: 'db.replica', failures: limit, cooldownMs: cooldown, clock });
|
|
114
|
+
const parked = (): boolean => gate.state() === 'open';
|
|
115
|
+
const replay = replayReader(
|
|
116
|
+
replica,
|
|
117
|
+
clock,
|
|
118
|
+
finiteCount(
|
|
119
|
+
'replicatedClient',
|
|
120
|
+
'positionCacheMs',
|
|
121
|
+
options.positionCacheMs ?? POSITION_CACHE_MS,
|
|
122
|
+
0,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
103
125
|
|
|
104
|
-
|
|
126
|
+
/** The breaker's ticket when the statement goes to the replica, else `undefined`. */
|
|
127
|
+
async function nodeIsReplica(text: string): Promise<BreakerTicket | undefined> {
|
|
105
128
|
const scope = replicaScope();
|
|
106
129
|
// No scope: nobody declared these reads replica-safe, so this is a single-pool client.
|
|
107
|
-
if (scope === undefined) return
|
|
130
|
+
if (scope === undefined) return undefined;
|
|
108
131
|
if (!isPlainRead(text)) {
|
|
109
132
|
markScopeWrote();
|
|
110
|
-
return
|
|
133
|
+
return undefined;
|
|
111
134
|
}
|
|
112
135
|
// Read-your-writes, and the reason the flag is on a mutable scope value rather than computed
|
|
113
136
|
// per statement: once this scope has written, every later read in it is the primary's. A
|
|
114
137
|
// replica is behind by an unbounded amount — streaming lag is not a number this tier can know
|
|
115
138
|
// — so "the row I just inserted" is the one question a standby is guaranteed to answer wrong.
|
|
116
|
-
if (scope.wrote) return
|
|
117
|
-
|
|
139
|
+
if (scope.wrote) return undefined;
|
|
140
|
+
// Read-your-writes ACROSS requests: an earlier request's write the replica has not replayed yet.
|
|
141
|
+
// Before `admit()`, never after: `admit()` spends the half-open probe, and a read then sent to
|
|
142
|
+
// the primary for being behind would strand the probe until it lapsed.
|
|
143
|
+
if (scope.minPosition !== undefined && !(await replay.caughtUp(scope.minPosition))) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
// Last, so only a statement about to reach the replica consumes the half-open probe.
|
|
147
|
+
return gate.admit();
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
async function send<T>(fragment: SqlFragment, on: (client: DbClient) => Promise<T>): Promise<T> {
|
|
121
|
-
|
|
151
|
+
const ticket = await nodeIsReplica(fragment.text);
|
|
152
|
+
if (ticket === undefined) {
|
|
122
153
|
primaryCount += 1;
|
|
123
154
|
return on(primary);
|
|
124
155
|
}
|
|
125
156
|
try {
|
|
126
157
|
const answer = await on(replica);
|
|
127
158
|
replicaCount += 1;
|
|
128
|
-
|
|
159
|
+
gate.succeeded(ticket);
|
|
129
160
|
return answer;
|
|
130
161
|
} catch (error) {
|
|
131
162
|
if (!replicaUnavailable(error)) {
|
|
132
163
|
// The replica answered — with a refusal of the statement — so it is up, and the run of
|
|
133
164
|
// failures the breaker is counting is over.
|
|
134
|
-
|
|
165
|
+
gate.succeeded(ticket);
|
|
135
166
|
throw error;
|
|
136
167
|
}
|
|
137
168
|
// Re-running is exactly-once, not at-least-once: only `isPlainRead` statements reach here,
|
|
138
169
|
// and a statement a standby refused (`25006`) never executed. A replica outage therefore
|
|
139
170
|
// costs latency and never an answer — which is the whole point, since a read replica is a
|
|
140
171
|
// capacity tier and must not become a new way for the app to be down.
|
|
141
|
-
|
|
142
|
-
if (consecutiveFailures >= limit) parkedUntil = clock.monotonic() + cooldown;
|
|
172
|
+
gate.failed(ticket);
|
|
143
173
|
fallbackCount += 1;
|
|
144
174
|
// `renderThrowable`, never `${error}`: a driver error's `message` getter is app code.
|
|
145
175
|
logger.warn('db.replica_fallback', {
|
|
146
176
|
error: renderThrowable(error),
|
|
147
|
-
consecutiveFailures,
|
|
177
|
+
consecutiveFailures: gate.failures(),
|
|
148
178
|
parked: parked(),
|
|
149
179
|
});
|
|
150
180
|
primaryCount += 1;
|
|
@@ -161,6 +191,7 @@ export function replicatedClient(
|
|
|
161
191
|
parked: parked(),
|
|
162
192
|
};
|
|
163
193
|
},
|
|
194
|
+
primaryPosition: () => primaryPosition(primary),
|
|
164
195
|
query: <T>(fragment: SqlFragment) => send(fragment, (client) => client.query<T>(fragment)),
|
|
165
196
|
one: <T>(fragment: SqlFragment) => send(fragment, (client) => client.one<T>(fragment)),
|
|
166
197
|
execute: (fragment: SqlFragment) => send(fragment, (client) => client.execute(fragment)),
|
|
@@ -172,6 +203,7 @@ export function replicatedClient(
|
|
|
172
203
|
get stats(): ReplicaStats {
|
|
173
204
|
return base.stats;
|
|
174
205
|
},
|
|
206
|
+
primaryPosition: base.primaryPosition,
|
|
175
207
|
reserve: (): Promise<DbConnection> => primary.reserve(),
|
|
176
208
|
};
|
|
177
209
|
return reservable;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Single responsibility: a WAL position — parsed from the wire, read off the primary after a write,
|
|
2
|
+
// and compared against how far a standby has replayed. Read-your-writes across requests is this
|
|
3
|
+
// one comparison: a request that carries the position its own write produced is served by a
|
|
4
|
+
// replica only once the replica has replayed past it.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from './client';
|
|
8
|
+
import { sql } from './sql';
|
|
9
|
+
|
|
10
|
+
/** A `pg_lsn` is two hex words, `X/Y`, each at most 32 bits. Anything else is not one. */
|
|
11
|
+
const LSN = /^([0-9A-Fa-f]{1,8})\/([0-9A-Fa-f]{1,8})$/;
|
|
12
|
+
|
|
13
|
+
/** How long one answer to "how far has the replica replayed" is reused, per process. */
|
|
14
|
+
export const POSITION_CACHE_MS = 50;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The position as a 64-bit number, or `undefined` for anything that is not a `pg_lsn`. A value off
|
|
18
|
+
* a cookie or a header is the sender's to choose, so it is parsed and never echoed: a forged one
|
|
19
|
+
* costs its sender a primary read, and nothing it writes reaches SQL or a response.
|
|
20
|
+
*/
|
|
21
|
+
export function parsePosition(value: string | null | undefined): bigint | undefined {
|
|
22
|
+
if (value === null || value === undefined) return undefined;
|
|
23
|
+
const match = LSN.exec(value.trim());
|
|
24
|
+
if (match === null) return undefined;
|
|
25
|
+
const [, high = '', low = ''] = match;
|
|
26
|
+
return (BigInt(`0x${high}`) << 32n) | BigInt(`0x${low}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The canonical `X/Y` spelling, the one Postgres prints. Only ever applied to a parsed value. */
|
|
30
|
+
export function formatPosition(position: bigint): string {
|
|
31
|
+
const high = (position >> 32n).toString(16).toUpperCase();
|
|
32
|
+
const low = (position & 0xffffffffn).toString(16).toUpperCase();
|
|
33
|
+
return `${high}/${low}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PositionRow {
|
|
37
|
+
readonly lsn: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The primary's WAL INSERT position, read once after a request that wrote has committed. Not
|
|
42
|
+
* `pg_current_wal_lsn()`, the WRITE position: under `synchronous_commit = off` it trails the
|
|
43
|
+
* commit just made, so a standby replayed exactly that far looked caught up without the row
|
|
44
|
+
* (measured on the test services' standby: write 0/3029ED0, insert 0/302AE80).
|
|
45
|
+
*/
|
|
46
|
+
export async function primaryPosition(primary: DbClient): Promise<bigint | undefined> {
|
|
47
|
+
const row = await primary.one<PositionRow>(sql`select pg_current_wal_insert_lsn()::text as lsn`);
|
|
48
|
+
return parsePosition(row?.lsn);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReplayReader {
|
|
52
|
+
/** Whether the replica has replayed at least `required`. Unknown answers `false`. */
|
|
53
|
+
caughtUp(required: bigint): Promise<boolean>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Sample {
|
|
57
|
+
readonly at: number;
|
|
58
|
+
readonly replayed: Promise<bigint | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One reader per replicated client, so the cache is per process and per standby. A sample is the
|
|
63
|
+
* PROMISE, published before it settles: twenty reads arriving in one tick ask the standby once.
|
|
64
|
+
* `pg_last_wal_replay_lsn()` is `null` on a server that is not in recovery and the query fails on a
|
|
65
|
+
* standby that is down; both are "unknown", which is the primary's — never a guess that it caught up.
|
|
66
|
+
*/
|
|
67
|
+
export function replayReader(replica: DbClient, clock: Clock, cacheMs: number): ReplayReader {
|
|
68
|
+
let sample: Sample | undefined;
|
|
69
|
+
const read = async (): Promise<bigint | undefined> => {
|
|
70
|
+
try {
|
|
71
|
+
const row = await replica.one<PositionRow>(sql`select pg_last_wal_replay_lsn()::text as lsn`);
|
|
72
|
+
return parsePosition(row?.lsn);
|
|
73
|
+
} catch {
|
|
74
|
+
// Deliberate: a standby that cannot say how far it is serves no read that needs to know. The
|
|
75
|
+
// read goes to the primary, and the breaker hears about the outage from the next plain read.
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
async caughtUp(required: bigint): Promise<boolean> {
|
|
81
|
+
const now = clock.monotonic();
|
|
82
|
+
if (sample === undefined || now - sample.at >= cacheMs) {
|
|
83
|
+
sample = { at: now, replayed: read() };
|
|
84
|
+
}
|
|
85
|
+
const replayed = await sample.replayed;
|
|
86
|
+
return replayed !== undefined && replayed >= required;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/replica-scope.ts
CHANGED
|
@@ -13,6 +13,17 @@ import { asyncContext } from '@ultimat3/core';
|
|
|
13
13
|
*/
|
|
14
14
|
export interface ReplicaScope {
|
|
15
15
|
wrote: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The WAL position an EARLIER request's write produced, carried back by its caller. A replica
|
|
18
|
+
* serves a read in this scope only once it has replayed past it (`replica-position.ts`). Absent:
|
|
19
|
+
* no earlier write this caller knows of, and a caught-up replica is not asked about.
|
|
20
|
+
*/
|
|
21
|
+
readonly minPosition?: bigint | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReplicaReadsOptions {
|
|
25
|
+
/** Parsed by `parsePosition` from the request — never a raw cookie value. */
|
|
26
|
+
readonly minPosition?: bigint | undefined;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
const scope = asyncContext<ReplicaScope>('the replica read scope');
|
|
@@ -30,12 +41,22 @@ const scope = asyncContext<ReplicaScope>('the replica read scope');
|
|
|
30
41
|
* mode of "nobody opened one" is today's behaviour rather than a wrong answer.
|
|
31
42
|
*
|
|
32
43
|
* Nesting is one scope, not two: an inner `withReplicaReads` inside a scope that has already
|
|
33
|
-
* written must not un-write it. The innermost call reuses the store it finds.
|
|
44
|
+
* written must not un-write it. The innermost call reuses the store it finds, `minPosition` too.
|
|
45
|
+
*
|
|
46
|
+
* `fn` receives the scope so the opener can ask, once `fn` settles, whether it wrote — the boot
|
|
47
|
+
* reads it to hand the caller the position its write produced.
|
|
34
48
|
*/
|
|
35
|
-
export function withReplicaReads<T>(
|
|
49
|
+
export function withReplicaReads<T>(
|
|
50
|
+
fn: (scope: ReplicaScope) => T,
|
|
51
|
+
options: ReplicaReadsOptions = {},
|
|
52
|
+
): T {
|
|
36
53
|
const open = scope.get();
|
|
37
|
-
if (open !== undefined) return fn();
|
|
38
|
-
|
|
54
|
+
if (open !== undefined) return fn(open);
|
|
55
|
+
const fresh: ReplicaScope =
|
|
56
|
+
options.minPosition === undefined
|
|
57
|
+
? { wrote: false }
|
|
58
|
+
: { wrote: false, minPosition: options.minPosition };
|
|
59
|
+
return scope.run(fresh, () => fn(fresh));
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
/** The scope in flight, or `undefined` — which is every caller that never opened one. */
|