Skip to content

pg: survive lost connections and open TLS directly - #689

Merged
demattosanthony merged 3 commits into
mainfrom
pg-resilient-connections
Oct 1, 2026
Merged

demattosanthony merged 3 commits into
mainfrom
pg-resilient-connections

Conversation

@quentinnippert

Copy link
Copy Markdown
Contributor

Summary

Three failures that took down long-running workers talking to a managed PostgreSQL over TLS:

Problem Before After
Connection dropped mid-transaction (failover, pg_terminate_backend, network) Process crash: null is not an object (evaluating 'socket.write'), thrown outside any promise The transaction rejects with the connection error; the process keeps running and the pool reconnects
Lock table full (SQLSTATE 53200) out of shared memory, which reads like the server ran out of RAM A [SixbPg] error naming the table, the setting that sizes it, and what to do
Large reads over TLS on Bun 1.4.0–1.4.2 Memory grows with every byte read, until the process is killed Opt-in direct TLS (sslnegotiation=direct) keeps memory flat

1. Transactions survive a lost connection

postgres.js 3.4.9 writes to a connection without checking that its socket is still open:

  • sql.begin answers a failed statement by sending ROLLBACK on the closed socket, and the process crashes;
  • reserve().release() hands a dead connection back to the pool, and the next query crashes the same way.

runPgTransaction now drives the transaction itself on a reserved connection:

reserve ─► BEGIN [ISOLATION …] ─► callback ─► COMMIT ─► release
                                   │ throws
                                   ├─ statement error ─► ROLLBACK ─► release ─► rethrow
                                   └─ connection lost ─► (send nothing, never release) ─► rethrow
  • "Lost" = porsager's closed/destroyed codes, socket resets, or any FATAL/PANIC server error (PostgreSQL always ends the session after those).
  • The pool replaces a closed connection on its own, so skipping release() loses nothing.
  • Migrations already ran their own transaction on a reserved connection; they now use the same helpers.
  • New guard: when a statement fails and the callback swallows the error, PostgreSQL answers COMMIT with a ROLLBACK tag, not an error. We used to report that as a success with the writes discarded; it now throws.

Known limit: a connection that drops between two statements still crashes, because nothing reports the loss before the next write. The upstream fix is porsager/postgres#1209.

2. Explicit error for a full lock table

[SixbPg] PostgreSQL aborted the transaction because its shared predicate lock table is full
(SQLSTATE 53200 "out of shared memory"). The table holds max_pred_locks_per_transaction ×
(max_connections + max_prepared_transactions) entries shared by all sessions. A long
serializable transaction also keeps the predicate locks of every serializable transaction
that commits while it is open. Raise max_pred_locks_per_transaction (applied at server
restart) or shorten the transactions that overlap.

The table is identified from PostgreSQL's hint. A 53200 without a lock hint is a real out-of-memory error and passes through unchanged.

3. Direct TLS, opt-in

postgresql://user:pass@host:5432/db?sslmode=require&sslnegotiation=direct
Classic (default) sslnegotiation=direct
Handshake SSLRequest on plain TCP, then tls.connect({ socket }) upgrade tls.connect({ host, port }) with ALPN postgresql
Round trips before TLS +1 0
Bun 1.4.0–1.4.2 keeps every received byte (+537 MB for 384 MB read) flat (~100 MB whatever the volume)
Server any PostgreSQL 17+
  • sslmode keeps its libpq meaning: require encrypts, verify-ca checks the chain, verify-full also checks the host name.
  • Misconfigurations fail when the client is created: an sslmode that could fall back to plaintext, several hosts, or a Unix socket.
  • A server that refuses direct TLS gets a [SixbPg] explanation, printed once, instead of a bare connection reset.

Why opt-in rather than "try direct, fall back": it matches libpq, and it leaves every existing deployment unchanged, including those behind a pooler or proxy. A fallback would add a probe and a second code path to work around a runtime bug that Bun 1.4.3 fixes.

Why the socket factory returns at once: with postgres.js 3.4.9, a factory that throws leaves its pool slot stuck forever, and a socket that is already dead stalls the slot's next attempt. Failures are reported through the socket's own error/close events, which porsager already handles.

Linked issue

None.

Scope

  • @sixb/pg only. No public contract changes beyond the documented connection-string parameter.
  • Left out on purpose:
    • a typed, retryable storage.unavailable failure. Nothing would read it yet; it comes with the run-lifecycle work, where attempts and last errors become visible.
    • a runtime guard on the Bun version. engines will move to >=1.4.3 once it is released.

Validation

  • New e2e pg-connection-loss: pg_terminate_backend mid-transaction, run in a child process so that a crash cannot take down the runner. Proven by reverting: both sql.begin and releasing a lost connection bring back the exact crash.
  • New e2e pg-direct-tls:
    • the session is encrypted;
    • verify-full rejects a self-signed certificate;
    • memory stays under half of what was read. Proven by reverting: the classic path fails it on Bun 1.4.2.
  • Unit tests for the emitted statements, lost-connection handling, the COMMIT/ROLLBACK guard, 53200, and the client configuration errors.
  • Checked by hand against PostgreSQL 16, which refuses direct TLS: the explanation is printed and the pool keeps working.
  • bun run test:e2e in storage/pg (411 pass), bun run typecheck, and bun run check.

Notes

  • Behavior change: if a callback catches a statement error and keeps using the transaction, it now gets PostgreSQL's own 25P02 (current transaction is aborted). sql.begin used to rethrow the first error at the end instead.
  • Remaining upstream issue: when the server kills a session during a query, postgres.js 3.4.9 replays that 57P01 onto the next query of the reconnected slot. One extra query fails (classified as a lost connection), then the pool is healthy. Settle startup queries when their connection keeps closing - fixes #1223, fixes #1193 porsager/postgres#1241 fixes only the startup variant.
  • Test database: it now runs with TLS on (the image's self-signed certificate); plain connections are still accepted.
  • Test-writing trap: expect(sqlQuery).rejects never runs a porsager query, because the query only starts when its then is called, and Bun's matcher then waits forever. The new tests catch such errors explicitly.

When PostgreSQL or the network dropped a connection in the middle of a
transaction, the whole process died with "null is not an object
(evaluating 'socket.write')". postgres.js 3.4.9 answers the failed
statement of sql.begin with a ROLLBACK written to the closed socket
from a setImmediate, outside any promise. reserve().release() has the
same flaw: it hands a dead connection back to the pool, and the next
query sent on it crashes the same way.

runPgTransaction now holds a reserved connection and sends BEGIN,
COMMIT and ROLLBACK itself. Once the connection is lost, nothing more
is sent on it and it is never released: the server has already rolled
the transaction back, and the pool replaces a closed connection on its
own. The transaction rejects with the connection error and the process
keeps running. Migrations, which already drove their own transaction
on a reserved connection, use the same helpers.

isConnectionLost replaces the unused isConnectionError: porsager's
closed or destroyed codes, socket resets, and any FATAL or PANIC server
error, after which PostgreSQL always ends the session.

A COMMIT that PostgreSQL answers with a ROLLBACK tag now fails. That
happens when a statement failed and the callback caught its error, and
it used to be reported as a success whose writes were gone.

The fix for the same bug in postgres.js is porsager/postgres#1209.
Until it ships, a connection lost between two statements still crashes,
because nothing reports the loss before the next deferred write.
When PostgreSQL's shared lock table fills up, it aborts the transaction
with "out of shared memory". The message reads like the server ran out
of RAM; only the hint says which table filled up. A long serializable
transaction hits this easily: it keeps the predicate locks of every
serializable transaction that commits while it is open, and the table
holds max_pred_locks_per_transaction × (max_connections +
max_prepared_transactions) entries for all sessions.

runPgTransaction now replaces that error with a [SixbPg] one that names
the table, the setting that sizes it, and what to do about it, keeping
the original as its cause. A 53200 whose hint names no lock setting is
a genuine out-of-memory and passes through unchanged.
porsager negotiates TLS the classic way: an SSLRequest on a plain TCP
socket, then tls.connect({ socket }) to upgrade it. On Bun 1.4.0-1.4.2
such an upgraded socket keeps every byte it receives in memory for
good, so a process that reads a lot from a TLS database grows until it
is killed. Measured here: +537 MB for 384 MB read, against a flat
~100 MB when TLS is opened directly.

A connection string with sslnegotiation=direct, the libpq parameter
for PostgreSQL 17+, now makes @sixb/pg open the TLS connection itself
with tls.connect({ host, port }) and the "postgresql" ALPN protocol.
That avoids the leaking path and saves the SSLRequest round trip on
every connection. sslmode keeps its libpq meaning: require encrypts,
verify-ca checks the chain, verify-full also checks the host name.

It is opt-in, like in libpq, rather than tried first with a fallback:
a fallback would change the negotiation for every deployment, including
those behind a pooler or proxy, to work around a runtime bug that Bun
1.4.3 fixes. Misconfigurations fail when the client is created: a
sslmode that could fall back to plaintext, several hosts, or a Unix
socket. A server that does not accept direct TLS gets a [SixbPg]
explanation instead of a bare connection reset.

The socket factory returns at once and never throws: with postgres.js
3.4.9, a factory that fails leaves its pool slot stuck for good, and a
socket that is already dead stalls the slot's next attempt.

The test database now runs with TLS on; plain connections are still
accepted.
@quentinnippert
quentinnippert added this pull request to stack #691 September 30, 2026 15:40
@demattosanthony
demattosanthony merged commit 7581e5e into main Oct 1, 2026
18 checks passed
@demattosanthony
demattosanthony deleted the pg-resilient-connections branch October 1, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants