@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.
Files changed (96) hide show
  1. package/CLAUDE.md +74 -110
  2. package/README.md +163 -15
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +20 -0
  14. package/src/concurrent-plan.ts +135 -17
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +17 -3
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +67 -0
  30. package/src/errors.ts +77 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +6 -1
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +105 -79
  43. package/src/index.ts +129 -6
  44. package/src/introspect-catalog.ts +26 -6
  45. package/src/introspect-rows.ts +82 -0
  46. package/src/introspect.ts +108 -50
  47. package/src/keyset.ts +182 -0
  48. package/src/lifecycle-errors.ts +15 -0
  49. package/src/local-settings.ts +38 -0
  50. package/src/marker-line.ts +12 -4
  51. package/src/migrate-lock-retry.ts +60 -0
  52. package/src/migrate-rollback.ts +45 -6
  53. package/src/migrate.ts +45 -4
  54. package/src/migration-errors.ts +27 -1
  55. package/src/migration-ledger.ts +38 -4
  56. package/src/migration-step.ts +50 -22
  57. package/src/partition-order.ts +56 -0
  58. package/src/partitioned-tables.ts +24 -0
  59. package/src/pool-gate.ts +120 -0
  60. package/src/pool-listeners.ts +143 -0
  61. package/src/pool-pins.ts +68 -0
  62. package/src/pool-profile.ts +23 -7
  63. package/src/pool-reserve.ts +11 -4
  64. package/src/pool-retire.ts +22 -0
  65. package/src/pool-sweep.ts +115 -0
  66. package/src/pool-timer.ts +10 -0
  67. package/src/pooler-fix.ts +53 -0
  68. package/src/pooler.ts +161 -0
  69. package/src/read-transaction.ts +59 -0
  70. package/src/readonly-query.ts +4 -4
  71. package/src/replica-client.ts +54 -22
  72. package/src/replica-position.ts +89 -0
  73. package/src/replica-scope.ts +25 -4
  74. package/src/retired.ts +131 -0
  75. package/src/retype-dependents.ts +2 -0
  76. package/src/rolling-errors.ts +97 -0
  77. package/src/rolling.ts +212 -0
  78. package/src/row-secure-client.ts +54 -0
  79. package/src/row-security-catalog.ts +26 -0
  80. package/src/row-security-migrator.ts +48 -0
  81. package/src/row-security-plan.ts +58 -0
  82. package/src/row-security-role.ts +60 -0
  83. package/src/row-security-roles.ts +64 -0
  84. package/src/row-security.ts +59 -0
  85. package/src/same-tenant.ts +173 -0
  86. package/src/schema-dump-policies.ts +26 -0
  87. package/src/schema-dump-table.ts +21 -6
  88. package/src/schema-dump.ts +13 -2
  89. package/src/snapshot-parse.ts +42 -2
  90. package/src/sqlstate.ts +4 -0
  91. package/src/statement-bound.ts +82 -0
  92. package/src/transaction-errors.ts +14 -0
  93. package/src/transaction-guard.ts +40 -0
  94. package/src/transaction-settings.ts +24 -0
  95. package/src/transaction.ts +74 -19
  96. 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
+ }
@@ -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 and truncated to an integer: the result is a JS number the caller never touches
100
- // as text, so there is nothing here for `raw()` to inject — `SET LOCAL` can't bind `$n`
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.execute(raw(`SET LOCAL statement_timeout = ${ms}`));
133
+ await setLocal(connection, { statement_timeout: String(ms) });
134
134
  guards.push(`timeout:${ms}ms`);
135
135
  }
136
136
 
@@ -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 { type Clock, finiteCount, logger, renderThrowable, systemClock } from '@ultimat3/core';
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
- // Both are comparisons and nothing else — `consecutiveFailures >= limit` opens the breaker,
79
- // `monotonic() < parkedUntil` holds it open — so a `NaN` in either is a breaker that never trips
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
- let consecutiveFailures = 0;
97
- let parkedUntil = 0;
98
-
99
- /** Monotonic, never wall clock: a leap second or an NTP step must not un-park the breaker. */
100
- function parked(): boolean {
101
- return clock.monotonic() < parkedUntil;
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
- function nodeIsReplica(text: string): boolean {
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 false;
130
+ if (scope === undefined) return undefined;
108
131
  if (!isPlainRead(text)) {
109
132
  markScopeWrote();
110
- return false;
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 false;
117
- return !parked();
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
- if (!nodeIsReplica(fragment.text)) {
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
- consecutiveFailures = 0;
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
- consecutiveFailures = 0;
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
- consecutiveFailures += 1;
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
+ }
@@ -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>(fn: () => T): 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
- return scope.run({ wrote: false }, fn);
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. */