Fix statement rollback discarding the whole transaction on a syntax error (#203) - #204
Merged
Merged
Conversation
…vs syntax error) With Protocol=7.4-2 (statement rollback), a syntax error currently rolls back the entire transaction instead of just the offending statement, silently discarding earlier work. This test inserts a row, triggers a syntax error, and asserts the row survives -- plus an execution-time error case that already works, as a regression guard. Fails on the current driver (row count 0 instead of 1 after the syntax error); the fix follows.
The existing error-rollback-test only exercised one error class (invalid integer input, 22P02) via SQLExecDirect, so it never caught the case where a *syntax* error (42601) silently aborts the whole transaction under statement-level rollback (Protocol=7.4-2). Add a matrix section that, under 7.4-2, inserts a marker row and then runs a failing statement, checking that the marker row survives (i.e. only the statement, not the transaction, was rolled back). Cases cover three error classes (42601 syntax, 42P01 undefined relation, 22P02 bad value) crossed with two execution paths (ExecDirect vs Prepare/Execute) and both UseServerSidePrepare settings. The syntax-error rows are the ones broken by the bug in the bundled 'SAVEPOINT ...; <stmt>' simple-query send: ExecDirect+SSP=0, Prepare+SSP=0 and ExecDirect+SSP=1 all report marker=0 on the current driver. Prepare+SSP=1 already works because Prepare has its own separate round-trip when server-side prepare is on. Uses SQLSTATE-only output for the new section so the expected file stays stable across PostgreSQL versions and locales. Existing protocol 0/1/2 blocks are unchanged to keep pre-existing expected lines identical.
With statement-level rollback (Protocol=7.4-2) the driver established the per-statement internal SAVEPOINT by prepending it to the user's statement and sending 'SAVEPOINT ...; <statement>' as a single simple-query string (the SVPOPT_REDUCE_ROUNDTRIP optimization). PostgreSQL parses a simple-query string in full before executing any of it, so a statement that fails at *parse* time -- a syntax error (SQLSTATE 42601) -- fails the whole string, and the leading SAVEPOINT never runs. With no savepoint to roll back to, the driver fell back to aborting the entire transaction, silently discarding work done earlier in it (e.g. a table lock, as reported). Execution-time errors (missing relation, type-coercion, etc.) were unaffected because the SAVEPOINT executed before the failing statement was analyzed. Send the SAVEPOINT as its own round-trip so it is always in place before the user's statement is parsed. A syntax error then rolls back only that statement and the transaction stays usable. This costs one extra round-trip per rolled-back statement under 7.4-2. Covered by rollback-syntax-error-test and the error-class matrix added to error-rollback-test.
With the #203 fix, SetStatementSvp no longer defers the internal SAVEPOINT to be prepended onto the next query, so PREPEND_IN_PROGRESS is never set. Remove the unreachable prepend handling in CC_send_query_append (the prepend_savepoint local and its branch) and the PREPEND_IN_PROGRESS enumerator. No behavior change.
davecramer
force-pushed
the
fix-203-statement-rollback-syntax-error
branch
from
August 27, 2026 15:06
08401bd to
d8e8f4a
Compare
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.
Problem
With statement-level rollback (
Protocol=7.4-2, the default on PG >= 8.0), a statement that fails with a syntax error silently rolls back the entire transaction instead of just the failing statement -- discarding work already done in that transaction. The reporter observed a tableLOCKobtained earlier being released, and the transaction ID advancing, after a malformedLOCK ... IN POTATOS MODE.Confusingly, other errors (undefined relation, type-coercion, undefined column, etc.) behaved correctly -- only syntax errors triggered it -- which made it look like "a lottery."
Fixes #203.
Root cause
To save a round-trip, the driver established the per-statement internal
SAVEPOINTby prepending it to the user's statement and sending them as a single simple-query string:PostgreSQL parses a simple-query string in full before executing any of it. A statement that fails at parse time -- a syntax error (
SQLSTATE 42601) -- fails the whole string, so the leadingSAVEPOINTnever runs. With no savepoint established, statement-level rollback has nothing to roll back to, and the driver falls back to aborting the whole transaction.Execution-time errors (missing relation, bad value, etc.) are unaffected, because parsing succeeds and the
SAVEPOINTexecutes before the failing statement is analyzed. That parse-time-vs-execution-time split is what made this look inconsistent.Fix
Send the internal
SAVEPOINTas its own round-trip, before the user's statement, rather than bundling them into one parse unit (SetStatementSvpinexecute.c). The savepoint is then guaranteed to be in place before the statement is parsed, so a syntax error rolls back only that statement and the transaction stays usable.Also removes the now-unreachable prepend machinery (
PREPEND_IN_PROGRESSand its handling inCC_send_query_append).Performance impact
This changes savepoint establishment from bundled (0 extra messages) to a separate round-trip, so it adds one extra round-trip for every statement except the first in a transaction, under
Protocol=7.4-2with autocommit off. This is a proactive savepoint taken before nearly every statement -- not only before ones that fail -- and it includes readSELECTs.Measured wire traffic for
INSERT; INSERT; SELECT; INSERT(autocommit off,7.4-2):Before -- 5 round-trips (savepoint bundled with each statement):
After -- 8 round-trips (savepoint sent separately):
So an N-statement transaction incurs roughly N-1 extra round-trips (nearly 2x the message count). On a high-latency link this is a real slowdown. It is the price of correctness -- the previous behavior could silently discard work already done in a transaction.
Not affected: the first statement of each transaction (bundled with
BEGIN), autocommit-on connections, andProtocol=7.4-0/7.4-1(no statement-level rollback, no savepoints).A future optimization could restore the single round-trip by piggybacking the next statement's
SAVEPOINTonto the previous statement's send; that is a larger change and is intentionally out of scope here. Maintainer input welcome on whether to ship this straightforward fix now and optimize later, or gate the old bundling behavior behind an option.Testing
Verified against PostgreSQL 18 via unixODBC.
rollback-syntax-error-test: inserts a row, triggers a syntax error, and asserts the row survives (statement-only rollback) -- plus an execution-time-error guard.error-rollback-testwith an error-class matrix under7.4-2: three error classes (42601syntax,42P01undefined relation,22P02bad value) x two execution paths (SQLExecDirect,SQLPrepare/SQLExecute) xUseServerSidePrepare0/1, each asserting a marker row survives. This is the coverage gap that let the bug through -- the pre-existing test only exercised execution-time errors.Both tests fail on
main(row/transaction lost on syntax error) and pass with this change. Existing transaction/cursor/error regression tests remain green (the oneparamsdiff is a pre-existing PG18 baseline mismatch, confirmed identical on the unmodified driver).