Skip to content

bug fix: implicit transactions - #3070

Merged
zachmu merged 7 commits into
mainfrom
zachmu/implicit-transactions
Aug 12, 2026
Merged

bug fix: implicit transactions#3070
zachmu merged 7 commits into
mainfrom
zachmu/implicit-transactions

Conversation

@zachmu

@zachmu zachmu commented Aug 12, 2026

Copy link
Copy Markdown
Member

Postgres's rules for handling implicit transactions are subtle and diverge dramatically from MySQL's. This PR implements the correct semantics for implicit transactions:

  • Multiple statements in a single query message execute in an implicit transaction
  • Queries using the extended query protocol all take place in an explicit transaction, with Sync triggering a commit

There are lots of additional subtleties in this behavior, fully documented at https://www.postgresql.org/docs/current/protocol-flow.html

@zachmu zachmu changed the title Zachmu/implicit transactions bug fix: implicit transactions Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
Main PR
Total 42090 42090
Successful 18947 18407
Failures 23143 23683
Partial Successes1 5336 5327
Main PR
Successful 45.0154% 43.7325%
Failures 54.9846% 56.2675%

${\color{red}Regressions (525)}$

aggregates

QUERY:          select max(unique1) from tenk1 where unique1 > 42000;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          create function avg_finalfn(state avg_state) returns int4 as
$$
begin
	if state is null then
		return NULL;
	else
		return state.total / state.count;
	end if;
end
$$ language plpgsql;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          create function sum_finalfn(state avg_state) returns int4 as
$$
begin
	if state is null then
		return NULL;
	else
		return state.total;
	end if;
end
$$ language plpgsql;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block

alter_generic

QUERY:          CREATE FUNCTION fn_opf12  (int4, int2) RETURNS BIGINT AS 'SELECT NULL::BIGINT;' LANGUAGE SQL;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          CREATE FUNCTION fn_opf13  (int4) RETURNS BIGINT AS 'SELECT NULL::BIGINT;' LANGUAGE SQL;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          CREATE FUNCTION fn_opf14 (int4) RETURNS BIGINT AS 'SELECT NULL::BIGINT;' LANGUAGE SQL;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          CREATE FUNCTION fn_opf15 (int4, int2) RETURNS BIGINT AS 'SELECT NULL::BIGINT;' LANGUAGE SQL;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block

alter_table

QUERY:          create temp table foo (f1 text, f2 mytype, f3 text);
RECEIVED ERROR: relation "foo" already exists
QUERY:          insert into foo values('bb','cc','dd');
RECEIVED ERROR: number of values does not match number of columns provided
QUERY:          update foo set f3 = 'zz';
RECEIVED ERROR: column "f3" could not be found in any table in scope
QUERY:          create temp table log (q1 int8, q2 int8);
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          alter table alterlock2 add foreign key (f1) references alterlock (f1);
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block

amutils

QUERY:          CREATE TEMP TABLE foo (f1 int, f2 int, f3 int, f4 int);
RECEIVED ERROR: relation "foo" already exists

case

QUERY:          CREATE DOMAIN foodomain AS text;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          CREATE FUNCTION volfoo(text) returns foodomain as
  'begin return $1::foodomain; end' language plpgsql volatile;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          CREATE FUNCTION inline_eq(foodomain, foodomain) returns boolean as
  'SELECT CASE $2::text WHEN $1::text THEN true ELSE false END' language sql;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block

cluster

QUERY:          SELECT * FROM clustertest;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          SELECT * FROM clustertest;
RECEIVED ERROR: could not find the following row in the result set:
        {100}

combocid

QUERY:          DELETE FROM combocidtest;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE combocidtest SET foobar = foobar + 10;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE testcase SET balance = balance + 400 WHERE id=1;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          SAVEPOINT subxact;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE testcase SET balance = balance - 100 WHERE id=1;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          ROLLBACK TO SAVEPOINT subxact;
RECEIVED ERROR: SAVEPOINT subxact does not exist

constraints

QUERY:          DELETE FROM unique_tbl WHERE t = 'tree';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          INSERT INTO unique_tbl VALUES (5, 'one');
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE unique_tbl SET i = 4 WHERE i = 2;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE unique_tbl SET i = 2 WHERE i = 4 AND t = 'four';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          DELETE FROM unique_tbl WHERE i = 1 AND t = 'one';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          DELETE FROM unique_tbl WHERE i = 5 AND t = 'five';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE unique_tbl SET t = 'THREE' WHERE i = 3 AND t = 'Three';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          UPDATE unique_tbl SET t = 'threex' WHERE t = 'tree';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          DELETE FROM unique_tbl WHERE t = 'three';
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block

copy2

QUERY:          SAVEPOINT s1;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          TRUNCATE vistest;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          SAVEPOINT s1;
RECEIVED ERROR: SAVEPOINT can only be used in transaction blocks
QUERY:          CREATE FUNCTION truncate_in_subxact() RETURNS VOID AS
$$
BEGIN
	TRUNCATE vistest;
EXCEPTION
  WHEN OTHERS THEN
	INSERT INTO vistest VALUES ('subxact failure');
END;
$$ language plpgsql;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          BEGIN;
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          INSERT INTO vistest VALUES ('z');
RECEIVED ERROR: current transaction is aborted, commands ignored until end of transaction block
QUERY:          DROP FUNCTION truncate_in_subxact();
RECEIVED ERROR: function truncate_in_subxact does not exist

${\color{lightgreen}Progressions (9)}$

alter_table

QUERY: ALTER TABLE tt9 ADD CONSTRAINT foo UNIQUE(c);

foreign_key

QUERY: INSERT INTO pktable VALUES (2000, 3);

returning

QUERY: ALTER TABLE foo ADD COLUMN f4 int8 DEFAULT 99;

rowsecurity

QUERY: DROP ROLE regress_rls_frank;

sequence

QUERY: SELECT setval('sequence_test2', 1);

subselect

QUERY: select count(*) from tenk1 t
where (exists(select 1 from tenk1 k where k.unique1 = t.unique2) or ten < 0);

transactions

QUERY: INSERT into trans_bar VALUES (1);
QUERY: SELECT 1; SAVEPOINT sp;
QUERY: SELECT 1; COMMIT; SAVEPOINT sp;

Footnotes

  1. These are tests that we're marking as Successful, however they do not match the expected output in some way. This is due to small differences, such as different wording on the error messages, or the column names being incorrect while the data itself is correct.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
Main PR
covering_index_scan_postgres 2080.75/s 2037.93/s -2.1%
groupby_scan_postgres 150.30/s 147.88/s -1.7%
index_join_postgres 666.85/s 665.43/s -0.3%
index_join_scan_postgres 839.80/s 837.36/s -0.3%
index_scan_postgres 31.80/s 31.15/s -2.1%
oltp_delete_insert_postgres 786.99/s 750.18/s -4.7%
oltp_insert 707.37/s 715.12/s +1.0%
oltp_point_select 3484.72/s 3389.75/s -2.8%
oltp_read_only 3445.75/s 3341.05/s -3.1%
oltp_read_write 2557.31/s 2526.25/s -1.3%
oltp_update_index 744.84/s 724.68/s -2.8%
oltp_update_non_index 776.97/s 783.74/s +0.8%
oltp_write_only 1800.39/s 1810.34/s +0.5%
select_random_points 2125.41/s 2095.06/s -1.5%
select_random_ranges 1561.80/s 1557.04/s -0.4%
table_scan_postgres 30.84/s 30.92/s +0.2%
types_delete_insert_postgres 815.40/s 821.70/s +0.7%
types_table_scan_postgres 14.20/s 14.15/s -0.4%

@itoqa

itoqa Bot commented Aug 12, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: e6c1465: 19 test cases ran, 19 passed ✅.

Summary

Coverage spans database transaction and session behavior across normal commits, explicit transaction boundaries, autocommit, synchronization, cross-connection visibility, streaming and batch failures, rollback, savepoint recovery, and connection reuse after errors. The exercised paths include both expected workflows and adversarial error cases, with healthy behavior observed throughout.

Safe to merge — the run found no regressions, new failures, or previously flagged failures attributable to this PR, and all exercised transaction, recovery, and visibility behaviors passed. No merge-blocking application issues were identified.

Tests run by Ito

View full run

Result Severity Type Description
Atomicity The multi-statement query returned both inserted rows, and a second connection could read rows 1 and 2 after the query finished.
Atomicity A failed statement removed the earlier write, and the next write worked normally. A separate connection saw no leftover row before the recovery write and saw the new row afterward.
Atomicity The explicit transaction boundary worked: work before COMMIT became visible to another connection, and later work started in a new implicit block. The checked-out test covered the same boundary behavior across messages rather than the exact three-insert sequence in the plan.
Atomicity A mid-result division-by-zero error removed the earlier writes, and a second connection saw zero rows afterward. The recorded check did not include the planned follow-up write on the original connection.
Autocommit A handler-only command followed by an insert committed the row successfully. A second connection could read row 1 after the query finished, and the session returned to the idle state.
Autocommit Creating a table through the extended query flow completed normally, and the next insert was committed and visible from another connection.
Autocommit Loading rows with COPY FROM STDIN completed successfully, and a later read found the committed rows. The connection remained usable after the load.
Autocommit After the table was created, another connection saw no rows before the next write. It then saw exactly one row after that write committed.
Recovery After a duplicate-key error, the open transaction rejected further reads and writes until rollback. Rollback completed successfully and left the table empty.
Recovery After a duplicate insert failed, rolling back to the savepoint kept row 1, allowed row 2 to be added, and committed both rows. The failed duplicate insert did not add an extra row.
Recovery After an invalid insert failed, rolling back to the savepoint kept the earlier row, allowed a new insert, and committed both valid rows.
State A normal query ends in idle status, and BEGIN keeps the transaction open across the next protocol message.
State A prepared insert ran successfully but stayed hidden from another connection after Flush. The row became visible only after Sync, as expected.
State A failed database request returned an error, ignored the commands queued after it, and rolled back the unfinished work. The connection became usable again after synchronization, and the later insert succeeded without leaving unwanted rows behind.
State An explicit transaction stayed open after Sync, so another connection could not see the inserted row until COMMIT was sent.
State A second connection saw none of the rows until the multi-statement batch finished, then saw all committed rows together. A failed batch left no partial rows behind.
Sync The prepared inserts ran successfully, stayed private before Sync, and became visible to a second connection after Sync.
Sync The prepared transaction stayed open across Sync, so the inserted row remained hidden from another connection until COMMIT.
Sync A failed database command rolled back the batch, skipped the queued insert, and let a later clean batch commit successfully.

Tip

Reply with @itoqa to send us feedback on this test run.

@zachmu

zachmu commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

A note on regressions: these new regressions are a consequence of the server now correctly implementing the semantics for a failed statement in the middle of a transaction block: all statements in a block from the failure until a COMMIT / ROLLBACK are discarded because the session is in an invalid state. I manually spot checked several of these regression failures to verify this was what was actually happening. This behavior has the effect of magnifying single-statement failures by marking every statement that follows until the end of the transaction as failed as well.

This PR will establish a new baseline for correctness, and fixing the statements that currently fail will also cause the ones that follow it in the same transaction to succeed as well.

@itoqa

itoqa Bot commented Aug 12, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Reporte6c14657b4378c: 9 test cases ran, 9 passing ✅.

Diff Summary

The run covers database transaction behavior across successful multi-step work, automatic commits, explicit recovery, malformed or failing requests, duplicate writes, and cross-connection visibility. It exercises both normal flows and adversarial error cases, with the application consistently preserving atomicity, session recovery, and data isolation.

Safe to merge — the exercised transaction and recovery behaviors show no PR-attributable regressions or new failures, and all observed results support correct behavior. No merge blocker is indicated by this run.

Tests run by Ito

View full run

Result State Severity Type Description
Passing Atomicity A request with an invalid statement after an insert was rejected before any part of it ran. A second connection found the table empty, so no partial write became visible.
Passing Atomicity The earlier committed row stayed in the table, while the failing duplicate-key batch left no rows behind and stopped before its third insert. The connection then accepted a new insert and the second connection could see it.
Passing Autocommit The failing batch left no rows behind, and the next standalone insert committed and was visible from another connection.
Passing Autocommit The handler-only command completed without leaving the session stuck in a transaction. The next two inserts committed separately and were immediately visible from another connection.
Passing Autocommit After a failed batch, the next insert succeeded immediately and was visible from another connection. The failed row stayed absent across three fresh sessions.
Passing Recovery COMMIT rolled back the failed transaction, returned the ROLLBACK result, and restored the connection for new work. The earlier row stayed absent while the later row became visible to another connection.
Passing Recovery After a missing-table error, the session rejected the next SELECT, INSERT, and nested BEGIN until ROLLBACK. The session then recovered, and only the later valid row was committed.
Passing State A malformed query returned an error, but the connection stayed usable. The next insert succeeded and another connection could read the new row.
Passing State A failed multi-statement query rolled back its writes, sent its completion message after cleanup, and allowed the next query to succeed. A second connection could not see the failed row.
⏸️ Skipped Atomicity The multi-statement query returned both inserted rows, and a second connection could read rows 1 and 2 after the query finished.
⏸️ Skipped Atomicity A failed statement removed the earlier write, and the next write worked normally. A separate connection saw no leftover row before the recovery write and saw the new row afterward.
⏸️ Skipped Atomicity The explicit transaction boundary worked: work before COMMIT became visible to another connection, and later work started in a new implicit block. The checked-out test covered the same boundary behavior across messages rather than the exact three-insert sequence in the plan.
⏸️ Skipped Atomicity A mid-result division-by-zero error removed the earlier writes, and a second connection saw zero rows afterward. The recorded check did not include the planned follow-up write on the original connection.
⏸️ Skipped Autocommit A handler-only command followed by an insert committed the row successfully. A second connection could read row 1 after the query finished, and the session returned to the idle state.
⏸️ Skipped Autocommit Creating a table through the extended query flow completed normally, and the next insert was committed and visible from another connection.
⏸️ Skipped Autocommit Loading rows with COPY FROM STDIN completed successfully, and a later read found the committed rows. The connection remained usable after the load.
⏸️ Skipped Autocommit After the table was created, another connection saw no rows before the next write. It then saw exactly one row after that write committed.
⏸️ Skipped Recovery After a duplicate-key error, the open transaction rejected further reads and writes until rollback. Rollback completed successfully and left the table empty.
⏸️ Skipped Recovery After a duplicate insert failed, rolling back to the savepoint kept row 1, allowed row 2 to be added, and committed both rows. The failed duplicate insert did not add an extra row.
⏸️ Skipped Recovery After an invalid insert failed, rolling back to the savepoint kept the earlier row, allowed a new insert, and committed both valid rows.
⏸️ Skipped State A normal query ends in idle status, and BEGIN keeps the transaction open across the next protocol message.
⏸️ Skipped State A prepared insert ran successfully but stayed hidden from another connection after Flush. The row became visible only after Sync, as expected.
⏸️ Skipped State A failed database request returned an error, ignored the commands queued after it, and rolled back the unfinished work. The connection became usable again after synchronization, and the later insert succeeded without leaving unwanted rows behind.
⏸️ Skipped State An explicit transaction stayed open after Sync, so another connection could not see the inserted row until COMMIT was sent.
⏸️ Skipped State A second connection saw none of the rows until the multi-statement batch finished, then saw all committed rows together. A failed batch left no partial rows behind.
⏸️ Skipped Sync The prepared inserts ran successfully, stayed private before Sync, and became visible to a second connection after Sync.
⏸️ Skipped Sync The prepared transaction stayed open across Sync, so the inserted row remained hidden from another connection until COMMIT.
⏸️ Skipped Sync A failed database command rolled back the batch, skipped the queued insert, and let a later clean batch commit successfully.

Tip

Reply with @itoqa to send us feedback on this test run.

@itoqa

itoqa Bot commented Aug 12, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Report7b4378c7ac4615: 9 test cases ran, 9 passing ✅.

Diff Summary

The run covers database write and transaction behavior across successful operations, failures, malformed or duplicate work, aborted transactions, rollback and recovery, and protocol timing around commit and synchronization. Overall, the exercised behavior is healthy across both normal flows and adversarial error-handling cases, though some previously passing scenarios were not covered in this run.

Safe to merge — the exercised transaction, recovery, visibility, and protocol behaviors passed without regressions or PR-attributable failures. Previously passing but untested areas are a flag for later, not a merge blocker.

Tests run by Ito

View full run

Result State Severity Type Description
Passing Atomicity Both rows were inserted, the query returned to an idle state, and a second connection could read the committed rows.
Passing Atomicity A failing statement stopped the rest of the query, removed the earlier write, and left the connection ready for the next insert.
Passing Atomicity The server committed the first write at COMMIT, handled the later division-by-zero error, and kept the session usable for the next write.
Passing Protocol The inserted row was visible from a second connection after Execute and Flush, before Sync was sent. The protocol flow then completed successfully after Sync.
Passing State A failed statement put the open transaction into an aborted state, and the server rejected ordinary work until ROLLBACK restored the session.
Passing State The inserted row was visible to the observer connection after COMMIT and Flush, before Sync. The row stayed visible after Sync, so the two connections agreed on the final state.
Passing State The inserted row stayed hidden from another connection after Flush and became visible after Sync. The connection then completed normally.
Passing State The explicit transaction stayed open after Sync, so the new row remained hidden from the other connection until COMMIT made it visible.
Passing Sync A failed database command rolled back the earlier write, skipped the queued write, and let the connection continue normally after Sync.
⏸️ Skipped Atomicity A request with an invalid statement after an insert was rejected before any part of it ran. A second connection found the table empty, so no partial write became visible.
⏸️ Skipped Atomicity The earlier committed row stayed in the table, while the failing duplicate-key batch left no rows behind and stopped before its third insert. The connection then accepted a new insert and the second connection could see it.
⏸️ Skipped Autocommit The failing batch left no rows behind, and the next standalone insert committed and was visible from another connection.
⏸️ Skipped Autocommit The handler-only command completed without leaving the session stuck in a transaction. The next two inserts committed separately and were immediately visible from another connection.
⏸️ Skipped Autocommit After a failed batch, the next insert succeeded immediately and was visible from another connection. The failed row stayed absent across three fresh sessions.
⏸️ Skipped Recovery COMMIT rolled back the failed transaction, returned the ROLLBACK result, and restored the connection for new work. The earlier row stayed absent while the later row became visible to another connection.
⏸️ Skipped Recovery After a missing-table error, the session rejected the next SELECT, INSERT, and nested BEGIN until ROLLBACK. The session then recovered, and only the later valid row was committed.
⏸️ Skipped State A malformed query returned an error, but the connection stayed usable. The next insert succeeded and another connection could read the new row.
⏸️ Skipped State A failed multi-statement query rolled back its writes, sent its completion message after cleanup, and allowed the next query to succeed. A second connection could not see the failed row.

Tip

Reply with @itoqa to send us feedback on this test run.

@fulghum fulghum left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty solid! I tested a couple more cases locally and couldn't find an issue.

@zachmu
zachmu merged commit 9ddb7a7 into main Aug 12, 2026
25 checks passed
@zachmu
zachmu deleted the zachmu/implicit-transactions branch August 12, 2026 19:26
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.

2 participants