fix(deps): keep the backend alive when a Postgres server process dies - #4061
Conversation
postgres.js 3.4.7 (and 3.4.9) ends the process with an uncaught "TypeError: Cannot read properties of null (reading 'write')" when a PostgreSQL server process dies under an open transaction or a reserved connection. closed() nulls the socket, then begin()'s ROLLBACK or the reservation's next statement is written to it from an Immediate (#4041, porsager/postgres#1154, #1208). Patch the pinned 3.4.7 through the root patchedDependencies, in each build the package ships (src/, cjs/src/, cf/src/): - connection.js: porsager/postgres#1209's nextWrite guard, verbatim; the write rejects the pending statements with CONNECTION_CLOSED. - index.js: a transaction's or reservation's statement runs only while it still holds its connection, else it rejects with CONNECTION_CLOSED, and release() no longer hands back a reservation whose connection closed. With the guard alone a one-connection pool hung or kept failing on the dead connection, and an idle transaction's next statement ran on the reconnected session outside the transaction, committing on its own. Every Dockerfile that installs from the root manifests copies patches/ (bun install refuses a missing patch). The platform's test task hashes patches/. connection-loss.test.ts runs postgres.js in a child process through its ESM and CommonJS builds against a fake server that drops the connection like a killed server process; it fails on the unpatched package and on the #1209 guard alone. patches/README.md records why, the proof and when to remove the patch.
Review follow-up on the postgres.js 3.4.7 patch (#4041). Pre-existing loss paths still broke its bar: every pending statement settles, nothing hangs, no connection is stranded or answers for another's statement. - closed() settles pending statements after a socket error too. On a reset the error event fires before the close, begin()'s ROLLBACK is written in between, and left in `sent` it took the reconnected connection's first answer. closed() also drops a cancelled write with its timer, which kept the next StartupMessage from being sent, and clears the settled `query` that kept sql.end() from ever resolving. - Statements queued in begin()'s or reserve()'s local queue behind a busy connection reject when it closes: a ROLLBACK queued there hung on the 57P01 path of transactSerializable's queued retry. - release() changes nothing once the reservation no longer holds its connection; it used to clear the next holder's c.reserved. connection-loss.test.ts adds a reset and a queued-statement case, answers nothing after FATAL as a real server process does, runs the cut cases on one connection and requires the ended pool to close by itself. The README now drops the false claim that the platform always ends a pool with a timeout (sandbox/service.ts does not).
|
CI on the exact head Passed:
Failed: UI docs container test (job 110637687411), cancelled; not caused by this change.
My dispatch allows no re-run of this check from me, so I leave it to the reviewer or a maintainer. |
|
Independent review: accepted at tree
Verdict: the fix is right and needed. In a real Evidence
Non-blocking (P3), for follow-up
Also known, and the author's to route: Not covered: no production contact. I didn't drive a browser, because the change has no UI surface. |
What changed
postgres.js 3.4.7, our pin, crashes the backend process when a PostgreSQL server process dies under an open transaction or a reserved connection. 3.4.9, the latest release, does the same.
sql.begin, so alsotransactSerializable; a reserved connection meanssql.reserve.docker kill.TypeError: Cannot read properties of null (reading 'write')atconnection.jsnextWrite.closed()nulls the socket, and thenbegin()'sROLLBACK, or the reservation's next statement, is written to it from an Immediate.This PR patches the pinned
postgres@3.4.7through the rootpatchedDependencies(bun patch, the option chosen on #4041). Each build the package ships gets the same change:src/(import, Bun),cjs/src/(require) andcf/src/(workerd).connection.jsnextWrite: Guard nextWrite against a closed socket - fixes #1208 porsager/postgres#1209's guard, verbatim. A write to a closed connection rejects the pending statements withCONNECTION_CLOSEDinstead of throwing.connection.jsclosed(), beyond Customer list combines name and email in a single column instead of showing them separately #1209 (markedTale:):query.index.js, beyond Customer list combines name and email in a single column instead of showing them separately #1209 (markedTale:):c.reservedis still its own); otherwise it rejects withCONNECTION_CLOSED.begin()'s orreserve()'s local queue reject when the connection closes.release()changes nothing once the reservation no longer holds its connection.Each "beyond #1209" hunk has a case in the regression suite that fails without it (see the map below).
Also in this PR:
patches/: the platform'sworkspace-depsand pruner stages, web, docs, ui-docs, ai-gateway and the plop service template.bun installrefuses to run without the patch file.services/platform/turbo.jsonhashespatches/**fortest, and the turbo-inputs guard knows about it.patches/README.mdrecords the why, the proof, the behaviour change and the removal condition..agents/repo.mdstates the rule.No
uncaughtExceptionfilter: strict uncaught-exception handling is unchanged.Remove the patch when a postgres.js release fixes the write to a closed connection, what
closed()leaves pending, and the pool's hand-back of a closed connection. Then:services/platformandpackages/shared;patchedDependenciesentry;connection-loss.test.ts, which must pass on that release unpatched.Finding → commit → test
All tests are in
services/platform/backend/db/connection-loss.test.ts. Each runs through both the ESM and the CJS build, and the suite asserts which file each build loaded.c782a4d01fulland a one-connection pool hangsc782a4d01['stray']in the table)c782a4d01release()hands the dead connection back and every later statement failsc782a4d01ROLLBACKis written between the'error'and'close'events.closed(true)never settles it, so it takes the reconnected connection's first answer and the pool hangs. A cancelled write's timer also stayed set47ba92bddtransactSerializable's queued retry47ba92bddsql.end()never resolves after a loss: the closed connection kept its settled statement.sandbox/service.tsends a pool with no timeout47ba92bddend-after-kill9andplain-kill9-endnow resolverelease()after the loss cleared the next holder'sc.reserved47ba92bddFindings 5–8 came from an independent read-only review pass on
c782a4d01.How the suite works. postgres.js runs in a child process (
connection-loss.child.mjs) against a fake PG v3 server in the test. The server ends the socket the way the kernel does for a killed server process, or resets it. The cut cases run on a one-connection pool.Each case asserts:
TimeoutNegativeWarningfor postgres.js's reconnect timer is filtered);COMMITand no stray statement;Evidence
Same source and environment for all of these: Node 22.21.1 (the image's), and my own PostgreSQL 16.14 on a free local port with synthetic data only.
Committed suite (16 tests), with each
postgrespackage swapped in:postgrespackaged6dd53ae0's install)c782a4d01's patchReal PostgreSQL, child-process rig with a real
kill -9. Each scenario ran through both builds.u1–u3)p7)kill -9of the transaction's own server processconnection.js:250:22, exit 1CONNECTION_CLOSED, pool reconnects,endin 3 mskill -9of another session's server process (crash recovery)CONNECTION_CLOSED, pool reconnectsCONNECTION_CLOSED, pool reconnectsINSERTINSERTcommits on its own; table holds['stray']sql.reserve, pending statement plus the next one; release on one connectionCONNECTION_CLOSED, pool reconnectssql.end()with no timeout after the losspg_terminate_backend(57P01); healthy commit and rollbackShipped paths
ESM and CommonJS. The suite resolves
postgresby its bare name from the platform, through the package'sexports. It asserts that ESM loadednode_modules/postgres/src/index.jsand CJSnode_modules/postgres/cjs/src/index.js.cf/src/carries the same hunks, checked by marker and hash.Clean frozen install (
git archiveof each head, bun 1.4.2,--frozen-lockfile): exit 0 for bothc782a4d01and47ba92bdd, and the six patched files are byte-identical to the patched worktree.Image install steps, replayed with the image's bun 1.3.12 from the files stage 1
COPYs, for both heads:HUSKY=0 bun install): exit 0, patched;bun install --productionon stage 1's manifests and lockfile): exit 0, patched;patches/: exit 1,Couldn't find patch file: 'patches/postgres@3.4.7.patch'.Main's stage 1 rewrites
bun.lockthe same way ("Removed: 9", replayed atd6dd53ae0), so that predates this PR.CI at
c782a4d01.[workspace-deps 6/21] COPY patches/ ./patches/and bun 1.3.12's install (1946 packages), then[pruner 2/18] COPY --from=workspace-deps /app/patches/ …andbun install --production(1689 packages).@tale/platform:testwas acache miss, executing, withconnection-loss.test.tsat 12/12.CI on this head is reported in a PR comment.
Sweep and observations
sql.beginusers.transactSerializable(packages/shared/src/db/serializable.ts, 64 backend/lib files) and directsql.begincalls, the migrations inbackend/db/migrate.tsincluded.sql.reserveusers. The knowledge corpus bootstrap (core/knowledge/ddl.ts:ROLLBACKafter a failed statement, thenrelease()), andtransactSerializable's queued retry (reserved.unsafe('BEGIN' / 'ROLLBACK' / 'COMMIT')). Both take the paths fixed here.Retry classification, as observed; nothing changed.
isTransientDbError(packages/shared/src/db/retry.ts) does not retryCONNECTION_CLOSED, the code a killed server process produced in every realkill -9run here. Its message regex wants "connection closed" with a space. SotransactSerializabledoes not replay such a transaction, and a COMMIT with an unknown outcome is not rerun.ECONNRESET(the 57P01 path, sometimes) is retried, as TALE-406's fast-shutdown rounds found safe.Migration-retry question (fix(platform): wait out a restarting database when the backend boots #4031): kept separate, observed live, not changed.
isDatabaseUnavailablecountsCONNECTION_CLOSEDas unavailable (backend/db/unavailable.ts). I ranrunBootMigrationsfrom a scratch worktree with an uncommitted probe migration that kills its own server process (COPY … TO PROGRAM 'kill -9 $PPID'), on PG 16.14 with Node 22.21.1:c782a4d01)CONNECTION_CLOSEDEach crash ended the session, so the session-level advisory lock went with it and each rerun took the lock again. Whether to cap the reruns after the lock is taken is fix(platform): wait out a restarting database when the backend boots #4031's decision.
Not changed, upstream: a
sql.reserve()waiting for a connection can be dropped when another connection closes and the pool reconnects that one for it.onclosehands the request to the reconnect, and the reconnect's type fetch discards it.Behaviour change: a statement issued on a transaction handle after its
COMMIT, or on a released reservation, now rejects withCONNECTION_CLOSEDinstead of running on the pool's connection outside the transaction. A grep found no platform caller doing either.CI path filters (follow-up; this token has no
workflowscope).build.yml'spaths,checks.yml's Backend integration filter andsecurity.ymldo not listpatches/**, so a later edit to the patch alone would skip the image builds and the real-Postgres suite. This PR also changespackage.jsonandbun.lock, so it is not affected.Docs. No page claims the backend crashes or survives a database crash, and the knowledge-Postgres crash page is about BM25 index damage.
Security: no new surface. The ownership rule stops a dead transaction's statement from running outside it.
Locales, migrations, UI: none.
Manual tests: none; the suite owns this.
Open PRs: none touches these files.
Checks run locally
connection-loss.test.ts: 16/16 on Node 24.21, and 16/16 in the Node 22.21.1 matrix.tests/guards/dockerfile-fail-closed.guard.test.ts,tests/guards/turbo-inputs.guard.test.ts(32/32),backend/db/migrate.test.tsandbackend/db/unavailable.test.ts: pass (atc782a4d01; the follow-up touches none of their inputs).oxlint --type-awareon the suite and the child: 0 problems.tsc --noEmit: 0 errors.oxfmton every changed file it formats, commitlint on both commits, and opengrep on the new files: 0 findings.tsc/lint, knip andbun run check. CI runs them.Closes #4041. Refs #4031.