From 6ab18155522c0407c1c26db6661e5b508e48030a Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 05:57:54 +0000 Subject: [PATCH 1/3] test: a prepared transaction, seen from other sessions and across a crash (#1341) prepared_writes covers PREPARE and COMMIT PREPARED in one session. A new suite, prepared_across_sessions, and its pytest twin (on a private cluster) cover the rest: - while a transaction is prepared, another session does not see its insert and still sees the rows its DELETE removed; - a second insert of a key it holds waits, then gets 23505 after COMMIT PREPARED; - an UPDATE of a row it updated waits, then gets 40001; - ROLLBACK PREPARED of a DELETE and of an UPDATE leaves every row as it was; - a prepared insert survives an immediate crash, is still listed, and after COMMIT PREPARED its rows equal a heap table's. Stacked on #1422: the 40001 arm needs that fix, and on main it reads 23505. 10/10 on each of PG15-19, and the twin on each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019VvWuX1zUwk6Y513waRd4N --- test/check_ledger.tsv | 10 ++ test/check_ledger_budget.txt | 14 +- test/prepared_across_sessions.sh | 101 ++++++++++++++ test/pytest/TESTS.md | 11 ++ test/pytest/expected_tests.txt | 4 +- test/pytest/test_compare_to_bash.py | 1 + test/pytest/test_prepared_across_sessions.py | 138 +++++++++++++++++++ test/run_all_versions.sh | 1 + 8 files changed, 278 insertions(+), 2 deletions(-) create mode 100755 test/prepared_across_sessions.sh create mode 100644 test/pytest/test_prepared_across_sessions.py diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 5c0a71a51..b023f9b62 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -2296,6 +2296,16 @@ partman_columnar partman_columnar premise: with default_table_access_method ever partman_columnar partman_columnar premise: without default_table_access_method pg_partman makes heap partitions 15;16;17;18;19 never - partman_columnar partman_columnar retention leaves no pgcolumnar storage row behind 15;16;17;18;19 2026-10-12 the drop hook stops deleting the storage row, so a partition pg_partman drops leaves it behind partman_columnar partman_columnar the rows went in 15;16;17;18;19 2026-10-12 a sequential scan drops the last row group from its list, so every one-group partition reads empty +prepared_across_sessions prepared_across_sessions ROLLBACK PREPARED of a DELETE leaves every row as it was 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions ROLLBACK PREPARED of an UPDATE leaves every row as it was, none duplicated 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions a prepared insert is invisible to another session 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions a prepared insert survives a crash: the cluster came back and still holds the prepared transaction 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions a second insert of a key the prepared transaction holds waits, then fails with 23505 after COMMIT PREPARED 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions an UPDATE of a row a prepared transaction updated waits, then fails with 40001 after COMMIT PREPARED 15;16;17;18;19 2026-10-15 the tree before #1421: a same-row UPDATE on a keyed table fails with 23505 rather than 40001 +prepared_across_sessions prepared_across_sessions and after COMMIT PREPARED its rows are heap's 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions and after COMMIT PREPARED the other session sees all the prepared rows 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions premise: prepared transactions are enabled 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions while a DELETE is prepared, another session still sees every row 15;16;17;18;19 never - prepared_writes prepared_writes a delete prepared and committed in one session leaves the session answering 15;16;17;18;19 2026-10-13 PREPARE letting go of neither (main before #1323);PREPARE without letting go of the delete-vector buffers prepared_writes prepared_writes an index fetch before PREPARE, and the same row fetched first after COMMIT PREPARED, in one session 15;16;17;18;19 never - prepared_writes prepared_writes an insert prepared and committed in one session leaves the session answering 15;16;17;18;19 2026-10-13 PREPARE letting go of neither (main before #1323);PREPARE without letting go of the pending write states diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 01fcbe715..c7353ab78 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -2316,4 +2316,16 @@ suites_not_covered 232 # never 2179 + 6 = 2185 # dated 694 + 9 = 703 # 2185 + 703 == 2888 -checks_never_observed_red 2185 +# +# #1341, a prepared transaction seen from other sessions and across a crash +# (jdatcmd, stacked on #1422): 2185 -> 2194. prepared_across_sessions enters with all +# 10 checks from five-major runs (10/10 on each of 15-19). One is dated on PG17 by +# the tree before #1421, where an UPDATE of a row a prepared transaction updated +# failed with 23505. The other nine arrive never: a discard at PRE_PREPARE did not +# redden them, because each statement's writes are flushed at its executor end. +# +# ledger rows 2888 + 10 = 2898 +# never 2185 + 9 = 2194 +# dated 703 + 1 = 704 +# 2194 + 704 == 2898 +checks_never_observed_red 2194 diff --git a/test/prepared_across_sessions.sh b/test/prepared_across_sessions.sh new file mode 100755 index 000000000..bfbeb4e4d --- /dev/null +++ b/test/prepared_across_sessions.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# A prepared transaction, seen from other sessions and across a crash (#1341). +# prepared_writes.sh covers PREPARE and COMMIT PREPARED in one session. Untested +# until now: +# +# - what another session sees while a transaction is prepared; +# - the unique-key and row locks a prepared transaction still holds: a second +# writer of the same key or row waits, then fails once COMMIT PREPARED runs; +# - ROLLBACK PREPARED of a DELETE and of an UPDATE; +# - a prepared insert surviving a crash and committing after recovery. +# +# The columnar table has a primary key; a heap table loaded with the same rows is the +# answer where one is needed. Waits are observed, not slept on. +# +# Usage: test/prepared_across_sessions.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +export PGC_EXTRA_CONF='max_prepared_transactions=4' +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +check "premise: prepared transactions are enabled" "$(q "SHOW max_prepared_transactions")" "4" +psql_run "CREATE TABLE px (k int PRIMARY KEY, v text) USING pgcolumnar; + INSERT INTO px SELECT g, 'r' || g FROM generate_series(1, 1000) g;" +rows() { q "SELECT count(*) || '/' || md5(string_agg(k || ':' || v, ',' ORDER BY k)) FROM px" | grep -E '^[0-9]+/' | tail -1; } +P() { env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" -Atq -v VERBOSITY=sqlstate "$@"; } +sqlstate_of() { sed -n 's/^ERROR: *\([0-9A-Z]\{5\}\).*/\1/p' "$1" | tail -1; } +waits_on_lock() { # waits_on_lock APPLICATION_NAME -> yes once that session waits on a lock + local i + for i in $(seq 1 300); do + [ "$(q "SELECT count(*) FROM pg_stat_activity WHERE application_name = '$1' AND wait_event_type = 'Lock'")" = 1 ] && { echo yes; return; } + sleep 0.1 + done + echo no +} + +# ---- a prepared insert, from another session ------------------------------------------ +psql_run "BEGIN; INSERT INTO px SELECT g, 'p' || g FROM generate_series(1001, 1100) g; PREPARE TRANSACTION 'px_ins';" >/dev/null +check "a prepared insert is invisible to another session" "$(q "SELECT count(*) FROM px WHERE k > 1000")" "0" +P -c "SET application_name = 'px_dup'; SET statement_timeout = '60s';" -c "INSERT INTO px VALUES (1050, 'dup');" \ + >"$PGC_WORKDIR/px_dup.out" 2>&1 & +dup=$! +waited="$(waits_on_lock px_dup)" +psql_run "COMMIT PREPARED 'px_ins';" >/dev/null +wait "$dup" +check "a second insert of a key the prepared transaction holds waits, then fails with 23505 after COMMIT PREPARED" \ + "$waited|$(sqlstate_of "$PGC_WORKDIR/px_dup.out")|$(q "SELECT count(*) || '/' || min(v) FROM px WHERE k = 1050")" "yes|23505|1/p1050" +check "and after COMMIT PREPARED the other session sees all the prepared rows" "$(q "SELECT count(*) FROM px WHERE k > 1000")" "100" + +# ---- ROLLBACK PREPARED of a DELETE and of an UPDATE ----------------------------------- +before="$(rows)" +psql_run "BEGIN; DELETE FROM px WHERE k % 20 = 0; PREPARE TRANSACTION 'px_del';" >/dev/null +check "while a DELETE is prepared, another session still sees every row" "$(rows)" "$before" +psql_run "ROLLBACK PREPARED 'px_del';" >/dev/null +check "ROLLBACK PREPARED of a DELETE leaves every row as it was" "$(rows)" "$before" +psql_run "BEGIN; UPDATE px SET v = v || '!' WHERE k % 20 = 1; PREPARE TRANSACTION 'px_upd';" >/dev/null +psql_run "ROLLBACK PREPARED 'px_upd';" >/dev/null +check "ROLLBACK PREPARED of an UPDATE leaves every row as it was, none duplicated" "$(rows)" "$before" + +# ---- a row lock carried across PREPARE -------------------------------------------------- +psql_run "BEGIN; UPDATE px SET v = 'prepared' WHERE k = 7; PREPARE TRANSACTION 'px_row';" >/dev/null +P -c "SET application_name = 'px_rowc'; SET statement_timeout = '60s';" -c "UPDATE px SET v = 'other' WHERE k = 7;" \ + >"$PGC_WORKDIR/px_rowc.out" 2>&1 & +rc=$! +waited="$(waits_on_lock px_rowc)" +psql_run "COMMIT PREPARED 'px_row';" >/dev/null +wait "$rc" +check "an UPDATE of a row a prepared transaction updated waits, then fails with 40001 after COMMIT PREPARED" \ + "$waited|$(sqlstate_of "$PGC_WORKDIR/px_rowc.out")|$(q "SELECT count(*) || '/' || min(v) FROM px WHERE k = 7")" "yes|40001|1/prepared" + +# ---- a prepared insert across a crash ------------------------------------------------- +crash_cluster() { + local pm i + pm="$(head -1 "$PGC_PGDATA/postmaster.pid" 2>/dev/null || true)" + [ -n "$pm" ] && { kill -9 -"$pm" 2>/dev/null || true; kill -9 "$pm" 2>/dev/null || true; } + for i in $(seq 1 100); do { [ -n "$pm" ] && kill -0 "$pm" 2>/dev/null; } || break; sleep 0.1; done + sleep 1 +} +restart_cluster() { + local attempt + for attempt in 1 2 3 4 5 6; do + pgc_pg "pg_ctl -D '$PGC_PGDATA' -l '$PGC_LOGFILE' start -w" >/dev/null 2>&1 && { echo ok; return; } + sleep 2 + done + echo "restart failed" +} +psql_run "CREATE TABLE px_heap (k int PRIMARY KEY, v text); + INSERT INTO px_heap SELECT g, md5(g::text) FROM generate_series(5001, 10000) g; + BEGIN; INSERT INTO px SELECT g, md5(g::text) FROM generate_series(5001, 10000) g; PREPARE TRANSACTION 'px_crash';" >/dev/null +crash_cluster +back="$(restart_cluster)" +check "a prepared insert survives a crash: the cluster came back and still holds the prepared transaction" \ + "$back|$(q "SELECT count(*) FROM pg_prepared_xacts WHERE gid = 'px_crash'")" "ok|1" +psql_run "COMMIT PREPARED 'px_crash';" >/dev/null +check "and after COMMIT PREPARED its rows are heap's" \ + "$(q "SELECT count(*) || '/' || md5(string_agg(k || ':' || v, ',' ORDER BY k)) FROM px WHERE k > 5000")" \ + "$(q "SELECT count(*) || '/' || md5(string_agg(k || ':' || v, ',' ORDER BY k)) FROM px_heap")" + +pgc_summary diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index f1359eb09..8463a950f 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -161,6 +161,7 @@ behaviour, the source of that number is named. - [113. test_vacuum_lock_privilege.py: maintenance refuses a non-owner before taking its lock](#113-test_vacuum_lock_privilegepy-maintenance-refuses-a-non-owner-before-taking-its-lock) - [114. test_vm_privilege.py: the visibility-map entry points are owner-only and refuse heap](#114-test_vm_privilegepy-the-visibility-map-entry-points-are-owner-only-and-refuse-heap) - [115. test_partition_rows.py: rows cross columnar partitions, and keys hold through the parent](#115-test_partition_rowspy-rows-cross-columnar-partitions-and-keys-hold-through-the-parent) +- [116. test_prepared_across_sessions.py: a prepared transaction, seen from other sessions and across a crash](#116-test_prepared_across_sessionspy-a-prepared-transaction-seen-from-other-sessions-and-across-a-crash) ## 1. How to read a test in here @@ -6969,3 +6970,13 @@ The pytest twin of `test/partition_rows.sh` (#1341, #1421), every check by the s | --- | --- | | `test_rows_cross_columnar_partitions_and_keys_hold_through_the_parent` | the row-moving update against heap, the three key arms, and the three races with each contender seen waiting | | `test_a_keyed_update_keeps_its_memory_bounded` | an UPDATE of 25,000 keyed rows, in place and moving partition, over a row group with deletes, grows the backend's memory by under 32 MB (#1422 review) | + +## 116. test_prepared_across_sessions.py: a prepared transaction, seen from other sessions and across a crash + +The pytest twin of `test/prepared_across_sessions.sh` (#1341), every check by the shell's name, on a private cluster because the last arm crashes the postmaster. While a transaction is prepared, another session does not see its insert and still sees the rows its DELETE removed. A second insert of a key it holds waits and gets 23505 after COMMIT PREPARED; an UPDATE of a row it updated waits and gets 40001, which needs #1421's fix (it got 23505 before, the table having a primary key). ROLLBACK PREPARED of a DELETE and of an UPDATE leaves every row as it was. A prepared insert survives an immediate stop, is still listed in `pg_prepared_xacts`, and after COMMIT PREPARED its rows equal a heap table's. + +### Every test + +| test | what it holds | +| --- | --- | +| `test_a_prepared_transaction_across_sessions_and_a_crash` | visibility while prepared, the two waits and their errors, both ROLLBACK PREPAREDs, and the crash | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index cf198d20a..97156ecbf 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -677,4 +677,6 @@ guard_tests 435 # over the cluster files on this tree. # cluster_tests 568 -> 569 for #1422 review's test_a_keyed_update_keeps_its_memory_bounded # in test_partition_rows.py, by collection over the cluster files on this tree. -cluster_tests 569 +# cluster_tests 569 -> 570 for #1341's test_prepared_across_sessions.py, one test, by +# collection over the cluster files on this tree (stacked on #1422). +cluster_tests 570 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index 220b08118..1455aca25 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -131,6 +131,7 @@ "partition_lifecycle", "partition_rows", "partman_columnar", + "prepared_across_sessions", "prepared_writes", "projection_drop_column", "projection_parallel", diff --git a/test/pytest/test_prepared_across_sessions.py b/test/pytest/test_prepared_across_sessions.py new file mode 100644 index 000000000..5651953de --- /dev/null +++ b/test/pytest/test_prepared_across_sessions.py @@ -0,0 +1,138 @@ +"""A prepared transaction, seen from other sessions and across a crash (#1341). The +pytest twin of `test/prepared_across_sessions.sh`, every check by the shell's name. + +prepared_writes covers PREPARE and COMMIT PREPARED in one session. This covers what +another session sees while a transaction is prepared; the unique-key and row locks a +prepared transaction still holds (a second writer waits, then fails once COMMIT +PREPARED runs); ROLLBACK PREPARED of a DELETE and an UPDATE; and a prepared insert +surviving a crash and committing after recovery. + +A PRIVATE CLUSTER, because the last arm crashes the postmaster (`-m immediate`). +""" +import pathlib +import shutil +import tempfile +import threading +import time + +import psycopg + +ROWS = "SELECT count(*) || '/' || md5(string_agg(k || ':' || v, ',' ORDER BY k)) FROM px" + + +def _one(dsn, sql): + try: + with psycopg.connect(dsn, autocommit=True) as c: + row = c.execute(sql).fetchone() + return row[0] if row else None + except psycopg.Error as exc: + return f"ERROR: {exc.sqlstate}" + + +def _run(dsn, *stmts): + with psycopg.connect(dsn, autocommit=True) as c: + for s in stmts: + c.execute(s) + + +def _contend(dsn, name, sql, release): + """Run `sql` as session `name`; once it waits on a lock, run `release`. + -> '|'.""" + result = {} + + def go(): + try: + with psycopg.connect(dsn, autocommit=True) as c: + c.execute(f"SET application_name = '{name}'") + c.execute("SET statement_timeout = '60s'") + c.execute(sql) + result["out"] = "ok" + except psycopg.Error as exc: + result["out"] = exc.sqlstate or "?" + + t = threading.Thread(target=go) + t.start() + waited = "no" + for _ in range(300): + if _one(dsn, f"SELECT count(*) FROM pg_stat_activity WHERE application_name = '{name}' " + "AND wait_event_type = 'Lock'") == 1: + waited = "yes" + break + time.sleep(0.1) + _run(dsn, release) + t.join(60) + return f"{waited}|{result.get('out', 'still running')}" + + +def test_a_prepared_transaction_across_sessions_and_a_crash(pgc_cluster, expect): + from pgc_cluster import Cluster, pick_port + + work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-prepx-")) + work.chmod(0o777) + datadir = work / "data" + datadir.mkdir() + cluster = Cluster(pgc_cluster.pg_config, "prepx", datadir, pick_port(14)) + try: + cluster.initdb() + with open(datadir / "postgresql.conf", "a") as fh: + fh.write("\nmax_prepared_transactions=4\n") + cluster.start() + dsn = cluster.dsn() + _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar") + expect.text(_one(dsn, "SHOW max_prepared_transactions"), "4", "premise: prepared transactions are enabled") + _run(dsn, "CREATE TABLE px (k int PRIMARY KEY, v text) USING pgcolumnar", + "INSERT INTO px SELECT g, 'r' || g FROM generate_series(1, 1000) g") + + _run(dsn, "BEGIN", "INSERT INTO px SELECT g, 'p' || g FROM generate_series(1001, 1100) g", + "PREPARE TRANSACTION 'px_ins'") + expect.num(_one(dsn, "SELECT count(*) FROM px WHERE k > 1000"), 0, + "a prepared insert is invisible to another session") + got = _contend(dsn, "px_dup", "INSERT INTO px VALUES (1050, 'dup')", "COMMIT PREPARED 'px_ins'") + row = _one(dsn, "SELECT count(*) || '/' || min(v) FROM px WHERE k = 1050") + expect.text(f"{got}|{row}", + "yes|23505|1/p1050", + "a second insert of a key the prepared transaction holds waits, then fails with 23505 after COMMIT PREPARED") + expect.num(_one(dsn, "SELECT count(*) FROM px WHERE k > 1000"), 100, + "and after COMMIT PREPARED the other session sees all the prepared rows") + + before = _one(dsn, ROWS) + _run(dsn, "BEGIN", "DELETE FROM px WHERE k % 20 = 0", "PREPARE TRANSACTION 'px_del'") + expect.text(_one(dsn, ROWS), before, "while a DELETE is prepared, another session still sees every row") + _run(dsn, "ROLLBACK PREPARED 'px_del'") + expect.text(_one(dsn, ROWS), before, "ROLLBACK PREPARED of a DELETE leaves every row as it was") + _run(dsn, "BEGIN", "UPDATE px SET v = v || '!' WHERE k % 20 = 1", "PREPARE TRANSACTION 'px_upd'") + _run(dsn, "ROLLBACK PREPARED 'px_upd'") + expect.text(_one(dsn, ROWS), before, "ROLLBACK PREPARED of an UPDATE leaves every row as it was, none duplicated") + + _run(dsn, "BEGIN", "UPDATE px SET v = 'prepared' WHERE k = 7", "PREPARE TRANSACTION 'px_row'") + got = _contend(dsn, "px_rowc", "UPDATE px SET v = 'other' WHERE k = 7", "COMMIT PREPARED 'px_row'") + row = _one(dsn, "SELECT count(*) || '/' || min(v) FROM px WHERE k = 7") + expect.text(f"{got}|{row}", + "yes|40001|1/prepared", + "an UPDATE of a row a prepared transaction updated waits, then fails with 40001 after COMMIT PREPARED") + + _run(dsn, "CREATE TABLE px_heap (k int PRIMARY KEY, v text)", + "INSERT INTO px_heap SELECT g, md5(g::text) FROM generate_series(5001, 10000) g", + "BEGIN", "INSERT INTO px SELECT g, md5(g::text) FROM generate_series(5001, 10000) g", + "PREPARE TRANSACTION 'px_crash'") + cluster.stop() + try: + cluster.start() + back = "ok" + except RuntimeError as exc: + back = f"restart failed: {exc}" + held = _one(dsn, "SELECT count(*) FROM pg_prepared_xacts WHERE gid = 'px_crash'") + expect.text(f"{back}|{held}", + "ok|1", + "a prepared insert survives a crash: the cluster came back and still holds the prepared transaction") + _run(dsn, "COMMIT PREPARED 'px_crash'") + expect.text(_one(dsn, "SELECT count(*) || '/' || md5(string_agg(k || ':' || v, ',' ORDER BY k)) FROM px WHERE k > 5000"), + _one(dsn, "SELECT count(*) || '/' || md5(string_agg(k || ':' || v, ',' ORDER BY k)) FROM px_heap"), + "and after COMMIT PREPARED its rows are heap's") + finally: + try: + for gid in ("px_ins", "px_del", "px_upd", "px_row", "px_crash"): + _one(cluster.dsn(), f"ROLLBACK PREPARED '{gid}'") + finally: + cluster.stop() + shutil.rmtree(work, ignore_errors=True) diff --git a/test/run_all_versions.sh b/test/run_all_versions.sh index f2d57a33b..f72fad1cd 100755 --- a/test/run_all_versions.sh +++ b/test/run_all_versions.sh @@ -266,6 +266,7 @@ SUITES=( phase6 planner_choice_quality preimage_rewrite + prepared_across_sessions prepared_writes projection_drop_column projection_parallel From 5d70610b652c187f82779cfb6c322d463f0b57e9 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 17:17:16 +0000 Subject: [PATCH 2/3] test: prepared_across_sessions asserts each ROLLBACK PREPARED arm's transaction was prepared, in both twins (#1341) Review of #1428 by OffgridwithJD: with the PREPAREs for px_del and px_upd broken, the shell passed 10/10 by comparing the unchanged table with itself, and the twin failed with a SyntaxError instead of a named check. Each now asserts pg_prepared_xacts lists its gid while it is prepared, and the twin leaves a failed PREPARE for that premise to report. Co-Authored-By: Claude Opus 5.5 --- test/prepared_across_sessions.sh | 4 ++++ test/pytest/test_prepared_across_sessions.py | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/test/prepared_across_sessions.sh b/test/prepared_across_sessions.sh index bfbeb4e4d..40c32c718 100755 --- a/test/prepared_across_sessions.sh +++ b/test/prepared_across_sessions.sh @@ -52,10 +52,14 @@ check "and after COMMIT PREPARED the other session sees all the prepared rows" " # ---- ROLLBACK PREPARED of a DELETE and of an UPDATE ----------------------------------- before="$(rows)" psql_run "BEGIN; DELETE FROM px WHERE k % 20 = 0; PREPARE TRANSACTION 'px_del';" >/dev/null +# Without these premises a PREPARE that failed would leave the table unchanged, and +# the checks after it would compare it with itself (OffgridwithJD, review of #1428). +check "premise: the DELETE is prepared" "$(q "SELECT count(*) FROM pg_prepared_xacts WHERE gid = 'px_del'")" "1" check "while a DELETE is prepared, another session still sees every row" "$(rows)" "$before" psql_run "ROLLBACK PREPARED 'px_del';" >/dev/null check "ROLLBACK PREPARED of a DELETE leaves every row as it was" "$(rows)" "$before" psql_run "BEGIN; UPDATE px SET v = v || '!' WHERE k % 20 = 1; PREPARE TRANSACTION 'px_upd';" >/dev/null +check "premise: the UPDATE is prepared" "$(q "SELECT count(*) FROM pg_prepared_xacts WHERE gid = 'px_upd'")" "1" psql_run "ROLLBACK PREPARED 'px_upd';" >/dev/null check "ROLLBACK PREPARED of an UPDATE leaves every row as it was, none duplicated" "$(rows)" "$before" diff --git a/test/pytest/test_prepared_across_sessions.py b/test/pytest/test_prepared_across_sessions.py index 5651953de..8f40f70dc 100644 --- a/test/pytest/test_prepared_across_sessions.py +++ b/test/pytest/test_prepared_across_sessions.py @@ -35,6 +35,14 @@ def _run(dsn, *stmts): c.execute(s) +def _try(dsn, *stmts): + """_run, but a failure is left for the premise after it to report, as the shell does.""" + try: + _run(dsn, *stmts) + except psycopg.Error: + pass + + def _contend(dsn, name, sql, release): """Run `sql` as session `name`; once it waits on a lock, run `release`. -> '|'.""" @@ -96,11 +104,17 @@ def test_a_prepared_transaction_across_sessions_and_a_crash(pgc_cluster, expect) "and after COMMIT PREPARED the other session sees all the prepared rows") before = _one(dsn, ROWS) - _run(dsn, "BEGIN", "DELETE FROM px WHERE k % 20 = 0", "PREPARE TRANSACTION 'px_del'") + # A PREPARE that failed would leave the table unchanged and the checks after + # it comparing it with itself, so its failure is reported by the premise. + _try(dsn, "BEGIN", "DELETE FROM px WHERE k % 20 = 0", "PREPARE TRANSACTION 'px_del'") + expect.num(_one(dsn, "SELECT count(*) FROM pg_prepared_xacts WHERE gid = 'px_del'"), 1, + "premise: the DELETE is prepared") expect.text(_one(dsn, ROWS), before, "while a DELETE is prepared, another session still sees every row") _run(dsn, "ROLLBACK PREPARED 'px_del'") expect.text(_one(dsn, ROWS), before, "ROLLBACK PREPARED of a DELETE leaves every row as it was") - _run(dsn, "BEGIN", "UPDATE px SET v = v || '!' WHERE k % 20 = 1", "PREPARE TRANSACTION 'px_upd'") + _try(dsn, "BEGIN", "UPDATE px SET v = v || '!' WHERE k % 20 = 1", "PREPARE TRANSACTION 'px_upd'") + expect.num(_one(dsn, "SELECT count(*) FROM pg_prepared_xacts WHERE gid = 'px_upd'"), 1, + "premise: the UPDATE is prepared") _run(dsn, "ROLLBACK PREPARED 'px_upd'") expect.text(_one(dsn, ROWS), before, "ROLLBACK PREPARED of an UPDATE leaves every row as it was, none duplicated") From 64eb1fd1f23242fdd41267510c5631ea80f573e5 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 17:21:17 +0000 Subject: [PATCH 3/3] test: the two prepared premises enter the ledger as never, on #1422's new head (#1341) Co-Authored-By: Claude Opus 5.5 --- test/check_ledger.tsv | 2 ++ test/check_ledger_budget.txt | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index b023f9b62..da9113581 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -2305,6 +2305,8 @@ prepared_across_sessions prepared_across_sessions an UPDATE of a row a prepared prepared_across_sessions prepared_across_sessions and after COMMIT PREPARED its rows are heap's 15;16;17;18;19 never - prepared_across_sessions prepared_across_sessions and after COMMIT PREPARED the other session sees all the prepared rows 15;16;17;18;19 never - prepared_across_sessions prepared_across_sessions premise: prepared transactions are enabled 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions premise: the DELETE is prepared 15;16;17;18;19 never - +prepared_across_sessions prepared_across_sessions premise: the UPDATE is prepared 15;16;17;18;19 never - prepared_across_sessions prepared_across_sessions while a DELETE is prepared, another session still sees every row 15;16;17;18;19 never - prepared_writes prepared_writes a delete prepared and committed in one session leaves the session answering 15;16;17;18;19 2026-10-13 PREPARE letting go of neither (main before #1323);PREPARE without letting go of the delete-vector buffers prepared_writes prepared_writes an index fetch before PREPARE, and the same row fetched first after COMMIT PREPARED, in one session 15;16;17;18;19 never - diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index c7353ab78..d2f2ef1eb 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -2318,14 +2318,16 @@ suites_not_covered 232 # 2185 + 703 == 2888 # # #1341, a prepared transaction seen from other sessions and across a crash -# (jdatcmd, stacked on #1422): 2185 -> 2194. prepared_across_sessions enters with all -# 10 checks from five-major runs (10/10 on each of 15-19). One is dated on PG17 by +# (jdatcmd, stacked on #1422): 2185 -> 2196. prepared_across_sessions enters with all +# 12 checks from five-major runs (12/12 on each of 15-19). One is dated on PG17 by # the tree before #1421, where an UPDATE of a row a prepared transaction updated # failed with 23505. The other nine arrive never: a discard at PRE_PREPARE did not # redden them, because each statement's writes are flushed at its executor end. +# Two more premises from the #1428 review, that the DELETE and the UPDATE are each +# prepared before their ROLLBACK PREPARED arms, arrive never. # -# ledger rows 2888 + 10 = 2898 -# never 2185 + 9 = 2194 +# ledger rows 2888 + 12 = 2900 +# never 2185 + 11 = 2196 # dated 703 + 1 = 704 -# 2194 + 704 == 2898 -checks_never_observed_red 2194 +# 2196 + 704 == 2900 +checks_never_observed_red 2196