This is the contract that src/connection.js (one connection), src/index.js (the pool and leases) and tests-startup/invariants.js (the legal states, checked in tests) implement together. It describes what each part guarantees and why. For how a phase handles a particular event, read the phase table on in src/connection.js: each row lists everything one phase does. If this document and the code disagree, the code is right and this document needs fixing.
The generated copies in cjs/, deno/ and cf/ follow src/ and are not described separately.
A connection is always in exactly one phase. Only transition() changes the phase, and every transition also increments generation, which is how late asynchronous results from an earlier phase are recognised and dropped (see Stale events).
| Phase | Meaning | Socket | Pool queues | Can be owned |
|---|---|---|---|---|
| Closed | No session and no acquisition in progress. Every connection starts here. | none | closed |
no |
| Backoff | An acquisition is waiting before its next attempt: 0 ms before the next host, the backoff delay between rounds, or the remainder of a delay inherited from the previous session. | none | connecting, or ended if end() was requested |
no |
| Connecting | One attempt on one host: socket creation, SSL negotiation or direct TLS, StartupMessage and authentication, up to the first ReadyForQuery. | being created, then open | connecting, ended |
no |
| Initializing | The server has accepted the session. Internal startup queries run (session state for target_session_attrs, array type discovery for fetch_types) before hand-off. |
open | connecting, ended |
no |
| Ready | The session is usable and accepts queries. | open | unowned: open, busy, full; owned: reserved, full |
yes |
| Draining | end() was requested; in-flight and owned work finishes, then the connection closes. |
open | unowned: ended; owned: reserved, full |
yes |
| Closing | Terminate was sent and the connection waits for the server to close the socket. It is entered only when the connection is idle and unowned. | closing | ended |
no |
Legal transitions. They are enforced by the edges table in tests-startup/invariants.js; src/ reports every edge to the test hook but does not check it.
| From | To |
|---|---|
| Closed | Backoff, Connecting |
| Backoff | Connecting, Closed |
| Connecting | Initializing, Backoff, Closed |
| Initializing | Ready, Backoff, Closed |
| Ready | Draining, Closing, Closed |
| Draining | Closing, Closed |
| Closing | Closed |
There is no Connecting → Connecting edge. Moving to the next host or retrying always goes through Backoff, with a delay of 0 between hosts. Closed is entered only through enterClosed().
The difference between Connecting and Initializing matters in two places:
- A FATAL error that arrives with no query in flight means the server refused the session in Connecting (cause
rejected). In Initializing it means an accepted session was dropped (causedropped), which a single-host acquisition retries. See the table under Acquisition. - Only internal startup queries may run in Initializing. User queries are refused with
CONNECTION_CLOSEDuntil hand-off.
Everything that can change a connection's state arrives as an event through dispatch(event, arg), which looks up on[phase][event]. This includes the public methods, socket listeners, timers, the socket factory, authentication, ReadyForQuery and ErrorResponse. No code outside transition() and dispatch() reads the phase.
| Event | Source |
|---|---|
acquire |
the pool's connect(c, request) |
execute |
connection.execute(q): pool, lease and internal queries |
end |
connection.end(): pool end()/close(), the idle timer, the lifetime timer |
terminate |
connection.terminate(): forced shutdown, a discarded lease, hand-out expiry |
release |
connection.release(): a released lease |
backoffElapsed, deadline, attemptTimeout, closeTimeout |
the acquisition and close timers |
socketCreated, factoryFailed |
the socket factory (or new net.Socket()) resolving or throwing |
socketReady, sslReply, secure |
socket connect, the one-byte SSL answer, TLS secureConnect |
socketError, socketClose, writable |
socket error, close, drain |
authFailed |
an authentication step rejecting, or a SASL server signature mismatch |
serverError |
ErrorResponse with no query in flight (a FATAL the server sends before closing) |
protocolError |
an exception while handling a backend message |
ready |
ReadyForQuery |
initFailed |
ReadyForQuery that ends an internal startup query with an error |
An event with no entry for the current phase is ignored in production and reported as unhandled by the test hook. So every missing pair should be one that cannot happen in that phase, and a test that hits one fails.
An acquisition starts when the pool hands a Closed connection a request (a query or a reservation) and ends at hand-off or in Closed. It owns three timers:
| Timer | Armed | Bound |
|---|---|---|
| attempt | each attempt, only with several hosts | connect_timeout per attempt; on expiry the next host is tried |
| deadline | once per acquisition, at the first attempt | connect_timeout × number of hosts; on expiry the acquisition fails with the last attempt's error, or CONNECT_TIMEOUT if there was none |
| backoff | each Backoff | the delay for that wait |
Time spent waiting out an inherited delay is not counted against the deadline. With connect_timeout: 0 neither the attempt nor the deadline timer is armed.
Every failed attempt goes through afterFailure(err, cause), which decides whether to try again:
| Cause | Meaning |
|---|---|
socket |
the socket emitted an error during the attempt |
factory |
the socket factory threw, or resolved to something that is not a socket |
protocol |
SSL negotiation, the TLS upgrade, writing the StartupMessage or handling a message failed |
timeout |
the attempt timer fired; the earlier error, if any, is kept as the reported one |
rejected |
the server sent a FATAL before accepting the session |
dropped |
the socket closed during the attempt, or the server sent a FATAL after accepting the session |
mismatch |
the host did not satisfy target_session_attrs |
Retry policy:
- Between hosts: after each failed attempt the next host is tried at once.
- After a full round: once every host has failed, the round counts as one retry and the next round starts after the backoff delay.
- Single host: the acquisition fails after one round with that attempt's error. The exceptions are
droppedandmismatch, which retry with backoff until the deadline: a session closing during startup is usually a restart or failover in progress, and a single endpoint that is read-only is often a cluster DNS name still pointing at the demoted primary. When the deadline passes after a mismatch, the acquisition fails withCONNECTION_CLOSEDwhosecauseis aTARGET_SESSION_ATTRSerror. - No host matches
target_session_attrs: with several hosts, when every host in a round answered but none matched, the acquisition fails at once withCONNECTION_CLOSEDwhosecauseis aTARGET_SESSION_ATTRSerror naming each host and why it did not match. If any host failed for another reason, the acquisition keeps retrying until the deadline instead. prefer-standby: a round first tries every host for a standby, then every host for any server, with no delay between the two passes. After a backoff the standby pass starts again, as in libpq.- Authentication and startup queries: an authentication failure (
authFailed) or an error in an internal startup query (initFailed) closes the connection without retrying.
The backoff delay is backoff(retries) seconds; by default (0.5..1) × min(3^retries / 100, 20). retries is shared by all connections in a pool, and a successful hand-off resets it to 0.
When a session is lost (a socket error, the peer closing the socket, or a FATAL with no query in flight), when a single-host acquisition fails, or when none of several hosts matches target_session_attrs, the connection records the backoff delay at that moment. The next acquisition of that connection waits out whatever is left of it in Backoff. A planned close is not paced: a Closing connection whose socket closes cleanly, or whose close timer fires, leaves no delay. So end(), idle_timeout and max_lifetime never slow down the next acquisition.
- Reservation request: the reservation rejects with
CONNECTION_ENDEDand the connection closes. - In Backoff after a failed attempt: the acquisition stops and the request rejects with that attempt's error.
- Otherwise: the acquisition is marked as ending and the connection is filed in
ended. If the attempt still succeeds, the request runs and the connection drains and closes. If it fails, the request rejects with the attempt's error and no retry is made.
Three functions tear a connection down, each with a different scope:
| Function | Does | Leaves to others |
|---|---|---|
endSession(err) |
Stops the session: cancels the lifetime, close and write timers; clears the protocol buffers; removes every socket listener, destroys the socket and drops it; ends a COPY stream with err; rejects the current query and every pipelined one with err. |
The phase, the acquisition and its timers, end waiters, the pool |
enterClosed(err) |
The only way into Closed: clears the acquisition timers, calls endSession(err), rejects the acquisition's request with err, resolves end() waiters and calls the pool's onclose. With no err given it uses CONNECTION_CLOSED. |
The idle timer and lease settlement, which are the pool's |
closing() |
The graceful path: moves to Closing, sends Terminate and arms the close timer for connect_timeout seconds. If the server never closes the socket, the timer closes the connection as a clean close would. With connect_timeout: 0 the close is unbounded. |
Cleanup, which happens when the resulting socketClose or closeTimeout calls enterClosed() |
A failed attempt that will be retried calls endSession and moves to Backoff, keeping the acquisition. An attempt that gives up calls enterClosed directly, so nothing is torn down twice.
Which error in-flight work receives:
- The server ended the session with a FATAL (e.g. 57P01 from
pg_terminate_backendor a failover):CONNECTION_CLOSED, with thatPostgresErroras itscause. - A socket error (e.g. ECONNRESET): that error, as upstream does.
- The peer closed the socket without either:
CONNECTION_CLOSED.
A reserved handle or transaction whose connection is lost this way rejects with CONNECTION_CLOSED, with the FATAL or socket error as its cause. The code stays CONNECTION_CLOSED because applications retry on it, as they did with upstream and 4.0.0; the cause adds the reason without changing that contract. A FATAL during startup is different: it fails the acquisition with the server's error, since nothing has run yet.
- Forced termination of a connection that is busy or still acquiring:
CONNECTION_DESTROYED. Terminating an idle open connection closes it withCONNECTION_CLOSED, which reaches only the pool'sonclose.
A protocol error while handling a message on an open session fails only the current query and any COPY stream; the connection stays open.
An asynchronous result can arrive after the phase or attempt that started it is gone. Each source is handled in one of these ways:
- Generation checks. The socket factory result, authentication continuations and internal startup queries capture
generationwhen they start and are dropped if it has moved. A socket created for a stale attempt is destroyed. - Timers. Acquisition timers are cleared on hand-off and in
enterClosed, and the attempt timer inafterFailure. The lifetime, close and write timers are cleared inendSession. None of them is generation-checked. - Socket listeners.
endSessionremoves every listener before destroying the socket, so a dead session's socket cannot dispatch events. - Socket identity. The message loop in
data()stops if handling a message replaced the socket. A suspended cursor's continuation checks that both the query and the socket are still current. - The idle timer belongs to the pool, which starts it when it files a connection in
openand cancels it on any other move.
A request is what the pool routes: a Query, or a lease waiting for a connection. A Query is also the unit a connection runs. There is no wrapper object, because the tracers read the query itself, so both kinds carry the same fields:
| Field | Query | Lease |
|---|---|---|
stage |
set in the constructor | set when the lease is created |
resolve, reject |
the arrows set in the constructor | from the promise it waits on; no-ops when a connection is handed over at once |
reserve |
absent | true, the marker that tells a lease from a query |
queue |
the lease queue it waits in, if any | its own statement queue |
Stage, step, succeed and fail are exported from src/query.js. step writes the stage unless the request is Settled. succeed and fail settle a request once: they do nothing for a request that is already Settled, and they look resolve and reject up when called.
| Stage | A query is | A lease is |
|---|---|---|
| New | built, not routed | created |
| Queued | in the pool queue, or in a lease's queue | in the pool queue |
| Opening | the opener of a connection | the same |
| Sent | admitted by a connection and waiting for its turn | not used |
| Active | the connection's current query | not used |
| Settled | resolved or rejected | handed a connection, or refused |
| From | To | Caused by |
|---|---|---|
| New | Queued | route() finds no free connection; a lease statement queues behind a full or non-accepting connection |
| New, Queued | Opening | connect() starts an acquisition for it |
| New, Queued, Opening | Sent or Active | the connection admits it (submit): Active when nothing is current, Sent otherwise |
| Sent | Active | its turn (pipelined calls activate) |
| Active | Sent | a stale-plan retry sends it again at the back |
| any but Settled | Settled | succeed or fail |
route(r) decides where a request goes and does not settle it. It takes a connection (an idle one, or for a plain query a closed slot first and then a busy one; for a lease that may use a busy connection, only when no slot is closed), starts an acquisition, or queues the request, and writes the Queued or Opening stage when it does. It returns the connection to use, null when the request now waits, or the refusal error (CONNECTION_ENDED while the pool is ending, COPY_IN_PROGRESS when every connection holds a COPY). handler() and lease() commit after it returns: they hand the connection over with assign, or settle the request with the refusal. That is the contract: route never settles a request, and nothing in it can execute or activate one, so everything that can settle or activate synchronously runs after it. Anything that withdraws a waiting request, such as an abort or a pool-wait timeout, belongs next to that commit step or in cancel(), not in route.
assign(c, r, fresh) is the one place a pool request gets a connection. fresh is true only for the opener of c (onopen's initial), and the number of requests already on the connection is read before the request is executed. A lease takes its connection and settles through the same call. Lease statements are not pool requests: send and the lease's next stay its own dispatcher.
Early settle while holding the slot. describe(), closing a cursor, a protocol error, UNSAFE_TRANSACTION and a build failure settle the request before ReadyForQuery. The connection keeps the query as current until ReadyForQuery, which calls succeed or fail again and does nothing. The abort statement, retried and the barrier are untouched.
COPY. deliver(q, stream) hands out the stream with a direct q.resolve(stream), then calls owner.next(). The request stays Active and is settled at its ReadyForQuery or by endSession, so resolve runs twice for COPY. dd-trace ends a COPY span on the second call, so folding the hand-out into succeed would leave every COPY span open.
Settled requests are still routed. A query cancelled before it is awaited, or while file() reads its file, is routed when it is awaited: step leaves the stage Settled, and the connection skips it (submit returns early for a cancelled query without an owner and not at max: 1) or, at max: 1 and on leases, writes the refused statement in its place.
Errors. queryError decorates an error with the query's details and fails the request. It wraps a thrown non-object in an Error, which covers a query that fails while being built or sent. Pool-side failures, a cursor callback that throws and the queries rejected after a failed reconnect while ending are raw, and fail wraps nothing.
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 | The handle is | A statement on it |
|---|---|---|
| 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 |
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.
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.
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():
| 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.
Each connection sits in exactly one pool queue, and the queue it is in must be one its phase and ownership allow (see Phases).
| Situation | Queue |
|---|---|
| Never used, or closed | closed |
| Acquisition in progress | connecting |
Acquisition in progress with end() requested |
ended |
| Ready, unowned and idle | open |
| Ready, unowned, running queries and able to take more; briefly also a connection that stopped accepting after a retry, until the next query is routed | busy |
| Ready, unowned, pipeline full, under write backpressure, or not accepting (see Admission) | full |
| Owned, idle or waiting for queued statements | reserved |
| Owned, statement in flight | full; reserved while a COPY is running, once the server has opened its stream |
| Draining or Closing, unowned | ended |
Admission. Replies are matched to queries by position, so nothing may be written while the server is partway through an exchange. The connection records the one query nothing may be written behind, its barrier: a statement that asks for its parameter types first (one with a value whose type is not known; with parameter_types: 'client', one with an untyped array), a named statement's first run on a connection when it has values (so identical queries pipelined behind it reuse the statement instead of each preparing their own; a first run without values holds nothing, as in Postgres.js, because a round trip on every first run costs more than the duplicate statements a burst of identical queries leaves open until the connection closes; a prepared re-run that hits a stale-plan error is retried only when nothing is held behind it, so with prepare: true a re-run with an unrelated first run pipelined behind it rejects instead of retrying, once per statement per connection), a cursor, or a simple query whose text contains the word copy, set when it is written and cleared at its ReadyForQuery (and in endSession). connection.accepting is true when the connection is idle, or has no barrier and room under max_pipeline, and execute() returns false when the connection stops accepting or the socket needs draining (and true after the COPY guard rejects a statement). The pool reads it: route() takes a busy connection only if it accepts and files the others in full, and a lease sends only to a connection that accepts, otherwise queues the statement and files the connection in full. A barrier is always the last query in the line, so it lifts when the connection goes idle, and the existing onopen and owner.next() calls are the signal that it accepts again. A retry after a plan-invalidation error is made only when no barrier is queued behind the failing statement; otherwise the statement rejects with its plan error.
COPY is recognised from the text because the client cannot know a simple query is a COPY until the server answers. A false positive only stops pipelining behind that query. When the server opens a COPY stream the connection calls owner.next(), so statements a lease queued before that are rejected with COPY_IN_PROGRESS, as are statements sent while the stream is open. Queries sent through the pool never go to a connection with a COPY in flight. When every connection has an open COPY stream whose writable has not been ended (connection.copyHeld), route() refuses with COPY_IN_PROGRESS instead of queueing, since only that COPY finishing could free a connection; work already queued keeps waiting. release() of a handle whose connection is partway through an exchange files it in full instead of handing it pool queries; the next ReadyForQuery picks it up.
The connection drives its own queue through four callbacks:
| Callback | When | The pool |
|---|---|---|
onopen(c, request, draining) |
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. With draining set, the request is the opener of a connection that was told to end while opening: it is handed over fresh and nothing else happens, so the connection is not moved and takes no queued queries |
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, 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.
Hand-out expiry. An idle connection leaves open only through takeOpen(). Before handing one out, it checks idle_timeout and max_lifetime against wall-clock due times (Date.now()). Any connection past either limit is terminated and the next one is tried. The check exists because timers cannot fire while a serverless instance is frozen, so on thaw an expired connection, often with a dead socket, could otherwise be handed out first. Terminating, rather than closing gracefully, frees the slot at once instead of holding it until the server answers Terminate.
Ending the pool.
end(): stops new work (CONNECTION_ENDEDfor anything submitted later) and ends every connection.- Queries accepted before the call still run. Plain queries waiting in the pool are taken by draining connections.
- If the last connection is lost while queries are still waiting, the pool reconnects once to run them, and
end()waits for that. If the reconnect fails, the waiting queries reject with its error. - Waiting reservations reject with
CONNECTION_ENDED.
end({ timeout }): at the timeout, rejects everything still queued withCONNECTION_DESTROYEDand terminates every connection.close(): ends the connections without stopping new work.
Cancel.
- Stages: a cancel before the query is sent settles it (
fail, stage Settled) whatever its stage was; a Sent query is only marked and keeps its stage until its own response; the current query is Active until its response. - A query not yet written: rejected with 57014 at once. If it belongs to a lease, or the pool has
max: 1, it stays in its queue, and when it reaches the connection the connection writes the refused statement from a failed build in its place, so an open transaction fails. Otherwise it is removed from the pool queue, or skipped if it reaches a connection. - A query pipelined behind another: marked, and its CancelRequest is sent once it becomes the current query. It is settled by its own response.
- The current query: the CancelRequest is sent at once.
Query.cancel() returns a promise that settles when the CancelRequest finishes. A CancelRequest opens its own socket and is not a connection; it does not go through any phase or pool queue.
src/tracing.js owns the diagnostics_channel channels; the channels and payloads are described in docs/api.md. Every hook sits on one transition above, and a request carries tracing state only while a subscriber watched its channel when it started: trace (a query's postgres:query context, or true on a lease) and pool (the postgres:pool:connect context, held until the query is Active or the lease is handed over, or the request fails first).
| Channel | Opens | Gets its fields | Settles |
|---|---|---|---|
postgres:query |
Query.handle(), when the query is New |
build() (text, name, session) |
succeed / fail |
postgres:pool:connect |
handler() for a traced query, lease() for a lease, around route() |
assign() (handoverTime, queuedBehind, client) |
activate() for a query; succeed for a lease; fail: rejected when the request never reached a connection, resolved when it was already handed over |
postgres:connect |
acquire() |
attach(), upgrade() |
handoff() or enterClosed() |
postgres:transaction |
begin() and savepoint() |
when the lease is taken | when the function settles |
postgres:pool:release |
stop() |
||
postgres:pool:remove |
enterClosed() |
Because route never settles or activates a request, a pool span's end is always published before anything can settle it, and a failing request settles its pool span before its query span (resolving it when the hand-over happened). The COPY hand-out is not a settlement, so a COPY query's span ends at its second resolve. Untraced, a query costs one hasSubscribers check in handle() and a field read in each of handler() (trace), build() (trace), succeed and fail (trace), assign() (pool) and activate() (pool), and allocates nothing. A lease adds one check in lease() and one read of the connection's process id in own(); a connection adds one check per acquisition, and one per release and per close.
globalThis[Symbol.for('postgres.js:check')] is read once when a connection is created. When it is unset, each reporting site costs one falsy check. When it is set, the connection calls check(kind, connection, a, b):
| Kind | Reported | Checked by tests-startup/invariants.js |
|---|---|---|
created |
each new connection | records the connection for a final check |
edge |
every transition | the edge is in edges |
unhandled |
dispatch found no entry |
always a violation |
settled |
after each outermost dispatch |
the connection's queue is one its phase and owner allow |
src/query.js and src/index.js read the hook once when they load and report requests with the request as the second argument:
| Kind | Reported | Checked by tests-startup/invariants.js |
|---|---|---|
routed |
a query starts being handled, or a lease is created | records an unsettled request for a final check; a scenario fails if one never settles |
step |
a request changes stage | the edge is one of the request edges above |
requestSettled |
succeed or fail settles a request |
the stage it settles from allows Settled |
handover |
assign gives a request a connection, with whether it was fresh and how many requests were already on the connection |
a request is handed over once, and fresh exactly when it was Opening |
The checker keeps the hand-overs in handovers for scenarios that assert them.
With the hook set, connection[Symbol.for('postgres.js:phase')] returns the phase name. Every scenario in tests-startup installs the hook and fails if any violation was recorded.
A test is only evidence for the claim it makes, so each test should fit one of these kinds:
- Bug tests reproduce a specific defect. They fail on the upstream source the fork started from (porsager/postgres 411429e7) for the reason they name, and pass here. Commits that claim an upstream issue (
porsager/postgres#N) should point to one. - Contract tests pin behaviour where the fork deliberately differs from upstream, such as error codes or retry counts. They are not evidence of an upstream bug.
- Defensive tests inject a failure, for example an error emitted on a destroyed socket, that no real socket has been shown to produce.
- Refactor guards check only fork-internal state: the invariant hook, the phase getter, and timers left behind after a connection closes.
To check a test against upstream, copy the tree, replace src/ with git show 411429e7:src/<file> for each file, and run the same harness. The fake peer in tests-startup/peer.js speaks the simple-query protocol, and answers extended-query exchanges with empty results while recording the types each Parse declares, the statement names and the Bind values, and filling in described types when a scenario asks for them. Cloudflare stub cases (cf:*) only show behaviour against the in-repo stub; npm run test:workerd is the evidence for real workerd.
cancelRequestalways dials the first host and port, which is wrong after a multi-host failover has moved the session to another host.onopentakes a share of queued queries asceil(queries / (connecting + 1))while the handed-off connection is still counted inconnecting, so it counts itself twice. The formula comes from upstream.- A function-valued
idle_timeoutormax_lifetimeis evaluated once per connection, not once per session. - A query cancelled before it is awaited is still routed when it is awaited. With several connections it can open a closed slot for nothing or be skipped by
submit, and atmax: 1it writes the refused statement. go()files an idle connectionbusyaftersubmitskipped a cancelled query, so a connection with nothing on it counts as busy until the next query.cancel()on afor awaitcursor after the loop ended returns a promise that never settles, because the cursor arrows never setsettledand the query is no longer in any line.sql.end({ timeout: 0 })can resolve before the listen pool's own destroy timer has fired, so the listen pool's re-listen query settles afterend()returned.