Summary: in transaction mode, Supavisor sometimes returns a
backend to its own pool with a transaction open on it and never relays the reply to the client that
issued it. The client waits for ever, Postgres shows the backend idle, and sixty seconds later
idle_in_transaction_session_timeout kills it.
Symptom
A begin read only reaches Postgres, executes, and is answered. The client never receives the
reply. The connection is left checked out on the client side and idle in a transaction on the
server side, and Supavisor's own log then records:
DbHandler: Session terminated by server while idle in the pool: terminating connection due to idle-in-transaction timeout (25P03)Four or five times per run. The same log carries DbHandler: Session terminated by server while checked out by a client.
For a Next.js server with a fixed connection pool this is fatal rather than merely slow. Each
occurrence removes one connection permanently. With max: 10, ten occurrences leave every
authenticated request queueing behind a connection that will never answer. Measured on the wedged
server: 459 transactions waiting, 0 running, 10 connections holding, while the unauthenticated page
that touches no database served in 6 to 7ms throughout.
Environment
| Pooler | Supabase transaction pooler, port 6543, region eu-west-2 |
| Client | postgres.js 3.4.9 |
| Runtime | Node v22.22.2 |
| Framework | Next.js 16.3.1 |
| Pool options | max: 10, idle_timeout: 20, prepare: false (the transaction pooler cannot hold a prepared statement across a transaction) |
| Server setting | idle_in_transaction_session_timeout = 60s, set on the application role |
Reproduction
This does not need our repository. It needs a Node HTTP server in front of the transaction pooler with a fixed pool, and a client that gives up mid-request.
- Serve a page (production build, not dev) whose render opens one
read onlytransaction on a pooled connection and runs about twenty statements inside it. Poolmax: 10,prepare: false. - Issue 400 HTTP requests to that page, six in flight at a time, paced 40ms apart, and abort each one from the client at a random point between 5ms and 205ms after it starts. All 400 aborted, nothing completed. The driver finishes in about 10 seconds.
- Then make one ordinary, un-aborted request to the same page.
What happens. Step 3 never answers. Measured with the unmodified client (sql.begin) on
2026-08-31, three probes returned HTTP 000 at bounds of 90s, 20s and 12s, and the server did not
recover for the life of the process. A probe of a page that opens no transaction returned 200 in 6
to 7ms at the same moment. Baseline before step 2, same server, same probe: 200 in 0.94s to 1.07s.
Re-measured clean on 2026-09-01, and the figures hold. The 2026-08-31 run may have shared its
pool with orphaned servers from earlier attempts, so it was repeated on a tree with the unmodified
client and nothing else running: no listener on the port, and TEST reporting zero app_user
backends before the run started. Baseline before step 2, three signed-in probes: 200 in 838ms,
786ms and 786ms. Step 2 then aborted 400 of 400 in 10 seconds with nothing signed out. Step 3,
three probes at a five-second bound: all three unanswered, at 5.003s, 5.002s and 5.003s. Three
more at a 120-second bound: all three unanswered, at 120.002s, 120.001s and 120.001s. The
120-second column had never been run for this variant before; it has now, and the server does not
recover.
Which parts are required and which are incidental.
- The pooler is required. The same driver against Postgres directly (no pooler, sub-millisecond socket) produced no wedge and no protocol error: 460 renders, 131 aborted, zero anomalies. That is evidence rather than proof, since a partly written message has almost no opportunity to sit in a buffer at that latency.
- The client aborts make it easy to see, and we cannot say they are required. They are what puts several requests' statements onto one connection in one flush. Unseparated: we have not produced the fault without them, and we have not shown they are necessary.
- The 40ms pacing is incidental to the fault. It exists because 400 unpaced requests tripped our own auth provider's rate limit (904 times in one run), after which the experiment measures nothing.
What the evidence shows
Two wedged runs on 2026-08-31, both against the transaction pooler.
The first wedged run, client side. Ten connections held, every one with begin read only as
its last statement. Sockets: nothing buffered unread, nothing buffered unsent, none paused, and
every one of the ten had read within 20 to 40ms of its last write. So nothing on the client side
truncated or withheld anything.
The first wedged run, server side. pg_stat_activity read on TEST while the server was still
wedged (not afterwards) showed seven app_user backends idle in transaction with begin read only as their last query. The statement arrived, executed and left a transaction open.
And "not afterwards" is the whole of it, which the 2026-09-01 re-measurement established by
getting it wrong. That run read pg_stat_activity about seven minutes after the driver, having
spent six of them on the 120-second probes, and saw one app_user backend, merely idle, with no
transaction. That does not contradict the seven above: the role carries
idle_in_transaction_session_timeout=60s, so any held transaction is reaped long before a late
reading arrives. The client-side figures from that run are unaffected and are reported above. Read
the server side inside the minute or do not report it.
The instrumented run. A copy of postgres.js's connection.js was patched to print every
execute, every ReadyForQuery with what it resolved, every promotion out of the sent queue and
every chunk the wire parser saw. On every stuck connection the last event is a promotion: the
client moves a query to the head of its queue, and then no data event ever arrives on that
socket again. 57 begin read only issued, 51 answered. So the client is not mis-dispatching a
reply it received. The reply does not arrive.
pg_stat_activity at that same moment showed all six backends on ClientRead with no lock
wait: Postgres had answered everything it was given and was itself waiting on the pooler.
Supavisor's log for that window carries the 25P03 line quoted at the top, four or five times
per run, plus Session terminated by server while checked out by a client.
Read together: Postgres answered, Supavisor did not relay the answer, Supavisor put the backend back in its own pool with the transaction still open, and sixty seconds later the server-side timeout killed it.
One shape from our side that may matter. postgres.js pipelines statements from several
concurrent requests onto one connection, so begin read only went out behind three selects in one
flush and the sent queue reached 4. Not established: whether that pipelining is necessary to
the fault or merely present. No Supavisor code path has been read by us.
An older shape, same log line, same pooler
This is the content of #1164, restated because it may be the same fault seen from further away. Three CI runs on 2026-08-26, at a measured 119 to 145ms to the pooler:
- Postgres:
FATAL 08P01 invalid frontend message type 109 - Supavisor:
DbHandler: Session terminated by server while checked out by a client - The application:
write CONNECTION_CLOSED, 94 to 97ms after the FATAL, on all three runs - Later in the same window, on another session:
incomplete message from client
A client-side instrument that reports a socket destroyed with bytes accepted from the application and not yet handed to the kernel read zero on three separate red runs, with an arming line in the log proving it was watching. What it cannot see is bytes the kernel accepted and did not deliver, or a partial send followed by a reset.
A second fault, possibly distinct, mechanism not established
While measuring client-side mitigations (below) we saw SQLSTATE 26000, unnamed prepared statement does not exist, which had never appeared in ten earlier logs from this investigation.
It is reported against a statement sent in simple query mode, with routine: exec_bind_message
in 84 of 86 occurrences. A simple query never reaches Bind. That says the reply stream and the
query stream have come apart, and that the client is naming the head of its own queue while an
ErrorResponse belonging to something else arrives.
The correlation, one run each:
| Variant | Connections closed by the client | 26000 occurrences |
|---|---|---|
| terminate the unanswered connection | 267 | 86 |
| drop it instead, closing nothing | 0 | 4 |
So closing a connection amplifies it by roughly twenty times and does not create it. The mechanism is not established. One candidate, offered as a hypothesis only: terminating a connection whose transaction is still open lets the backend go back to the pool without being reset, and the next client to receive it binds against a session that never saw its Parse. Treat 86 against 4 as an order of magnitude, not a number.
What was ruled out, with the evidence
| Candidate | Ruled out by |
|---|---|
idle_timeout on the client pool | Turned off deliberately on one CI run; the same 08P01 and CONNECTION_CLOSED followed 94ms later. The connection that died had been open 2m31s with nothing on our side closing anything |
| A transaction held open by our own code | A thirty-second bound was armed and made audible (it had been silently swallowing its own rejection). On the next red run it printed nothing, which now means it did not fire. Independently, the role carries idle_in_transaction_session_timeout=60s, so a hold of 3m35s was arithmetically impossible |
| Our client truncating a message | Socket instrument read zero bytes buffered on three red runs with the arming line present. On the wedged runs: nothing buffered unread, nothing buffered unsent, none paused |
| Postgres | Backends on ClientRead with no lock waits. It had answered everything it was given |
| Our auth provider | lsof -a -p <server> -i on the wedged server showed established sockets to the pooler's port and nothing else but the listener, so nothing was blocked on an HTTPS call |
Client-side mitigations tried, and none worked
Recorded so nobody spends the time again. Every tenant transaction was moved off postgres.js's
sql.begin and onto sql.reserve(), so that a connection is released synchronously rather than at
the end of a callback that may never run. Three variants were measured against one bar: a
signed-in page returns 200 within five seconds, after the 400 aborts of the reproduction above.
| Variant | Probes at the 5s bound | Recovery, measured separately at a 120s bound |
|---|---|---|
unmodified (sql.begin) | 000 at 90s, 20s, 12s (2026-08-31); 000 at 5.003s, 5.002s, 5.003s (2026-09-01) | none: 000 at 120.002s, 120.001s, 120.001s, measured at the bound on 2026-09-01 |
| terminate the unanswered connection | 000 at 5.006s, 5.006s, 5.005s | 200 at 72.7s, then 33.5s, then 2.4s |
| drop it, closing nothing | 000 at 5.011s, 5.012s, 5.010s | none: still refusing at 60s, 18 pool dumps all showing ten holding, nine stuck on begin read write for 212s |
All three fail. The 120s column is a second, separate measurement and is not comparable with
the first row's 90/20/12, which was never run at that bound; the first row's 2026-09-01 figures
were, and they are the only ones in that column measured against the same bar as the rows below. Terminating is the only variant
measurably better than doing nothing, and it brings the 26000 above with it.
Where the evidence stops
Established: Postgres answers, the reply does not reach the client, the backend goes back to Supavisor's pool with a transaction open, and Supavisor's own log says it terminated a session "while idle in the pool" for an idle-in-transaction timeout.
Not established: which Supavisor code path drops the reply; whether the client aborts are necessary
or merely amplifying; whether the pipelining of several requests' statements into one flush is
necessary; whether the 26000 above is the same fault or a second one; and whether the 2026-08-26
08P01 shape is the same event seen at 140ms of latency.
The one question we cannot answer from this side: can Supavisor in transaction mode return a backend to its pool, with a transaction still open on it, without relaying the reply that opened it? Our measurements say the observable state is exactly that.
Session IDs, timestamps and the project ref are deliberately not in this document. They can be supplied privately.
Related: #1164, opened 2026-08-27, is the same pooler and the same Session terminated by server while checked out by a client line seen at 140ms of latency without a reproduction. Whether the two are one fault is not established.
Activity
17One more measurement, added because it is evidence about the fault rather than a fix for it, and because it answers something from the client side that the report above left open.
We bounded the client's begin from outside its callback, at 35 seconds. postgres.js awaits begin before it runs the transaction callback, so where the reply to begin read only never arrives the callback is never entered and the promise pends for the life of the process. That is why the probes above return nothing at all rather than returning slowly: nothing on the client side was ever going to end the wait.
Same reproduction as above (400 aborted renders, six in flight, paced 40ms), three probes each time, with a healthy baseline taken immediately before each run at 200 in 1.17s, 731ms and 776ms.
| unmodified | bounded from outside | |
|---|---|---|
| probes at a 5s bound | 000 at 5.003s, 5.002s, 5.003s | 000 at 5.005s, 5.004s, 5.006s |
| probes at a 120s bound | 000 at 120.002s, 120.001s, 120.001s | 500 at 35.017s, 35.010s, 35.008s |
Two things follow that may be useful to whoever picks this up.
The client is never answered, rather than answered late. A 120-second bound is not enough and a 35-second one produces an error rather than a reply, so the reply is not in flight anywhere. This is the same conclusion as the instrumented run in the report, arrived at from the other direction.
Nothing on the client side frees the connection. After the bound fires the pool stays exhausted and every subsequent request fails at the bound too. The connection remains reserved inside the client because, as far as it knows, a transaction is still open on it. So the client cannot recover from this without closing connections, and closing them causes a second problem: the SQLSTATE 26000 in the report's "A second fault" section is roughly twenty times more frequent when we terminate the unanswered connection than when we leave it alone.
We are not proposing the bound as a fix and have not changed anything else. It is in place only so that the failure is bounded and visible rather than silent. Still happy to reproduce on demand, add any instrumentation you suggest, or hand over the project ref and session IDs privately.
One more measurement, added because it is evidence about the fault rather than a fix for it, and because it answers something from the client side that the report above left open.
We bounded the client's begin from outside its callback, at 35 seconds. postgres.js awaits begin before it runs the transaction callback, so where the reply to begin read only never arrives the callback is never entered and the promise pends for the life of the process. That is why the probes above return nothing at all rather than returning slowly: nothing on the client side was ever going to end the wait.
Same reproduction as above (400 aborted renders, six in flight, paced 40ms), three probes each time, with a healthy baseline taken immediately before each run at 200 in 1.17s, 731ms and 776ms.
| unmodified | bounded from outside | |
|---|---|---|
| probes at a 5s bound | 000 at 5.003s, 5.002s, 5.003s | 000 at 5.005s, 5.004s, 5.006s |
| probes at a 120s bound | 000 at 120.002s, 120.001s, 120.001s | 500 at 35.017s, 35.010s, 35.008s |
Two things follow that may be useful to whoever picks this up.
The client is never answered, rather than answered late. A 120-second bound is not enough and a 35-second one produces an error rather than a reply, so the reply is not in flight anywhere. This is the same conclusion as the instrumented run in the report, arrived at from the other direction.
Nothing on the client side frees the connection. After the bound fires the pool stays exhausted and every subsequent request fails at the bound too. The connection remains reserved inside the client because, as far as it knows, a transaction is still open on it. So the client cannot recover from this without closing connections, and closing them causes a second problem: the SQLSTATE 26000 in the report's "A second fault" section is roughly twenty times more frequent when we terminate the unanswered connection than when we leave it alone.
We are not proposing the bound as a fix and have not changed anything else. It is in place only so that the failure is bounded and visible rather than silent. Still happy to reproduce on demand, add any instrumentation you suggest, or hand over the project ref and session IDs privately.
Client-side evidence from a Next.js server using postgres.js against the transaction pooler on port 6543, eu-west-2.
On a red run the client's socket to the pooler closed cleanly (no error, hadError: false), with the last whole message received being an ErrorResponse and seven pipelined statements still unanswered. writableLength was 0 at the last write and at close, so nothing was truncated on the client side.
In the same window the pooler logged DbHandler: Session terminated by server while checked out by a client: invalid frontend message type 109 (08P01), 16.6s after the ErrorResponse was delivered to the client (5.4s on another occurrence).
So the malformed frontend message reaches Postgres on the pooler-to-backend link, not from this client, and the backend it kills is one checked out by a different client at the time. Happy to share the socket-level trace and timestamps.
Client-side evidence from a Next.js server using postgres.js against the transaction pooler on port 6543, eu-west-2.
On a red run the client's socket to the pooler closed cleanly (no error, hadError: false), with the last whole message received being an ErrorResponse and seven pipelined statements still unanswered. writableLength was 0 at the last write and at close, so nothing was truncated on the client side.
In the same window the pooler logged DbHandler: Session terminated by server while checked out by a client: invalid frontend message type 109 (08P01), 16.6s after the ErrorResponse was delivered to the client (5.4s on another occurrence).
So the malformed frontend message reaches Postgres on the pooler-to-backend link, not from this client, and the backend it kills is one checked out by a different client at the time. Happy to share the socket-level trace and timestamps.
Client-side evidence from a Next.js server using postgres.js against the transaction pooler on port 6543, eu-west-2.
On a red run the client's socket to the pooler closed cleanly (no error, hadError: false), with the last whole message received being an ErrorResponse and seven pipelined statements still unanswered. writableLength was 0 at the last write and at close, so nothing was truncated on the client side.
In the same window the pooler logged DbHandler: Session terminated by server while checked out by a client: invalid frontend message type 109 (08P01), 16.6s after the ErrorResponse was delivered to the client (5.4s on another occurrence).
So the malformed frontend message reaches Postgres on the pooler-to-backend link, not from this client, and the backend it kills is one checked out by a different client at the time. Happy to share the socket-level trace and timestamps.
A third reproduction, and this one shows the fault from the Postgres side rather than from the client's.
Same driver as before: 400 client-aborted renders through the transaction pooler, six in flight, 40ms apart, against a page whose render pipelines eight statements per transaction. Healthy baseline first (one render, 762ms), no app_user backends open beforehand.
Inside 250ms of the burst, Postgres's own log (by session id, which I can supply privately):
- one backend logged
WARNING 25001 there is already a transaction in progressthree times in 6ms, and a second backend once more 200ms later. The client sends exactly oneBEGINper transaction, so these areBEGINs arriving at a backend that was already inside another client's transaction; - the backend the baseline render had used, last statement
COMMIT, loggedERROR 08P01 bind message supplies 3 parameters, but prepared statement "" requires 0. The client writes Parse, Bind, Execute, Sync for that statement in one flush. A Bind arriving at a backend whose unnamed statement is stillCOMMITis a Bind delivered without the Parse that preceded it on the same client socket.
That second one was not fatal, so it came back to the client as an ordinary ErrorResponse, which is the first time this has reached the application as anything but a closed connection. Every earlier sighting was the fatal invalid frontend message type 109 form. Both read as the same thing: bytes from one client's flush reaching a backend assigned to another, once cut between messages and once cut inside one.
Sixty seconds later idle_in_transaction_session_timeout reaped two backends that Supavisor logged as Session terminated by server while checked out by a client, and the client's socket saw the FATAL as an ErrorResponse before the close, so the relay of a backend's FATAL to its client does work.
And one backend was held for five minutes: active, waiting on ClientRead, inside a transaction from the burst, logging nothing. It was still there 4m48s later after the client process had been killed and every client socket closed. active on ClientRead is a backend between an Execute and its Sync, so this one received a Parse, Bind and Execute whose Sync went elsewhere. The idle-in-transaction timeout does not apply to that state. Supavisor closed it at exactly 5m00s after the burst, 65s after the last client socket had gone (Postgres: unexpected EOF on client connection with an open transaction), and the tenant pool shut down for want of subscribers a minute later. So each occurrence costs the pool one backend for five minutes.
Client side is unchanged from the earlier runs: every socket eight statements out and unanswered, nothing buffered in either direction, and on seven of ten sockets the last reply before the silence was COMMIT plus ReadyForQuery idle.
A third reproduction, and this one shows the fault from the Postgres side rather than from the client's.
Same driver as before: 400 client-aborted renders through the transaction pooler, six in flight, 40ms apart, against a page whose render pipelines eight statements per transaction. Healthy baseline first (one render, 762ms), no app_user backends open beforehand.
Inside 250ms of the burst, Postgres's own log (by session id, which I can supply privately):
- one backend logged
WARNING 25001 there is already a transaction in progressthree times in 6ms, and a second backend once more 200ms later. The client sends exactly oneBEGINper transaction, so these areBEGINs arriving at a backend that was already inside another client's transaction; - the backend the baseline render had used, last statement
COMMIT, loggedERROR 08P01 bind message supplies 3 parameters, but prepared statement "" requires 0. The client writes Parse, Bind, Execute, Sync for that statement in one flush. A Bind arriving at a backend whose unnamed statement is stillCOMMITis a Bind delivered without the Parse that preceded it on the same client socket.
That second one was not fatal, so it came back to the client as an ordinary ErrorResponse, which is the first time this has reached the application as anything but a closed connection. Every earlier sighting was the fatal invalid frontend message type 109 form. Both read as the same thing: bytes from one client's flush reaching a backend assigned to another, once cut between messages and once cut inside one.
Sixty seconds later idle_in_transaction_session_timeout reaped two backends that Supavisor logged as Session terminated by server while checked out by a client, and the client's socket saw the FATAL as an ErrorResponse before the close, so the relay of a backend's FATAL to its client does work.
And one backend was held for five minutes: active, waiting on ClientRead, inside a transaction from the burst, logging nothing. It was still there 4m48s later after the client process had been killed and every client socket closed. active on ClientRead is a backend between an Execute and its Sync, so this one received a Parse, Bind and Execute whose Sync went elsewhere. The idle-in-transaction timeout does not apply to that state. Supavisor closed it at exactly 5m00s after the burst, 65s after the last client socket had gone (Postgres: unexpected EOF on client connection with an open transaction), and the tenant pool shut down for want of subscribers a minute later. So each occurrence costs the pool one backend for five minutes.
Client side is unchanged from the earlier runs: every socket eight statements out and unanswered, nothing buffered in either direction, and on seven of ten sockets the last reply before the silence was COMMIT plus ReadyForQuery idle.
A third reproduction, and this one shows the fault from the Postgres side rather than from the client's.
Same driver as before: 400 client-aborted renders through the transaction pooler, six in flight, 40ms apart, against a page whose render pipelines eight statements per transaction. Healthy baseline first (one render, 762ms), no app_user backends open beforehand.
Inside 250ms of the burst, Postgres's own log (by session id, which I can supply privately):
- one backend logged
WARNING 25001 there is already a transaction in progressthree times in 6ms, and a second backend once more 200ms later. The client sends exactly oneBEGINper transaction, so these areBEGINs arriving at a backend that was already inside another client's transaction; - the backend the baseline render had used, last statement
COMMIT, loggedERROR 08P01 bind message supplies 3 parameters, but prepared statement "" requires 0. The client writes Parse, Bind, Execute, Sync for that statement in one flush. A Bind arriving at a backend whose unnamed statement is stillCOMMITis a Bind delivered without the Parse that preceded it on the same client socket.
That second one was not fatal, so it came back to the client as an ordinary ErrorResponse, which is the first time this has reached the application as anything but a closed connection. Every earlier sighting was the fatal invalid frontend message type 109 form. Both read as the same thing: bytes from one client's flush reaching a backend assigned to another, once cut between messages and once cut inside one.
Sixty seconds later idle_in_transaction_session_timeout reaped two backends that Supavisor logged as Session terminated by server while checked out by a client, and the client's socket saw the FATAL as an ErrorResponse before the close, so the relay of a backend's FATAL to its client does work.
And one backend was held for five minutes: active, waiting on ClientRead, inside a transaction from the burst, logging nothing. It was still there 4m48s later after the client process had been killed and every client socket closed. active on ClientRead is a backend between an Execute and its Sync, so this one received a Parse, Bind and Execute whose Sync went elsewhere. The idle-in-transaction timeout does not apply to that state. Supavisor closed it at exactly 5m00s after the burst, 65s after the last client socket had gone (Postgres: unexpected EOF on client connection with an open transaction), and the tenant pool shut down for want of subscribers a minute later. So each occurrence costs the pool one backend for five minutes.
Client side is unchanged from the earlier runs: every socket eight statements out and unanswered, nothing buffered in either direction, and on seven of ten sockets the last reply before the silence was COMMIT plus ReadyForQuery idle.
A fourth run this morning, read from pg_stat_activity inside the wedge (0.6s, 8s and 25s after the driver started, so before the 60-second idle-in-transaction reaper). The client sends eight statements per transaction in one flush: BEGIN READ ONLY, a set_config, five reads, and a final SELECT EXISTS.
- At 0.6s: ten backends, seven active on the page's reads, one idle in transaction on
BEGIN READ ONLY, three idle afterCOMMIT. - At 8s: every backend quiet since the same instant. Nine idle with no transaction, five of them having last executed the flush's LAST statement, four
COMMIT; one idle in transaction on that last statement. Unchanged at 25s. - Postgres logged
25001 there is already a transaction in progressfour times in 31ms on one backend, which 74ms earlier was 3.6ms into a transaction on a different client's read. FourBEGINs from four clients reached a backend already inside a fifth's transaction. - Client side at the same instant: ten sockets, each eight statements out and none answered, last reply the previous transaction's
COMMIT+ReadyForQuery(I), last statement written the flush's last one. Seven sockets had 451 bytes back after that reply and three had 110 to 118, none containing aReadyForQuery. - Supavisor logged nothing at the instant.
So one flush, sent as one TCP write, was delivered statement by statement to different backends: its BEGIN to a backend already in somebody else's transaction, its last statement to a backend that ran it outside any transaction and answered, and that answer never reached the client that sent it.
The same split shows from inside a completed request: the one render of 400 that was not aborted was refused by our own invariant check, because current_setting('request.jwt.claim.sub') was empty on the transaction serving a signed-in user. That is the read landing on a backend that never received the set_config pipelined ahead of it. Without the check, the page would have told the user they belong to no organisation, which is what two earlier end-to-end runs saw.
Also: postgres.js's idle_timeout: 20 closed none of these sockets in 85 seconds, so they are not idle to the client; it is waiting for replies that will not come.
A fourth run this morning, read from pg_stat_activity inside the wedge (0.6s, 8s and 25s after the driver started, so before the 60-second idle-in-transaction reaper). The client sends eight statements per transaction in one flush: BEGIN READ ONLY, a set_config, five reads, and a final SELECT EXISTS.
- At 0.6s: ten backends, seven active on the page's reads, one idle in transaction on
BEGIN READ ONLY, three idle afterCOMMIT. - At 8s: every backend quiet since the same instant. Nine idle with no transaction, five of them having last executed the flush's LAST statement, four
COMMIT; one idle in transaction on that last statement. Unchanged at 25s. - Postgres logged
25001 there is already a transaction in progressfour times in 31ms on one backend, which 74ms earlier was 3.6ms into a transaction on a different client's read. FourBEGINs from four clients reached a backend already inside a fifth's transaction. - Client side at the same instant: ten sockets, each eight statements out and none answered, last reply the previous transaction's
COMMIT+ReadyForQuery(I), last statement written the flush's last one. Seven sockets had 451 bytes back after that reply and three had 110 to 118, none containing aReadyForQuery. - Supavisor logged nothing at the instant.
So one flush, sent as one TCP write, was delivered statement by statement to different backends: its BEGIN to a backend already in somebody else's transaction, its last statement to a backend that ran it outside any transaction and answered, and that answer never reached the client that sent it.
The same split shows from inside a completed request: the one render of 400 that was not aborted was refused by our own invariant check, because current_setting('request.jwt.claim.sub') was empty on the transaction serving a signed-in user. That is the read landing on a backend that never received the set_config pipelined ahead of it. Without the check, the page would have told the user they belong to no organisation, which is what two earlier end-to-end runs saw.
Also: postgres.js's idle_timeout: 20 closed none of these sockets in 85 seconds, so they are not idle to the client; it is waiting for replies that will not come.
A fourth run this morning, read from pg_stat_activity inside the wedge (0.6s, 8s and 25s after the driver started, so before the 60-second idle-in-transaction reaper). The client sends eight statements per transaction in one flush: BEGIN READ ONLY, a set_config, five reads, and a final SELECT EXISTS.
- At 0.6s: ten backends, seven active on the page's reads, one idle in transaction on
BEGIN READ ONLY, three idle afterCOMMIT. - At 8s: every backend quiet since the same instant. Nine idle with no transaction, five of them having last executed the flush's LAST statement, four
COMMIT; one idle in transaction on that last statement. Unchanged at 25s. - Postgres logged
25001 there is already a transaction in progressfour times in 31ms on one backend, which 74ms earlier was 3.6ms into a transaction on a different client's read. FourBEGINs from four clients reached a backend already inside a fifth's transaction. - Client side at the same instant: ten sockets, each eight statements out and none answered, last reply the previous transaction's
COMMIT+ReadyForQuery(I), last statement written the flush's last one. Seven sockets had 451 bytes back after that reply and three had 110 to 118, none containing aReadyForQuery. - Supavisor logged nothing at the instant.
So one flush, sent as one TCP write, was delivered statement by statement to different backends: its BEGIN to a backend already in somebody else's transaction, its last statement to a backend that ran it outside any transaction and answered, and that answer never reached the client that sent it.
The same split shows from inside a completed request: the one render of 400 that was not aborted was refused by our own invariant check, because current_setting('request.jwt.claim.sub') was empty on the transaction serving a signed-in user. That is the read landing on a backend that never received the set_config pipelined ahead of it. Without the check, the page would have told the user they belong to no organisation, which is what two earlier end-to-end runs saw.
Also: postgres.js's idle_timeout: 20 closed none of these sockets in 85 seconds, so they are not idle to the client; it is waiting for replies that will not come.
Correction to my previous comment, from reading our client's code rather than the dump. Two details were wrong; the measurements stand.
-
The client does not pipeline
BEGINwith anything. It awaitsBEGIN READ ONLY, then awaits theset_config, and only then sends the page's nine reads in one flush, withCOMMITwritten behind them as soon as the render resolves or is abandoned. postgres.js reserves a connection on theBEGINit writes and sends no secondBEGINon it. So the fourBEGINs that reached one backend in 31ms came from four client sockets, not one flush. -
The
COMMIT+ReadyForQuery(I)that each wedged socket had last read was the reply to its OWNCOMMIT, the statement written behind the reads, not the previous transaction's. So on each socket the pooler answered the last statement of the transaction and none of the nine reads before it. The five backends idle with no transaction had executed that read flush outside any transaction (as nobody), and their answers did not reach the socket that sent them.
So the shape is a client-to-backend pin lost inside a transaction: BEGIN answered by one backend, the reads that followed run on another with no transaction open, COMMIT answered, and four clients' BEGINs landing on a backend that was already pinned to a fifth. Same conclusion as before, more precisely stated.
Correction to my previous comment, from reading our client's code rather than the dump. Two details were wrong; the measurements stand.
-
The client does not pipeline
BEGINwith anything. It awaitsBEGIN READ ONLY, then awaits theset_config, and only then sends the page's nine reads in one flush, withCOMMITwritten behind them as soon as the render resolves or is abandoned. postgres.js reserves a connection on theBEGINit writes and sends no secondBEGINon it. So the fourBEGINs that reached one backend in 31ms came from four client sockets, not one flush. -
The
COMMIT+ReadyForQuery(I)that each wedged socket had last read was the reply to its OWNCOMMIT, the statement written behind the reads, not the previous transaction's. So on each socket the pooler answered the last statement of the transaction and none of the nine reads before it. The five backends idle with no transaction had executed that read flush outside any transaction (as nobody), and their answers did not reach the socket that sent them.
So the shape is a client-to-backend pin lost inside a transaction: BEGIN answered by one backend, the reads that followed run on another with no transaction open, COMMIT answered, and four clients' BEGINs landing on a backend that was already pinned to a fifth. Same conclusion as before, more precisely stated.
Correction to my previous comment, from reading our client's code rather than the dump. Two details were wrong; the measurements stand.
-
The client does not pipeline
BEGINwith anything. It awaitsBEGIN READ ONLY, then awaits theset_config, and only then sends the page's nine reads in one flush, withCOMMITwritten behind them as soon as the render resolves or is abandoned. postgres.js reserves a connection on theBEGINit writes and sends no secondBEGINon it. So the fourBEGINs that reached one backend in 31ms came from four client sockets, not one flush. -
The
COMMIT+ReadyForQuery(I)that each wedged socket had last read was the reply to its OWNCOMMIT, the statement written behind the reads, not the previous transaction's. So on each socket the pooler answered the last statement of the transaction and none of the nine reads before it. The five backends idle with no transaction had executed that read flush outside any transaction (as nobody), and their answers did not reach the socket that sent them.
So the shape is a client-to-backend pin lost inside a transaction: BEGIN answered by one backend, the reads that followed run on another with no transaction open, COMMIT answered, and four clients' BEGINs landing on a backend that was already pinned to a fifth. Same conclusion as before, more precisely stated.
An A/B that isolates the client-side trigger, on one build and one server process.
The one thing every wedged socket had in common was ordering: on an abandoned response our release wrote COMMIT behind nine reads it had not waited for, all inside one transaction, all on one client socket. I changed only that, so the release waits (bounded at 5s) for the transaction's outstanding reads to be answered before COMMIT is written.
- With the wait: three runs of 400 client-aborted renders (1,200 total). No wedge, every backend idle after
COMMITwithin ten seconds, and the Postgres log for the window contains no25001, no08P01, nothing but idle closes. - Without the wait (same server restarted with the wait set to 0): wedged inside 200ms of the burst. Seven
25001 there is already a transaction in progressacross three backends, onethere is no transaction in progress, one08P01 bind message supplies 3 parameters, but prepared statement "" requires 0, and a backend left idle in transaction onBEGIN READ ONLY.
So the reproducer is: in transaction mode, a client inside a transaction sends COMMIT while earlier statements of that same transaction are still unanswered, under concurrent client disconnects. Legal for a client to send. Afterwards the client's pin is lost: its COMMIT is answered, the reads before it are not, other clients' BEGINs land on the backend it was pinned to, and a later Bind reaches a backend that never saw its Parse.
We have stopped sending that sequence, which works around it on our side. Happy to supply timestamps and session ids privately.
An A/B that isolates the client-side trigger, on one build and one server process.
The one thing every wedged socket had in common was ordering: on an abandoned response our release wrote COMMIT behind nine reads it had not waited for, all inside one transaction, all on one client socket. I changed only that, so the release waits (bounded at 5s) for the transaction's outstanding reads to be answered before COMMIT is written.
- With the wait: three runs of 400 client-aborted renders (1,200 total). No wedge, every backend idle after
COMMITwithin ten seconds, and the Postgres log for the window contains no25001, no08P01, nothing but idle closes. - Without the wait (same server restarted with the wait set to 0): wedged inside 200ms of the burst. Seven
25001 there is already a transaction in progressacross three backends, onethere is no transaction in progress, one08P01 bind message supplies 3 parameters, but prepared statement "" requires 0, and a backend left idle in transaction onBEGIN READ ONLY.
So the reproducer is: in transaction mode, a client inside a transaction sends COMMIT while earlier statements of that same transaction are still unanswered, under concurrent client disconnects. Legal for a client to send. Afterwards the client's pin is lost: its COMMIT is answered, the reads before it are not, other clients' BEGINs land on the backend it was pinned to, and a later Bind reaches a backend that never saw its Parse.
We have stopped sending that sequence, which works around it on our side. Happy to supply timestamps and session ids privately.
One more measurement, added because it is evidence about the fault rather than a fix for it, and because it answers something from the client side that the report above left open.
We bounded the client's
beginfrom outside its callback, at 35 seconds. postgres.js awaitsbeginbefore it runs the transaction callback, so where the reply tobegin read onlynever arrives the callback is never entered and the promise pends for the life of the process. That is why the probes above return nothing at all rather than returning slowly: nothing on the client side was ever going to end the wait.Same reproduction as above (400 aborted renders, six in flight, paced 40ms), three probes each time, with a healthy baseline taken immediately before each run at 200 in 1.17s, 731ms and 776ms.
Two things follow that may be useful to whoever picks this up.
The client is never answered, rather than answered late. A 120-second bound is not enough and a 35-second one produces an error rather than a reply, so the reply is not in flight anywhere. This is the same conclusion as the instrumented run in the report, arrived at from the other direction.
Nothing on the client side frees the connection. After the bound fires the pool stays exhausted and every subsequent request fails at the bound too. The connection remains reserved inside the client because, as far as it knows, a transaction is still open on it. So the client cannot recover from this without closing connections, and closing them causes a second problem: the
SQLSTATE 26000in the report's "A second fault" section is roughly twenty times more frequent when we terminate the unanswered connection than when we leave it alone.We are not proposing the bound as a fix and have not changed anything else. It is in place only so that the failure is bounded and visible rather than silent. Still happy to reproduce on demand, add any instrumentation you suggest, or hand over the project ref and session IDs privately.