@ultimat3/db 29.3.2 → 30.0.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 (98) hide show
  1. package/CLAUDE.md +78 -116
  2. package/README.md +199 -16
  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 +157 -0
  14. package/src/concurrent-plan.ts +294 -0
  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 +21 -35
  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 +75 -0
  30. package/src/errors.ts +80 -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 +99 -0
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +128 -89
  43. package/src/index-ddl.ts +23 -35
  44. package/src/index-plan.ts +35 -14
  45. package/src/index.ts +136 -6
  46. package/src/introspect-catalog.ts +26 -6
  47. package/src/introspect-rows.ts +82 -0
  48. package/src/introspect.ts +108 -50
  49. package/src/keyset.ts +182 -0
  50. package/src/lifecycle-errors.ts +15 -0
  51. package/src/local-settings.ts +38 -0
  52. package/src/marker-line.ts +47 -0
  53. package/src/migrate-lock-retry.ts +60 -0
  54. package/src/migrate-rollback.ts +45 -6
  55. package/src/migrate.ts +67 -69
  56. package/src/migration-errors.ts +48 -1
  57. package/src/migration-ledger.ts +38 -4
  58. package/src/migration-step.ts +192 -0
  59. package/src/partition-order.ts +56 -0
  60. package/src/partitioned-tables.ts +24 -0
  61. package/src/pool-gate.ts +120 -0
  62. package/src/pool-listeners.ts +143 -0
  63. package/src/pool-pins.ts +68 -0
  64. package/src/pool-profile.ts +23 -7
  65. package/src/pool-reserve.ts +11 -4
  66. package/src/pool-retire.ts +22 -0
  67. package/src/pool-sweep.ts +115 -0
  68. package/src/pool-timer.ts +10 -0
  69. package/src/pooler-fix.ts +53 -0
  70. package/src/pooler.ts +161 -0
  71. package/src/read-transaction.ts +59 -0
  72. package/src/readonly-query.ts +4 -4
  73. package/src/replica-client.ts +54 -22
  74. package/src/replica-position.ts +89 -0
  75. package/src/replica-scope.ts +25 -4
  76. package/src/retired.ts +131 -0
  77. package/src/retype-dependents.ts +2 -0
  78. package/src/rolling-errors.ts +97 -0
  79. package/src/rolling.ts +212 -0
  80. package/src/row-secure-client.ts +54 -0
  81. package/src/row-security-catalog.ts +26 -0
  82. package/src/row-security-migrator.ts +48 -0
  83. package/src/row-security-plan.ts +58 -0
  84. package/src/row-security-role.ts +60 -0
  85. package/src/row-security-roles.ts +64 -0
  86. package/src/row-security.ts +59 -0
  87. package/src/same-tenant.ts +173 -0
  88. package/src/schema-dump-policies.ts +26 -0
  89. package/src/schema-dump-table.ts +21 -6
  90. package/src/schema-dump.ts +13 -2
  91. package/src/snapshot-parse.ts +42 -2
  92. package/src/sqlstate.ts +4 -0
  93. package/src/statement-bound.ts +82 -0
  94. package/src/transaction-errors.ts +14 -0
  95. package/src/transaction-guard.ts +40 -0
  96. package/src/transaction-settings.ts +24 -0
  97. package/src/transaction.ts +74 -19
  98. package/src/ungeneratable.ts +30 -0
@@ -0,0 +1,143 @@
1
+ // Single responsibility: the LISTEN sessions a `postgresClient` holds beside its pool, and carrying
2
+ // them across a retired pool. `Bun.SQL` re-dials a session whose backend died, but `close()` ends it
3
+ // for good, so a retirement would leave `startQueueWake` reporting live on a session that is gone.
4
+
5
+ import { backoffDelay, logger, renderThrowable, type Scheduler } from '@ultimat3/core';
6
+ import type { BunSqlDriver } from './bun-sql';
7
+ import { DbError, driverError } from './errors';
8
+ import { assertListenChannel, type DbSubscription, listenUnsupported } from './listen';
9
+ import { unrefSchedule } from './pool-timer';
10
+
11
+ const REDIAL_BASE_MS = 1_000;
12
+ const REDIAL_MAX_MS = 30_000;
13
+
14
+ type Session = Awaited<ReturnType<NonNullable<BunSqlDriver['listen']>>>;
15
+
16
+ export interface PoolListeners {
17
+ listen(
18
+ channel: string,
19
+ onNotify: (payload: string) => void,
20
+ onListening?: () => void,
21
+ ): Promise<DbSubscription>;
22
+ /** The pool behind every session is gone: establish each again on the current one. */
23
+ redial(): void;
24
+ /** `close()` ended every session, and the client is done with them. */
25
+ drop(): void;
26
+ }
27
+
28
+ interface Held {
29
+ readonly channel: string;
30
+ readonly onNotify: (payload: string) => void;
31
+ readonly onListening: (() => void) | undefined;
32
+ session: Session | undefined;
33
+ /** Moved by each redial: a dial the next retirement overtook is nobody's. */
34
+ dial: number;
35
+ cancelRetry: () => void;
36
+ ended: boolean;
37
+ }
38
+
39
+ export function poolListeners(
40
+ currentPool: () => Pick<BunSqlDriver, 'listen'>,
41
+ schedule: Scheduler = unrefSchedule,
42
+ ): PoolListeners {
43
+ const held = new Set<Held>();
44
+
45
+ /** Best-effort, the rule `releaseReserved` states: the session may be gone with its pool. */
46
+ const end = (session: Session): Promise<void> =>
47
+ session.unlisten().catch((error: unknown) => {
48
+ logger.debug('db.unlisten_failed', { error: renderThrowable(error) });
49
+ });
50
+
51
+ const dialOn = (entry: Held): Promise<Session> => {
52
+ const pool = currentPool();
53
+ if (pool.listen === undefined) throw listenUnsupported('this Bun.SQL');
54
+ return pool.listen(entry.channel, entry.onNotify, entry.onListening);
55
+ };
56
+
57
+ function redialOne(entry: Held, attempt: number): void {
58
+ // The session is gone NOW, whether or not a re-dial ever lands: `startQueueWake` un-proves the
59
+ // wake on this cue, so the loops drop to the poll's ceiling instead of trusting a dead session.
60
+ // Once per retirement, not per refused attempt: each cue sends a probe.
61
+ if (attempt === 1) entry.onListening?.();
62
+ entry.dial += 1;
63
+ const dial = entry.dial;
64
+ const overtaken = (): boolean => entry.ended || dial !== entry.dial;
65
+ entry.cancelRetry();
66
+ entry.session = undefined;
67
+ // `async` so a pool that cannot be built (`currentPool` throws) lands in the retry too.
68
+ void (async () => dialOn(entry))().then(
69
+ (session) => {
70
+ if (overtaken()) void end(session);
71
+ else entry.session = session;
72
+ },
73
+ (error: unknown) => {
74
+ if (overtaken()) return;
75
+ logger.warn('db.listen.redial_failed', {
76
+ channel: entry.channel,
77
+ attempt,
78
+ error: renderThrowable(error),
79
+ });
80
+ // Equal jitter: a fleet that lost its database together must not re-dial it together.
81
+ const wait = backoffDelay({
82
+ attempt,
83
+ base: REDIAL_BASE_MS,
84
+ max: REDIAL_MAX_MS,
85
+ jitter: 'equal',
86
+ });
87
+ entry.cancelRetry = schedule(() => redialOne(entry, attempt + 1), wait);
88
+ },
89
+ );
90
+ }
91
+
92
+ return {
93
+ async listen(channel, onNotify, onListening) {
94
+ assertListenChannel(channel);
95
+ const entry: Held = {
96
+ channel,
97
+ onNotify,
98
+ onListening,
99
+ session: undefined,
100
+ dial: 0,
101
+ cancelRetry: () => undefined,
102
+ ended: false,
103
+ };
104
+ held.add(entry);
105
+ try {
106
+ // The driver's own session, never a pin out of the pool: a reserved connection holds the
107
+ // LISTEN and surfaces no notification, and it would cost the pool a slot for good.
108
+ const session = await dialOn(entry);
109
+ if (entry.dial === 0 && !entry.ended) entry.session = session;
110
+ else void end(session);
111
+ } catch (error) {
112
+ // A retirement mid-dial already took this subscription over: its redial is the answer.
113
+ if (entry.dial === 0) {
114
+ held.delete(entry);
115
+ if (error instanceof DbError) throw error;
116
+ throw driverError(`LISTEN ${channel}`, error);
117
+ }
118
+ }
119
+ let ended: Promise<void> | undefined;
120
+ return {
121
+ unlisten: () => {
122
+ ended ??= (async () => {
123
+ entry.ended = true;
124
+ entry.cancelRetry();
125
+ held.delete(entry);
126
+ if (entry.session !== undefined) await end(entry.session);
127
+ })();
128
+ return ended;
129
+ },
130
+ };
131
+ },
132
+ redial() {
133
+ for (const entry of held) redialOne(entry, 1);
134
+ },
135
+ drop() {
136
+ for (const entry of held) {
137
+ entry.ended = true;
138
+ entry.cancelRetry();
139
+ }
140
+ held.clear();
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,68 @@
1
+ // Single responsibility: the unanswered-statement bound of ONE pinned connection. A pin's statements
2
+ // are swept like any other, against the `statement_timeout` in force on that connection: the role's,
3
+ // until a `set_config('statement_timeout', …)` or `SET [LOCAL] statement_timeout` it answered moves
4
+ // it, and back to the role's at the transaction's end. A miss leaves the pin dead, never reused.
5
+
6
+ import type { SqlFragment } from './sql';
7
+
8
+ /** What one pin is bound by now. `boundMs` 0 is no bound, as the server reads it. */
9
+ export interface PinBound {
10
+ boundMs: number;
11
+ /** A statement on it went unanswered: nothing more is sent on this connection. */
12
+ dead: boolean;
13
+ }
14
+
15
+ export function pinBound(roleBoundMs: number): PinBound {
16
+ return { boundMs: roleBoundMs, dead: false };
17
+ }
18
+
19
+ /** The sweep's deadline for the next statement on `pin`; `0` is none to judge. */
20
+ export function pinDeadlineMs(pin: PinBound, extraMs: number): number {
21
+ return pin.boundMs === 0 ? 0 : pin.boundMs + extraMs;
22
+ }
23
+
24
+ /** `setLocal`'s text (`local-settings.ts`), the framework's one writer, held by `pool-pins.test.ts`. */
25
+ const SET_CONFIG = 'select set_config($1, $2, true)';
26
+ const SET_TEXT = /^\s*set\s+(?:local\s+|session\s+)?statement_timeout\s*(?:=|to)\s*(.+?)\s*;?\s*$/i;
27
+ const SET_CONFIG_TEXT =
28
+ /^\s*select\s+set_config\s*\(\s*'statement_timeout'\s*,\s*'([^']*)'\s*,\s*(?:true|false)\s*\)\s*;?\s*$/i;
29
+ const ENDS_TRANSACTION = /^\s*(?:commit|rollback|end|abort)(?:\s+(?:work|transaction))?\s*;?\s*$/i;
30
+ const UNITS: ReadonlyMap<string, number> = new Map([
31
+ ['us', 0.001],
32
+ ['ms', 1],
33
+ ['s', 1_000],
34
+ ['min', 60_000],
35
+ ['h', 3_600_000],
36
+ ['d', 86_400_000],
37
+ ]);
38
+
39
+ /** A `statement_timeout` value in Postgres' own grammar, as ms: a bare number is ms. */
40
+ export function timeoutValueMs(value: unknown): number | undefined {
41
+ if (typeof value !== 'string' && typeof value !== 'number') return undefined;
42
+ const match = /^\s*'?\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*'?\s*$/i.exec(String(value));
43
+ if (match === null) return undefined;
44
+ const scale = UNITS.get((match[2] ?? '').toLowerCase() || 'ms');
45
+ if (scale === undefined) return undefined;
46
+ return Math.round(Number(match[1]) * scale);
47
+ }
48
+
49
+ /** The bound `fragment` sets, or `undefined` when it sets none (or one this cannot read). */
50
+ export function boundSetBy(fragment: SqlFragment): number | undefined {
51
+ if (fragment.text === SET_CONFIG) {
52
+ return fragment.values[0] === 'statement_timeout'
53
+ ? timeoutValueMs(fragment.values[1])
54
+ : undefined;
55
+ }
56
+ const text = SET_TEXT.exec(fragment.text) ?? SET_CONFIG_TEXT.exec(fragment.text);
57
+ return text === null ? undefined : timeoutValueMs(text[1]);
58
+ }
59
+
60
+ /** After `fragment` ANSWERED on `pin`: a refused statement moved nothing. */
61
+ export function noteAnswered(pin: PinBound, fragment: SqlFragment, roleBoundMs: number): void {
62
+ if (ENDS_TRANSACTION.test(fragment.text)) {
63
+ pin.boundMs = roleBoundMs;
64
+ return;
65
+ }
66
+ const set = boundSetBy(fragment);
67
+ if (set !== undefined) pin.boundMs = set;
68
+ }
@@ -1,6 +1,6 @@
1
- // Single responsibility: the six numbers a Postgres pool runs on — the per-role defaults, the one
2
- // environment override an operator may layer over them, and the screen every resolved profile
3
- // passes. Split from `client.ts`, which now owns connecting and nothing about sizing.
1
+ // Single responsibility: the seven numbers a Postgres pool runs on — the per-role defaults, the
2
+ // environment an operator may layer over them (a pool size), and the screen
3
+ // every resolved profile passes. Split from `client.ts`, which now owns connecting and nothing about sizing.
4
4
 
5
5
  import { assert, type Role, resolveRole } from '@ultimat3/core';
6
6
  import { poolMaxInvalid } from './errors';
@@ -19,7 +19,8 @@ export interface PoolProfile {
19
19
  */
20
20
  readonly lockTimeoutMs: number;
21
21
  /**
22
- * How long `reserve()` may wait for a free connection before `X_DB_POOL_EXHAUSTED`. 0 waits
22
+ * How long a plain statement or `reserve()` may wait for a free connection before
23
+ * `X_DB_POOL_EXHAUSTED`. 0 waits
23
24
  * forever, which is what a run-once role wants and what a request-serving one must never do:
24
25
  * queueing turns exhaustion into a hang, `/readyz`'s `select 1` joins the same queue, the kubelet
25
26
  * kills the pod, and the replacement inherits the same saturated database.
@@ -43,6 +44,14 @@ export interface PoolProfile {
43
44
  * run-once role cutting off its own session mid-statement is worse than a slow exit.
44
45
  */
45
46
  readonly drainTimeoutMs: number;
47
+ /**
48
+ * How long a transaction may sit idle before the SERVER closes it, `25P03`. 0 disables the bound:
49
+ * `migrate` and `replicator`, for `statementTimeoutMs`' reason. A handler awaiting a hung call
50
+ * inside a transaction holds a slot and its row locks past the request's own 504, and nothing
51
+ * else ends it: `statement_timeout` only times a statement, and an idle transaction runs none.
52
+ * Longer on `worker` and `scheduler`, whose steps are expected to take their time.
53
+ */
54
+ readonly idleInTransactionMs: number;
46
55
  }
47
56
 
48
57
  /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
@@ -54,6 +63,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
54
63
  lockTimeoutMs: 0,
55
64
  acquireTimeoutMs: 5_000,
56
65
  drainTimeoutMs: 5_000,
66
+ idleInTransactionMs: 30_000,
57
67
  },
58
68
  sync: {
59
69
  max: 10,
@@ -62,6 +72,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
62
72
  lockTimeoutMs: 0,
63
73
  acquireTimeoutMs: 5_000,
64
74
  drainTimeoutMs: 5_000,
75
+ idleInTransactionMs: 30_000,
65
76
  },
66
77
  worker: {
67
78
  max: 8,
@@ -70,6 +81,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
70
81
  lockTimeoutMs: 0,
71
82
  acquireTimeoutMs: 10_000,
72
83
  drainTimeoutMs: 15_000,
84
+ idleInTransactionMs: 120_000,
73
85
  },
74
86
  scheduler: {
75
87
  max: 2,
@@ -78,6 +90,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
78
90
  lockTimeoutMs: 0,
79
91
  acquireTimeoutMs: 10_000,
80
92
  drainTimeoutMs: 5_000,
93
+ idleInTransactionMs: 120_000,
81
94
  },
82
95
  // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
83
96
  // a deadline here would refuse the migration's own session. The wait that needed bounding is the
@@ -89,6 +102,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
89
102
  lockTimeoutMs: 3_000,
90
103
  acquireTimeoutMs: 0,
91
104
  drainTimeoutMs: 0,
105
+ idleInTransactionMs: 0,
92
106
  },
93
107
  replicator: {
94
108
  max: 4,
@@ -97,6 +111,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
97
111
  lockTimeoutMs: 0,
98
112
  acquireTimeoutMs: 0,
99
113
  drainTimeoutMs: 0,
114
+ idleInTransactionMs: 0,
100
115
  },
101
116
  });
102
117
 
@@ -125,13 +140,13 @@ export function poolMaxFromEnv(
125
140
  }
126
141
 
127
142
  /**
128
- * The six numbers a pool runs on, screened on the MERGED profile — an override is spread over a
143
+ * The seven numbers a pool runs on, screened on the MERGED profile — an override is spread over a
129
144
  * role default the caller never restated, so the resolved object is the only one that can be
130
145
  * judged. Every one of them is a plausible `Number(process.env.…)`, which is `NaN` for an unset
131
- * variable and not nullish, so `??` and the spread both keep it. None of the six then fails
146
+ * variable and not nullish, so `??` and the spread both keep it. None of the seven then fails
132
147
  * loudly: `idleTimeout: NaN` goes to `Bun.SQL`, `statement_timeout=NaN` goes into the libpq
133
148
  * options string for the SERVER to reject on connect, and a timer given `NaN` fires at 1ms in this
134
- * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the five
149
+ * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the six
135
150
  * budgets that document it as "no bound"; `max` is at least one connection, or nothing can run.
136
151
  */
137
152
  export function assertPoolProfile(profile: PoolProfile): PoolProfile {
@@ -148,5 +163,6 @@ export function assertPoolProfile(profile: PoolProfile): PoolProfile {
148
163
  whole('lockTimeoutMs', profile.lockTimeoutMs, 0);
149
164
  whole('acquireTimeoutMs', profile.acquireTimeoutMs, 0);
150
165
  whole('drainTimeoutMs', profile.drainTimeoutMs, 0);
166
+ whole('idleInTransactionMs', profile.idleInTransactionMs, 0);
151
167
  return profile;
152
168
  }
@@ -15,10 +15,14 @@ import type { PoolProfile } from './pool-profile';
15
15
  * The losing reservation is released, never dropped: the pool hands out a connection whenever one
16
16
  * frees, deadline or no deadline, and a pin nobody holds is a connection nobody gets back. That is
17
17
  * the whole reason this is not a bare `Promise.race`.
18
+ *
19
+ * `leftMs` is what the caller's wait left of the ONE budget, and the timer runs on it; the refusal
20
+ * still names `acquireTimeoutMs`, the budget the operator configured and the wait it describes.
18
21
  */
19
22
  export async function reserveWithin(
20
23
  pool: Pick<BunSqlDriver, 'reserve'>,
21
24
  profile: PoolProfile,
25
+ leftMs: number = profile.acquireTimeoutMs,
22
26
  ): Promise<BunSqlReserved> {
23
27
  const budget = profile.acquireTimeoutMs;
24
28
  if (budget <= 0) return pool.reserve();
@@ -29,10 +33,13 @@ export async function reserveWithin(
29
33
  return await Promise.race([
30
34
  pending,
31
35
  new Promise<never>((_resolve, reject) => {
32
- timer = setTimeout(() => {
33
- expired = true;
34
- reject(poolAcquireTimeout(budget, profile.max));
35
- }, budget);
36
+ timer = setTimeout(
37
+ () => {
38
+ expired = true;
39
+ reject(poolAcquireTimeout(budget, profile.max));
40
+ },
41
+ Math.max(1, leftMs),
42
+ );
36
43
  // The deadline must not be what keeps a finished process alive.
37
44
  timer.unref?.();
38
45
  }),
@@ -0,0 +1,22 @@
1
+ // Single responsibility: letting go of a pool that stopped answering. Bun.SQL exposes no one
2
+ // connection to close, and a statement past its bound means a dead path, so its siblings on that path
3
+ // are suspect too: the whole pool goes, in the background, and the next statement builds a new one.
4
+
5
+ import { logger, renderThrowable } from '@ultimat3/core';
6
+ import type { BunSqlDriver } from './bun-sql';
7
+
8
+ /**
9
+ * Measured against a TCP proxy blackholing every connection: `close({ timeout: 1 })` returned at
10
+ * 1003 ms, the hung statement rejected `ERR_POSTGRES_CONNECTION_CLOSED`, a new pool answered on the
11
+ * same proxy. **The unit is seconds.** Total: a caller is already holding the error that matters.
12
+ */
13
+ export function closeRetiredPool(pool: Pick<BunSqlDriver, 'close'>): void {
14
+ const report = (error: unknown): void => {
15
+ logger.warn('db.pool.retire_failed', { error: renderThrowable(error) });
16
+ };
17
+ try {
18
+ void pool.close({ timeout: 1 }).then(undefined, report);
19
+ } catch (error) {
20
+ report(error);
21
+ }
22
+ }
@@ -0,0 +1,115 @@
1
+ // Single responsibility: noticing a statement nobody answered, pooled or on a pin. `statement_timeout`
2
+ // is the server's, so it cannot fire when the server or the path is gone, and the kernel's retransmit
3
+ // window is about 15 minutes. One timer per pool walks the statements in flight; none arms its own.
4
+
5
+ import type { Scheduler } from '@ultimat3/core';
6
+ import { statementUnanswered } from './errors';
7
+ import { unrefSchedule } from './pool-timer';
8
+ import { statementExcerpt } from './statement-excerpt';
9
+
10
+ /** Past `statementTimeoutMs` plus this, the server's own bound had to have answered. */
11
+ export const UNANSWERED_GRACE_MS = 5_000;
12
+ export const SWEEP_INTERVAL_MS = 1_000;
13
+
14
+ export interface PoolSweepOptions<Owner> {
15
+ /** `statementTimeoutMs + UNANSWERED_GRACE_MS`. The caller skips the sweep when the role has none. */
16
+ readonly deadlineMs: number;
17
+ /** A statement on `owner` missed. Called before its caller hears, so a retry finds the new pool. */
18
+ readonly onMiss: (owner: Owner, excerpt: string, waitedMs: number) => void;
19
+ readonly schedule?: Scheduler | undefined;
20
+ }
21
+
22
+ export interface PoolSweep<Owner> {
23
+ /**
24
+ * `sending`'s answer, or `X_DB_UNAVAILABLE` once it is past the deadline: the pool's, or
25
+ * `deadlineMs` for a pin whose own bound moved (`pool-pins.ts`). `0` judges nothing.
26
+ */
27
+ track<T>(owner: Owner, text: string, sending: Promise<T>, deadlineMs?: number): Promise<T>;
28
+ }
29
+
30
+ interface InFlight<Owner> {
31
+ readonly owner: Owner;
32
+ readonly text: string;
33
+ readonly startedAtTick: number;
34
+ readonly deadlineMs: number;
35
+ readonly ticksToMiss: number;
36
+ readonly reject: (reason: unknown) => void;
37
+ }
38
+
39
+ /**
40
+ * Age is counted in ticks that FIRED, never read from a clock. The funnel promises a statement no
41
+ * clock read when nothing observes it (`observe.test.ts`), and the count is also the right answer to
42
+ * a frozen process: a long GC or a SIGSTOP costs one tick however long it lasted, so answers sitting
43
+ * unread in their sockets are never judged late. A statement began somewhere inside its first tick,
44
+ * hence the extra one: nothing is judged before the deadline, and everything within one interval of it.
45
+ */
46
+ export function poolSweep<Owner>(options: PoolSweepOptions<Owner>): PoolSweep<Owner> {
47
+ const schedule = options.schedule ?? unrefSchedule;
48
+ const ticksFor = (deadlineMs: number): number => Math.ceil(deadlineMs / SWEEP_INTERVAL_MS) + 1;
49
+ const ticksToMiss = ticksFor(options.deadlineMs);
50
+ // Insertion order is start order and every statement has the same deadline, so a walk stops at
51
+ // the first one still in time, whatever the pool's size.
52
+ const inFlight = new Set<InFlight<Owner>>();
53
+ // Each its own deadline, so walked whole: at most one per pin, and pins are at most `max`.
54
+ const ownDeadline = new Set<InFlight<Owner>>();
55
+ let ticks = 0;
56
+ let armed = false;
57
+
58
+ function arm(): void {
59
+ armed = true;
60
+ schedule(tick, SWEEP_INTERVAL_MS);
61
+ }
62
+
63
+ function tick(): void {
64
+ armed = false;
65
+ ticks += 1;
66
+ for (const entry of inFlight) {
67
+ if (ticks - entry.startedAtTick < ticksToMiss) break;
68
+ miss(inFlight, entry);
69
+ }
70
+ for (const entry of ownDeadline) {
71
+ if (ticks - entry.startedAtTick >= entry.ticksToMiss) miss(ownDeadline, entry);
72
+ }
73
+ if (inFlight.size > 0 || ownDeadline.size > 0) arm();
74
+ }
75
+
76
+ function miss(from: Set<InFlight<Owner>>, entry: InFlight<Owner>): void {
77
+ from.delete(entry);
78
+ const excerpt = statementExcerpt(entry.text);
79
+ options.onMiss(entry.owner, excerpt, (ticks - entry.startedAtTick) * SWEEP_INTERVAL_MS);
80
+ entry.reject(statementUnanswered(excerpt, entry.deadlineMs));
81
+ }
82
+
83
+ return {
84
+ track<T>(owner: Owner, text: string, sending: Promise<T>, deadlineMs?: number): Promise<T> {
85
+ if (deadlineMs === 0) return sending;
86
+ const own = deadlineMs !== undefined && deadlineMs !== options.deadlineMs;
87
+ const bound = own ? deadlineMs : options.deadlineMs;
88
+ const into = own ? ownDeadline : inFlight;
89
+ return new Promise<T>((resolve, reject) => {
90
+ const entry: InFlight<Owner> = {
91
+ owner,
92
+ text,
93
+ startedAtTick: ticks,
94
+ deadlineMs: bound,
95
+ ticksToMiss: own ? ticksFor(bound) : ticksToMiss,
96
+ reject,
97
+ };
98
+ into.add(entry);
99
+ if (!armed) arm();
100
+ // Both arms stay attached after a miss: the statement rejects when its pool closes, and
101
+ // nobody else is left holding that promise.
102
+ sending.then(
103
+ (value) => {
104
+ into.delete(entry);
105
+ resolve(value);
106
+ },
107
+ (error: unknown) => {
108
+ into.delete(entry);
109
+ reject(error);
110
+ },
111
+ );
112
+ });
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,10 @@
1
+ // Single responsibility: the timer every pool deadline arms. A deadline guards work in flight, so it
2
+ // must never be the thing keeping a finished process alive.
3
+
4
+ import type { Scheduler } from '@ultimat3/core';
5
+
6
+ export const unrefSchedule: Scheduler = (fn, ms) => {
7
+ const timer = setTimeout(fn, ms);
8
+ timer.unref?.();
9
+ return () => clearTimeout(timer);
10
+ };
@@ -0,0 +1,53 @@
1
+ // Single responsibility: the instructions a pooler in the path needs, as text with no imports, so
2
+ // `errors.ts`' classifier and the factories over it can both read them without a cycle.
3
+
4
+ /**
5
+ * `web`'s numbers, as a command an operator pastes: `<user>` is the one thing this text cannot
6
+ * know, because per-role bounds mean one database user per role. Through the pooler or direct,
7
+ * `alter role` is an ordinary statement. A login opened before the `alter` keeps the old values,
8
+ * which is why reopening is in the comment after `#`, where prose belongs.
9
+ */
10
+ /**
11
+ * `DATABASE_URL` with its `options` query parameter cut out, as shell: the pooler refused the
12
+ * connection for an option in there, so a `psql` that sent it again would fail before `alter role`
13
+ * ran. Every other parameter (`sslmode`, `application_name`) stays, and only the query is
14
+ * searched: a password may read `&options=`, the query starts at the first `?`.
15
+ */
16
+ const PSQL_WITHOUT_OPTIONS = `psql "$(printf '%s' "$DATABASE_URL" | sed -E 's/^([^?]*\\?([^&]*&)*)options=[^&]*(&|$)/\\1/; s/[?&]$//')"`;
17
+
18
+ const ALTER_ROLE =
19
+ `${PSQL_WITHOUT_OPTIONS} -c "alter role <user> set statement_timeout = '10s'" ` +
20
+ `-c "alter role <user> set idle_in_transaction_session_timeout = '30s'"`;
21
+
22
+ const ALTER_ROLE_NOTE =
23
+ "<user> is this role's database user, POOL_PROFILES holds every role's numbers; then reopen the pooler's server connections (RECONNECT on its admin console), a role setting applies at login";
24
+
25
+ /** The two settings the framework itself sends as `options` when no pooler is named. */
26
+ const FRAMEWORK_OPTIONS: ReadonlySet<string> = new Set([
27
+ 'statement_timeout',
28
+ 'idle_in_transaction_session_timeout',
29
+ ]);
30
+
31
+ /**
32
+ * The fix for a refused startup parameter, decided by which one the pooler named. One of the
33
+ * framework's own means no pooler is named yet: the switch is the answer. Any other is the
34
+ * operator's, in `DATABASE_URL`'s `options` — with the switch set the framework sends none, so
35
+ * naming the switch again would be a loop. A name that is not an identifier is the server's text,
36
+ * not ours to paste, and is left out.
37
+ */
38
+ export function poolerOptionsRefusedFix(parameter: string | undefined): string {
39
+ if (parameter !== undefined && FRAMEWORK_OPTIONS.has(parameter)) {
40
+ return `export DATABASE_POOLER=transaction && ${ALTER_ROLE} # session on a session pool; ${ALTER_ROLE_NOTE}`;
41
+ }
42
+ const named = parameter !== undefined && /^[a-z_][a-z0-9_.]*$/.test(parameter);
43
+ const setting = named ? parameter : '<setting>';
44
+ return `${PSQL_WITHOUT_OPTIONS} -c "alter role <user> set ${setting} = '<value>'" # then remove -c ${setting}=… from the options of DATABASE_URL, or add it to ignore_startup_parameters of the pooler: a pooler forwards no startup option`;
45
+ }
46
+
47
+ export const STATEMENT_BOUND_LOST_FIX = `${ALTER_ROLE} # ${ALTER_ROLE_NOTE}; and export DATABASE_POOLER=transaction (or session) if a pooler sits in the path`;
48
+
49
+ export const POOLER_SESSION_REQUIRED_FIX =
50
+ 'unset DATABASE_POOLER # for this role only, and point its DATABASE_URL at the server port rather than the pooler';
51
+
52
+ export const IDLE_IN_TRANSACTION_FIX =
53
+ 'do slow outbound work before opening the transaction: the server closed it for sitting idle past idleInTransactionMs (POOL_PROFILES), and the await in the handler is the cause, not the connection';