From 1ac184938bbe8fa2e40f9c3a41b594edaf7ce126 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 23 Oct 2026 12:49:55 +0100 Subject: [PATCH 1/3] test: handle misuse has its own codes, and a closed connection reports CONNECTION_CLOSED until the owner is done Reserved handles after release() expect CONNECTION_RELEASED, transaction handles after their transaction ended expect TRANSACTION_ENDED, and CONNECTION_ENDED stays for sql.end(). A new scenario pins that a transaction handle leaked from a callback that outlived its dropped connection gets TRANSACTION_ENDED, not CONNECTION_CLOSED. --- tests-startup/integration.js | 4 +-- tests-startup/lease.js | 52 ++++++++++++++++++++++++++++-------- tests/index.js | 22 ++++++++++++--- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/tests-startup/integration.js b/tests-startup/integration.js index 2e739baa..b9f1245c 100644 --- a/tests-startup/integration.js +++ b/tests-startup/integration.js @@ -348,7 +348,7 @@ async function finishedHandles(postgres, admin) { const held = await sql.reserve() await held`select 1 as x` held.release() - assert.strictEqual(await outcome(held`insert into lease_probe values (5)`), 'CONNECTION_ENDED') + assert.strictEqual(await outcome(held`insert into lease_probe values (5)`), 'CONNECTION_RELEASED') const leaks = [] await sql.begin(async t => { leaks.push(t) @@ -365,7 +365,7 @@ async function finishedHandles(postgres, admin) { }) await admin.unsafe(`rollback prepared 'lease_finished_${ process.pid }'`) for (const leak of [...leaks, ...prepared]) - assert.strictEqual(await outcome(leak`insert into lease_probe values (6)`), 'CONNECTION_ENDED') + assert.strictEqual(await outcome(leak`insert into lease_probe values (6)`), 'TRANSACTION_ENDED') assert.strictEqual((await admin`select count(*)::int as n from lease_probe`)[0].n, 0) assert.strictEqual((await sql`select 1 as x`)[0].x, 1) } finally { diff --git a/tests-startup/lease.js b/tests-startup/lease.js index da2c3776..319c7235 100644 --- a/tests-startup/lease.js +++ b/tests-startup/lease.js @@ -16,10 +16,11 @@ export const leaseNames = ['reserve-close', 'reserve-release-close', 'reserve-st 'settling-end-commit', 'settling-end-rollback', 'settling-end-prepare', 'settling-fail-rollback', 'settling-fail-prepare', 'outcome-commit-released', 'outcome-release-released', 'reserve-release-after-error', 'begin-late-drain', - 'begin-queued-reset', 'begin-inflight-end', 'begin-inflight-end-reset'] + 'begin-queued-reset', 'begin-inflight-end', 'begin-inflight-end-reset', 'begin-close-leaked'] const closed = 'rejected:CONNECTION_CLOSED' -const ended = 'rejected:CONNECTION_ENDED' +const released = 'rejected:CONNECTION_RELEASED' +const transactionEnded = 'rejected:TRANSACTION_ENDED' const select = (client, text) => client.unsafe(text, [], { simple: true }) const sent = (server, text) => server.events.filter(x => x.type === 'Q' && x.sql === text) @@ -120,8 +121,10 @@ export async function leases(name, postgres, onEvent) { const held = await sql.reserve() await marker(held) sockets[0].emit('error', new Error('socket failed')) + assert.strictEqual(await settle(select(held, 'select before release')), closed) + assert.strictEqual(sent(server, 'select before release').length, 0) held.release() - assert.strictEqual(await settle(select(held, 'select stale')), closed) + assert.strictEqual(await settle(select(held, 'select stale')), released) assert.strictEqual(sent(server, 'select stale').length, 0) await sleep(100) assert.strictEqual(await settle(marker(sql)), 'resolved') @@ -151,7 +154,7 @@ export async function leases(name, postgres, onEvent) { const second = await sql.reserve() const pool = settle(select(sql, 'select 44 as marker')) first.release() - assert.strictEqual(await settle(select(first, 'select stale')), ended) + assert.strictEqual(await settle(select(first, 'select stale')), released) assert.strictEqual((await select(second, 'select 42 as marker')).state.pid, pid) assert.strictEqual(sent(server, 'select 44 as marker').length, 0) assert.strictEqual(sent(server, 'select stale').length, 0) @@ -168,6 +171,21 @@ export async function leases(name, postgres, onEvent) { assert.strictEqual(await outcome, closed) assert.strictEqual(await settle(marker(sql)), 'resolved') assert.strictEqual(await settle(sql.end()), 'resolved') + } else if (name === 'begin-close-leaked') { + let leaked + const outcome = settle(sql.begin(async t => { + leaked = t + await marker(t) + server.disconnect() + await sleep(50) + })) + assert.strictEqual(await outcome, closed) + await sleep(100) + assert.strictEqual(await settle(select(leaked, 'select stale')), transactionEnded) + assert.strictEqual(await settle(leaked.savepoint(async() => undefined)), transactionEnded) + assert.strictEqual(sent(server, 'select stale').length, 0) + assert.strictEqual(await settle(marker(sql)), 'resolved') + assert.strictEqual(await settle(sql.end()), 'resolved') } else if (name === 'begin-stale') { let late let savepoint @@ -297,7 +315,7 @@ export async function leases(name, postgres, onEvent) { const held = await sql.reserve() await marker(held) held.release() - assert.strictEqual(await settle(select(held, 'select stale')), ended) + assert.strictEqual(await settle(select(held, 'select stale')), released) assert.strictEqual(sent(server, 'select stale').length, 0) assert.strictEqual(await settle(marker(sql)), 'resolved') assert.strictEqual(await settle(sql.end()), 'resolved') @@ -306,7 +324,7 @@ export async function leases(name, postgres, onEvent) { const results = ['select hang 1', 'select hang 2', 'select hang 3'].map(text => settle(select(held, text))) await until(() => sent(server, 'select hang 1').length === 1) held.release() - assert.deepStrictEqual(await Promise.all(results.slice(1)), [ended, ended]) + assert.deepStrictEqual(await Promise.all(results.slice(1)), [released, released]) assert.strictEqual(sent(server, 'select hang 2').length, 0) assert.strictEqual(await settle(sql.end({ timeout: 0 })), 'resolved') } else if (name === 'begin-leaked-commit' || name === 'begin-leaked-rollback' || name === 'begin-leaked-savepoint') { @@ -317,8 +335,8 @@ export async function leases(name, postgres, onEvent) { name === 'begin-leaked-rollback' && assert.fail('mine') })) assert.strictEqual(await outcome, name === 'begin-leaked-rollback' ? 'rejected:ERR_ASSERTION' : 'resolved') - assert.strictEqual(await settle(select(leaked, 'select stale')), ended) - assert.strictEqual(await settle(leaked.savepoint(async() => { /* Never entered. */ })), ended) + assert.strictEqual(await settle(select(leaked, 'select stale')), transactionEnded) + assert.strictEqual(await settle(leaked.savepoint(async() => { /* Never entered. */ })), transactionEnded) assert.strictEqual(sent(server, 'select stale').length, 0) assert.strictEqual(await settle(marker(sql)), 'resolved') assert.strictEqual(await settle(sql.end()), 'resolved') @@ -368,8 +386,8 @@ export async function leases(name, postgres, onEvent) { })) await until(() => server.heldStatement()) const pooled = settle(select(sql, 'select 43 as marker')) - assert.strictEqual(await settle(select(leaked, 'select late')), ended) - assert.strictEqual(await settle(leaked.savepoint(async() => undefined)), ended) + assert.strictEqual(await settle(select(leaked, 'select late')), transactionEnded) + assert.strictEqual(await settle(leaked.savepoint(async() => undefined)), transactionEnded) await sleep(30) assert.strictEqual(sent(server, 'select late').length, 0) assert.strictEqual(sent(server, 'select 43 as marker').length, 0) @@ -409,7 +427,7 @@ export async function leases(name, postgres, onEvent) { } else if (name === 'begin-leaked-savepoint-late') { let leaked assert.strictEqual(await settle(sql.begin(async t => { leaked = t.savepoint(async() => { await sleep(50) }) })), 'resolved') - assert.strictEqual(await leaked.then(() => 'resolved', e => e === null ? 'null' : e.code), 'CONNECTION_ENDED') + assert.strictEqual(await leaked.then(() => 'resolved', e => e === null ? 'null' : e.code), 'TRANSACTION_ENDED') assert.strictEqual(await settle(sql.end()), 'resolved') } else if (name.endsWith('-cancel-before-send')) { const cancelled = async client => { @@ -514,7 +532,9 @@ export async function leases(name, postgres, onEvent) { } else if (settling.length) { const [, mode, statement] = settling const first = (await select(sql, 'select 42 as marker')).state.pid + let leaked const outcome = settle(sql.begin(async t => { + leaked = t await marker(t) statement === 'prepare' && t.prepare('tx1') if (statement === 'rollback') @@ -522,6 +542,11 @@ export async function leases(name, postgres, onEvent) { })) await until(() => server.heldStatement()) const pooled = settle(sql`select 43 as marker`) + if (statement !== 'commit') { + assert.strictEqual(await settle(select(leaked, 'select late')), transactionEnded) + assert.strictEqual(await settle(leaked.savepoint(async() => undefined)), transactionEnded) + assert.strictEqual(sent(server, 'select late').length, 0) + } await sleep(5) let ending if (mode === 'disconnect') { @@ -535,6 +560,11 @@ export async function leases(name, postgres, onEvent) { } assert.strictEqual(await outcome, mode === 'disconnect' ? closed : mode === 'fail' ? 'rejected:40002' : statement === 'rollback' ? 'rejected:USER' : 'resolved') + if (mode === 'disconnect') { + assert.strictEqual(await settle(select(leaked, 'select after loss')), transactionEnded) + assert.strictEqual(await settle(leaked.savepoint(async() => undefined)), transactionEnded) + assert.strictEqual(sent(server, 'select after loss').length, 0) + } assert.strictEqual(await pooled, 'resolved') mode === 'fail' && assert.strictEqual(startups(server), 2) mode === 'fail' && assert.notStrictEqual((await select(sql, 'select 42 as marker')).state.pid, first) diff --git a/tests/index.js b/tests/index.js index 803355ec..124b7502 100644 --- a/tests/index.js +++ b/tests/index.js @@ -5500,7 +5500,7 @@ t('reserved dispose releases the connection', async() => { const code = await reserved`select 1`.catch(x => x.code) const [{ x }] = await sql`select 1 as x` await sql.end() - return ['CONNECTION_ENDED 1', code + ' ' + x] + return ['CONNECTION_RELEASED 1', code + ' ' + x] }) t('reserved asyncDispose releases the connection', async() => { @@ -5510,7 +5510,7 @@ t('reserved asyncDispose releases the connection', async() => { const code = await reserved`select 1`.catch(x => x.code) const [{ x }] = await sql`select 1 as x` await sql.end() - return ['CONNECTION_ENDED 1', code + ' ' + x] + return ['CONNECTION_RELEASED 1', code + ' ' + x] }) t('reserved dispose after release does not throw', async() => { @@ -5523,6 +5523,22 @@ t('reserved dispose after release does not throw', async() => { return [true, true] }) +t('handle misuse rejects with its own code, not a connection error', async() => { + const sql = postgres({ ...options, max: 2 }) + const reserved = await sql.reserve() + reserved.release() + let leaked + await sql.begin(async tx => { leaked = tx; await tx`select 1` }) + const shape = e => [e.code, e.message.startsWith(e.code + ': '), 'address' in e, 'port' in e].join(' ') + const afterRelease = await reserved`select 1`.catch(shape) + const afterCommit = await leaked`select 1`.catch(shape) + await sql.end() + return [ + 'CONNECTION_RELEASED true false false|TRANSACTION_ENDED true false false', + afterRelease + '|' + afterCommit + ] +}) + t('transaction and savepoint handles are not disposable', async() => { const sql = postgres({ ...options, max: 2 }) const reserved = await sql.reserve() @@ -5557,7 +5573,7 @@ t('using declarations end the pool and release the reservation', async() => { } const released = await handle\`select 1\`.catch(x => x.code) const [{ x }] = await sql\`select 1 as x\` - if (released !== 'CONNECTION_ENDED' || x !== 1) + if (released !== 'CONNECTION_RELEASED' || x !== 1) return 'reservation not released' } return await pool\`select 1\`.catch(x => x.code) From de96a295c9400453a518f7ebb33928ef06820e71 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 23 Oct 2026 12:49:55 +0100 Subject: [PATCH 2/3] fix: handle misuse rejects with TRANSACTION_ENDED or CONNECTION_RELEASED, not CONNECTION_ENDED A lease's state says what the handle is doing: Active (in use), Closed (in use, but its connection closed), Settling (a transaction finishing its COMMIT, ROLLBACK or PREPARE), Ended (a transaction that is over) or Released (a reservation that has been released). A transaction ends when its callback has finished, even if begin() already rejected because the connection closed. stop() records that and returns the connection only if the lease still holds it; the caller then releases or terminates it. No longer CONNECTION_ENDED: - a reserved handle used after release() or dispose: CONNECTION_RELEASED - a statement queued on a reserved handle when release() ran: CONNECTION_RELEASED - a transaction handle used after commit, rollback or prepare: TRANSACTION_ENDED - a transaction handle used while COMMIT, ROLLBACK or PREPARE TRANSACTION is in flight: TRANSACTION_ENDED - a savepoint on a transaction handle that started late or finished after the transaction ended: TRANSACTION_ENDED No longer CONNECTION_CLOSED: - a reserved handle used after release() when its connection had closed first: CONNECTION_RELEASED - a transaction handle used after its begin() callback finished when its connection had closed first: TRANSACTION_ENDED CONNECTION_ENDED now means only sql.end(): new queries, reserve() and begin() after it, and reserve() or begin() waiting when it was called. Closes #138 --- docs/api.md | 16 +++++++--- docs/lifecycle.md | 44 +++++++++++---------------- docs/upgrading.md | 2 +- src/index.js | 77 ++++++++++++++++++++++++++++++----------------- types/index.d.ts | 2 ++ 5 files changed, 82 insertions(+), 59 deletions(-) diff --git a/docs/api.md b/docs/api.md index b0425a33..bb31ed4a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -560,7 +560,7 @@ Use `sql.begin` to start a new transaction. Postgres.js will reserve a connectio `BEGIN` is automatically sent with the optional options, such as `'read write'` or `'isolation level serializable'`; characters other than letters and spaces are removed from them. If the callback throws or a query in it fails, `ROLLBACK` is sent and `sql.begin` rejects with that error, including when the query failed before reaching the server, such as one with an `undefined` value. If the server answers the `COMMIT` with `ROLLBACK`, for example after a failed query that was not awaited or a caught savepoint failure, `sql.begin` rejects with [`TRANSACTION_ROLLED_BACK`](#transaction_rolled_back) and nothing is committed. -The scoped `sql` only works inside the callback. Statements on it after the transaction has finished, including statements sent while its `COMMIT` or `ROLLBACK` is in flight, reject with `CONNECTION_ENDED`, and statements after its connection closed reject with `CONNECTION_CLOSED`. +The scoped `sql` only works inside the callback. Statements on it after the transaction has finished, including statements sent while its `COMMIT` or `ROLLBACK` is in flight, reject with [`TRANSACTION_ENDED`](#transaction_ended), and statements after its connection closed reject with `CONNECTION_CLOSED` for as long as the callback is still running. ```js const [user, account] = await sql.begin(async sql => { @@ -1507,7 +1507,7 @@ using reserved = await sql.reserve() await reserved`select * from users` ``` -After release, queries through that reserved handle reject with [`CONNECTION_ENDED`](#connection_ended). Call `sql.reserve()` again to acquire a new handle. A reserved handle whose connection has closed rejects with `CONNECTION_CLOSED` and cannot be reused after reconnect. +After release, queries through that reserved handle reject with [`CONNECTION_RELEASED`](#connection_released). Call `sql.reserve()` again to acquire a new handle. A reserved handle whose connection has closed rejects with `CONNECTION_CLOSED` until it is released, and cannot be reused after reconnect. ## Error handling @@ -1582,9 +1582,17 @@ This error is thrown if the connection was closed, or could not be opened, witho ### CONNECTION_ENDED > write CONNECTION_ENDED host:port -This error is thrown if the user has called [`sql.end()`](#teardown--cleanup) and performed a query afterward. +This error is thrown if the user has called [`sql.end()`](#teardown--cleanup) and then starts a query, `sql.reserve()` or `sql.begin()`, or one of the last two was still waiting for a connection when `sql.end()` was called. A reserved handle or transaction handle that is already held is not affected: it keeps working until it is released or its transaction finishes. If `sql.end({ timeout })` closes its connection first, a statement in flight and `sql.begin()` reject with `CONNECTION_DESTROYED`, and statements sent afterwards reject with `CONNECTION_CLOSED`. Before 4.7.0 it was also raised for the handle misuse described under [`TRANSACTION_ENDED`](#transaction_ended) and [`CONNECTION_RELEASED`](#connection_released). -A reserved `sql` used after `release()`, or a transaction `sql` used after `begin()` has committed, rolled back or prepared, or while its `COMMIT` or `ROLLBACK` is in flight, also rejects with `CONNECTION_ENDED`. +### TRANSACTION_ENDED +> TRANSACTION_ENDED: Transaction handle used after its transaction ended + +A statement was sent on a `sql.begin()` handle, or a savepoint handle in it, after the transaction had committed, rolled back or prepared, or while its `COMMIT`, `ROLLBACK` or `PREPARE TRANSACTION` is in flight. The statement is not sent. A savepoint whose callback was still running when the outer transaction ended rejects with it too, though no statement was sent. This also holds after the callback finished on a transaction whose connection had closed. This is a bug in the calling code, for example a handle that escaped the callback or a query that was not awaited, so fix it rather than retry it. Since 4.7.0; before, `CONNECTION_ENDED`, or `CONNECTION_CLOSED` when the connection had closed before the callback finished. + +### CONNECTION_RELEASED +> CONNECTION_RELEASED: Reserved connection used after release() + +A statement was sent on a [`sql.reserve()`](#reserving-connections) handle after `release()`, or after it was disposed. Statements sent before `release()` that were still waiting behind an earlier one reject with it as well. This holds even if the connection had closed before `release()`. The statement is not sent, because the connection may already belong to someone else. This is a bug in the calling code, so fix it rather than retry it; call `sql.reserve()` again for a new handle. Since 4.7.0; before, `CONNECTION_ENDED`, or `CONNECTION_CLOSED` when the connection had closed before `release()`. ### CONNECTION_DESTROYED > write CONNECTION_DESTROYED host:port diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 2e9b1197..b2998681 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -140,37 +140,29 @@ An asynchronous result can arrive after the phase or attempt that started it is ## Leases -`reserve()` and `begin()` take a connection out of the pool through a lease. The lease is the only owner of that connection until it settles. +`reserve()` and `begin()` take a connection out of the pool through a lease. The lease's state says what the handle is doing now. -| State | Entered by | Meaning | +| State | The handle is | A statement on it | |---|---|---| -| Active | `own(c, l)`, before `reserve()`/`begin()` resolve | The connection is owned; statements run, or wait in the lease's queue under backpressure. | -| Settling | `begin()` sending COMMIT, ROLLBACK or PREPARE TRANSACTION | Only that statement may run. | -| Released | `settle(l, Released)` | Terminal. The connection returns to the pool. | -| Discarded | `settle(l, Discarded)` | Terminal. The connection is terminated, so the server rolls back anything open. | -| Closed | `settle(l, Closed, err)` from the pool's `onclose` | Terminal. The connection is already gone; the lease only records why. | +| Active | in use | Runs, or waits in the lease's queue while the connection is `full` or not accepting; rejects with `COPY_IN_PROGRESS` while the connection's COPY stream is open | +| Closed | in use, but its connection closed | `CONNECTION_CLOSED` | +| Settling | a transaction finishing its COMMIT, ROLLBACK or PREPARE TRANSACTION; only that statement runs | `TRANSACTION_ENDED` | +| Ended | a transaction that is over | `TRANSACTION_ENDED` | +| Released | a reservation that has been released | `CONNECTION_RELEASED` | -`settle` does nothing for a lease that is already terminal. Otherwise it clears the connection's owner, rejects every statement still waiting in the lease's queue, and then releases the connection, terminates it, or records the error. +A reservation ends at `release()` or dispose. A transaction ends when its callback has finished, even if `begin()` already rejected because the connection closed. Statements still waiting in the lease's queue when it ends reject with its code, and so does a savepoint callback still running when its transaction ended. -Who decides the outcome: +`own(c, l)` makes a lease Active before `reserve()` or `begin()` resolve. `finish()` makes an Active transaction lease Settling. When a connection closes, the pool's `onclose` marks an Active lease Closed, records the error, clears the connection's owner, rejects the statements waiting in the lease's queue with `CONNECTION_CLOSED` and tells `begin()`, which rejects with that error: `CONNECTION_CLOSED` with the FATAL or socket error as `cause`, or `CONNECTION_DESTROYED` after a forced `end({ timeout })` with a statement in flight. -| Situation | Outcome | -|---|---| -| `sql.release()` on a reserved handle | Released | -| `begin()`: COMMIT, ROLLBACK or PREPARE TRANSACTION succeeded | Released | -| `begin()`: the server refused BEGIN with a `PostgresError` | Released; the connection is healthy | -| `begin()`: the settle statement failed | Discarded, and the error is rethrown | -| `begin()`: any other way out of the callback | Discarded (no effect if the lease is already terminal) | -| The connection closed while owned | Closed; `begin()` rejects with `CONNECTION_CLOSED`, whose `cause` is the FATAL or socket error | - -What a statement sent on a lease handle gets: +Ending a lease goes through `stop(l, state)`, which sets Ended or Released, rejects the statements still waiting in its queue, and returns the connection only if the lease still holds it. The caller then returns the connection to the pool with `c.release()` or closes it with `c.terminate()`: -| Lease state | Result | -|---|---| -| Active | Runs, or waits in the lease's queue while the connection is `full` or not accepting; rejects with `COPY_IN_PROGRESS` while the connection's COPY stream is open | -| Settling | `CONNECTION_ENDED`, except the settle statement itself | -| Released, Discarded | `CONNECTION_ENDED` | -| Closed | `CONNECTION_CLOSED` | +| Situation | State | Connection | +|---|---|---| +| `sql.release()` or dispose on a reserved handle | Released | returned to the pool | +| `begin()`: COMMIT, ROLLBACK or PREPARE TRANSACTION succeeded | Ended | returned to the pool | +| `begin()`: the server refused BEGIN with a `PostgresError` | Ended | returned to the pool; the session is healthy | +| `begin()`: the settle statement failed | Ended | closed, and the error is rethrown | +| `begin()`: the callback finished in any other way | Ended | closed, so the server rolls back anything open | A query records its owner when it is sent. `UNSAFE_TRANSACTION` is decided by that owner, so a raw `BEGIN` sent through the pool rejects even if a lease takes the same connection before it completes. A prepared statement is retried after a plan-invalidation error only under the same owner it was sent with. @@ -201,7 +193,7 @@ The connection drives its own queue through four callbacks: | `onopen(c, request)` | hand-off; a ReadyForQuery with nothing pipelined and no owner; a released lease; socket `drain` | hands `c` to a waiting reservation, files it in `open`, or runs the request and up to its share of queued queries and files it `busy` or `full` | | `onend(c)` | the connection starts ending | files it in `ended` | | `ondrain(c)` | a Draining connection has gone idle | if the pool is ending, runs queued plain queries on it (rejecting queued reservations with `CONNECTION_ENDED`) and keeps it Draining; otherwise lets it close | -| `onclose(c, err)` | `enterClosed` | files it in `closed`, settles its lease as Closed, and either starts the next queued request on it or, while ending, handles the queue as described below | +| `onclose(c, err)` | `enterClosed` | files it in `closed`, marks its lease Closed, and either starts the next queued request on it or, while ending, handles the queue as described below | **User callbacks.** Filing a connection in `open` starts its idle timer and calls the user's `onidle(c.id)`. The internal listen and subscribe pools do not inherit `onidle`. These user callbacks (`onidle`, `onclose`, `onnotice`, `onparameter`, `onnotify`, `debug`, and the `sql.listen()` and `sql.subscribe()` callbacks and `onlisten`, `onsubscribe` and `onerror`) go through the `callback` helper in `src/errors.js`, which calls them synchronously and rethrows anything they throw in a microtask, so a user error cannot reach the phase table. A transform or type parser that throws while `src/subscribe.js` decodes a change is caught there: the change is skipped and the error goes to the matching subscribers' `onerror`, so it does not reach `protocolError`, unless the change's relation is unknown or the message is not an insert, update or delete, in which case the stream restarts as before. The error is rethrown as an uncaught exception when a matching subscription has no `onerror`. diff --git a/docs/upgrading.md b/docs/upgrading.md index 285541b1..2e1a9e2f 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -47,7 +47,7 @@ This covers Sentry's runtime hook, not its bundler plugins. With pnpm, add `@sen What changes for code written against Postgres.js 3.4 or `@steadybrew/postgresjs` 3.x. Version 4.0.0 holds the breaking changes; the later items are behaviour changes in 4.x releases. - **Node.js compatibility.** Tested on Node.js 22, 24 and 26. Older versions may work but aren't tested; the package declares `engines.node: >=22`, so package managers may warn or reject installation on older versions. With TypeScript, the declarations need `@types/node` 22 or newer, or `skipLibCheck`. -- **Handles stay with their owner.** A reserved handle used after `release()`, and a transaction handle used after its transaction finished or while its `COMMIT` is in flight, rejects with `CONNECTION_ENDED`. A handle whose connection closed rejects with `CONNECTION_CLOSED` instead of running on a reconnected session. +- **Handles stay with their owner.** A reserved handle used after `release()`, and a transaction handle used after its transaction finished or while its `COMMIT` is in flight, rejects with `CONNECTION_RELEASED` for a reservation and `TRANSACTION_ENDED` for a transaction. 4.0 to 4.6 used `CONNECTION_ENDED` for both, so if you retry on `CONNECTION_ENDED`, these now fall outside that set, which is right: they are bugs to fix, not to retry. `CONNECTION_ENDED` now means only that `sql.end()` was called. Since 4.7.0. A handle whose connection closed rejects with `CONNECTION_CLOSED` instead of running on a reconnected session, until its reservation is released or its transaction callback has finished; 4.0 to 4.6 kept `CONNECTION_CLOSED` after that, and 4.7.0 gives the codes above. - **`sql.prepare(name)`** in a transaction takes a non-empty string without NUL characters; other names throw `INVALID_TRANSACTION_NAME`. - **`fetch_types: false`** now decodes built-in arrays as JavaScript arrays instead of raw strings. SQL `NULL` elements become `null`; quoted `"NULL"` text remains a string. - **Callbacks that throw** (`onnotice`, `onparameter`, `onnotify`, `onclose`, `onidle`, `debug`, `backoff`, and the `sql.listen()` and `sql.subscribe()` callbacks) surface as uncaught exceptions, which end the process under Node's default handling. Since 4.4.0. diff --git a/src/index.js b/src/index.js index bb8a5c0f..a91c5ef1 100644 --- a/src/index.js +++ b/src/index.js @@ -29,7 +29,7 @@ import { Errors, PostgresError, callback } from './errors.js' import Subscribe from './subscribe.js' import largeObject from './large.js' -const Lease = { Active: 0, Settling: 1, Released: 2, Discarded: 3, Closed: 4 } +const Lease = { Active: 0, Settling: 1, Released: 2, Ended: 3, Closed: 4 } Object.assign(Postgres, { PostgresError, @@ -301,35 +301,40 @@ function Postgres(a, b) { ? c.copying || (c.queue !== full && c.accepting) ? c.execute(q) || move(c, full) : (move(c, full), q.queue = l.queue, l.queue.push(q)) - : q.reject(leaseError(l.state)) + : q.reject(leaseError(l)) } - function leaseError(state) { - return Errors.connection(state === Lease.Closed ? 'CONNECTION_CLOSED' : 'CONNECTION_ENDED', options) + function leaseError(l) { + return l.state === Lease.Closed + ? Errors.connection('CONNECTION_CLOSED', options) + : l.state === Lease.Released + ? Errors.generic('CONNECTION_RELEASED', 'Reserved connection used after release()') + : Errors.generic('TRANSACTION_ENDED', 'Transaction handle used after its transaction ended') } - function settle(l, outcome, err) { - if (l.state === Lease.Released || l.state === Lease.Discarded || l.state === Lease.Closed) - return + function stop(l, state) { + if (l.state === Lease.Released || l.state === Lease.Ended) + return null - const c = l.connection - l.state = outcome - c.owner = null + l.state = state while (l.queue.length) - l.queue.shift().reject(leaseError(outcome)) + l.queue.shift().reject(leaseError(l)) - if (outcome === Lease.Closed) { - l.error = err || Errors.connection('CONNECTION_CLOSED', options) - return l.onclose && l.onclose(l.error) - } + const c = l.connection + if (c.owner !== l) + return null - outcome === Lease.Released ? c.release() : c.terminate() + c.owner = null + return c } async function reserve() { const l = await lease(false) const sql = Sql(handler) - sql.release = () => settle(l, Lease.Released) + sql.release = () => { + const c = stop(l, Lease.Released) + c && c.release() + } Symbol.dispose && (sql[Symbol.dispose] = () => sql.release()) Symbol.asyncDispose && (sql[Symbol.asyncDispose] = async() => sql.release()) sql.stats = stats @@ -347,16 +352,13 @@ function Postgres(a, b) { let savepoints = 0 , prepare = null - try { - const closing = new Promise((_, reject) => l.onclose = reject) - return await Promise.race([transaction(), closing]) - } finally { - settle(l, Lease.Discarded) - } + const closing = new Promise((_, reject) => l.onclose = reject) + const work = transaction().finally(terminate) + return await Promise.race([work, closing]) async function transaction() { await Sql(handler).unsafe('begin ' + options.replace(/[^a-z ]/ig, ''), []).execute().catch(e => { - e instanceof PostgresError && settle(l, Lease.Released) + e instanceof PostgresError && release() throw e }) return scope(fn) @@ -368,13 +370,23 @@ function Postgres(a, b) { try { result = await statement(Sql(q => handler(q, Lease.Settling))) } catch (e) { - settle(l, Lease.Discarded) + terminate() throw e } - settle(l, Lease.Released) + release() return result } + function release() { + const c = stop(l, Lease.Ended) + c && c.release() + } + + function terminate() { + const c = stop(l, Lease.Ended) + c && c.terminate() + } + async function scope(fn, name) { const sql = Sql(track) sql.savepoint = savepoint @@ -405,7 +417,7 @@ function Postgres(a, b) { } if (l.state !== Lease.Active) - throw l.error || leaseError(l.state) + throw l.state === Lease.Closed ? l.error : leaseError(l) if (!name) { const done = await finish(final => prepare @@ -626,7 +638,16 @@ function Postgres(a, b) { function onclose(c, e) { move(c, closed) - c.owner && settle(c.owner, Lease.Closed, e) + const l = c.owner + if (l) { + l.state === Lease.Active && (l.state = Lease.Closed) + l.error = e || Errors.connection('CONNECTION_CLOSED', options) + c.owner = null + while (l.queue.length) + l.queue.shift().reject(Errors.connection('CONNECTION_CLOSED', options)) + + l.onclose && l.onclose(l.error) + } callback(options.onclose, c.id) if (ending) return closedWhileEnding(c, e) diff --git a/types/index.d.ts b/types/index.d.ts index b96a7f3e..7574d4b0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -488,6 +488,8 @@ declare namespace postgres { | 'TLS_CLOSED' | 'COPY_IN_PROGRESS' | 'INVALID_TRANSACTION_NAME' + | 'TRANSACTION_ENDED' + | 'CONNECTION_RELEASED' | 'TARGET_SESSION_ATTRS' | 'UNSAFE_TRANSACTION' | 'TRANSACTION_ROLLED_BACK'; From 5264e6a9e082ac33417098ce552f3a497a114fb2 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 23 Oct 2026 12:49:55 +0100 Subject: [PATCH 3/3] feat(codemod): advice names TRANSACTION_ENDED and CONNECTION_RELEASED, and connection-ended-check is info Handle misuse no longer raises CONNECTION_ENDED, so a retry set that lists it does not need the misuse codes. The README review examples drop the CONNECTION_ENDED mention. --- codemod/README.md | 2 +- codemod/src/changes.mjs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codemod/README.md b/codemod/README.md index b08b64d9..6cc62ef6 100644 --- a/codemod/README.md +++ b/codemod/README.md @@ -16,7 +16,7 @@ Requires Node.js 18 or newer to run. The fork declares Node.js 22 or newer; olde Every finding has a file and line, grouped by what to do about it: - **Change before switching:** the project would break or behave differently. Examples: Node.js older than 22 in `engines`, `.nvmrc` or a Dockerfile; a `postgres@3.4.9` patch; a package your code imports that brings its own Postgres.js; `PGSSL=disable`, which 3.4.9 read as "require TLS" and the fork reads as "no TLS". -- **Review:** code whose behaviour changes in a way that may matter. Examples: a reserved handle used after `release()`; `fetch_types: false`, which now decodes arrays; code that checks for `CONNECTION_ENDED`; a CI job on Node.js older than 22. +- **Review:** code whose behaviour changes in a way that may matter. Examples: a reserved handle used after `release()`; `fetch_types: false`, which now decodes arrays; a CI job on Node.js older than 22. - **Worth knowing:** changes that need no action, or workarounds the fork makes unnecessary. Examples: `prepare: false` behind a pooler; `Promise.race` timeouts; callbacks that throw, which now surface as uncaught exceptions. `--json` prints the findings and the facts behind them (clients, ORMs, tracers, lockfiles) instead. diff --git a/codemod/src/changes.mjs b/codemod/src/changes.mjs index f9adb836..2933d407 100644 --- a/codemod/src/changes.mjs +++ b/codemod/src/changes.mjs @@ -47,13 +47,13 @@ export const changes = { level: 'review', since: '4.0.0', title: 'A reserved connection is used after release()', - advice: 'Queries on a released handle reject with CONNECTION_ENDED instead of running on whatever session the connection has next.' + advice: 'Queries on a released handle reject with CONNECTION_RELEASED (CONNECTION_ENDED before 4.7.0) instead of running on whatever session the connection has next.' }, 'transaction-handle-escapes': { level: 'review', since: '4.0.0', title: 'A transaction handle may be used after sql.begin() returns', - advice: 'Statements on a finished transaction handle reject with CONNECTION_ENDED instead of running outside the transaction. Keep them inside the callback and await them.' + advice: 'Statements on a finished transaction handle reject with TRANSACTION_ENDED (CONNECTION_ENDED before 4.7.0) instead of running outside the transaction. Keep them inside the callback and await them.' }, 'transaction-prepare-name': { level: 'review', @@ -86,10 +86,10 @@ export const changes = { advice: 'Postgres.js still logged them with console.log.' }, 'connection-ended-check': { - level: 'review', + level: 'info', since: '4.0.0', title: 'Code checks for CONNECTION_ENDED', - advice: 'On 3.4.9 this code meant sql.end() was called. The fork also raises it for a query on a reserved connection after release(), on a transaction handle after the transaction ended, and for work sent while COMMIT runs. Code that reads it as "the pool is shutting down", or retries it as transient, now also sees these programming errors.' + advice: 'On the fork this still means only that sql.end() was called, as on 3.4.9. A reserved connection used after release() rejects with CONNECTION_RELEASED and a transaction handle used after its transaction ended with TRANSACTION_ENDED. Those are bugs to fix, so keep them out of any retry set.' }, 'postgres-via-dependency': { level: 'act',