Skip to content

Fix statement rollback discarding the whole transaction on a syntax error (#203) - #204

Merged
davecramer merged 4 commits into
mainfrom
fix-203-statement-rollback-syntax-error
Aug 27, 2026
Merged

Fix statement rollback discarding the whole transaction on a syntax error (#203)#204
davecramer merged 4 commits into
mainfrom
fix-203-statement-rollback-syntax-error

Conversation

@davecramer

Copy link
Copy Markdown
Contributor

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 table LOCK obtained earlier being released, and the transaction ID advancing, after a malformed LOCK ... 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 SAVEPOINT by prepending it to the user's statement and sending them as a single simple-query string:

RELEASE _EXEC_SVP_x; SAVEPOINT _EXEC_SVP_x; <user statement>

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 leading SAVEPOINT never 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 SAVEPOINT executes before the failing statement is analyzed. That parse-time-vs-execution-time split is what made this look inconsistent.

Fix

Send the internal SAVEPOINT as its own round-trip, before the user's statement, rather than bundling them into one parse unit (SetStatementSvp in execute.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_PROGRESS and its handling in CC_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-2 with autocommit off. This is a proactive savepoint taken before nearly every statement -- not only before ones that fail -- and it includes read SELECTs.

Measured wire traffic for INSERT; INSERT; SELECT; INSERT (autocommit off, 7.4-2):

Before -- 5 round-trips (savepoint bundled with each statement):

BEGIN;INSERT INTO rt VALUES (1)
SAVEPOINT ...;INSERT INTO rt VALUES (2)
RELEASE ...;SAVEPOINT ...;SELECT count(*) FROM rt
RELEASE ...;SAVEPOINT ...;INSERT INTO rt VALUES (3)
ROLLBACK

After -- 8 round-trips (savepoint sent separately):

BEGIN;INSERT INTO rt VALUES (1)
SAVEPOINT ...
INSERT INTO rt VALUES (2)
RELEASE ...;SAVEPOINT ...
SELECT count(*) FROM rt
RELEASE ...;SAVEPOINT ...
INSERT INTO rt VALUES (3)
ROLLBACK

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, and Protocol=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 SAVEPOINT onto 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.

  • New 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.
  • Extended error-rollback-test with an error-class matrix under 7.4-2: three error classes (42601 syntax, 42P01 undefined relation, 22P02 bad value) x two execution paths (SQLExecDirect, SQLPrepare/SQLExecute) x UseServerSidePrepare 0/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 one params diff is a pre-existing PG18 baseline mismatch, confirmed identical on the unmodified driver).

…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
davecramer force-pushed the fix-203-statement-rollback-syntax-error branch from 08401bd to d8e8f4a Compare August 27, 2026 15:06
@davecramer
davecramer merged commit 54b6eef into main Aug 27, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Whole transaction aborted with Protocol=7.4-2 (MS Windows)

1 participant