@ultimat3/db
Advanced tools
+2
-2
| { | ||
| "name": "@ultimat3/db", | ||
| "version": "18.0.0", | ||
| "version": "19.0.0", | ||
| "description": "Postgres access, transactions, migrations and drift detection", | ||
@@ -34,3 +34,3 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "18.0.0" | ||
| "@ultimat3/core": "19.0.0" | ||
| }, | ||
@@ -37,0 +37,0 @@ "peerDependencies": { |
+46
-4
@@ -1,5 +0,7 @@ | ||
| // Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, and the | ||
| // lazy lookup of the global that provides it. Reached through a function so importing the client | ||
| // never touches `Bun` at module evaluation — the CLI imports it to print help. | ||
| // Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, the lazy | ||
| // lookup of the global that provides it, and the one safe way to hand a pinned connection back. | ||
| // Reached through a function so importing the client never touches `Bun` at module evaluation — | ||
| // the CLI imports it to print help. | ||
| import { logger, renderThrowable } from '@ultimat3/core'; | ||
| import { dbUnavailable } from './errors'; | ||
@@ -10,5 +12,45 @@ | ||
| unsafe(text: string, values?: readonly unknown[]): Promise<unknown>; | ||
| release(): void; | ||
| /** | ||
| * **Answers a PROMISE, and typing it `void` is what made both callers float it.** Measured on | ||
| * Bun 1.3.14 and 1.4.0 against a real server: `release()` returns a promise on both, and on | ||
| * 1.3.14 that promise REJECTS with `ERR_POSTGRES_CONNECTION_CLOSED` when the pool has already | ||
| * been closed. Nothing was attached to it, so it surfaced as an UNHANDLED REJECTION — which Bun | ||
| * takes the process down for. `unknown` rather than `Promise<void>` because a fake reserved | ||
| * connection returns nothing at all, and the caller has to handle both anyway. | ||
| */ | ||
| release(): unknown; | ||
| } | ||
| /** | ||
| * Hand a pin back, totally. The one place that knows `release()` answers a promise, so neither | ||
| * caller can forget it (axiom 1) — `client.ts`'s `DbConnection.release` and `pool-reserve.ts`'s | ||
| * late arrival both route here. | ||
| * | ||
| * A failed release is **best-effort, exactly where a throw would mask the error that caused it** — | ||
| * the rule this package already applies to `ROLLBACK`. `[Symbol.dispose]` is `DbConnection.release` | ||
| * itself, so a throw there replaces whatever error reached the `using` block, or invents one where | ||
| * the body succeeded. And the news is unactionable: the connection this would hand back is gone | ||
| * either way. | ||
| * | ||
| * Reachable, and reachable BECAUSE `close()` is bounded: an abandoned drain leaves every | ||
| * still-pinned connection to be released against a pool that no longer exists. | ||
| */ | ||
| export function releaseReserved(reserved: BunSqlReserved): void { | ||
| const report = (error: unknown): void => { | ||
| logger.debug('db.release_failed', { error: renderThrowable(error) }); | ||
| }; | ||
| let settled: unknown; | ||
| try { | ||
| settled = reserved.release(); | ||
| } catch (error) { | ||
| report(error); | ||
| return; | ||
| } | ||
| // `then` and not `instanceof Promise`: the value comes from the driver, and a thenable is the | ||
| // contract every await in this package already relies on. | ||
| if (typeof (settled as PromiseLike<unknown> | undefined)?.then === 'function') { | ||
| void (settled as PromiseLike<unknown>).then(undefined, report); | ||
| } | ||
| } | ||
| /** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */ | ||
@@ -15,0 +57,0 @@ export interface BunSqlDriver { |
+38
-4
@@ -8,3 +8,3 @@ // Single responsibility: the pooled Postgres client and the ambient `db()` handle — the lazy | ||
| import { type Role, resolveRole } from '@ultimat3/core'; | ||
| import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory } from './bun-sql'; | ||
| import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory, releaseReserved } from './bun-sql'; | ||
| import { connectionUrl } from './connection-url'; | ||
@@ -14,3 +14,3 @@ // Deliberate cycle, the same shape as `client.ts ⇄ transaction.ts`: nothing here is referenced at | ||
| import { defaultClient } from './default-client'; | ||
| import { DbError, driverError } from './errors'; | ||
| import { DbError, drainTimeout, driverError } from './errors'; | ||
| import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile'; | ||
@@ -125,3 +125,4 @@ import { reserveWithin } from './pool-reserve'; | ||
| held = false; | ||
| reserved.release(); | ||
| // Total by construction — `releaseReserved` owns the reason (`bun-sql.ts`). | ||
| releaseReserved(reserved); | ||
| }; | ||
@@ -147,3 +148,36 @@ return { | ||
| driver = undefined; | ||
| await pool?.close(); | ||
| if (pool === undefined) return; | ||
| // BOUNDED, `As of 2026-08-27`, and through the driver's OWN option rather than a race here. | ||
| // This was a bare `await pool.close()`, and `Bun.SQL`'s `end()` waits on an outstanding | ||
| // reserved connection without ever giving up — measured three runs per case on Bun 1.3.14 | ||
| // AND 1.4.0, no database outage involved (#394). So a role whose database went away | ||
| // mid-shutdown never finished shutting down, and the operator's only signal was a container | ||
| // that burned its whole termination grace period before SIGKILL. | ||
| // | ||
| // `BunSqlDriver.close` has declared `{ timeout }` since this port was written and NOTHING | ||
| // ever passed it — the capability was in the seam, unused, exactly like `setOfflineMode` on | ||
| // the CDP port. Measured with a reserve outstanding: `close({ timeout: 1 })` returns in | ||
| // ~1002ms on 1.3.14, 1.4.0 and 1.4.1-canary alike, where a bare `close()` never returns. | ||
| // | ||
| // **The unit is SECONDS**, not milliseconds. `timeout: 5000` would be an eighty-three minute | ||
| // shutdown budget, which is the same hang with extra steps. | ||
| if (profile.drainTimeoutMs === 0) { | ||
| // `migrate` and `replicator`, for `acquireTimeoutMs`' reason: a run-once role cutting off | ||
| // its own session mid-statement is worse than a slow exit. | ||
| await pool.close(); | ||
| return; | ||
| } | ||
| // `performance.now()`, never `Date.now()`, and the reason is this repo rather than NTP: the | ||
| // framework preload freezes `Date` for every test in the tree (`installDeterminism`), so a | ||
| // duration subtracted from `Date.now()` is 0 in all of them — the branch below could not | ||
| // fire, and the test asserting it would have been one that cannot fail. | ||
| const started = performance.now(); | ||
| await pool.close({ timeout: profile.drainTimeoutMs / 1000 }); | ||
| // The driver RESOLVES when it gives up — it does not reject — so the elapsed time is the only | ||
| // thing that separates "drained" from "abandoned". Reporting it is the point: a drain that | ||
| // silently gave up looks exactly like a clean one, and the work still in flight is lost with | ||
| // no line anywhere saying so. The pool is gone either way, which is why this is terminal. | ||
| if (performance.now() - started >= profile.drainTimeoutMs) { | ||
| throw drainTimeout(profile.drainTimeoutMs, role); | ||
| } | ||
| }, | ||
@@ -150,0 +184,0 @@ }; |
+20
-0
@@ -28,2 +28,3 @@ // The database layer's stable error codes. Every factory produces the exact command that | ||
| 'X_DB_POOL_EXHAUSTED', | ||
| 'X_DB_DRAIN_TIMEOUT', | ||
| 'X_DB_DRIFT', | ||
@@ -70,2 +71,3 @@ 'X_MIGRATION_CONFLICT', | ||
| X_DB_POOL_EXHAUSTED: 'no connection was available', | ||
| X_DB_DRAIN_TIMEOUT: 'the pool did not drain inside its shutdown budget', | ||
| X_DB_DRIFT: 'schema differs from migrations', | ||
@@ -244,2 +246,20 @@ X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build', | ||
| /** | ||
| * `close()` gave up waiting for the pool. TERMINAL, and deliberately not retryable: the pool is | ||
| * gone either way — `close()` clears the handle before it awaits — so a caller that retried would | ||
| * be closing a pool that no longer exists. What this reports is that connections were still held | ||
| * when the process stopped waiting, which is a fact about the shutdown an operator has to see. | ||
| * | ||
| * The alternative was to resolve quietly on the deadline, and that is the version that hides the | ||
| * bug: a drain that silently gave up looks exactly like a clean one, and the rows still in flight | ||
| * are lost with no line anywhere saying so. | ||
| */ | ||
| export const drainTimeout = (ms: number, role: string): DbError => | ||
| new DbError({ | ||
| code: 'X_DB_DRAIN_TIMEOUT', | ||
| cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`, | ||
| 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`, | ||
| meta: { drainTimeoutMs: ms, role }, | ||
| }); | ||
| /** | ||
| * The pool answered nothing inside `acquireTimeoutMs`. Distinct from the server's own `53300` and | ||
@@ -246,0 +266,0 @@ * deliberately the same code: to a caller both mean "there was no connection for this unit of |
+29
-4
@@ -1,2 +0,2 @@ | ||
| // Single responsibility: the five numbers a Postgres pool runs on — the per-role defaults, the one | ||
| // Single responsibility: the six numbers a Postgres pool runs on — the per-role defaults, the one | ||
| // environment override an operator may layer over them, and the screen every resolved profile | ||
@@ -28,2 +28,20 @@ // passes. Split from `client.ts`, which now owns connecting and nothing about sizing. | ||
| readonly acquireTimeoutMs: number; | ||
| /** | ||
| * How long `close()` may wait for the pool to drain before `X_DB_DRAIN_TIMEOUT`. 0 waits forever. | ||
| * | ||
| * **A drain that cannot finish is the failure this bounds, and it is not hypothetical.** Measured | ||
| * against a real Postgres, three runs per case: `Bun.SQL`'s `end()` waits on an outstanding | ||
| * RESERVED connection and never stops waiting — 3 of 3 on Bun 1.3.14 *and* 3 of 3 on 1.4.0, with | ||
| * no database outage involved at all. Once that connection's backend has been terminated it | ||
| * 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 | ||
| * variable; an unbounded await is (#394). | ||
| * | ||
| * What that cost, before this: `releaseQueue` awaits `db.close()`, so a role whose database went | ||
| * away mid-shutdown never finished shutting down. A container that will not drain is drained by | ||
| * SIGKILL, and the operator's only signal is a pod that took its full termination grace period. | ||
| * | ||
| * `migrate` and `replicator` wait forever, deliberately, for `acquireTimeoutMs`' reason: a | ||
| * run-once role cutting off its own session mid-statement is worse than a slow exit. | ||
| */ | ||
| readonly drainTimeoutMs: number; | ||
| } | ||
@@ -39,2 +57,3 @@ | ||
| acquireTimeoutMs: 5_000, | ||
| drainTimeoutMs: 5_000, | ||
| }, | ||
@@ -47,2 +66,3 @@ sync: { | ||
| acquireTimeoutMs: 5_000, | ||
| drainTimeoutMs: 5_000, | ||
| }, | ||
@@ -55,2 +75,3 @@ worker: { | ||
| acquireTimeoutMs: 10_000, | ||
| drainTimeoutMs: 15_000, | ||
| }, | ||
@@ -63,2 +84,3 @@ scheduler: { | ||
| acquireTimeoutMs: 10_000, | ||
| drainTimeoutMs: 5_000, | ||
| }, | ||
@@ -74,2 +96,3 @@ // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so | ||
| acquireTimeoutMs: 0, | ||
| drainTimeoutMs: 0, | ||
| }, | ||
@@ -82,2 +105,3 @@ replicator: { | ||
| acquireTimeoutMs: 0, | ||
| drainTimeoutMs: 0, | ||
| }, | ||
@@ -109,9 +133,9 @@ }); | ||
| /** | ||
| * The five numbers a pool runs on, screened on the MERGED profile — an override is spread over a | ||
| * The six numbers a pool runs on, screened on the MERGED profile — an override is spread over a | ||
| * role default the caller never restated, so the resolved object is the only one that can be | ||
| * judged. Every one of them is a plausible `Number(process.env.…)`, which is `NaN` for an unset | ||
| * variable and not nullish, so `??` and the spread both keep it. None of the five then fails | ||
| * variable and not nullish, so `??` and the spread both keep it. None of the six then fails | ||
| * loudly: `idleTimeout: NaN` goes to `Bun.SQL`, `statement_timeout=NaN` goes into the libpq | ||
| * options string for the SERVER to reject on connect, and a timer given `NaN` fires at 1ms in this | ||
| * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the three | ||
| * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the five | ||
| * budgets that document it as "no bound"; `max` is at least one connection, or nothing can run. | ||
@@ -132,3 +156,4 @@ */ | ||
| whole('acquireTimeoutMs', profile.acquireTimeoutMs, 0); | ||
| whole('drainTimeoutMs', profile.drainTimeoutMs, 0); | ||
| return profile; | ||
| } |
@@ -5,3 +5,3 @@ // Single responsibility: pinning a connection out of the pool under the profile's acquire deadline, | ||
| import type { BunSqlDriver, BunSqlReserved } from './bun-sql'; | ||
| import { type BunSqlDriver, type BunSqlReserved, releaseReserved } from './bun-sql'; | ||
| import { poolAcquireTimeout } from './errors'; | ||
@@ -46,3 +46,3 @@ import type { PoolProfile } from './pool-profile'; | ||
| (late) => { | ||
| if (expired) late.release(); | ||
| if (expired) releaseReserved(late); | ||
| }, | ||
@@ -49,0 +49,0 @@ () => undefined, |
Sorry, the diff of this file is too big to display
612724
1.58%8461
1.43%+ Added
+ Added
- Removed
- Removed
Updated