@ultimat3/db 11.1.0 → 11.2.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 +35 -0
- package/README.md +9 -1
- package/package.json +2 -2
- package/src/errors.ts +56 -0
- package/src/index.ts +1 -0
- package/src/transaction-backoff.ts +43 -0
- package/src/transaction.ts +20 -0
package/CLAUDE.md
CHANGED
|
@@ -137,6 +137,41 @@ that would start attempt two answers `25P01 ROLLBACK TO SAVEPOINT can only be us
|
|
|
137
137
|
blocks`. There is nothing to retry into, and an author who believes they hold a budget they do not
|
|
138
138
|
is worse off than one who is told.
|
|
139
139
|
|
|
140
|
+
**A re-run waits first, `As of 2026-08-23`, and the default still waits not at all.** The loop had
|
|
141
|
+
NO backoff: two transactions that deadlocked woke in the same microsecond, took the same locks in
|
|
142
|
+
the same order, and one of them lost again — so a `retry: 8` budget was spent inside one round
|
|
143
|
+
trip's worth of wall clock, which is the deadlock reproduced rather than resolved.
|
|
144
|
+
`transaction-backoff.ts` is the schedule: `@ultimat3/core`'s `backoffDelay`, exponential from
|
|
145
|
+
**10ms**, capped at **500ms**, **full** jitter. The constants are contention's, not an outage's — the
|
|
146
|
+
winner of the race is already committing, and this loop holds a connection on a request's critical
|
|
147
|
+
path, so ai's 500ms base and jobs' one-second base would turn a recovered transaction into a
|
|
148
|
+
timed-out one. Full jitter because the two callers whose retries must not re-collide are, by
|
|
149
|
+
construction, scheduled at the same offset from the same event. Nothing waits when `retry` is 0 or
|
|
150
|
+
absent, and nothing waits after the LAST attempt. `{ sleep, random }` on `TransactionOptions` are
|
|
151
|
+
the injection seams and production passes neither.
|
|
152
|
+
|
|
153
|
+
**Four codes are classified `retryable`, and the terminal ones are deliberately NOT classified,
|
|
154
|
+
`As of 2026-08-23`.** `DB_ERROR_RETRY` registers `X_DB_SERIALIZATION_FAILURE`, `X_DB_LOCK_TIMEOUT`,
|
|
155
|
+
`X_DB_POOL_EXHAUSTED` and `X_MIGRATE_CONCURRENT` — each is a resource that frees. Before it, this
|
|
156
|
+
package classified nothing, so `X_DB_SERIALIZATION_FAILURE` rendered `retry: "terminal"` in every
|
|
157
|
+
problem document while its own `fix:` line read `withTransaction(fn, { retry: 3 })`.
|
|
158
|
+
|
|
159
|
+
The half that needs the argument is the codes left OUT. Core's shape (`CORE_ERROR_RETRY` lists only
|
|
160
|
+
the exceptions) rather than `@ultimat3/scraping`'s exhaustive one, because a REGISTERED `terminal` is
|
|
161
|
+
not the same as an unclassified code: `@ultimat3/jobs`' `nextRetryForError` dead-letters the first on
|
|
162
|
+
attempt 1 and keeps the attempt count for the second. `X_DB_UNAVAILABLE: 'terminal'` is defensible
|
|
163
|
+
for an HTTP client — four of its six throw sites are permanent config faults — and would dead-letter
|
|
164
|
+
every in-flight job the moment Postgres fails over. A code that means two things to two readers stays
|
|
165
|
+
unclassified until it is two codes. `errors-retry.test.ts` asserts the absence, so adding one is a
|
|
166
|
+
failing test first.
|
|
167
|
+
|
|
168
|
+
**What did NOT move down is core's `retry()` executor.** It stops on a `terminal` classification and
|
|
169
|
+
retries everything else, so an UNCLASSIFIED throw is retried — and the value caught here is a raw
|
|
170
|
+
driver error carrying a SQLSTATE, which core cannot see and nobody classified. Adopting it would
|
|
171
|
+
have re-run `fn` on a unique violation, a statement timeout and a throw from `fn` itself.
|
|
172
|
+
`isRetryableState` stays the guard, `40001`/`40P01` stays this package's Postgres knowledge, and only
|
|
173
|
+
the arithmetic is core's.
|
|
174
|
+
|
|
140
175
|
**`BEGIN` re-derives its isolation level from the closed set, `As of 2026-08-23`.** `BEGIN` takes
|
|
141
176
|
no parameters, so `beginStatement` is one of the two statements here built as TEXT — and the level
|
|
142
177
|
was `options.isolation.toUpperCase()` spliced into it. The TYPE is not the guard: the value reaches
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ await withTransaction(async (tx) => {
|
|
|
28
28
|
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
29
29
|
| `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
|
|
30
30
|
| `DbTx.origin` | `As of 2026-08`: the client the transaction was **opened on** — `options.client` or `baseClient()`, never the reservation it runs statements through. `@ultimat3/entity` compares a pinned repository's client against it, so a pinned repo joins its own shard's transaction instead of being refused |
|
|
31
|
-
| `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it |
|
|
31
|
+
| `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it. Each re-run **waits first**, `As of 2026-08-23`: exponential from 10ms, capped at 500ms, full jitter (`@ultimat3/core`'s `backoffDelay`). A budget of 0 waits not at all |
|
|
32
32
|
| `sqlState()` / `sqlStateCode()` / `isRetryableState()` / `SQLSTATE` | `As of 2026-08`: the SQLSTATE a driver error carries, and the closed table from it to a code. `Bun.SQL` puts it on `errno`; PGlite puts it on `code`; **one** reader answers for both |
|
|
33
33
|
| `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
|
|
34
34
|
| `statementsOf()` | `As of 2026-08`: a SQL script → the statements a driver sends one at a time. One send is one statement, so `migrate()` splits with this — a `;` inside a literal, an identifier, a dollar-quoted body or a comment is data |
|
|
@@ -316,6 +316,14 @@ always on the error and nothing read it, so a `23505` from two clicks racing a s
|
|
|
316
316
|
"cannot reach the database" and paged on-call for an outage that never happened. The table
|
|
317
317
|
(`sqlstate.ts`) is closed; everything outside it is still `X_DB_UNAVAILABLE`, unchanged.
|
|
318
318
|
|
|
319
|
+
**Four of them carry `retry: "retryable"` in `--json`, `As of 2026-08-23`** — `DB_ERROR_RETRY`:
|
|
320
|
+
`X_DB_SERIALIZATION_FAILURE`, `X_DB_LOCK_TIMEOUT`, `X_DB_POOL_EXHAUSTED`, `X_MIGRATE_CONCURRENT`.
|
|
321
|
+
Each is a resource that frees, so the same call has a real chance of a different answer with no edit
|
|
322
|
+
in between. Everything else keeps core's fail-closed `terminal`, and is deliberately left
|
|
323
|
+
UNREGISTERED rather than registered as terminal: `@ultimat3/jobs` dead-letters a registered
|
|
324
|
+
`terminal` on attempt 1, so classifying `X_DB_UNAVAILABLE` that way would dead-letter every in-flight
|
|
325
|
+
job the moment Postgres fails over.
|
|
326
|
+
|
|
319
327
|
| Code | Meaning |
|
|
320
328
|
|---|---|
|
|
321
329
|
| `X_DB_UNAVAILABLE` | no reachable database, or a SQLSTATE the table does not name; `fix:` names `DATABASE_URL` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.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": "11.
|
|
34
|
+
"@ultimat3/core": "11.2.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/errors.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// fixes the situation — `X_DB_DRIFT` is the flagship and its rendering is byte-for-byte
|
|
3
3
|
// pinned by the framework contract, so change its strings only with the contract.
|
|
4
4
|
|
|
5
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
5
6
|
import {
|
|
6
7
|
describeValue,
|
|
7
8
|
registerErrorCodes,
|
|
9
|
+
registerErrorRetry,
|
|
8
10
|
renderThrowable,
|
|
9
11
|
stringField,
|
|
10
12
|
UltimateError,
|
|
@@ -80,6 +82,60 @@ registerErrorCodes(
|
|
|
80
82
|
Object.fromEntries(Object.entries(DB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
81
83
|
);
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* May a client run this again? Unclassified means `terminal` — core fails closed — and until
|
|
87
|
+
* 2026-08-23 this package classified nothing, so `X_DB_SERIALIZATION_FAILURE` reached every HTTP
|
|
88
|
+
* client carrying `retry: "terminal"` while its own `fix:` line read
|
|
89
|
+
* `withTransaction(fn, { retry: 3 })`. The document contradicted the instruction beside it.
|
|
90
|
+
*
|
|
91
|
+
* The rule, stated once: **retryable means the same call, made again, has a real chance of a
|
|
92
|
+
* different answer, with no edit in between.** A resource that frees qualifies — a lock, a
|
|
93
|
+
* connection, a serialization race, another migrator's advisory lock. A statement about the data or
|
|
94
|
+
* about this deployment's configuration does not.
|
|
95
|
+
*
|
|
96
|
+
* **Only the exceptions are listed**, which is core's own `CORE_ERROR_RETRY` shape and not
|
|
97
|
+
* `@ultimat3/scraping`'s exhaustive one, for a reason specific to this package: a REGISTERED
|
|
98
|
+
* `terminal` is read by `@ultimat3/jobs` as "dead-letter on attempt 1"
|
|
99
|
+
* (`retry-classification.ts`), where an unclassified code keeps the attempt count. Registering
|
|
100
|
+
* `X_DB_UNAVAILABLE: 'terminal'` — defensible on the client side, since four of its six throw sites
|
|
101
|
+
* are permanent config faults — would therefore dead-letter every in-flight job the moment Postgres
|
|
102
|
+
* fails over, which is the opposite of what that code means to a worker. A code that means two
|
|
103
|
+
* things to two readers stays unclassified until it is two codes.
|
|
104
|
+
*/
|
|
105
|
+
export const DB_ERROR_RETRY = {
|
|
106
|
+
// The canonical try-again. Postgres aborted one of two transactions precisely SO one of them can
|
|
107
|
+
// be re-run, and `withTransaction(fn, { retry })` is the framework's answer to it.
|
|
108
|
+
X_DB_SERIALIZATION_FAILURE: 'retryable',
|
|
109
|
+
// `55P03 lock_not_available` — `lock_timeout` fired while somebody else held the lock. The
|
|
110
|
+
// blocker commits or rolls back and the lock is free; nothing about the statement was refused.
|
|
111
|
+
X_DB_LOCK_TIMEOUT: 'retryable',
|
|
112
|
+
// Both halves are "no connection RIGHT NOW": the server's `53300`/`53200` and this pool's own
|
|
113
|
+
// acquire timeout. A slot frees when an in-flight unit of work finishes. Not `retry-after`, which
|
|
114
|
+
// is the spelling for a responder that NAMED a time — nothing here carries `retryAfterSeconds`,
|
|
115
|
+
// and inventing a number for `Retry-After` would be a guess presented as an answer.
|
|
116
|
+
X_DB_POOL_EXHAUSTED: 'retryable',
|
|
117
|
+
// Another migrator holds the advisory lock and will let it go when it finishes. This is the one
|
|
118
|
+
// code here a deploy pipeline reads: a `ROLE=migrate` job that exits non-zero on it should come
|
|
119
|
+
// back, which is what `backoffLimit` already does, and `terminal` said not to.
|
|
120
|
+
X_MIGRATE_CONCURRENT: 'retryable',
|
|
121
|
+
} as const satisfies Readonly<Partial<Record<DbOwnedErrorCode, ErrorRetry>>>;
|
|
122
|
+
|
|
123
|
+
// `Partial`, so the table may be a SUBSET — but every key is still checked against the owned set, so
|
|
124
|
+
// a typo or a renamed code is a build error rather than a classification for a code nothing throws.
|
|
125
|
+
//
|
|
126
|
+
// Left to the fail-closed default, deliberately, each for its own reason:
|
|
127
|
+
// X_DB_UNAVAILABLE two failures in one code — see the note above
|
|
128
|
+
// X_DB_STATEMENT_TIMEOUT `57014`, and this package's fix for it is "add the index": an edit.
|
|
129
|
+
// The queued-behind-a-lock case has its own code, above
|
|
130
|
+
// X_DB_UNIQUE_VIOLATION the same row, the same constraint, the same refusal
|
|
131
|
+
// X_DB_FOREIGN_KEY_VIOLATION the parent it names is still gone
|
|
132
|
+
// X_DB_DRIFT, X_MIGRATION_*, X_SQL_UNSAFE, X_BRANCH_EXISTS
|
|
133
|
+
// author-time and deploy-time faults; every fix is a command or an edit
|
|
134
|
+
//
|
|
135
|
+
// The borrowed codes are absent for a different reason: `registerErrorRetry` refuses a code the
|
|
136
|
+
// framework owns, and `X_ENV_MISSING` / `X_INVARIANT` belong to core, which classifies its own.
|
|
137
|
+
registerErrorRetry(DB_ERROR_RETRY);
|
|
138
|
+
|
|
83
139
|
export interface DbErrorInit {
|
|
84
140
|
readonly code: DbErrorCode;
|
|
85
141
|
readonly cause: string;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single responsibility: how long a lost serialization race waits before `withTransaction` re-runs
|
|
2
|
+
// `fn`. The curve is `@ultimat3/core`'s — this file is the two constants, and the case for them.
|
|
3
|
+
|
|
4
|
+
import { backoffDelay, type Random } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A serialization conflict is CONTENTION, not overload, and the constants say so.
|
|
8
|
+
*
|
|
9
|
+
* `base: 10` — the transaction that won the race is already committing, and one round trip to a
|
|
10
|
+
* Postgres in the same network is under 2ms. A provider-outage base (`@ultimat3/ai` uses 500ms,
|
|
11
|
+
* `@ultimat3/jobs` a second) would put a `retry: 8` budget seconds past the deadline of the request
|
|
12
|
+
* it is serving, which turns a recovered transaction into a timed-out one.
|
|
13
|
+
*
|
|
14
|
+
* `max: 500` — this loop runs on a request's critical path, holding a connection nothing else can
|
|
15
|
+
* use. The eight-attempt worst case is under a second of waiting; a minute-long ceiling would be a
|
|
16
|
+
* queue's, and a queue can afford one because nobody is waiting on the other end.
|
|
17
|
+
*
|
|
18
|
+
* `full` jitter — the only mode that decorrelates. Two transactions that just deadlocked are, by
|
|
19
|
+
* construction, two callers whose retries would otherwise be scheduled at the same offset from the
|
|
20
|
+
* same event, and re-colliding is the failure this wait exists to prevent. `equal` keeps a latency
|
|
21
|
+
* floor for a client that must not be starved, which is not this.
|
|
22
|
+
*/
|
|
23
|
+
export const TRANSACTION_RETRY_BACKOFF = {
|
|
24
|
+
base: 10,
|
|
25
|
+
max: 500,
|
|
26
|
+
curve: 'exponential',
|
|
27
|
+
jitter: 'full',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Milliseconds before attempt `attempt + 1`. 1-based, like core's, and `random` is injected so the
|
|
32
|
+
* schedule is a list a test can assert rather than a range it can only sample.
|
|
33
|
+
*/
|
|
34
|
+
export function serializationRetryDelayMs(attempt: number, random?: Random): number {
|
|
35
|
+
return backoffDelay({
|
|
36
|
+
attempt,
|
|
37
|
+
base: TRANSACTION_RETRY_BACKOFF.base,
|
|
38
|
+
max: TRANSACTION_RETRY_BACKOFF.max,
|
|
39
|
+
curve: TRANSACTION_RETRY_BACKOFF.curve,
|
|
40
|
+
jitter: TRANSACTION_RETRY_BACKOFF.jitter,
|
|
41
|
+
random,
|
|
42
|
+
});
|
|
43
|
+
}
|
package/src/transaction.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// the transactional outbox is only atomic because `currentTx()` finds this store. Nesting maps
|
|
4
4
|
// to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work.
|
|
5
5
|
|
|
6
|
+
import type { Random } from '@ultimat3/core';
|
|
6
7
|
import { assert, asyncContext, nanoid } from '@ultimat3/core';
|
|
7
8
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
8
9
|
import { isolationLevelInvalid, serializationExhausted } from './errors';
|
|
9
10
|
import { raw, type SqlFragment } from './sql';
|
|
10
11
|
import { isRetryableState } from './sqlstate';
|
|
12
|
+
import { serializationRetryDelayMs } from './transaction-backoff';
|
|
11
13
|
|
|
12
14
|
export interface DbTx extends DbClient {
|
|
13
15
|
readonly id: string;
|
|
@@ -56,8 +58,17 @@ export interface TransactionOptions {
|
|
|
56
58
|
*
|
|
57
59
|
* **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
|
|
58
60
|
* `onRollback` undos fire before each retry, in reverse registration order.
|
|
61
|
+
*
|
|
62
|
+
* Each re-run waits first (`transaction-backoff.ts`). A budget of 0 waits not at all.
|
|
59
63
|
*/
|
|
60
64
|
readonly retry?: number | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* The wait between attempts, and the roll behind its jitter. Injected for one reason — a schedule
|
|
67
|
+
* provable only by waiting for it is a schedule no test pins — and production passes neither.
|
|
68
|
+
* They are only ever read when `retry` is 1 or more.
|
|
69
|
+
*/
|
|
70
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
71
|
+
readonly random?: Random | undefined;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
interface TxState {
|
|
@@ -263,6 +274,9 @@ export async function withTransaction<T>(
|
|
|
263
274
|
}
|
|
264
275
|
|
|
265
276
|
const attempts = (options.retry ?? 0) + 1;
|
|
277
|
+
// `Bun.sleep`, never a `node:timers` import: this is the runtime's own, and `migrate.ts` polls
|
|
278
|
+
// the advisory lock through the same call.
|
|
279
|
+
const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
|
|
266
280
|
let last: unknown;
|
|
267
281
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
268
282
|
try {
|
|
@@ -278,6 +292,12 @@ export async function withTransaction<T>(
|
|
|
278
292
|
// who has no budget.
|
|
279
293
|
if (attempts === 1) throw error;
|
|
280
294
|
last = error;
|
|
295
|
+
// Jittered, and only between attempts. Re-running instantly is what this loop did until
|
|
296
|
+
// 2026-08-23, and it is the deadlock reproduced rather than resolved: both losers wake in the
|
|
297
|
+
// same microsecond, take the same locks in the same order, and one of them loses again — so a
|
|
298
|
+
// budget of 8 was spent inside a single round trip's worth of wall clock. Nothing waits after
|
|
299
|
+
// the LAST attempt: there is nothing behind it to give the contention room for.
|
|
300
|
+
if (attempt < attempts) await sleep(serializationRetryDelayMs(attempt, options.random));
|
|
281
301
|
}
|
|
282
302
|
}
|
|
283
303
|
throw serializationExhausted(attempts, last);
|