@ultimat3/db 18.0.0 → 19.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 +25 -1
- package/package.json +2 -2
- package/src/bun-sql.ts +46 -4
- package/src/client.ts +38 -4
- package/src/errors.ts +50 -7
- package/src/pglite.ts +8 -2
- package/src/pool-profile.ts +29 -4
- package/src/pool-reserve.ts +2 -2
- package/src/sqlstate.ts +13 -1
- package/src/statement-funnel.ts +2 -1
package/CLAUDE.md
CHANGED
|
@@ -233,6 +233,30 @@ means the nested scope never opened, and a release that failed means its work is
|
|
|
233
233
|
the outer one. Swallowing either would keep running against a transaction that is not the one the
|
|
234
234
|
caller thinks it is in.
|
|
235
235
|
|
|
236
|
+
**`close()` is BOUNDED, `As of 2026-08-27`, and by the driver's OWN option rather than a race here**
|
|
237
|
+
(#394). `BunSqlDriver.close` has declared `{ timeout }` since this package's `Bun.SQL` slice was
|
|
238
|
+
written and **nothing ever passed it** — a capability sitting unused in the seam, the same shape as
|
|
239
|
+
`setOfflineMode` on the CDP port. Measured against a real server, three runs per case: `end()` waits
|
|
240
|
+
on an outstanding RESERVED connection and never returns, on Bun 1.3.14 **and** on 1.4.0, with the
|
|
241
|
+
database perfectly healthy; once that connection's backend has been terminated it becomes a race
|
|
242
|
+
1.3.14 loses 3 of 3 and 1.4.0 loses 1 of 3. So the runtime was never the variable — an unbounded
|
|
243
|
+
await was, and `@ultimat3/cli`'s `releaseQueue` awaits this method. A container that will not drain
|
|
244
|
+
is drained by SIGKILL, and the operator's only signal is a pod that took its full grace period.
|
|
245
|
+
|
|
246
|
+
Three rules ride with it. **The unit is SECONDS** — `close({ timeout: profile.drainTimeoutMs /
|
|
247
|
+
1000 })`, and `timeout: 5000` would be an eighty-three minute budget, which is the same hang with
|
|
248
|
+
extra steps. **`drainTimeoutMs: 0` sends no option at all**, rather than `{ timeout: 0 }`: `migrate`
|
|
249
|
+
and `replicator` mean "wait", for `acquireTimeoutMs`' reason, and a zero handed to the driver is an
|
|
250
|
+
instruction whose reading is the driver's. **The verdict is the elapsed time**, because the driver
|
|
251
|
+
RESOLVES when it gives up rather than rejecting — a drain that abandoned in-flight work looks exactly
|
|
252
|
+
like a clean one, so `X_DB_DRAIN_TIMEOUT` is raised on the clock or nothing is said at all. That
|
|
253
|
+
clock is `performance.now()` and never `Date.now()`, and the reason is this repo rather than NTP:
|
|
254
|
+
the framework preload freezes `Date` for every test in the tree (`installDeterminism`), so a duration
|
|
255
|
+
subtracted from `Date.now()` is 0 in all of them and the branch could not fire — a test asserting it
|
|
256
|
+
would have been one that cannot fail. `pool-drain.test.ts` pins what is ASKED for, against a fake
|
|
257
|
+
pool; `pool-drain.live.test.ts` pins that a real server's driver honours it, because a fake's
|
|
258
|
+
`close()` is whatever the fake decided and the finding is about the real one.
|
|
259
|
+
|
|
236
260
|
`close()` reads its cached driver into a local, clears the field, **then** awaits the teardown —
|
|
237
261
|
`client.ts` and `pglite.ts` both. A teardown that rejects has still torn the pool down, so clearing
|
|
238
262
|
after the await left the corpse cached for the next `connect()`, and a second `close()` threw in
|
|
@@ -1312,7 +1336,7 @@ survives the round trip whole.
|
|
|
1312
1336
|
is 0 and not 1.
|
|
1313
1337
|
|
|
1314
1338
|
- **`client.ts` reached the 500-line ceiling on 2026-08-26, and shed the five jobs that were not
|
|
1315
|
-
"open a connection and send a statement".** `pool-profile.ts` owns the
|
|
1339
|
+
"open a connection and send a statement".** `pool-profile.ts` owns the six numbers a pool runs
|
|
1316
1340
|
on — the per-role table, `DATABASE_POOL_MAX` and the screen every merged profile passes;
|
|
1317
1341
|
`connection-url.ts` builds the connection string (the libpq `options` merge and the
|
|
1318
1342
|
`application_name` label); `bun-sql.ts` declares the slice of `Bun.SQL` this package uses and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "19.1.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "19.1.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/bun-sql.ts
CHANGED
|
@@ -1,13 +1,55 @@
|
|
|
1
|
-
// Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally,
|
|
2
|
-
//
|
|
3
|
-
// never touches `Bun` at module evaluation —
|
|
1
|
+
// Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, the lazy
|
|
2
|
+
// lookup of the global that provides it, and the one safe way to hand a pinned connection back.
|
|
3
|
+
// Reached through a function so importing the client never touches `Bun` at module evaluation —
|
|
4
|
+
// the CLI imports it to print help.
|
|
4
5
|
|
|
6
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
5
7
|
import { dbUnavailable } from './errors';
|
|
6
8
|
|
|
7
9
|
/** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
|
|
8
10
|
export interface BunSqlReserved {
|
|
9
11
|
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
10
|
-
|
|
12
|
+
/**
|
|
13
|
+
* **Answers a PROMISE, and typing it `void` is what made both callers float it.** Measured on
|
|
14
|
+
* Bun 1.3.14 and 1.4.0 against a real server: `release()` returns a promise on both, and on
|
|
15
|
+
* 1.3.14 that promise REJECTS with `ERR_POSTGRES_CONNECTION_CLOSED` when the pool has already
|
|
16
|
+
* been closed. Nothing was attached to it, so it surfaced as an UNHANDLED REJECTION — which Bun
|
|
17
|
+
* takes the process down for. `unknown` rather than `Promise<void>` because a fake reserved
|
|
18
|
+
* connection returns nothing at all, and the caller has to handle both anyway.
|
|
19
|
+
*/
|
|
20
|
+
release(): unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Hand a pin back, totally. The one place that knows `release()` answers a promise, so neither
|
|
25
|
+
* caller can forget it (axiom 1) — `client.ts`'s `DbConnection.release` and `pool-reserve.ts`'s
|
|
26
|
+
* late arrival both route here.
|
|
27
|
+
*
|
|
28
|
+
* A failed release is **best-effort, exactly where a throw would mask the error that caused it** —
|
|
29
|
+
* the rule this package already applies to `ROLLBACK`. `[Symbol.dispose]` is `DbConnection.release`
|
|
30
|
+
* itself, so a throw there replaces whatever error reached the `using` block, or invents one where
|
|
31
|
+
* the body succeeded. And the news is unactionable: the connection this would hand back is gone
|
|
32
|
+
* either way.
|
|
33
|
+
*
|
|
34
|
+
* Reachable, and reachable BECAUSE `close()` is bounded: an abandoned drain leaves every
|
|
35
|
+
* still-pinned connection to be released against a pool that no longer exists.
|
|
36
|
+
*/
|
|
37
|
+
export function releaseReserved(reserved: BunSqlReserved): void {
|
|
38
|
+
const report = (error: unknown): void => {
|
|
39
|
+
logger.debug('db.release_failed', { error: renderThrowable(error) });
|
|
40
|
+
};
|
|
41
|
+
let settled: unknown;
|
|
42
|
+
try {
|
|
43
|
+
settled = reserved.release();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
report(error);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// `then` and not `instanceof Promise`: the value comes from the driver, and a thenable is the
|
|
49
|
+
// contract every await in this package already relies on.
|
|
50
|
+
if (typeof (settled as PromiseLike<unknown> | undefined)?.then === 'function') {
|
|
51
|
+
void (settled as PromiseLike<unknown>).then(undefined, report);
|
|
52
|
+
}
|
|
11
53
|
}
|
|
12
54
|
|
|
13
55
|
/** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */
|
package/src/client.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
// importing this module never opens a socket.
|
|
6
6
|
|
|
7
7
|
import { type Role, resolveRole } from '@ultimat3/core';
|
|
8
|
-
import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory } from './bun-sql';
|
|
8
|
+
import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory, releaseReserved } from './bun-sql';
|
|
9
9
|
import { connectionUrl } from './connection-url';
|
|
10
10
|
// Deliberate cycle, the same shape as `client.ts ⇄ transaction.ts`: nothing here is referenced at
|
|
11
11
|
// module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ.
|
|
12
12
|
import { defaultClient } from './default-client';
|
|
13
|
-
import { DbError, driverError } from './errors';
|
|
13
|
+
import { DbError, drainTimeout, driverError } from './errors';
|
|
14
14
|
import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile';
|
|
15
15
|
import { reserveWithin } from './pool-reserve';
|
|
16
16
|
import { type SqlFragment, sql } from './sql';
|
|
@@ -120,7 +120,8 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
120
120
|
const release = (): void => {
|
|
121
121
|
if (!held) return;
|
|
122
122
|
held = false;
|
|
123
|
-
|
|
123
|
+
// Total by construction — `releaseReserved` owns the reason (`bun-sql.ts`).
|
|
124
|
+
releaseReserved(reserved);
|
|
124
125
|
};
|
|
125
126
|
return {
|
|
126
127
|
query: async <T>(fragment: SqlFragment) => rowsOf<T>(await on(fragment)),
|
|
@@ -141,7 +142,40 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
|
|
|
141
142
|
// rejection still reaches the caller — a shutdown that could not drain wants to know.
|
|
142
143
|
const pool = driver;
|
|
143
144
|
driver = undefined;
|
|
144
|
-
|
|
145
|
+
if (pool === undefined) return;
|
|
146
|
+
// BOUNDED, `As of 2026-08-27`, and through the driver's OWN option rather than a race here.
|
|
147
|
+
// This was a bare `await pool.close()`, and `Bun.SQL`'s `end()` waits on an outstanding
|
|
148
|
+
// reserved connection without ever giving up — measured three runs per case on Bun 1.3.14
|
|
149
|
+
// AND 1.4.0, no database outage involved (#394). So a role whose database went away
|
|
150
|
+
// mid-shutdown never finished shutting down, and the operator's only signal was a container
|
|
151
|
+
// that burned its whole termination grace period before SIGKILL.
|
|
152
|
+
//
|
|
153
|
+
// `BunSqlDriver.close` has declared `{ timeout }` since this port was written and NOTHING
|
|
154
|
+
// ever passed it — the capability was in the seam, unused, exactly like `setOfflineMode` on
|
|
155
|
+
// the CDP port. Measured with a reserve outstanding: `close({ timeout: 1 })` returns in
|
|
156
|
+
// ~1002ms on 1.3.14, 1.4.0 and 1.4.1-canary alike, where a bare `close()` never returns.
|
|
157
|
+
//
|
|
158
|
+
// **The unit is SECONDS**, not milliseconds. `timeout: 5000` would be an eighty-three minute
|
|
159
|
+
// shutdown budget, which is the same hang with extra steps.
|
|
160
|
+
if (profile.drainTimeoutMs === 0) {
|
|
161
|
+
// `migrate` and `replicator`, for `acquireTimeoutMs`' reason: a run-once role cutting off
|
|
162
|
+
// its own session mid-statement is worse than a slow exit.
|
|
163
|
+
await pool.close();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// `performance.now()`, never `Date.now()`, and the reason is this repo rather than NTP: the
|
|
167
|
+
// framework preload freezes `Date` for every test in the tree (`installDeterminism`), so a
|
|
168
|
+
// duration subtracted from `Date.now()` is 0 in all of them — the branch below could not
|
|
169
|
+
// fire, and the test asserting it would have been one that cannot fail.
|
|
170
|
+
const started = performance.now();
|
|
171
|
+
await pool.close({ timeout: profile.drainTimeoutMs / 1000 });
|
|
172
|
+
// The driver RESOLVES when it gives up — it does not reject — so the elapsed time is the only
|
|
173
|
+
// thing that separates "drained" from "abandoned". Reporting it is the point: a drain that
|
|
174
|
+
// silently gave up looks exactly like a clean one, and the work still in flight is lost with
|
|
175
|
+
// no line anywhere saying so. The pool is gone either way, which is why this is terminal.
|
|
176
|
+
if (performance.now() - started >= profile.drainTimeoutMs) {
|
|
177
|
+
throw drainTimeout(profile.drainTimeoutMs, role);
|
|
178
|
+
}
|
|
145
179
|
},
|
|
146
180
|
};
|
|
147
181
|
return client;
|
package/src/errors.ts
CHANGED
|
@@ -19,12 +19,15 @@ import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
|
|
|
19
19
|
*/
|
|
20
20
|
export const DB_OWNED_ERROR_CODES = [
|
|
21
21
|
'X_DB_UNAVAILABLE',
|
|
22
|
+
'X_DB_SCHEMA_STALE',
|
|
23
|
+
'X_DB_STATEMENT_FAILED',
|
|
22
24
|
'X_DB_UNIQUE_VIOLATION',
|
|
23
25
|
'X_DB_FOREIGN_KEY_VIOLATION',
|
|
24
26
|
'X_DB_SERIALIZATION_FAILURE',
|
|
25
27
|
'X_DB_STATEMENT_TIMEOUT',
|
|
26
28
|
'X_DB_LOCK_TIMEOUT',
|
|
27
29
|
'X_DB_POOL_EXHAUSTED',
|
|
30
|
+
'X_DB_DRAIN_TIMEOUT',
|
|
28
31
|
'X_DB_DRIFT',
|
|
29
32
|
'X_MIGRATION_CONFLICT',
|
|
30
33
|
'X_MIGRATION_IRREVERSIBLE',
|
|
@@ -60,12 +63,15 @@ export type DbErrorCode = (typeof DB_ERROR_CODES)[number];
|
|
|
60
63
|
|
|
61
64
|
export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
62
65
|
X_DB_UNAVAILABLE: 'cannot reach the database',
|
|
66
|
+
X_DB_SCHEMA_STALE: 'the statement names a table or column the database does not have',
|
|
67
|
+
X_DB_STATEMENT_FAILED: 'the database refused the statement',
|
|
63
68
|
X_DB_UNIQUE_VIOLATION: 'a unique constraint rejected the row',
|
|
64
69
|
X_DB_FOREIGN_KEY_VIOLATION: 'a foreign key constraint rejected the row',
|
|
65
70
|
X_DB_SERIALIZATION_FAILURE: 'the transaction lost a serialization race',
|
|
66
71
|
X_DB_STATEMENT_TIMEOUT: 'the statement ran past its statement_timeout',
|
|
67
72
|
X_DB_LOCK_TIMEOUT: 'the statement waited past its lock_timeout',
|
|
68
73
|
X_DB_POOL_EXHAUSTED: 'no connection was available',
|
|
74
|
+
X_DB_DRAIN_TIMEOUT: 'the pool did not drain inside its shutdown budget',
|
|
69
75
|
X_DB_DRIFT: 'schema differs from migrations',
|
|
70
76
|
X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build',
|
|
71
77
|
X_MIGRATE_CONCURRENT: 'another migrator holds the migration lock',
|
|
@@ -125,7 +131,9 @@ export const DB_ERROR_RETRY = {
|
|
|
125
131
|
// a typo or a renamed code is a build error rather than a classification for a code nothing throws.
|
|
126
132
|
//
|
|
127
133
|
// Left to the fail-closed default, deliberately, each for its own reason:
|
|
128
|
-
// X_DB_UNAVAILABLE
|
|
134
|
+
// X_DB_UNAVAILABLE a connection failure — see the note above
|
|
135
|
+
// X_DB_SCHEMA_STALE `42P01`/`42703`: the migration is the fix, and no attempt runs it
|
|
136
|
+
// X_DB_STATEMENT_FAILED the server's verdict on the SQL; the same SQL gets the same verdict
|
|
129
137
|
// X_DB_STATEMENT_TIMEOUT `57014`, and this package's fix for it is "add the index": an edit.
|
|
130
138
|
// The queued-behind-a-lock case has its own code, above
|
|
131
139
|
// X_DB_UNIQUE_VIOLATION the same row, the same constraint, the same refusal
|
|
@@ -169,7 +177,9 @@ export class DbError extends UltimateError {
|
|
|
169
177
|
export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
|
|
170
178
|
new DbError({
|
|
171
179
|
code: 'X_DB_UNAVAILABLE',
|
|
172
|
-
|
|
180
|
+
// The driver's own words ride along when there are any: `ECONNREFUSED 127.0.0.1:5432` is the
|
|
181
|
+
// half of "cannot reach the database" an operator acts on, and it was dropped on the floor.
|
|
182
|
+
cause: sourceError === undefined ? detail : `${detail}: ${renderThrowable(sourceError)}`,
|
|
173
183
|
fix: 'set DATABASE_URL to a reachable Postgres url, or run `x dev` to use the embedded PGlite',
|
|
174
184
|
sourceError,
|
|
175
185
|
});
|
|
@@ -184,6 +194,9 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
|
|
|
184
194
|
* of one; `driverError` substitutes the placeholder when the driver reported none.
|
|
185
195
|
*/
|
|
186
196
|
const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({
|
|
197
|
+
X_DB_SCHEMA_STALE:
|
|
198
|
+
'x db gen "<what changed>" && x db migrate # the entity is ahead of the schema; ' +
|
|
199
|
+
'if the migration already exists, only the migrate half is due',
|
|
187
200
|
X_DB_UNIQUE_VIOLATION:
|
|
188
201
|
'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' +
|
|
189
202
|
'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
|
|
@@ -217,19 +230,31 @@ const UNNAMED_CONSTRAINT = 'the constraint named in cause';
|
|
|
217
230
|
* given where it is true, and a new SQLSTATE arrives as a new row here rather than as a new
|
|
218
231
|
* `catch` at a call site.
|
|
219
232
|
*/
|
|
220
|
-
export const driverError = (
|
|
221
|
-
const code = sqlStateCode(sourceError);
|
|
222
|
-
if (code === undefined) return dbUnavailable(detail, sourceError);
|
|
233
|
+
export const driverError = (statement: string, sourceError: unknown): DbError => {
|
|
223
234
|
const state = sqlState(sourceError);
|
|
235
|
+
// No SQLSTATE means the failure never reached a server — a refused socket, a closed pool, a
|
|
236
|
+
// driver that would not load. THAT is unavailability, and it is the only thing that is.
|
|
237
|
+
if (state === undefined) return dbUnavailable(`statement failed: ${statement}`, sourceError);
|
|
238
|
+
// A state the table does not classify still proves the server answered: it read the statement
|
|
239
|
+
// and refused it. `X_DB_STATEMENT_FAILED` says so and carries the server's own words, where
|
|
240
|
+
// `X_DB_UNAVAILABLE` said "set DATABASE_URL" to a developer whose database was fine.
|
|
241
|
+
const code = sqlStateCode(sourceError) ?? 'X_DB_STATEMENT_FAILED';
|
|
224
242
|
const constraint = stringField(sourceError, 'constraint');
|
|
225
243
|
return new DbError({
|
|
226
244
|
code,
|
|
227
|
-
|
|
245
|
+
// The server's SQLSTATE and message FIRST, the statement after: a cause is rendered on one
|
|
246
|
+
// line and cut when long, and a column list of any width put the one thing an author needs —
|
|
247
|
+
// `column "host_id" does not exist` — past the cut. The statement is `statementExcerpt`'d by
|
|
248
|
+
// the caller, so the whole line has a known ceiling.
|
|
249
|
+
cause: `[SQLSTATE ${state}] ${renderThrowable(sourceError)} — statement: ${statement}`,
|
|
228
250
|
// A FUNCTION as the replacement, never the string: `String.replace` expands `$&`, `` $` ``,
|
|
229
251
|
// `$'` and `$$` inside a replacement literal, and a constraint name is the server's, not
|
|
230
252
|
// ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
|
|
231
253
|
// `{constraint}` back into the fix line an author is meant to paste.
|
|
232
|
-
fix:
|
|
254
|
+
fix:
|
|
255
|
+
code === 'X_DB_STATEMENT_FAILED'
|
|
256
|
+
? `psql "$DATABASE_URL" -c "<the statement in cause>" # SQLSTATE ${state} is the server's verdict on it: fix the SQL or the data it names, not the connection`
|
|
257
|
+
: SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
|
|
233
258
|
meta: {
|
|
234
259
|
sqlState: state,
|
|
235
260
|
...(constraint === undefined ? {} : { constraint }),
|
|
@@ -238,6 +263,24 @@ export const driverError = (detail: string, sourceError: unknown): DbError => {
|
|
|
238
263
|
});
|
|
239
264
|
};
|
|
240
265
|
|
|
266
|
+
/**
|
|
267
|
+
* `close()` gave up waiting for the pool. TERMINAL, and deliberately not retryable: the pool is
|
|
268
|
+
* gone either way — `close()` clears the handle before it awaits — so a caller that retried would
|
|
269
|
+
* be closing a pool that no longer exists. What this reports is that connections were still held
|
|
270
|
+
* when the process stopped waiting, which is a fact about the shutdown an operator has to see.
|
|
271
|
+
*
|
|
272
|
+
* The alternative was to resolve quietly on the deadline, and that is the version that hides the
|
|
273
|
+
* bug: a drain that silently gave up looks exactly like a clean one, and the rows still in flight
|
|
274
|
+
* are lost with no line anywhere saying so.
|
|
275
|
+
*/
|
|
276
|
+
export const drainTimeout = (ms: number, role: string): DbError =>
|
|
277
|
+
new DbError({
|
|
278
|
+
code: 'X_DB_DRAIN_TIMEOUT',
|
|
279
|
+
cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`,
|
|
280
|
+
fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in createPostgresClient({ profile }) for the ${role} role`,
|
|
281
|
+
meta: { drainTimeoutMs: ms, role },
|
|
282
|
+
});
|
|
283
|
+
|
|
241
284
|
/**
|
|
242
285
|
* The pool answered nothing inside `acquireTimeoutMs`. Distinct from the server's own `53300` and
|
|
243
286
|
* deliberately the same code: to a caller both mean "there was no connection for this unit of
|
package/src/pglite.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
7
|
import type { DbConnection, ReservableClient } from './client';
|
|
8
|
-
import { DbError,
|
|
8
|
+
import { DbError, driverError } from './errors';
|
|
9
9
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
10
|
import { statementObserver } from './observe';
|
|
11
11
|
import { createTurnQueue } from './pglite-turns';
|
|
12
12
|
import type { SqlFragment } from './sql';
|
|
13
|
+
import { statementExcerpt } from './statement-excerpt';
|
|
13
14
|
import { withStatementSpan } from './statement-span';
|
|
14
15
|
import { inLiveTx } from './transaction';
|
|
15
16
|
|
|
@@ -142,7 +143,12 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
142
143
|
try {
|
|
143
144
|
return await driver.query(fragment.text, fragment.values);
|
|
144
145
|
} catch (error) {
|
|
145
|
-
|
|
146
|
+
// `driverError`, as `statement-funnel.ts` already does for Bun's driver: this site passed
|
|
147
|
+
// every failure to `dbUnavailable`, so under `x dev` — which IS this driver when no
|
|
148
|
+
// `DATABASE_URL` is set — a `select` naming a column whose migration had not run answered
|
|
149
|
+
// "cannot reach the database" with the fix "set DATABASE_URL", against a database that was
|
|
150
|
+
// answering fine (measured 2026-09-05). PGlite carries the SQLSTATE on `code`.
|
|
151
|
+
throw driverError(statementExcerpt(fragment.text), error);
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
|
package/src/pool-profile.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Single responsibility: the
|
|
1
|
+
// Single responsibility: the six numbers a Postgres pool runs on — the per-role defaults, the one
|
|
2
2
|
// environment override an operator may layer over them, and the screen every resolved profile
|
|
3
3
|
// passes. Split from `client.ts`, which now owns connecting and nothing about sizing.
|
|
4
4
|
|
|
@@ -25,6 +25,24 @@ export interface PoolProfile {
|
|
|
25
25
|
* kills the pod, and the replacement inherits the same saturated database.
|
|
26
26
|
*/
|
|
27
27
|
readonly acquireTimeoutMs: number;
|
|
28
|
+
/**
|
|
29
|
+
* How long `close()` may wait for the pool to drain before `X_DB_DRAIN_TIMEOUT`. 0 waits forever.
|
|
30
|
+
*
|
|
31
|
+
* **A drain that cannot finish is the failure this bounds, and it is not hypothetical.** Measured
|
|
32
|
+
* against a real Postgres, three runs per case: `Bun.SQL`'s `end()` waits on an outstanding
|
|
33
|
+
* RESERVED connection and never stops waiting — 3 of 3 on Bun 1.3.14 *and* 3 of 3 on 1.4.0, with
|
|
34
|
+
* no database outage involved at all. Once that connection's backend has been terminated it
|
|
35
|
+
* becomes a race, which 1.3.14 loses 3 of 3 and 1.4.0 loses 1 of 3. So the runtime is not the
|
|
36
|
+
* variable; an unbounded await is (#394).
|
|
37
|
+
*
|
|
38
|
+
* What that cost, before this: `releaseQueue` awaits `db.close()`, so a role whose database went
|
|
39
|
+
* away mid-shutdown never finished shutting down. A container that will not drain is drained by
|
|
40
|
+
* SIGKILL, and the operator's only signal is a pod that took its full termination grace period.
|
|
41
|
+
*
|
|
42
|
+
* `migrate` and `replicator` wait forever, deliberately, for `acquireTimeoutMs`' reason: a
|
|
43
|
+
* run-once role cutting off its own session mid-statement is worse than a slow exit.
|
|
44
|
+
*/
|
|
45
|
+
readonly drainTimeoutMs: number;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
/** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
|
|
@@ -35,6 +53,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
|
35
53
|
idleTimeoutMs: 30_000,
|
|
36
54
|
lockTimeoutMs: 0,
|
|
37
55
|
acquireTimeoutMs: 5_000,
|
|
56
|
+
drainTimeoutMs: 5_000,
|
|
38
57
|
},
|
|
39
58
|
sync: {
|
|
40
59
|
max: 10,
|
|
@@ -42,6 +61,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
|
42
61
|
idleTimeoutMs: 60_000,
|
|
43
62
|
lockTimeoutMs: 0,
|
|
44
63
|
acquireTimeoutMs: 5_000,
|
|
64
|
+
drainTimeoutMs: 5_000,
|
|
45
65
|
},
|
|
46
66
|
worker: {
|
|
47
67
|
max: 8,
|
|
@@ -49,6 +69,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
|
49
69
|
idleTimeoutMs: 30_000,
|
|
50
70
|
lockTimeoutMs: 0,
|
|
51
71
|
acquireTimeoutMs: 10_000,
|
|
72
|
+
drainTimeoutMs: 15_000,
|
|
52
73
|
},
|
|
53
74
|
scheduler: {
|
|
54
75
|
max: 2,
|
|
@@ -56,6 +77,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
|
56
77
|
idleTimeoutMs: 60_000,
|
|
57
78
|
lockTimeoutMs: 0,
|
|
58
79
|
acquireTimeoutMs: 10_000,
|
|
80
|
+
drainTimeoutMs: 5_000,
|
|
59
81
|
},
|
|
60
82
|
// `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
|
|
61
83
|
// a deadline here would refuse the migration's own session. The wait that needed bounding is the
|
|
@@ -66,6 +88,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
|
66
88
|
idleTimeoutMs: 10_000,
|
|
67
89
|
lockTimeoutMs: 3_000,
|
|
68
90
|
acquireTimeoutMs: 0,
|
|
91
|
+
drainTimeoutMs: 0,
|
|
69
92
|
},
|
|
70
93
|
replicator: {
|
|
71
94
|
max: 4,
|
|
@@ -73,6 +96,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
|
|
|
73
96
|
idleTimeoutMs: 60_000,
|
|
74
97
|
lockTimeoutMs: 0,
|
|
75
98
|
acquireTimeoutMs: 0,
|
|
99
|
+
drainTimeoutMs: 0,
|
|
76
100
|
},
|
|
77
101
|
});
|
|
78
102
|
|
|
@@ -99,13 +123,13 @@ export function poolMaxFromEnv(): Partial<PoolProfile> {
|
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
/**
|
|
102
|
-
* The
|
|
126
|
+
* The six numbers a pool runs on, screened on the MERGED profile — an override is spread over a
|
|
103
127
|
* role default the caller never restated, so the resolved object is the only one that can be
|
|
104
128
|
* judged. Every one of them is a plausible `Number(process.env.…)`, which is `NaN` for an unset
|
|
105
|
-
* variable and not nullish, so `??` and the spread both keep it. None of the
|
|
129
|
+
* variable and not nullish, so `??` and the spread both keep it. None of the six then fails
|
|
106
130
|
* loudly: `idleTimeout: NaN` goes to `Bun.SQL`, `statement_timeout=NaN` goes into the libpq
|
|
107
131
|
* options string for the SERVER to reject on connect, and a timer given `NaN` fires at 1ms in this
|
|
108
|
-
* Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the
|
|
132
|
+
* Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the five
|
|
109
133
|
* budgets that document it as "no bound"; `max` is at least one connection, or nothing can run.
|
|
110
134
|
*/
|
|
111
135
|
export function assertPoolProfile(profile: PoolProfile): PoolProfile {
|
|
@@ -121,5 +145,6 @@ export function assertPoolProfile(profile: PoolProfile): PoolProfile {
|
|
|
121
145
|
whole('idleTimeoutMs', profile.idleTimeoutMs, 0);
|
|
122
146
|
whole('lockTimeoutMs', profile.lockTimeoutMs, 0);
|
|
123
147
|
whole('acquireTimeoutMs', profile.acquireTimeoutMs, 0);
|
|
148
|
+
whole('drainTimeoutMs', profile.drainTimeoutMs, 0);
|
|
124
149
|
return profile;
|
|
125
150
|
}
|
package/src/pool-reserve.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// and giving back a reservation that arrives after the deadline has passed. Split from `client.ts`,
|
|
3
3
|
// which now asks for a pin rather than owning what "waited too long" means.
|
|
4
4
|
|
|
5
|
-
import type
|
|
5
|
+
import { type BunSqlDriver, type BunSqlReserved, releaseReserved } from './bun-sql';
|
|
6
6
|
import { poolAcquireTimeout } from './errors';
|
|
7
7
|
import type { PoolProfile } from './pool-profile';
|
|
8
8
|
|
|
@@ -42,7 +42,7 @@ export async function reserveWithin(
|
|
|
42
42
|
// Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled.
|
|
43
43
|
void pending.then(
|
|
44
44
|
(late) => {
|
|
45
|
-
if (expired) late
|
|
45
|
+
if (expired) releaseReserved(late);
|
|
46
46
|
},
|
|
47
47
|
() => undefined,
|
|
48
48
|
);
|
package/src/sqlstate.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { stringField } from '@ultimat3/core';
|
|
|
14
14
|
export const SQLSTATE = Object.freeze({
|
|
15
15
|
/** `undefined_table` — the ledger's absence is a class, not a message to match on. */
|
|
16
16
|
undefinedTable: '42P01',
|
|
17
|
+
/** `undefined_column` — an entity edited before the migration that adds the column ran. */
|
|
18
|
+
undefinedColumn: '42703',
|
|
17
19
|
uniqueViolation: '23505',
|
|
18
20
|
foreignKeyViolation: '23503',
|
|
19
21
|
serializationFailure: '40001',
|
|
@@ -70,6 +72,7 @@ export function sqlState(error: unknown): string | undefined {
|
|
|
70
72
|
|
|
71
73
|
/** The codes a SQLSTATE can classify into. `errors.ts` owns their titles and their fixes. */
|
|
72
74
|
export type DbSqlStateCode =
|
|
75
|
+
| 'X_DB_SCHEMA_STALE'
|
|
73
76
|
| 'X_DB_UNIQUE_VIOLATION'
|
|
74
77
|
| 'X_DB_FOREIGN_KEY_VIOLATION'
|
|
75
78
|
| 'X_DB_SERIALIZATION_FAILURE'
|
|
@@ -84,6 +87,11 @@ export type DbSqlStateCode =
|
|
|
84
87
|
* class 53, insufficient resources, and both are answered by asking for fewer connections.
|
|
85
88
|
*/
|
|
86
89
|
export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Object.freeze({
|
|
90
|
+
// Both class 42 "the schema does not have what this statement names": a table or a column
|
|
91
|
+
// declared in code whose migration has not run. One code, because the instruction is one
|
|
92
|
+
// instruction — generate the migration and apply it — and the cause names which it was.
|
|
93
|
+
[SQLSTATE.undefinedTable]: 'X_DB_SCHEMA_STALE',
|
|
94
|
+
[SQLSTATE.undefinedColumn]: 'X_DB_SCHEMA_STALE',
|
|
87
95
|
[SQLSTATE.uniqueViolation]: 'X_DB_UNIQUE_VIOLATION',
|
|
88
96
|
[SQLSTATE.foreignKeyViolation]: 'X_DB_FOREIGN_KEY_VIOLATION',
|
|
89
97
|
[SQLSTATE.serializationFailure]: 'X_DB_SERIALIZATION_FAILURE',
|
|
@@ -94,7 +102,11 @@ export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Objec
|
|
|
94
102
|
[SQLSTATE.outOfMemory]: 'X_DB_POOL_EXHAUSTED',
|
|
95
103
|
} as const);
|
|
96
104
|
|
|
97
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* `undefined` when the state is absent or not in the table. What the caller does with that is
|
|
107
|
+
* `driverError`'s decision, not this file's: a state the table does not name still proves the
|
|
108
|
+
* statement REACHED a server, which is the opposite of unavailable.
|
|
109
|
+
*/
|
|
98
110
|
export function sqlStateCode(error: unknown): DbSqlStateCode | undefined {
|
|
99
111
|
const state = sqlState(error);
|
|
100
112
|
return state === undefined ? undefined : DB_SQLSTATE_CODES[state];
|
package/src/statement-funnel.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { driverError } from './errors';
|
|
|
10
10
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
11
11
|
import { statementObserver } from './observe';
|
|
12
12
|
import type { SqlFragment } from './sql';
|
|
13
|
+
import { statementExcerpt } from './statement-excerpt';
|
|
13
14
|
import { withStatementSpan } from './statement-span';
|
|
14
15
|
|
|
15
16
|
export function rowsOf<T>(result: unknown): readonly T[] {
|
|
@@ -42,7 +43,7 @@ async function sendOn(
|
|
|
42
43
|
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
|
43
44
|
// unreachable and paged on-call for an outage that never happened. Everything the table does
|
|
44
45
|
// not classify is still `X_DB_UNAVAILABLE`, byte for byte.
|
|
45
|
-
throw driverError(
|
|
46
|
+
throw driverError(statementExcerpt(fragment.text), error);
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|