Repository navigation
Retry a stale prepared statement only where it is safe - #1257
Open
GrahamCampbell wants to merge 3 commits into
Open
GrahamCampbell wants to merge 3 commits into
GrahamCampbell wants to merge 3 commits into
Conversation
With `prepare: true`, `begin()` sent `savepoint`, `rollback to`, `rollback`, `commit` and `prepare transaction` as tagged templates, so each became a named prepared statement cached on the client connection. On a transaction pooler that does not track named statements (or after the pooler evicts one), the Bind of a named `commit` reaches a backend that never parsed it and fails with SQLSTATE 26000. That error aborts the transaction, and `FetchPreparedStatement` is in `retryRoutines`, so the driver re-sends `commit` on the aborted transaction. Postgres answers that COMMIT with ROLLBACK and no error, and the transaction's writes are lost silently. Reproduced against a Supavisor pooler in transaction mode with one client connection and a second client keeping the other backends busy, twenty sequential `sql.begin` inserts, body statement unnamed: before: 9 of 20 rows, 31 `commit` sends, 0 errors after: 20 of 20 rows, 20 `commit` sends, 0 errors `begin` already goes through `unsafe`. This sends the other five control statements the same way. A zero-argument `unsafe` is `simple: true`, so no `prepare` setting can name it, and the transaction lifecycle (connection close, release only when idle, per-query error capture) is unchanged. Identifier quoting matches `sql(name)`; the `prepare transaction` name doubles single quotes instead of being spliced in raw.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is stacked on #1212, whose commit is the first one here, unchanged; once #1212 is merged, this is the last two commits. It contains #1239's change, so the two conflict.
A prepared statement the server answers as stale (
cached plan must not change result typeafter a column change,prepared statement ... does not existafterDEALLOCATE ALLor behind a pooler) is sent again, but the resend does not hold its connection and is written behind whatever is already pipelined. On a busy pool the resend and another statement can then resolve with each other's rows, with no error when the rows have the same shape. Two stale statements piped together leave the second pending until the next statement's answer arrives, which it takes, while its cached statement keeps that statement's columns. A stale write can be resent inside a transaction begun behind it and rolled back with it.This holds the connection during a resend, through a new
onfullcallback, and resends only when the statement is not cancelled, nothing is pipelined behind it and its ReadyForQuery says idle. Otherwise the cached statement is evicted and the statement rejects with the server's error.That is a trade, made on purpose: a stale statement with another statement pipelined behind it now fails once with the server's error (
0A000,42804or26000) instead of being resent, and its next run prepares afresh. A stale statement with nothing behind it is still resent as before.prepare: truenever namescommit#1212 and passing with the change. Fix: Do not mutate parameters when serializing in Bind - fixes multiple serialization on retry #1198's threedeallocate alltests now use their own client.Mechanism, the trade in full, reproduction and output
This is stacked on #1212 (transaction control on the simple protocol), whose commit is the first one here, unchanged. Like #1239, the status check below needs it: without #1212, a
rollbackwhose prepared statement the server no longer has, afterDEALLOCATE ALLor behind a pooler, fails inside the aborted transaction and is not resent, sosql.beginrejects with26000and leaves its connection in that transaction. This also contains #1239's change (the idle check andforget), so the two conflict; if #1239 is merged first, this reduces to the!query.cancelled && !sent.lengthterms,onfulland the tests. Once #1212 is merged, this is the last two commits.A prepared statement answered with an error whose routine is in
retryRoutines(FetchPreparedStatement,RevalidateCachedQuery,transformAssignedExpr) is sent again, which is how an application gets past #120'scached plan must not change result typewhen a column is added to a table it reads withselect *orreturning *.retryevicts the cached statement and callsexecuteagain. That puts the statement at the tail ofsent, andbuildfinds it no longer prepared, so a statement with parameters becomes describe-first: only Parse/Describe/Flush is written, and its Bind/Execute/Sync follows when its ParameterDescription arrives. A statement without parameters is resent whole in one write. The resend has two faults: it does not hold its connection, and it is written behind whatever is already pipelined.executereturns false for a describe-first statement. Everywhere else that false moves the connection tofull, so the pool hands it nothing more until that statement has been answered:go,onopen, and thereserve()andbegin()handlers.retrydrops it, so a connection that was inbusystays there, and when the pool has noopenorclosedconnection,handlergives the next statement to it. That statement's Bind/Execute/Sync goes out between the resend's describe and its Bind, and the server answers in that order while the resend is current, so the resend resolves with the other statement's rows decoded with its own columns, and the other statement, current next, resolves with the resend's. When the rows have the same shape, as in cases 2 and 3 below, nothing reports an error.When the stale statement's error arrives with another statement already in
sent, the resend is written behind that statement. If that statement is stale too, it fails the same way and is resent, and two describes are in flight: the second statement waits for a ParameterDescription that has already gone by, and settles only when the next statement on the connection is answered, with that statement's rows, while its cached statement keeps that statement's columns for every later run (case 4). If it is a statement with parameters running for the first time, a stale statement without parameters resent behind it is answered out of turn: the stale statement rejects with aTypeError, and the statement behind it resolves with the stale statement's rows and caches its columns, so its next run fails withERR_OUT_OF_RANGE. If it is a write, the resent write runs after it: on areserve()connection, aninsertresent behind theupdatethat followed it leaves the row as theinsertwrote it, and both resolve. And the resend is decided by the status byte of the stale statement's own ReadyForQuery, but runs after everything insent.sql.beginsends itsBEGINlike any statement, so on a busy connection it can be pipelined behind a stale autocommit write, whose ReadyForQuery still says idle. The resend then goes out after theBEGINand runs inside the transaction, the write resolves with its row, and the transaction's rollback erases it (case 5). #1239 (for #1234) reads the same byte and resends here too.This change holds the connection, and resends only where the resend runs as the original statement would have.
retryreports a false return fromexecutethrough a newonfullcallback, besideonopen,onendandonclose, and the pool answers it withmove(c, full), the hold it gives any describe-first statement. Areserve()connection is held the same way: its handler queues while the connection is infull, andconnection.reserved()hands it back at the resend's ReadyForQuery. The statement is resent only when it is not cancelled, nothing is pipelined behind it, and its ReadyForQuery says idle; otherwise #1239'sforgetevicts the cached statement and rejects the statement with the server's error. With nothing behind it, the status the statement was answered with is the status its resend runs in, and no other describe can be in flight ahead of the resend. Inside a transaction the status isEafter the error, so nothing is resent there, as with #1239. The!sent.lengthterm is also what makes the hold safe: withonfulland without it, case 4's second statement keeps its connection infullbehind a describe that never completes, and that connection and every statement given to it stay pending for good.The
!query.cancelledterm is for #1252's cancelled statements. A statement cancelled while it waits pipelined, that stays current because it was last insentand is answered with one of these errors, goes toretry, whoseexecutereturns at once for a cancelled statement, so it never settles. With this change it rejects with its own error.This is a trade, made on purpose. A stale statement that has another statement pipelined behind it on its connection when its error arrives now fails once with the server's error instead of being resent. That is
0A000(cached plan must not change result type) after a column change,42804(fromtransformAssignedExpr) after a column's type changes under a cachedinsertorupdate, or26000(prepared statement ... does not exist) afterDEALLOCATE ALL, afterDISCARD ALLor behind a pooler that loses named statements, with or without parameters, and on areserve()connection as on the pool. Its cached statement is evicted, so its next run prepares afresh, and a stale statement with nothing behind it is still resent as before. Where the statements behind a stale statement without parameters are fully written, open no transaction and do not depend on it having run first, master's resend was correct, so there the change costs one failure and gains nothing. For a statement with parameters, master's resend was right only if nothing reached the connection during its describe. The connection keeps no state that would tell those followers apart from a describe in flight, aBEGINor a dependent write, so the stale statement fails once behind any of them. In return, the hold fixes case 2 without the permanent hang it would otherwise cause in case 4, and the term ends case 3's crossed answers (there the stream is already pipelined behind the stale statement, soread(1)now fails once instead), the second statement of case 4 taking another statement's answer, the write resent into a transaction begun after it, the stale statement answered with a first-run statement's rows while that statement caches the wrong columns, and the writes on a reservation run out of order.#1198's three
deallocate alltests now use their own client. On the suite's sharedmax: 1connection they left every statement cached before them stale, and "reserve connection" then pipelined a staleselect 1 as xahead ofselect 3 as xon a reservation, which this change fails once with26000. The new tests are "Fails a stale prepared statement once when a query is piped behind it" (the trade, and the next run preparing afresh), "Holds the connection while retrying a prepared statement" (case 2), "Answers two stale prepared statements piped together" (case 4), "Does not retry a stale prepared statement inside a transaction begun behind it" (case 5), "Does not give a stale prepared statement the answer of a statement piped behind it", "Does not reorder writes on a reserved connection when retrying a stale prepared statement" and "Does not retry a cancelled stale prepared statement". Each fails on #1212 and passes with this change. The last commit is the output ofnpm run build. Since master's generated copies have not been rebuilt since #1198, it carries #1198's line and tests into them as well, and it can be dropped if you would rather build them yourself.Only
postgres, against a throwaway server started from the stock image:Each case uses a pool of its own with
max: 1, so its statements share one connection, as on a larger pool whose connections are all busy, andotheris a second client that creates the tables and adds the column that makes the warm statements stale. Case 1 is the control: a stale statement alone on its connection is resent and gets its own row. In case 2 thedebugoption, whichbuildcalls, hands a statement to the pool while the resend is built; it reaches the pool'shandlera microtask later, afterretryhas returned. Case 3 has no hook, only a stream of statements of the same row shape, one per turn of the event loop, from the stale statement's first run until it settles. In case 4 the two stale reads are issued together, andnextfollows once the second has been pending for 3 s. In case 5 the insert and thesql.beginare issued together, so theBEGINis pipelined behind the insert.On master (411429e), and the same on #1212:
With this change:
Each output was the same on a second run apart from case 3's row and counts, which follow timing. With the change,
read(1)in cases 3 and 4 and the insert in case 5 fail once with0A000, because other statements were pipelined behind each when its error arrived, and the eviction makes the next run prepare afresh (case 4's last line). Each part is needed: withoutonfull, case 2 prints as on master; without!sent.length, case 3'sread(1)resolves with its own row, but in case 4read(2)and every statement after it stay pending, and case 5 prints as on master; with #1239's change alone, cases 2 to 5 print as on master.Not covered here:
sql.release()called while a describe-first statement is still in flight on a reserved connection returns the connection to the pool, which can then hand it a statement before that statement's Bind is written, and any statement already queued on the reservation behind it is never sent, as on master for any describe-first statement. Since the resend now holds a reserved connection the same way, a statement sent on a reservation during a resend and followed byrelease()before the resend is answered now stays pending, where on master the resend took that statement's answer instead; the change leavesrelease()itself alone. A statement cancelled while it is running is not marked cancelled, so if one of these errors answers it before the CancelRequest arrives, it is still resent, as on master.