From 0511974612d5140df81854b60c2d3a8eb1578ce5 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 23:13:43 +0000 Subject: [PATCH 1/6] test: unique_conc gets its pytest twin, and its "does not block" arms can fail (#1341 item 23) test_unique_conc.py is the twin of unique_conc.sh, every check by the shell's name, on a private cluster at unique_lock_buckets 100003 (fixed at server start). T1 is held mid-INSERT by a second row waiting in cq_block; T2 runs in a thread; every barrier polls pg_stat_activity for a lock wait. Porting it found four shell arms that could not fail: 3, 4 (differing key), 5 (outside the predicate) and 6 (NULLS DISTINCT). When T2 did block, the server's 60s lock_timeout aborted it as the 60s bounded wait ran out, and the waiter count read 0: a mutant that hashes only a key's first column left the shell at 33/33. In both twins T2 now runs those arms and the lock-off controls with a 5s lock_timeout, and each asserts nothing waits and its INSERT returned in a live transaction. A wait the next check asserts no longer records a second, fixed-name failure. The 23505 arms assert the SQLSTATE (psql VERBOSITY verbose), not the index name alone. The suite uses lib.sh's check, pgc_fail and pgc_summary instead of a local recorder, so the twin grader can read it and the log has an accounting line. The PG < 15 skip of NULLS NOT DISTINCT is gone: no supported major takes it. Co-Authored-By: Claude Opus 5.5 --- test/pytest/TESTS.md | 22 ++ test/pytest/expected_tests.txt | 4 +- test/pytest/test_compare_to_bash.py | 1 + test/pytest/test_unique_conc.py | 357 ++++++++++++++++++++++++++++ test/unique_conc.sh | 185 +++++++------- 5 files changed, 477 insertions(+), 92 deletions(-) create mode 100644 test/pytest/test_unique_conc.py diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index fe802fb8e..6381b3aa5 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -179,6 +179,7 @@ behaviour, the source of that number is named. - [131. test_type_matrix.py: types the differential matrix never stored answer as heap](#131-test_type_matrixpy-types-the-differential-matrix-never-stored-answer-as-heap) - [132. test_iceberg_data_files.py: a delete entry in a data manifest is refused](#132-test_iceberg_data_filespy-a-delete-entry-in-a-data-manifest-is-refused) - [133. test_native_skip.py: a type binary-coercible to its opclass's type prunes](#133-test_native_skippy-a-type-binary-coercible-to-its-opclasss-type-prunes) +- [134. test_unique_conc.py: concurrent inserters of one unique key serialize](#134-test_unique_concpy-concurrent-inserters-of-one-unique-key-serialize) ## 1. How to read a test in here @@ -7221,3 +7222,24 @@ A partial pytest twin of `test/native_skip.sh` (#1433). It ports only the arms t | --- | --- | | `test_a_range_on_a_coercible_type_prunes` | a range on inet, cidr, a domain over cidr, text, varchar and a domain over varchar returns heap's rows and removes chunk groups | | `test_equality_on_a_coercible_type_prunes` | equality on cidr and on varchar, which also takes the bloom probe, returns heap's row and removes chunk groups | + +## 134. test_unique_conc.py: concurrent inserters of one unique key serialize + +The pytest twin of `test/unique_conc.sh` (#5, ported for #1341), every check by the shell's name. A columnar row's btree entry is written at once while its data waits for the stripe to flush, so a second inserter of the same key could not see the first's row and would insert a duplicate. The table AM takes a transaction-scoped advisory lock per unique key, hashed with the key type's default hash. So the second inserter waits for the first to commit and gets 23505, asserted by SQLSTATE and the index's name. T1 is held mid-INSERT, its key row indexed and unflushed, by a second row that waits in `cq_block` on a lock a holder session has. Every barrier polls `pg_stat_activity` for a lock wait. + +The shapes: one key, with the lock off in T2 as the control that shows the bug; keys equal under the opclass with different bytes (numeric 1.0 and 1.00, and citext by case); different keys, which must not wait; a multi-column key, equal and differing in its second column; a partial index, inside and outside its predicate; NULL keys under NULLS DISTINCT and NULLS NOT DISTINCT; a duplicate within one statement; and a first inserter that aborts. A private cluster runs `pgcolumnar.unique_lock_buckets` at 100003, which is fixed at server start, so unrelated keys do not share a bucket. + +### Every test + +| test | what it holds | +| --- | --- | +| `test_the_bucket_count_is_fixed_at_server_start` | `SET` of the bucket count is refused 55P02, and the cluster runs 100003 | +| `test_one_key_serializes_and_the_lock_is_what_does_it` | with the lock off in T2 both inserts commit; with it on T2 waits, gets 23505, and one row is left | +| `test_keys_equal_under_the_opclass_serialize` | numeric 1.0 and 1.00: the same pair, lock off and on | +| `test_citext_keys_equal_by_case_serialize` | citext 'ABC' and 'abc' serialize and conflict; unrunnable without citext | +| `test_different_keys_do_not_block_each_other` | T2's different key returns at once, and both rows are kept | +| `test_a_multi_column_key` | (1,2) twice serializes and conflicts; (5,6) and (5,7) do not wait | +| `test_a_partial_unique_index` | inside the predicate the keys serialize and conflict; outside it neither waits and both rows are kept | +| `test_null_keys` | two NULLs never wait under NULLS DISTINCT; under NULLS NOT DISTINCT they serialize and conflict | +| `test_a_duplicate_within_one_statement` | one INSERT of the same key twice raises 23505 and inserts nothing | +| `test_an_aborted_first_inserter` | T2 waits behind T1's flushed, uncommitted row; T1 rolls back and T2's insert succeeds | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index ec0e00570..87123bd16 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -733,4 +733,6 @@ guard_tests 438 # listed controls and three parametrized refusals), by collection over the cluster files. # cluster_tests 609 -> 617 for #1433's test_native_skip.py, eight tests (six ranges and # two equalities, parametrized), by collection over the cluster files on this tree. -cluster_tests 617 +# cluster_tests 617 -> 627 for #1341's test_unique_conc.py, the twin of unique_conc.sh: +# ten tests, by collection over the cluster files on this tree. +cluster_tests 627 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index 9967566da..55ed8584b 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -165,6 +165,7 @@ "timeseries_recipes", "ttl_expire", "type_matrix", + "unique_conc", "vacuum_lock_privilege", "validity_elision", "vm_privilege", diff --git a/test/pytest/test_unique_conc.py b/test/pytest/test_unique_conc.py new file mode 100644 index 000000000..4b9c7d3fe --- /dev/null +++ b/test/pytest/test_unique_conc.py @@ -0,0 +1,357 @@ +"""Concurrent inserters of one unique key serialize on a columnar table (issue #5, +#1341). The pytest twin of `test/unique_conc.sh`, every check by the shell's name. + +A columnar row's data is invisible to other backends until its stripe is flushed at +statement end, but its btree entry is written at once. So while T1's INSERT is still +running, T2 inserting the same key finds T1's index entry and cannot resolve the row, +and the btree check would let a duplicate through. The table AM takes a +transaction-scoped advisory lock per unique key (columnar_unique.c) before the btree +check runs: T2 waits for T1 to commit, then gets 23505. + +HOLDING T1 MID-STATEMENT. T1 runs a two-row INSERT whose second row calls `cq_block`, +which waits on an advisory lock a third session (the holder) has taken. The first +row, the real key, is inserted and indexed, and the statement has not ended, so it is +not flushed. T2 then inserts while T1 waits there. Every barrier is a poll of +pg_stat_activity for a real lock wait, never a sleep. + +A PRIVATE CLUSTER, because `pgcolumnar.unique_lock_buckets` is fixed at server start. +It runs at 100003 buckets, as the shell's does, so two unrelated keys do not share a +bucket and a serialization observed here is the guarantee, not a collision. +""" +import pathlib +import shutil +import tempfile +import threading +import time + +import psycopg +import pytest + +BUCKETS = "100003" +WAIT = 60 # seconds a bounded wait allows, as the shell's 1200 polls of 0.05s + + +@pytest.fixture(scope="module") +def uc(pgc_cluster): + """-> the dsn of a private cluster at BUCKETS buckets, with cq_block defined.""" + from pgc_cluster import Cluster, pick_port + + work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-uconc-")) + work.chmod(0o777) + datadir = work / "data" + datadir.mkdir() + cluster = Cluster(pgc_cluster.pg_config, "uconc", datadir, pick_port(15)) + try: + cluster.initdb() + with open(datadir / "postgresql.conf", "a") as fh: + # lock_timeout caps a real hang, so a bug cannot pass by blocking forever. + fh.write(f"\nlock_timeout=60000\npgcolumnar.unique_lock_buckets={BUCKETS}\n") + cluster.start() + dsn = cluster.dsn() + with psycopg.connect(dsn, autocommit=True) as c: + c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar") + # plpgsql so it is never inlined; anyelement so one function serves every key type. + c.execute("CREATE FUNCTION cq_block(k bigint, v anyelement) RETURNS anyelement LANGUAGE plpgsql " + "VOLATILE AS $$ BEGIN PERFORM pg_advisory_xact_lock(k); RETURN v; END $$") + yield dsn + finally: + cluster.stop() + shutil.rmtree(work, ignore_errors=True) + + +class _Session: + """One connection, whose statements run in a thread so the test can watch it wait.""" + + def __init__(self, dsn, app): + self.conn = psycopg.connect(dsn, autocommit=True, application_name=app) + self.thread = None + self.error = None + + def start(self, sql): + self.error = None + + def go(): + try: + self.conn.execute(sql) + except psycopg.Error as exc: + self.error = exc + self.thread = threading.Thread(target=go, daemon=True) + self.thread.start() + + def finished(self): + self.thread.join(WAIT) + return not self.thread.is_alive() + + +class _Race: + """The three sessions of one scenario, and the waits the shell bounds. Each wait + that runs out records its failure under the shell's fixed name for that kind.""" + + def __init__(self, dsn, expect): + self.expect = expect + self.watch = psycopg.connect(dsn, autocommit=True) + self.s1 = _Session(dsn, "cc_s1") + self.s2 = _Session(dsn, "cc_s2") + self.sh = _Session(dsn, "cc_sh") + + def close(self): + for c in (self.s1.conn, self.s2.conn, self.sh.conn, self.watch): + c.close() + + def one(self, sql): + return self.watch.execute(sql).fetchone()[0] + + def run(self, sess, sql): + """sql on sess, which must complete within the bound. -> its error, or None.""" + expect = self.expect + sess.start(sql) + if not sess.finished(): + expect.text(f"timed out on {sql}", "completed", "a bounded wait for a command's sentinel completed") + return sess.error + + def finish(self, sess): + """Wait for sess's statement, already running. -> its error, or None.""" + expect = self.expect + if not sess.finished(): + expect.text("timed out", "completed", "a bounded wait for a standalone sentinel completed") + return sess.error + + def waiting(self, app): + return self.one(f"SELECT count(*) FROM pg_stat_activity WHERE application_name = '{app}' " + "AND wait_event_type = 'Lock'") + + def wait_blocked(self, app, asserted=False): + """Wait for app to wait on a lock. `asserted`: the caller's next check counts + the waiter itself, so running out returns and is that check's failure, after + 10s, rather than a second failure under the wait's own name, as the shell does.""" + expect = self.expect + deadline = time.monotonic() + (10 if asserted else WAIT) + while self.waiting(app) != 1: + if time.monotonic() > deadline: + if asserted: + return + expect.text(f"{app} never waited", "completed", "a bounded wait for a session to block completed") + time.sleep(0.05) + + def wait_idle_intx(self, app): + expect = self.expect + deadline = time.monotonic() + WAIT + while self.one(f"SELECT state FROM pg_stat_activity WHERE application_name = '{app}'") \ + != "idle in transaction": + if time.monotonic() > deadline: + expect.text(f"{app} never went idle in transaction", "completed", + "a bounded wait for a session to reach idle-in-transaction completed") + time.sleep(0.05) + + def pause_s1(self, hkey, insert): + """T1 left waiting mid-INSERT: its real-key row buffered and indexed, unflushed.""" + self.run(self.sh, f"SELECT pg_advisory_lock({hkey})") + self.run(self.s1, "BEGIN") + self.s1.start(insert) + self.wait_blocked("cc_s1") + + def release_s1(self, hkey): + """The pause released: T1's INSERT finishes, which flushes it.""" + self.run(self.sh, f"SELECT pg_advisory_unlock({hkey})") + return self.finish(self.s1) + + def conflict(self, hkey, table, first, second, index): + """T1 holds `first` unflushed; T2 inserts `second`, waits, and gets 23505 once T1 + commits. -> (sessions waiting while T1 held the key, _violated's answer).""" + self.pause_s1(hkey, f"INSERT INTO {table} {first}") + self.run(self.s2, "BEGIN") + self.s2.start(f"INSERT INTO {table} {second}") + self.wait_blocked("cc_s2", asserted=True) + waiting = self.waiting("cc_s2") + self.release_s1(hkey) + self.run(self.s1, "COMMIT") + violated = _violated(self.finish(self.s2), index) + self.run(self.s2, "ROLLBACK") + return waiting, violated + + def no_conflict(self, hkey, table, first, second): + """T1 holds `first` unflushed; T2's `second` must not wait for it. + -> '|returned', or '|refused + ' when the INSERT was refused. T2 runs with a 5s lock_timeout, so + an INSERT that waits is refused well inside the 60s bound. Under the server's + 60s lock_timeout the two ran out together, T2 had stopped waiting by the time + the count was read, and these arms could not fail (#1341).""" + self.pause_s1(hkey, f"INSERT INTO {table} {first}") + self.run(self.s2, "BEGIN") + self.run(self.s2, "SET LOCAL lock_timeout = '5s'") + err = self.run(self.s2, f"INSERT INTO {table} {second}") + if err is None: + self.wait_idle_intx("cc_s2") + waiting = f"{self.waiting('cc_s2')}|{'returned' if err is None else f'refused {err.sqlstate}'}" + self.run(self.s2, "COMMIT") + self.release_s1(hkey) + self.run(self.s1, "COMMIT") + return waiting + + def duplicate_with_the_lock_off(self, hkey, table, first, second): + """T1 holds `first` unflushed; T2, with the lock off for itself, inserts `second` + and commits without waiting. The bug the lock fixes.""" + self.pause_s1(hkey, f"INSERT INTO {table} {first}") + self.run(self.s2, "BEGIN") + self.run(self.s2, "SET LOCAL pgcolumnar.enable_unique_insert_lock = off") + self.run(self.s2, "SET LOCAL lock_timeout = '5s'") + self.run(self.s2, f"INSERT INTO {table} {second}") + self.run(self.s2, "COMMIT") + self.release_s1(hkey) + self.run(self.s1, "COMMIT") + + +def _violated(err, index): + """'yes' when err is a 23505 naming index, else what arrived.""" + if err is not None and err.sqlstate == "23505" and f'"{index}"' in str(err): + return "yes" + return f"no ({err.sqlstate}: {str(err).splitlines()[0]})" if err is not None else "no (no error)" + + +def _paused(key_sql, block_sql, rest=""): + """The two-row SELECT whose second row waits in cq_block: `key_sql` is the real key.""" + return f"SELECT CASE WHEN g = 2 THEN {block_sql} ELSE {key_sql} END{rest} FROM generate_series(1, 2) g" + + +@pytest.fixture +def race(uc, expect): + r = _Race(uc, expect) + try: + yield r + finally: + r.close() + + +def test_the_bucket_count_is_fixed_at_server_start(race, expect): + # 55P02 cant_change_runtime_param: a PGC_POSTMASTER setting, refused in a session + with pytest.raises(psycopg.errors.CantChangeRuntimeParam) as exc: + race.s2.conn.execute("SET pgcolumnar.unique_lock_buckets = 1") + expect.sqlstate(exc.value, "55P02", "the bucket count cannot be changed per session") + expect.text(race.one("SHOW pgcolumnar.unique_lock_buckets"), BUCKETS, + "the cluster runs the bucket count the suite needs") + + +def test_one_key_serializes_and_the_lock_is_what_does_it(race, expect): + race.run(race.s1, "CREATE TABLE s_int (k int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_int_uidx ON s_int (k)") + race.duplicate_with_the_lock_off(1001, "s_int", _paused("1", "cq_block(1001, -1)"), "VALUES (1)") + expect.num(race.one("SELECT count(*) FROM s_int WHERE k = 1"), 2, + "1a pre-fix: lock disabled lets both same-key inserts commit (bug)") + + race.run(race.s1, "TRUNCATE s_int") + waiting, violated = race.conflict(1002, "s_int", _paused("1", "cq_block(1002, -1)"), "VALUES (1)", "s_int_uidx") + expect.num(waiting, 1, "1b post-fix: same-key second inserter blocks") + expect.text(violated, "yes", "1b post-fix: blocked inserter got a unique_violation") + expect.num(race.one("SELECT count(*) FROM s_int WHERE k = 1"), 1, "1b post-fix: exactly one row with key 1") + + +def test_keys_equal_under_the_opclass_serialize(race, expect): + """numeric 1.0 and 1.00 are equal and differ in bytes; a raw-byte hash would split them.""" + race.run(race.s1, "CREATE TABLE s_num (v numeric) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_num_uidx ON s_num (v)") + race.duplicate_with_the_lock_off(1003, "s_num", _paused("1.0", "cq_block(1003, (-1)::numeric)"), + "VALUES (1.00)") + expect.num(race.one("SELECT count(*) FROM s_num WHERE v = 1.0"), 2, + "2a pre-fix: numeric 1.0 and 1.00 both commit with lock off (bug)") + + race.run(race.s1, "TRUNCATE s_num") + waiting, violated = race.conflict(1004, "s_num", _paused("1.0", "cq_block(1004, (-1)::numeric)"), + "VALUES (1.00)", "s_num_uidx") + expect.num(waiting, 1, "2b post-fix: numeric 1.00 serializes behind 1.0 (opclass-safe hash)") + expect.text(violated, "yes", "2b post-fix: numeric equal-but-different-bytes got unique_violation") + expect.num(race.one("SELECT count(*) FROM s_num WHERE v = 1.0"), 1, "2b post-fix: exactly one row equal to 1.0") + + +def test_citext_keys_equal_by_case_serialize(race, expect): + try: + race.watch.execute("CREATE EXTENSION IF NOT EXISTS citext") + except psycopg.Error as exc: + expect.cannot_run("MISSING_DEPENDENCY", f"citext extension not available: {exc}", + name="the 2c citext case") + return + race.run(race.s1, "CREATE TABLE s_ci (v citext) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_ci_uidx ON s_ci (v)") + waiting, violated = race.conflict(1005, "s_ci", _paused("'ABC'::citext", "cq_block(1005, 'zzz'::citext)"), + "VALUES ('abc')", "s_ci_uidx") + expect.num(waiting, 1, "2c post-fix: citext 'abc' serializes behind 'ABC' (case-folded hash)") + expect.text(violated, "yes", "2c post-fix: citext case-insensitive duplicate got unique_violation") + expect.num(race.one("SELECT count(*) FROM s_ci WHERE v = 'abc'"), 1, "2c post-fix: exactly one row equal to 'abc'") + + +def test_different_keys_do_not_block_each_other(race, expect): + race.run(race.s1, "CREATE TABLE s_diff (k int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_diff_uidx ON s_diff (k)") + expect.text(race.no_conflict(1006, "s_diff", _paused("10", "cq_block(1006, -1)"), "VALUES (20)"), "0|returned", + "3 different keys do not block each other") + expect.text(race.one("SELECT string_agg(k::text, ',' ORDER BY k) FROM s_diff WHERE k IN (10, 20)"), "10,20", + "3 both distinct keys present") + + +def test_a_multi_column_key(race, expect): + race.run(race.s1, "CREATE TABLE s_mc (a int, b int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_mc_uidx ON s_mc (a, b)") + waiting, violated = race.conflict(1007, "s_mc", _paused("1", "cq_block(1007, -1)", ", 2"), "VALUES (1, 2)", + "s_mc_uidx") + expect.num(waiting, 1, "4 multi-column equal key serializes") + expect.text(violated, "yes", "4 multi-column duplicate got unique_violation") + expect.num(race.one("SELECT count(*) FROM s_mc WHERE a = 1 AND b = 2"), 1, "4 exactly one (1,2) row") + # (5,7) differs from (5,6) only in its second column + expect.text(race.no_conflict(1008, "s_mc", _paused("5", "cq_block(1008, -1)", ", 6"), "VALUES (5, 7)"), "0|returned", + "4 multi-column differing key does not block") + + +def test_a_partial_unique_index(race, expect): + race.run(race.s1, "CREATE TABLE s_part (k int, active boolean) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_part_uidx ON s_part (k) WHERE active") + waiting, violated = race.conflict(1009, "s_part", _paused("1", "cq_block(1009, -1)", ", true"), + "VALUES (1, true)", "s_part_uidx") + expect.num(waiting, 1, "5 partial index, row inside predicate serializes") + expect.text(violated, "yes", "5 partial index in-predicate duplicate got unique_violation") + expect.num(race.one("SELECT count(*) FROM s_part WHERE k = 1 AND active"), 1, "5 exactly one active row with k=1") + # Outside the predicate the index covers neither row, so nothing serializes. + expect.text(race.no_conflict(1010, "s_part", _paused("2", "cq_block(1010, -1)", ", false"), "VALUES (2, false)"), + "0|returned", "5 partial index, rows outside predicate do not serialize") + expect.num(race.one("SELECT count(*) FROM s_part WHERE k = 2 AND NOT active"), 2, + "5 both inactive k=2 rows kept (predicate not enforced)") + + +def test_null_keys(race, expect): + race.run(race.s1, "CREATE TABLE s_nd (k int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_nd_uidx ON s_nd (k)") + expect.text(race.no_conflict(1011, "s_nd", _paused("NULL", "cq_block(1011, -1)"), "VALUES (NULL)"), "0|returned", + "6 NULLS DISTINCT: two NULL keys do not block") + expect.num(race.one("SELECT count(*) FROM s_nd WHERE k IS NULL"), 2, "6 NULLS DISTINCT: both NULL rows kept") + + # NULLS NOT DISTINCT is PG15's, the oldest major this tree supports. + race.run(race.s1, "CREATE TABLE s_nn (k int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_nn_uidx ON s_nn (k) NULLS NOT DISTINCT") + waiting, violated = race.conflict(1012, "s_nn", _paused("NULL", "cq_block(1012, -1)"), "VALUES (NULL)", + "s_nn_uidx") + expect.num(waiting, 1, "6 NULLS NOT DISTINCT: second NULL key serializes") + expect.text(violated, "yes", "6 NULLS NOT DISTINCT: second NULL got unique_violation") + expect.num(race.one("SELECT count(*) FROM s_nn WHERE k IS NULL"), 1, "6 NULLS NOT DISTINCT: exactly one NULL row") + + +def test_a_duplicate_within_one_statement(race, expect): + race.run(race.s1, "CREATE TABLE s_ss (k int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_ss_uidx ON s_ss (k)") + expect.text(_violated(race.run(race.s2, "INSERT INTO s_ss SELECT 7 FROM generate_series(1, 2)"), "s_ss_uidx"), + "yes", "7 same-statement duplicate raises unique_violation") + expect.num(race.one("SELECT count(*) FROM s_ss"), 0, "7 same-statement duplicate inserted no rows") + + +def test_an_aborted_first_inserter(race, expect): + """T1's INSERT has completed and flushed, uncommitted, and holds the key lock.""" + race.run(race.s1, "CREATE TABLE s_ab (k int) USING pgcolumnar") + race.run(race.s1, "CREATE UNIQUE INDEX s_ab_uidx ON s_ab (k)") + race.run(race.s1, "BEGIN") + race.run(race.s1, "INSERT INTO s_ab VALUES (1)") + race.run(race.s2, "BEGIN") + race.s2.start("INSERT INTO s_ab VALUES (1)") + race.wait_blocked("cc_s2", asserted=True) + expect.num(race.waiting("cc_s2"), 1, "8 second inserter blocks behind uncommitted first") + race.run(race.s1, "ROLLBACK") + err = race.finish(race.s2) + race.run(race.s2, "COMMIT") + expect.text(f"{race.one('SELECT count(*) FROM s_ab WHERE k = 1')}|{err.sqlstate if err else 'no error'}", + "1|no error", "8 after first aborts, second insert succeeds; one row") diff --git a/test/unique_conc.sh b/test/unique_conc.sh index 2be14e1ab..00422280d 100755 --- a/test/unique_conc.sh +++ b/test/unique_conc.sh @@ -66,16 +66,19 @@ # failed. That is worse than invisibility, because a missing number can be noticed # and a balancing one cannot. Induced and measured rather than argued. # -# THE NAME IS FIXED PER WAIT KIND, with the session and sentinel in pgc_record's -# REASON field. The ledger is keyed on (suite, part, name), so interpolating +# THE NAME IS FIXED PER WAIT KIND, with the session and sentinel in pgc_fail's +# DETAIL. The ledger is keyed on (suite, part, name), so interpolating # "$name/$label" into the name would mint rows nobody can enumerate and therefore # nobody can seed. # # NO GREEN RUN EXECUTES THIS. It records only on the timeout path, so a passing # suite emits nothing here and the record stream is unchanged. +# +# lib.sh's pgc_fail, not a direct pgc_record (#1341): the pytest twin's grading +# reads names through lib.sh's recorders, and a suite that records on its own is +# one the twin check cannot read. wait_timeout() { # wait_timeout FIXED-NAME DETAIL - pgc_record FAIL "$1" "FAIL $1 (timed out waiting for $2)" "$2" - fail=1 + pgc_fail "$1" "timed out waiting for $2" } @@ -192,35 +195,11 @@ ctl_q() { run_pg "$PSQL -c \"$1\""; } # subject is the error itself. ctl_qe() { run_pg "$SPSQL -c \"$1\" 2>&1"; } -fail=0 -# RECORDS RATHER THAN ONLY PRINTING (#965). This suite emitted human PASS lines -# and no RESULT records, so every mechanism built on the record vocabulary -- the -# ledger, the census, checks_never_observed_red, the red-observation record, -# duplicate-name detection -- was blind to all of them. The ledger did not merely -# return nothing on this log, it REFUSED it: "no RESULT records, so there is -# nothing to reconcile". -# -# `pgc_record` takes the DISPLAY whole, so the human output below is byte-for-byte -# what it was. NOT lib.sh's own `check`: that composes its own display and would -# drop the `: $got` suffix, which is the measured value rather than a label. -# -# `fail` is still set, so this suite's exit logic is untouched. Its verdict line -# stays for the same reason: under `set -euo pipefail` a failing command aborts the -# suite, and the verdict is what distinguishes finished from stopped. -# -# The `checks run:` line is READ BY NOTHING YET. The matrix gates reconciliation on -# the ACCOUNTING line via `pgc_log_shows_accounting`, and this suite emits none -# because it emits no accounting line. The line is still correct and wanted -- -# it is the total the records reconcile against -- so the remaining step is a gate -# flip rather than new work (@jdatcmd, #969 review). -check() { # name got want - if [ "$2" = "$3" ]; then - pgc_record PASS "$1" "PASS $1: $2" - else - pgc_record FAIL "$1" "FAIL $1: got [$2] want [$3]" - fail=1 - fi -} +# lib.sh's `check` records every result (#965), and since #1341 this suite uses +# it rather than a local wrapper around pgc_record. The local one printed the +# measured value on a PASS too. lib.sh's prints it on a FAIL, which is when it is +# read, and the twin check can read the names, which it could not before. +# pgc_summary at the end prints the accounting line the matrix reconciles. # --- persistent interactive sessions over FIFOs (as in test/concurrency.sh) --- start_session() { # name @@ -263,13 +242,20 @@ wait_sentinel() { # name label done return 0 } -wait_blocked() { # application_name - local app="$1" i=0 n +# wait_blocked APP [asserted]: with "asserted", the caller's next check counts the +# waiter itself, so a wait that runs out is that check's failure and is not +# recorded twice (#1341: a mutant that took no key lock recorded a second, fixed- +# name failure per arm, which no green run emits and so no ledger row can hold). +# It also gives up after 10s, as a waiter that is going to block does so at once. +wait_blocked() { + local app="$1" i=0 n limit=1200 + [ "${2:-}" = asserted ] && limit=200 while :; do n="$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='$app' AND wait_event_type='Lock';")" [ "$n" = "1" ] && return 0 sleep 0.05; i=$((i + 1)) - if [ "$i" -ge 1200 ]; then + if [ "$i" -ge "$limit" ]; then + [ "${2:-}" = asserted ] && return 1 wait_timeout "a bounded wait for a session to block completed" "$app"; return 1 fi done @@ -287,6 +273,22 @@ wait_idle_intx() { # application_name } # does session $1's cumulative output contain the substring $2? out_has() { grep -qF -- "$2" "$WORKDIR/$1.out" 2>/dev/null; } +# returned SESSION LABEL -> "returned" when the session's INSERT came back inside a +# live transaction, which the SELECT of LABEL after it shows, else "refused". The +# no-wait arms give T2 a 5s lock_timeout, so an INSERT that waits is refused well +# inside the 60s bounded wait. Without it, the server's 60s lock_timeout and the +# wait ran out together, T2 was no longer waiting when the count was read, and +# these arms could not fail (#1341, measured with a mutant that hashes only a key's +# first column: 33/33). +returned() { + out_has "$1" "$2" && echo returned || echo refused +} +# violated SESSION INDEX -> yes when the session's output holds a 23505 naming the +# index. Its psql runs with VERBOSITY verbose, so the SQLSTATE is printed: the +# index name alone would also match another error that named it (#1341). +violated() { + out_has "$1" "ERROR: 23505: duplicate key value violates unique constraint \"$2\"" && echo yes || echo no +} ctl_q "CREATE EXTENSION pgcolumnar;" >/dev/null # A row whose second value blocks on a holder-session advisory lock, holding T1 @@ -316,6 +318,7 @@ start_session s2 start_session sh # holder of the mid-statement pause lock send s1 "SET application_name='cc_s1';" send s2 "SET application_name='cc_s2';" +send s2 "\\set VERBOSITY verbose" # This suite's cluster is started with the large prime above. Pin both halves of # what that buys: a session cannot change the count out from under the advisory # lock tag, and the cluster really is running the count the suite needs. @@ -357,6 +360,7 @@ HKEY=1001 pause_s1 "INSERT INTO s_int SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE 1 END FROM generate_series(1,2) g;" send_wait s2 "s2off_beg" "BEGIN;" send s2 "SET LOCAL pgcolumnar.enable_unique_insert_lock = off;" +send s2 "SET LOCAL lock_timeout = '5s';" # T1 holds key 1 unflushed. With the lock off, T2 must NOT block; it inserts a # duplicate and commits. (With the fix on, it would block here instead.) send_wait s2 "s2off_ins" "INSERT INTO s_int VALUES (1);" @@ -373,14 +377,14 @@ pause_s1 "INSERT INTO s_int SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE 1 send_wait s2 "s2on_beg" "BEGIN;" send s2 "INSERT INTO s_int VALUES (1);" send s2 "\\echo <>" -wait_blocked cc_s2 +wait_blocked cc_s2 asserted check "1b post-fix: same-key second inserter blocks" \ "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" release_s1 send_wait s1 "s1_com_1b" "COMMIT;" # releases the key lock; T2 unblocks wait_sentinel s2 s2on_ins # T2's INSERT completed (with an error) check "1b post-fix: blocked inserter got a unique_violation" \ - "$(out_has s2 's_int_uidx' && echo yes || echo no)" "yes" + "$(violated s2 s_int_uidx)" "yes" send_wait s2 "s2on_rb" "ROLLBACK;" check "1b post-fix: exactly one row with key 1" \ "$(ctl_q "SELECT count(*) FROM s_int WHERE k = 1;")" "1" @@ -398,6 +402,7 @@ HKEY=1003 pause_s1 "INSERT INTO s_num SELECT CASE WHEN g=2 THEN cq_block($HKEY, (-1)::numeric) ELSE 1.0 END FROM generate_series(1,2) g;" send_wait s2 "n_off_beg" "BEGIN;" send s2 "SET LOCAL pgcolumnar.enable_unique_insert_lock = off;" +send s2 "SET LOCAL lock_timeout = '5s';" send_wait s2 "n_off_ins" "INSERT INTO s_num VALUES (1.00);" send_wait s2 "n_off_com" "COMMIT;" release_s1 @@ -411,14 +416,14 @@ pause_s1 "INSERT INTO s_num SELECT CASE WHEN g=2 THEN cq_block($HKEY, (-1)::nume send_wait s2 "n_on_beg" "BEGIN;" send s2 "INSERT INTO s_num VALUES (1.00);" # byte-different, opclass-equal to 1.0 send s2 "\\echo <>" -wait_blocked cc_s2 +wait_blocked cc_s2 asserted check "2b post-fix: numeric 1.00 serializes behind 1.0 (opclass-safe hash)" \ "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" release_s1 send_wait s1 "s1_com_2b" "COMMIT;" wait_sentinel s2 n_on_ins check "2b post-fix: numeric equal-but-different-bytes got unique_violation" \ - "$(out_has s2 's_num_uidx' && echo yes || echo no)" "yes" + "$(violated s2 s_num_uidx)" "yes" send_wait s2 "n_on_rb" "ROLLBACK;" check "2b post-fix: exactly one row equal to 1.0" \ "$(ctl_q "SELECT count(*) FROM s_num WHERE v = 1.0;")" "1" @@ -432,14 +437,14 @@ if [ "$CITEXT" = 1 ]; then send_wait s2 "ci_beg" "BEGIN;" send s2 "INSERT INTO s_ci VALUES ('abc');" # equals 'ABC' under citext send s2 "\\echo <>" - wait_blocked cc_s2 + wait_blocked cc_s2 asserted check "2c post-fix: citext 'abc' serializes behind 'ABC' (case-folded hash)" \ "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" release_s1 send_wait s1 "s1_com_2c" "COMMIT;" wait_sentinel s2 ci_ins check "2c post-fix: citext case-insensitive duplicate got unique_violation" \ - "$(out_has s2 's_ci_uidx' && echo yes || echo no)" "yes" + "$(violated s2 s_ci_uidx)" "yes" send_wait s2 "ci_rb" "ROLLBACK;" check "2c post-fix: exactly one row equal to 'abc'" \ "$(ctl_q "SELECT count(*) FROM s_ci WHERE v = 'abc';")" "1" @@ -455,10 +460,12 @@ ctl_q "CREATE UNIQUE INDEX s_diff_uidx ON s_diff (k);" >/dev/null HKEY=1006 pause_s1 "INSERT INTO s_diff SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE 10 END FROM generate_series(1,2) g;" send_wait s2 "d_beg" "BEGIN;" -send_wait s2 "d_ins" "INSERT INTO s_diff VALUES (20);" # different key: must not block -wait_idle_intx cc_s2 +send s2 "SET LOCAL lock_timeout = '5s';" +send_wait s2 "d_ins" "INSERT INTO s_diff VALUES (20);" +send_wait s2 "d_ok" "SELECT 'returned_d';" +[ "$(returned s2 returned_d)" = returned ] && wait_idle_intx cc_s2 check "3 different keys do not block each other" \ - "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "0" + "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")|$(returned s2 returned_d)" "0|returned" send_wait s2 "d_com" "COMMIT;" release_s1 send_wait s1 "s1_com_3" "COMMIT;" @@ -476,14 +483,14 @@ pause_s1 "INSERT INTO s_mc SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE 1 send_wait s2 "mc_beg" "BEGIN;" send s2 "INSERT INTO s_mc VALUES (1, 2);" send s2 "\\echo <>" -wait_blocked cc_s2 +wait_blocked cc_s2 asserted check "4 multi-column equal key serializes" \ "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" release_s1 send_wait s1 "s1_com_4a" "COMMIT;" wait_sentinel s2 mc_ins check "4 multi-column duplicate got unique_violation" \ - "$(out_has s2 's_mc_uidx' && echo yes || echo no)" "yes" + "$(violated s2 s_mc_uidx)" "yes" send_wait s2 "mc_rb" "ROLLBACK;" check "4 exactly one (1,2) row" \ "$(ctl_q "SELECT count(*) FROM s_mc WHERE a=1 AND b=2;")" "1" @@ -491,10 +498,12 @@ check "4 exactly one (1,2) row" \ HKEY=1008 pause_s1 "INSERT INTO s_mc SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE 5 END, 6 FROM generate_series(1,2) g;" send_wait s2 "mc2_beg" "BEGIN;" -send_wait s2 "mc2_ins" "INSERT INTO s_mc VALUES (5, 7);" # (5,7) != (5,6) -wait_idle_intx cc_s2 +send s2 "SET LOCAL lock_timeout = '5s';" +send_wait s2 "mc2_ins" "INSERT INTO s_mc VALUES (5, 7);" +send_wait s2 "mc2_ok" "SELECT 'returned_mc2';" +[ "$(returned s2 returned_mc2)" = returned ] && wait_idle_intx cc_s2 check "4 multi-column differing key does not block" \ - "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "0" + "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")|$(returned s2 returned_mc2)" "0|returned" send_wait s2 "mc2_com" "COMMIT;" release_s1 send_wait s1 "s1_com_4b" "COMMIT;" @@ -510,14 +519,14 @@ pause_s1 "INSERT INTO s_part SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE send_wait s2 "pt_beg" "BEGIN;" send s2 "INSERT INTO s_part VALUES (1, true);" send s2 "\\echo <>" -wait_blocked cc_s2 +wait_blocked cc_s2 asserted check "5 partial index, row inside predicate serializes" \ "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" release_s1 send_wait s1 "s1_com_5a" "COMMIT;" wait_sentinel s2 pt_ins check "5 partial index in-predicate duplicate got unique_violation" \ - "$(out_has s2 's_part_uidx' && echo yes || echo no)" "yes" + "$(violated s2 s_part_uidx)" "yes" send_wait s2 "pt_rb" "ROLLBACK;" check "5 exactly one active row with k=1" \ "$(ctl_q "SELECT count(*) FROM s_part WHERE k=1 AND active;")" "1" @@ -526,10 +535,12 @@ check "5 exactly one active row with k=1" \ HKEY=1010 pause_s1 "INSERT INTO s_part SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE 2 END, false FROM generate_series(1,2) g;" send_wait s2 "pt2_beg" "BEGIN;" +send s2 "SET LOCAL lock_timeout = '5s';" send_wait s2 "pt2_ins" "INSERT INTO s_part VALUES (2, false);" -wait_idle_intx cc_s2 +send_wait s2 "pt2_ok" "SELECT 'returned_pt2';" +[ "$(returned s2 returned_pt2)" = returned ] && wait_idle_intx cc_s2 check "5 partial index, rows outside predicate do not serialize" \ - "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "0" + "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")|$(returned s2 returned_pt2)" "0|returned" send_wait s2 "pt2_com" "COMMIT;" release_s1 send_wait s1 "s1_com_5b" "COMMIT;" @@ -545,48 +556,48 @@ ctl_q "CREATE UNIQUE INDEX s_nd_uidx ON s_nd (k);" >/dev/null HKEY=1011 pause_s1 "INSERT INTO s_nd SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE NULL END FROM generate_series(1,2) g;" send_wait s2 "nd_beg" "BEGIN;" +send s2 "SET LOCAL lock_timeout = '5s';" send_wait s2 "nd_ins" "INSERT INTO s_nd VALUES (NULL);" -wait_idle_intx cc_s2 +send_wait s2 "nd_ok" "SELECT 'returned_nd';" +[ "$(returned s2 returned_nd)" = returned ] && wait_idle_intx cc_s2 check "6 NULLS DISTINCT: two NULL keys do not block" \ - "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "0" + "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")|$(returned s2 returned_nd)" "0|returned" send_wait s2 "nd_com" "COMMIT;" release_s1 send_wait s1 "s1_com_6a" "COMMIT;" check "6 NULLS DISTINCT: both NULL rows kept" \ "$(ctl_q "SELECT count(*) FROM s_nd WHERE k IS NULL;")" "2" -# NULLS NOT DISTINCT (PG15+): NULL keys DO conflict -> serialize and violate. -if [ "$PG_MAJOR" -ge 15 ]; then - ctl_q "CREATE TABLE s_nn (k int) USING pgcolumnar;" >/dev/null - ctl_q "CREATE UNIQUE INDEX s_nn_uidx ON s_nn (k) NULLS NOT DISTINCT;" >/dev/null - HKEY=1012 - pause_s1 "INSERT INTO s_nn SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE NULL END FROM generate_series(1,2) g;" - send_wait s2 "nn_beg" "BEGIN;" - send s2 "INSERT INTO s_nn VALUES (NULL);" - send s2 "\\echo <>" - wait_blocked cc_s2 - check "6 NULLS NOT DISTINCT: second NULL key serializes" \ - "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" - release_s1 - send_wait s1 "s1_com_6b" "COMMIT;" - wait_sentinel s2 nn_ins - check "6 NULLS NOT DISTINCT: second NULL got unique_violation" \ - "$(out_has s2 's_nn_uidx' && echo yes || echo no)" "yes" - send_wait s2 "nn_rb" "ROLLBACK;" - check "6 NULLS NOT DISTINCT: exactly one NULL row" \ - "$(ctl_q "SELECT count(*) FROM s_nn WHERE k IS NULL;")" "1" -else - check_skip "the NULLS NOT DISTINCT case" "SKIP 6 NULLS NOT DISTINCT test (PostgreSQL < 15)" "PostgreSQL < 15" -fi +# NULLS NOT DISTINCT: NULL keys DO conflict -> serialize and violate. It is +# PG15's, the oldest major this tree builds, so the PG < 15 skip that stood here +# was a branch no run could take (#1341). +ctl_q "CREATE TABLE s_nn (k int) USING pgcolumnar;" >/dev/null +ctl_q "CREATE UNIQUE INDEX s_nn_uidx ON s_nn (k) NULLS NOT DISTINCT;" >/dev/null +HKEY=1012 +pause_s1 "INSERT INTO s_nn SELECT CASE WHEN g=2 THEN cq_block($HKEY, -1) ELSE NULL END FROM generate_series(1,2) g;" +send_wait s2 "nn_beg" "BEGIN;" +send s2 "INSERT INTO s_nn VALUES (NULL);" +send s2 "\\echo <>" +wait_blocked cc_s2 asserted +check "6 NULLS NOT DISTINCT: second NULL key serializes" \ + "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" +release_s1 +send_wait s1 "s1_com_6b" "COMMIT;" +wait_sentinel s2 nn_ins +check "6 NULLS NOT DISTINCT: second NULL got unique_violation" \ + "$(violated s2 s_nn_uidx)" "yes" +send_wait s2 "nn_rb" "ROLLBACK;" +check "6 NULLS NOT DISTINCT: exactly one NULL row" \ + "$(ctl_q "SELECT count(*) FROM s_nn WHERE k IS NULL;")" "1" # =========================================================================== # Scenario 7: same-statement duplicate is still caught (no regression). # =========================================================================== ctl_q "CREATE TABLE s_ss (k int) USING pgcolumnar;" >/dev/null ctl_q "CREATE UNIQUE INDEX s_ss_uidx ON s_ss (k);" >/dev/null -ss_err="$(run_pg "$SPSQL -c \"INSERT INTO s_ss SELECT 7 FROM generate_series(1,2);\" 2>&1" )" +ss_err="$(run_pg "$SPSQL -v VERBOSITY=verbose -c \"INSERT INTO s_ss SELECT 7 FROM generate_series(1,2);\" 2>&1" )" check "7 same-statement duplicate raises unique_violation" \ - "$(grep -qF 's_ss_uidx' <<<"$ss_err" && echo yes || echo no)" "yes" + "$(grep -qF 'ERROR: 23505: duplicate key value violates unique constraint "s_ss_uidx"' <<<"$ss_err" && echo yes || echo no)" "yes" check "7 same-statement duplicate inserted no rows" \ "$(ctl_q "SELECT count(*) FROM s_ss;")" "0" @@ -603,7 +614,7 @@ send_wait s1 "ab_ins" "INSERT INTO s_ab VALUES (1);" # completes, flushes, hol send_wait s2 "ab_beg2" "BEGIN;" send s2 "INSERT INTO s_ab VALUES (1);" send s2 "\\echo <>" -wait_blocked cc_s2 +wait_blocked cc_s2 asserted check "8 second inserter blocks behind uncommitted first" \ "$(ctl_q "SELECT count(*) FROM pg_stat_activity WHERE application_name='cc_s2' AND wait_event_type='Lock';")" "1" send_wait s1 "ab_rb" "ROLLBACK;" # first inserter aborts; row never committed @@ -616,12 +627,4 @@ send s1 "\\q" send s2 "\\q" send sh "\\q" -echo -if [ "$fail" = 0 ]; then - echo "checks run: $PGC_CHECKS" - echo "UNIQUE_CONC TEST PASSED" -else - echo "checks run: $PGC_CHECKS" - echo "UNIQUE_CONC TEST FAILED" -fi -exit "$fail" +pgc_summary From dad089509f51aadf28c921d70885a147a82851d0 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 23:13:43 +0000 Subject: [PATCH 2/6] test: unique_conc seeds the ledger, 27 of 33 arms dated by six mutations of columnar_unique.c (#1341) 33 rows from five-major runs (33/33 on each of 15-19). Dated on PG18 by: the per-key lock never taken (18), the lock taken with the setting off (2), every key in one bucket (3), only the first key column hashed (1, shared), the partial predicate ignored (2) and a NULL key locked under NULLS DISTINCT (2). suites_not_covered 224 -> 223; the census is 3507 rows, 2607 never, 900 dated, restated on main 45eaa19d. Co-Authored-By: Claude Opus 5.5 --- test/check_ledger.tsv | 49 ++++++++++++++++++++++++++++++------ test/check_ledger_budget.txt | 23 +++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 08c1885fd..7f25d1ba7 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -2348,6 +2348,10 @@ native_join_runtime_filter native_join_runtime_filter ANTI refusal 15;16;17;18;1 native_join_runtime_filter native_join_runtime_filter CROSS refusal 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter LEFT refusal 15;16;17;18;19 2026-09-23 the join-type gate widened past JOIN_INNER to SEMI, LEFT and ANTI native_join_runtime_filter native_join_runtime_filter SEMI refusal 15;16;17;18;19 2026-09-23 the join-type gate widened past JOIN_INNER to SEMI, LEFT and ANTI +native_join_runtime_filter native_join_runtime_filter a (SELECT false) gate over a columnar dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) +native_join_runtime_filter native_join_runtime_filter a (SELECT false) gate over a heap dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) +native_join_runtime_filter native_join_runtime_filter a (SELECT true) gate over a columnar dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) +native_join_runtime_filter native_join_runtime_filter a (SELECT true) gate over a heap dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) native_join_runtime_filter native_join_runtime_filter baseline core Hash Join 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter baseline reads all groups 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter build rows omit NULL 15;16;17;18;19 never - @@ -2373,6 +2377,10 @@ native_join_runtime_filter native_join_runtime_filter join runtime filter defaul native_join_runtime_filter native_join_runtime_filter plan has build tap 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter plan has runtime coordinator 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter plan retains core Hash Join 15;16;17;18;19 never - +native_join_runtime_filter native_join_runtime_filter premise: heap's answer under a true gate over a columnar dimension holds 200 rows 15;16;17;18;19 never - +native_join_runtime_filter native_join_runtime_filter premise: heap's answer under a true gate over a heap dimension holds 200 rows 15;16;17;18;19 never - +native_join_runtime_filter native_join_runtime_filter premise: with no gate, the columnar-dimension join has the coordinator 15;16;17;18;19 never - +native_join_runtime_filter native_join_runtime_filter premise: with no gate, the heap-dimension join has the coordinator 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter projection join equals heap 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter projection outer is not wrapped 15;16;17;18;19 never - native_join_runtime_filter native_join_runtime_filter projection scan is chosen 15;16;17;18;19 never - @@ -3387,6 +3395,39 @@ type_matrix type_matrix premise: both tables hold 12000 rows, the columnar one i type_matrix type_matrix premise: the server supports xml (built with libxml) 15;16;17;18;19 never - type_matrix type_matrix the aggregates over numeric NaN and Infinity and timestamp and date infinity are heap's 15;16;17;18;19 never - type_matrix type_matrix the composite's fields and the enum's order read back as heap's 15;16;17;18;19 never - +unique_conc unique_conc 1a pre-fix: lock disabled lets both same-key inserts commit (bug) 15;16;17;18;19 2026-10-15 pgcolumnar.enable_unique_insert_lock ignored, so the lock is taken when it is off +unique_conc unique_conc 1b post-fix: blocked inserter got a unique_violation 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 1b post-fix: exactly one row with key 1 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 1b post-fix: same-key second inserter blocks 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 2a pre-fix: numeric 1.0 and 1.00 both commit with lock off (bug) 15;16;17;18;19 2026-10-15 pgcolumnar.enable_unique_insert_lock ignored, so the lock is taken when it is off +unique_conc unique_conc 2b post-fix: exactly one row equal to 1.0 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 2b post-fix: numeric 1.00 serializes behind 1.0 (opclass-safe hash) 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 2b post-fix: numeric equal-but-different-bytes got unique_violation 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 2c post-fix: citext 'abc' serializes behind 'ABC' (case-folded hash) 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 2c post-fix: citext case-insensitive duplicate got unique_violation 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 2c post-fix: exactly one row equal to 'abc' 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 3 both distinct keys present 15;16;17;18;19 2026-10-15 every key hashed to one bucket, as the coarse lock is +unique_conc unique_conc 3 different keys do not block each other 15;16;17;18;19 2026-10-15 every key hashed to one bucket, as the coarse lock is +unique_conc unique_conc 4 exactly one (1,2) row 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 4 multi-column differing key does not block 15;16;17;18;19 2026-10-15 every key hashed to one bucket, as the coarse lock is;the bucket hash combines only the first key column +unique_conc unique_conc 4 multi-column duplicate got unique_violation 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 4 multi-column equal key serializes 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 5 both inactive k=2 rows kept (predicate not enforced) 15;16;17;18;19 2026-10-15 the partial index predicate ignored, so a row outside it takes the key lock +unique_conc unique_conc 5 exactly one active row with k=1 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 5 partial index in-predicate duplicate got unique_violation 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 5 partial index, row inside predicate serializes 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 5 partial index, rows outside predicate do not serialize 15;16;17;18;19 2026-10-15 the partial index predicate ignored, so a row outside it takes the key lock +unique_conc unique_conc 6 NULLS DISTINCT: both NULL rows kept 15;16;17;18;19 2026-10-15 a key holding a NULL locked under NULLS DISTINCT +unique_conc unique_conc 6 NULLS DISTINCT: two NULL keys do not block 15;16;17;18;19 2026-10-15 a key holding a NULL locked under NULLS DISTINCT +unique_conc unique_conc 6 NULLS NOT DISTINCT: exactly one NULL row 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 6 NULLS NOT DISTINCT: second NULL got unique_violation 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 6 NULLS NOT DISTINCT: second NULL key serializes 15;16;17;18;19 2026-10-15 the per-key advisory lock never taken +unique_conc unique_conc 7 same-statement duplicate inserted no rows 15;16;17;18;19 never - +unique_conc unique_conc 7 same-statement duplicate raises unique_violation 15;16;17;18;19 never - +unique_conc unique_conc 8 after first aborts, second insert succeeds; one row 15;16;17;18;19 never - +unique_conc unique_conc 8 second inserter blocks behind uncommitted first 15;16;17;18;19 never - +unique_conc unique_conc the bucket count cannot be changed per session 15;16;17;18;19 never - +unique_conc unique_conc the cluster runs the bucket count the suite needs 15;16;17;18;19 never - vacuum_lock_privilege vacuum_lock_privilege add_projection refuses a non-owner before taking its lock (#749) 15;16;17;18;19 2026-10-14 every maintenance entry point checks ownership after taking its lock vacuum_lock_privilege vacuum_lock_privilege cluster refuses a non-owner before taking the exclusive lock 15;16;17;18;19 2026-10-14 every maintenance entry point checks ownership after taking its lock vacuum_lock_privilege vacuum_lock_privilege compact does the same, which is the behaviour drop_projection now matches 15;16;17;18;19 never - @@ -3464,11 +3505,3 @@ vm_privilege vm_privilege vm entry point is covered by this boundary test: vm_se vm_privilege vm_privilege vm_is_visible refuses a heap relation even for its owner (#748) 15;16;17;18;19 never - vm_privilege vm_privilege vm_selftest did not report success to the unprivileged role 15;16;17;18;19 2026-10-14 vm_selftest without its owner check vm_privilege vm_privilege vm_selftest refuses a heap relation even for its owner (#748) 15;16;17;18;19 never - -native_join_runtime_filter native_join_runtime_filter premise: with no gate, the heap-dimension join has the coordinator 15;16;17;18;19 never - -native_join_runtime_filter native_join_runtime_filter premise: heap's answer under a true gate over a heap dimension holds 200 rows 15;16;17;18;19 never - -native_join_runtime_filter native_join_runtime_filter a (SELECT false) gate over a heap dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) -native_join_runtime_filter native_join_runtime_filter a (SELECT true) gate over a heap dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) -native_join_runtime_filter native_join_runtime_filter premise: with no gate, the columnar-dimension join has the coordinator 15;16;17;18;19 never - -native_join_runtime_filter native_join_runtime_filter premise: heap's answer under a true gate over a columnar dimension holds 200 rows 15;16;17;18;19 never - -native_join_runtime_filter native_join_runtime_filter a (SELECT false) gate over a columnar dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) -native_join_runtime_filter native_join_runtime_filter a (SELECT true) gate over a columnar dimension equals heap 15;16;17;18;19 2026-10-15 the join runtime filter took a join carrying a gating qual and failed on the Result over it (the tree before #1437) diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 50569a517..70685e939 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -15,7 +15,7 @@ # # Adding a check to a suite that is already covered does not move it, which is # what makes it safe to bound. -suites_not_covered 224 +suites_not_covered 223 # # checks_never_observed_red -- A CENSUS. NOT a ceiling, and it must not become # one. @@ -2815,4 +2815,23 @@ suites_not_covered 224 # never 2602 - 1 = 2601 # dated 872 + 1 = 873 # 2601 + 873 == 3474 -checks_never_observed_red 2601 +# +# #1341, unique_conc seeded, with its twin (OffgridwithJD): 2601 -> 2607, and +# suites_not_covered 224 -> 223. The suite had no rows: its local check() recorded +# through pgc_record with no accounting line, and it now uses lib.sh's recorders. +# It enters with all 33 checks from five-major runs (33/33 on each of 15-19). 27 are +# dated by six mutations of columnar_unique.c: the per-key lock never taken (the 18 +# serialize, unique_violation and one-row arms), the lock taken with the setting off +# (1a, 2a), every key in one bucket (3, and 4's differing key), only the first key +# column hashed (4's differing key), the partial predicate ignored (5 outside it), +# and a NULL key locked under NULLS DISTINCT (6). The "does not block" arms could not +# fail before this change, so their dates are new evidence. Six arrive never: the two +# bucket-count premises, the same-statement duplicate (7, the btree's own check), and +# the aborted first inserter (8), which waits on the first's xid with or without the +# key lock. +# +# ledger rows 3474 + 33 = 3507 +# never 2601 + 6 = 2607 +# dated 873 + 27 = 900 +# 2607 + 900 == 3507 +checks_never_observed_red 2607 From 77661ad4b6d79316f325a521c8867d6e80faf7fb Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 23:19:36 +0000 Subject: [PATCH 3/6] test: unique_conc leaves suites_without_accounting, as it now ends with pgc_summary (#1341) Co-Authored-By: Claude Opus 5.5 --- test/suites_without_accounting.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/test/suites_without_accounting.txt b/test/suites_without_accounting.txt index 674a67fd9..be55a73e2 100644 --- a/test/suites_without_accounting.txt +++ b/test/suites_without_accounting.txt @@ -23,5 +23,4 @@ phase4 phase5 phase6 smoke -unique_conc update_conc From 668bce977a7361aaa87896ecc9cd847f780fd90c Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 23:34:43 +0000 Subject: [PATCH 4/6] test: unique_conc's twin asserts the waiter while T1 still holds the key (#1341) conflict() ran the whole race before the test asserted anything. With no key lock taken, T2 did not wait, T1 then stalled behind T2's uncommitted duplicate, and each test's first red was "a bounded wait for a standalone sentinel completed", not the arm the mutant breaks. conflict() now returns the waiter count with T1 held, the test asserts it, and settle() finishes the race. Measured on PG18: under the no-lock mutant the six tests now go red on their "serializes" arm (got 0 want 1), in 62s rather than 422s; on the head, 33/33. The sessions close holder first, so a T1 still paused when a test stops is released before its own connection closes. Co-Authored-By: Claude Opus 5.5 --- test/pytest/test_unique_conc.py | 54 +++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/test/pytest/test_unique_conc.py b/test/pytest/test_unique_conc.py index 4b9c7d3fe..b29d32a89 100644 --- a/test/pytest/test_unique_conc.py +++ b/test/pytest/test_unique_conc.py @@ -95,7 +95,7 @@ def __init__(self, dsn, expect): self.sh = _Session(dsn, "cc_sh") def close(self): - for c in (self.s1.conn, self.s2.conn, self.sh.conn, self.watch): + for c in (self.sh.conn, self.s2.conn, self.s1.conn, self.watch): c.close() def one(self, sql): @@ -155,19 +155,26 @@ def release_s1(self, hkey): self.run(self.sh, f"SELECT pg_advisory_unlock({hkey})") return self.finish(self.s1) - def conflict(self, hkey, table, first, second, index): - """T1 holds `first` unflushed; T2 inserts `second`, waits, and gets 23505 once T1 - commits. -> (sessions waiting while T1 held the key, _violated's answer).""" + def conflict(self, hkey, table, first, second): + """T1 holds `first` unflushed; T2 inserts `second` and should wait for it. + -> how many sessions wait, read while T1 still holds the key. The test + asserts it before `settle` releases T1, so a T2 that did not wait is that + check's red, not a later wait's (#1341: with no key lock, T1 then stalled + behind T2 and the first red was a bounded wait).""" self.pause_s1(hkey, f"INSERT INTO {table} {first}") self.run(self.s2, "BEGIN") self.s2.start(f"INSERT INTO {table} {second}") self.wait_blocked("cc_s2", asserted=True) - waiting = self.waiting("cc_s2") + return self.waiting("cc_s2") + + def settle(self, hkey, index): + """After `conflict`: T1 finishes and commits, and T2 should get 23505. + -> _violated's answer.""" self.release_s1(hkey) self.run(self.s1, "COMMIT") violated = _violated(self.finish(self.s2), index) self.run(self.s2, "ROLLBACK") - return waiting, violated + return violated def no_conflict(self, hkey, table, first, second): """T1 holds `first` unflushed; T2's `second` must not wait for it. @@ -239,8 +246,9 @@ def test_one_key_serializes_and_the_lock_is_what_does_it(race, expect): "1a pre-fix: lock disabled lets both same-key inserts commit (bug)") race.run(race.s1, "TRUNCATE s_int") - waiting, violated = race.conflict(1002, "s_int", _paused("1", "cq_block(1002, -1)"), "VALUES (1)", "s_int_uidx") - expect.num(waiting, 1, "1b post-fix: same-key second inserter blocks") + expect.num(race.conflict(1002, "s_int", _paused("1", "cq_block(1002, -1)"), "VALUES (1)"), 1, + "1b post-fix: same-key second inserter blocks") + violated = race.settle(1002, "s_int_uidx") expect.text(violated, "yes", "1b post-fix: blocked inserter got a unique_violation") expect.num(race.one("SELECT count(*) FROM s_int WHERE k = 1"), 1, "1b post-fix: exactly one row with key 1") @@ -255,9 +263,9 @@ def test_keys_equal_under_the_opclass_serialize(race, expect): "2a pre-fix: numeric 1.0 and 1.00 both commit with lock off (bug)") race.run(race.s1, "TRUNCATE s_num") - waiting, violated = race.conflict(1004, "s_num", _paused("1.0", "cq_block(1004, (-1)::numeric)"), - "VALUES (1.00)", "s_num_uidx") - expect.num(waiting, 1, "2b post-fix: numeric 1.00 serializes behind 1.0 (opclass-safe hash)") + expect.num(race.conflict(1004, "s_num", _paused("1.0", "cq_block(1004, (-1)::numeric)"), "VALUES (1.00)"), 1, + "2b post-fix: numeric 1.00 serializes behind 1.0 (opclass-safe hash)") + violated = race.settle(1004, "s_num_uidx") expect.text(violated, "yes", "2b post-fix: numeric equal-but-different-bytes got unique_violation") expect.num(race.one("SELECT count(*) FROM s_num WHERE v = 1.0"), 1, "2b post-fix: exactly one row equal to 1.0") @@ -271,9 +279,9 @@ def test_citext_keys_equal_by_case_serialize(race, expect): return race.run(race.s1, "CREATE TABLE s_ci (v citext) USING pgcolumnar") race.run(race.s1, "CREATE UNIQUE INDEX s_ci_uidx ON s_ci (v)") - waiting, violated = race.conflict(1005, "s_ci", _paused("'ABC'::citext", "cq_block(1005, 'zzz'::citext)"), - "VALUES ('abc')", "s_ci_uidx") - expect.num(waiting, 1, "2c post-fix: citext 'abc' serializes behind 'ABC' (case-folded hash)") + expect.num(race.conflict(1005, "s_ci", _paused("'ABC'::citext", "cq_block(1005, 'zzz'::citext)"), "VALUES ('abc')"), 1, + "2c post-fix: citext 'abc' serializes behind 'ABC' (case-folded hash)") + violated = race.settle(1005, "s_ci_uidx") expect.text(violated, "yes", "2c post-fix: citext case-insensitive duplicate got unique_violation") expect.num(race.one("SELECT count(*) FROM s_ci WHERE v = 'abc'"), 1, "2c post-fix: exactly one row equal to 'abc'") @@ -290,9 +298,9 @@ def test_different_keys_do_not_block_each_other(race, expect): def test_a_multi_column_key(race, expect): race.run(race.s1, "CREATE TABLE s_mc (a int, b int) USING pgcolumnar") race.run(race.s1, "CREATE UNIQUE INDEX s_mc_uidx ON s_mc (a, b)") - waiting, violated = race.conflict(1007, "s_mc", _paused("1", "cq_block(1007, -1)", ", 2"), "VALUES (1, 2)", - "s_mc_uidx") - expect.num(waiting, 1, "4 multi-column equal key serializes") + expect.num(race.conflict(1007, "s_mc", _paused("1", "cq_block(1007, -1)", ", 2"), "VALUES (1, 2)"), 1, + "4 multi-column equal key serializes") + violated = race.settle(1007, "s_mc_uidx") expect.text(violated, "yes", "4 multi-column duplicate got unique_violation") expect.num(race.one("SELECT count(*) FROM s_mc WHERE a = 1 AND b = 2"), 1, "4 exactly one (1,2) row") # (5,7) differs from (5,6) only in its second column @@ -303,9 +311,9 @@ def test_a_multi_column_key(race, expect): def test_a_partial_unique_index(race, expect): race.run(race.s1, "CREATE TABLE s_part (k int, active boolean) USING pgcolumnar") race.run(race.s1, "CREATE UNIQUE INDEX s_part_uidx ON s_part (k) WHERE active") - waiting, violated = race.conflict(1009, "s_part", _paused("1", "cq_block(1009, -1)", ", true"), - "VALUES (1, true)", "s_part_uidx") - expect.num(waiting, 1, "5 partial index, row inside predicate serializes") + expect.num(race.conflict(1009, "s_part", _paused("1", "cq_block(1009, -1)", ", true"), "VALUES (1, true)"), 1, + "5 partial index, row inside predicate serializes") + violated = race.settle(1009, "s_part_uidx") expect.text(violated, "yes", "5 partial index in-predicate duplicate got unique_violation") expect.num(race.one("SELECT count(*) FROM s_part WHERE k = 1 AND active"), 1, "5 exactly one active row with k=1") # Outside the predicate the index covers neither row, so nothing serializes. @@ -325,9 +333,9 @@ def test_null_keys(race, expect): # NULLS NOT DISTINCT is PG15's, the oldest major this tree supports. race.run(race.s1, "CREATE TABLE s_nn (k int) USING pgcolumnar") race.run(race.s1, "CREATE UNIQUE INDEX s_nn_uidx ON s_nn (k) NULLS NOT DISTINCT") - waiting, violated = race.conflict(1012, "s_nn", _paused("NULL", "cq_block(1012, -1)"), "VALUES (NULL)", - "s_nn_uidx") - expect.num(waiting, 1, "6 NULLS NOT DISTINCT: second NULL key serializes") + expect.num(race.conflict(1012, "s_nn", _paused("NULL", "cq_block(1012, -1)"), "VALUES (NULL)"), 1, + "6 NULLS NOT DISTINCT: second NULL key serializes") + violated = race.settle(1012, "s_nn_uidx") expect.text(violated, "yes", "6 NULLS NOT DISTINCT: second NULL got unique_violation") expect.num(race.one("SELECT count(*) FROM s_nn WHERE k IS NULL"), 1, "6 NULLS NOT DISTINCT: exactly one NULL row") From a372532dee8adbc6080cae76c24d32e3cc8813ec Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 23:27:40 +0000 Subject: [PATCH 5/6] test: update_conc gets its pytest twin, and a savepoint arm that reaches the subtransaction discard (#1341 item 24) test_update_conc.py asserts every shell check by name; compare_to_bash grades it as covering all 31 (28 checks and the three bounded-wait names), and it joins COMPLETE. update_conc.sh moves onto lib.sh's check, pgc_fail and pgc_summary, so it leaves suites_without_accounting. Its savepoint arm could not reach the discard it claims: the inner UPDATE completes, executor-end flushes it under the inner subtransaction, and with either discard in pgcolumnar_subxact_callback removed it stays green. A new arm fails a statement partway inside the savepoint, with the scan order asserted as its premise, and goes red under each (0/|1 and 2/10,99|3). cluster_tests 627 -> 638 by collection; TESTS.md gets section 135. Co-Authored-By: Claude Opus 5.5 --- test/pytest/TESTS.md | 23 ++ test/pytest/expected_tests.txt | 4 +- test/pytest/test_compare_to_bash.py | 1 + test/pytest/test_update_conc.py | 334 ++++++++++++++++++++++++++++ test/suites_without_accounting.txt | 1 - test/update_conc.sh | 94 ++++---- 6 files changed, 413 insertions(+), 44 deletions(-) create mode 100644 test/pytest/test_update_conc.py diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 6381b3aa5..9444bbdda 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -180,6 +180,7 @@ behaviour, the source of that number is named. - [132. test_iceberg_data_files.py: a delete entry in a data manifest is refused](#132-test_iceberg_data_filespy-a-delete-entry-in-a-data-manifest-is-refused) - [133. test_native_skip.py: a type binary-coercible to its opclass's type prunes](#133-test_native_skippy-a-type-binary-coercible-to-its-opclasss-type-prunes) - [134. test_unique_conc.py: concurrent inserters of one unique key serialize](#134-test_unique_concpy-concurrent-inserters-of-one-unique-key-serialize) +- [135. test_update_conc.py: two writers of one row serialize](#135-test_update_concpy-two-writers-of-one-row-serialize) ## 1. How to read a test in here @@ -7243,3 +7244,25 @@ The shapes: one key, with the lock off in T2 as the control that shows the bug; | `test_null_keys` | two NULLs never wait under NULLS DISTINCT; under NULLS NOT DISTINCT they serialize and conflict | | `test_a_duplicate_within_one_statement` | one INSERT of the same key twice raises 23505 and inserts nothing | | `test_an_aborted_first_inserter` | T2 waits behind T1's flushed, uncommitted row; T1 rolls back and T2's insert succeeds | + +## 135. test_update_conc.py: two writers of one row serialize + +The pytest twin of `test/update_conc.sh` (#5, ported for #1341), every check by the shell's name. A columnar UPDATE marks the old row deleted and appends a new one, so without a lock on the row identity two transactions updating one row each kept their own new version, and the row was duplicated. `columnar_row_lock.c` takes a transaction-scoped advisory lock per row at the delete-vector flush, re-reads the latest committed state, and gives the loser a retryable 40001. A table with a unique index takes the lock during the UPDATE, so the unique check cannot answer 23505 first (#1421). + +Each concurrent case runs one interleaving on a heap table, the oracle, and on a columnar one: s1 writes and holds its transaction, s2's statement waits on a lock, which a poll of `pg_stat_activity` confirms, and s1 commits. At READ COMMITTED a heap re-applies s2's update where columnar fails it, so the columnar value is compared with the heap's only at REPEATABLE READ. A private database; each session runs at a 30s `lock_timeout`, as the shell's cluster does. + +### Every test + +| test | what it holds | +| --- | --- | +| `test_update_vs_update_read_committed` | one row is left and the loser gets 40001; the heap composes both updates into one row | +| `test_update_vs_update_repeatable_read` | count and values match the heap, and both losers get 40001 | +| `test_update_vs_update_serializable` | the count matches the heap and the loser gets 40001, which held before the row lock too | +| `test_update_vs_delete_read_committed` | the heap's DELETE removes the updated row; columnar's loser gets 40001 | +| `test_delete_vs_update_read_committed` | the committed DELETE stands on both, and columnar's loser gets 40001 | +| `test_a_row_updated_twice_by_one_statement` | `UPDATE ... FROM (VALUES (1), (1))` leaves one row on both | +| `test_overlapping_multi_row_updates` | 41 overlapping rows: 200 rows remain, and the loser gets 40001 | +| `test_serial_updates_control` | the same two updates without overlap leave one row with the last writer's value | +| `test_a_covering_unique_index` | with a unique index the loser still gets 40001, not 23505; the heap composes | +| `test_a_savepoint_rolled_back` | an UPDATE rolled back to a savepoint leaves the pre-savepoint value, as on the heap | +| `test_a_statement_failing_inside_a_savepoint` | an UPDATE that fails partway inside a savepoint leaves nothing behind: its buffered mark and row are discarded | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index 87123bd16..b83dc3d14 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -735,4 +735,6 @@ guard_tests 438 # two equalities, parametrized), by collection over the cluster files on this tree. # cluster_tests 617 -> 627 for #1341's test_unique_conc.py, the twin of unique_conc.sh: # ten tests, by collection over the cluster files on this tree. -cluster_tests 627 +# cluster_tests 627 -> 638 for #1341's test_update_conc.py, the twin of update_conc.sh: +# eleven tests, by collection over the cluster files on this tree. +cluster_tests 638 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index 55ed8584b..d4be4af57 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -166,6 +166,7 @@ "ttl_expire", "type_matrix", "unique_conc", + "update_conc", "vacuum_lock_privilege", "validity_elision", "vm_privilege", diff --git a/test/pytest/test_update_conc.py b/test/pytest/test_update_conc.py new file mode 100644 index 000000000..5c71d9a11 --- /dev/null +++ b/test/pytest/test_update_conc.py @@ -0,0 +1,334 @@ +"""Two writers of the same columnar row serialize (issue #5, the UPDATE half, #1341). +The pytest twin of `test/update_conc.sh`, every check by the shell's name. + +A columnar UPDATE marks the old row deleted and appends a new one. With nothing +serializing the row identity, two transactions updating one row each kept their own +new version: the row was duplicated and one update lost. columnar_row_lock.c takes a +transaction-scoped advisory lock per row identity at the delete-vector flush, then +re-reads the latest committed state and raises a retryable serialization_failure +(40001) when a concurrent transaction committed the row as deleted. A table with a +unique index takes the lock during the UPDATE instead, so the unique check cannot +answer 23505 first (#1421). + +Each concurrent scenario runs the same interleaving on a heap table, the oracle, and +on a columnar one. s1 updates and holds its transaction open; s2's statement runs in +a thread and must wait on a lock, which a poll of pg_stat_activity confirms, never a +sleep; then s1 commits. At READ COMMITTED a heap re-applies s2's update to the new +version, where columnar gives s2 a 40001, so at RC the columnar VALUE is not compared +with the heap's. + +A PRIVATE DATABASE on the shared cluster: the waits are read from pg_stat_activity +for this database only. Each session runs at a 30s lock_timeout, as the shell's +cluster does, so a real hang ends in an error rather than a pass. +""" +import threading +import time + +import psycopg +import pytest + +WAIT = 60 # seconds a bounded wait allows, as the shell's 1200 polls of 0.05s +LOCK_TIMEOUT = "30s" + + +@pytest.fixture(scope="module") +def upconc(pgc_cluster): + """-> the dsn of a private database holding the four tables the scenarios use.""" + name = "pgc_upconc" + + def admin(sql): + with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as c: + c.execute(sql) + + admin(f"DROP DATABASE IF EXISTS {name}") + admin(f"CREATE DATABASE {name}") + dsn = pgc_cluster.dsn(dbname=name) + try: + with psycopg.connect(dsn, autocommit=True) as c: + c.execute("CREATE EXTENSION pgcolumnar") + c.execute("CREATE TABLE t_heap (id int, v int)") + c.execute("CREATE TABLE t_col (id int, v int) USING pgcolumnar") + c.execute("CREATE TABLE t_heapu (id int, v int)") + c.execute("CREATE UNIQUE INDEX t_heapu_uidx ON t_heapu (id)") + c.execute("CREATE TABLE t_colu (id int, v int) USING pgcolumnar") + c.execute("CREATE UNIQUE INDEX t_colu_uidx ON t_colu (id)") + yield dsn + finally: + admin(f"DROP DATABASE IF EXISTS {name} WITH (FORCE)") + + +class _Session: + """One connection, whose statements run in a thread so the test can watch it wait.""" + + def __init__(self, dsn, app): + self.conn = psycopg.connect(dsn, autocommit=True, application_name=app, + options=f"-c lock_timeout={LOCK_TIMEOUT}") + self.thread = None + self.error = None + + def start(self, sql): + self.error = None + + def go(): + try: + self.conn.execute(sql) + except psycopg.Error as exc: + self.error = exc + self.thread = threading.Thread(target=go, daemon=True) + self.thread.start() + + def finished(self): + self.thread.join(WAIT) + return not self.thread.is_alive() + + +def _code(err): + """The SQLSTATE a statement ended with: '00000' when it succeeded, as psql's + :SQLSTATE reads.""" + return "00000" if err is None else err.sqlstate + + +class _Race: + """s1, s2 and a watcher, and the waits the shell bounds. Each wait that runs out + records its failure under the shell's fixed name for that kind.""" + + def __init__(self, dsn, expect): + self.expect = expect + self.watch = psycopg.connect(dsn, autocommit=True) + self.s1 = _Session(dsn, "cc_s1") + self.s2 = _Session(dsn, "cc_s2") + + def close(self): + for c in (self.s1.conn, self.s2.conn, self.watch): + c.close() + + def one(self, sql): + return self.watch.execute(sql).fetchone()[0] + + def run(self, sess, sql): + """sql on sess, which must complete within the bound. -> its error, or None.""" + expect = self.expect + sess.start(sql) + if not sess.finished(): + expect.text(f"timed out on {sql}", "completed", "a bounded wait for a command's sentinel completed") + return sess.error + + def finish(self, sess): + """Wait for sess's statement, already running. -> its error, or None.""" + expect = self.expect + if not sess.finished(): + expect.text("timed out", "completed", "a bounded wait for a standalone sentinel completed") + return sess.error + + def wait_blocked(self, app): + expect = self.expect + deadline = time.monotonic() + WAIT + while self.one(f"SELECT count(*) FROM pg_stat_activity WHERE application_name = '{app}' " + "AND datname = current_database() AND wait_event_type = 'Lock'") != 1: + if time.monotonic() > deadline: + expect.text(f"{app} never waited", "completed", "a bounded wait for a session to block completed") + return + time.sleep(0.05) + + def seed(self, table, rows=1): + self.watch.execute(f"TRUNCATE {table}") + self.watch.execute(f"INSERT INTO {table} SELECT g, 0 FROM generate_series(1, {rows}) g") + + def count(self, table): + return self.one(f"SELECT count(*) FROM {table} WHERE id = 1") + + def values(self, table): + return self.one(f"SELECT coalesce(string_agg(v::text, ',' ORDER BY v), '') FROM {table} WHERE id = 1") + + def overlap(self, iso, first, second): + """s1 runs `first` and holds its transaction; s2's `second` must wait on it; + s1 commits, then s2 finishes and commits. -> s2's SQLSTATE.""" + self.run(self.s1, f"BEGIN ISOLATION LEVEL {iso}") + self.run(self.s2, f"BEGIN ISOLATION LEVEL {iso}") + self.run(self.s1, first) + self.s2.start(second) + self.wait_blocked("cc_s2") + self.run(self.s1, "COMMIT") + code = _code(self.finish(self.s2)) + self.run(self.s2, "COMMIT") + return code + + def uu(self, table, iso): + """update-vs-update: s1 sets v=10, s2 sets v=20, on id=1. Distinct constants, + never v+1, so the duplicated and the composed states cannot coincide. + -> (rows with id=1, their values, s2's SQLSTATE).""" + self.seed(table) + code = self.overlap(iso, f"UPDATE {table} SET v = 10 WHERE id = 1", + f"UPDATE {table} SET v = 20 WHERE id = 1") + return self.count(table), self.values(table), code + + +@pytest.fixture +def race(upconc, expect): + r = _Race(upconc, expect) + try: + yield r + finally: + r.close() + + +def test_update_vs_update_read_committed(race, expect): + h_cnt, _, _ = race.uu("t_heap", "READ COMMITTED") + c_cnt, _, c_ss = race.uu("t_col", "READ COMMITTED") + expect.num(h_cnt, 1, "uu/RC heap oracle composes both updates (one row)") + expect.num(c_cnt, 1, "uu/RC columnar keeps a single row (no duplicate)") + expect.text(c_ss, "40001", "uu/RC columnar loser gets serialization_failure") + # The value is not compared with the heap's: columnar's loser retries (it ends + # {10}) where a heap re-applies transparently (it ends {20}). + + +def test_update_vs_update_repeatable_read(race, expect): + h_cnt, h_vals, h_ss = race.uu("t_heap", "REPEATABLE READ") + c_cnt, c_vals, c_ss = race.uu("t_col", "REPEATABLE READ") + expect.text(h_ss, "40001", "uu/RR heap oracle: loser serialization_failure") + expect.num(c_cnt, h_cnt, "uu/RR columnar count matches heap oracle") + expect.text(c_vals, h_vals, "uu/RR columnar value-set matches heap oracle") + expect.text(c_ss, "40001", "uu/RR columnar loser gets serialization_failure") + + +def test_update_vs_update_serializable(race, expect): + """Safe before the row lock existed too: the delete-vector conflict, or SSI, + already failed the loser at this level. A regression guard.""" + h_cnt, _, _ = race.uu("t_heap", "SERIALIZABLE") + c_cnt, _, c_ss = race.uu("t_col", "SERIALIZABLE") + expect.num(c_cnt, h_cnt, "uu/SER columnar count matches heap oracle") + expect.text(c_ss, "40001", "uu/SER columnar loser gets serialization_failure") + + +def test_update_vs_delete_read_committed(race, expect): + """At RC the count is 1 whether the DELETE was lost or the loser aborted, so the + SQLSTATE is what tells them apart.""" + def ud(table): + race.seed(table) + code = race.overlap("READ COMMITTED", f"UPDATE {table} SET v = 10 WHERE id = 1", + f"DELETE FROM {table} WHERE id = 1") + return race.count(table), code + h_cnt, _ = ud("t_heap") + _, c_ss = ud("t_col") + expect.num(h_cnt, 0, "ud/RC heap oracle: delete applies to the live row (count 0)") + expect.text(c_ss, "40001", "ud/RC columnar loser gets serialization_failure") + + +def test_delete_vs_update_read_committed(race, expect): + """Without the lock, s2 wrote a successor to a row s1 had deleted and committed.""" + def du(table): + race.seed(table) + code = race.overlap("READ COMMITTED", f"DELETE FROM {table} WHERE id = 1", + f"UPDATE {table} SET v = 20 WHERE id = 1") + return race.count(table), code + h_cnt, _ = du("t_heap") + c_cnt, c_ss = du("t_col") + expect.num(h_cnt, 0, "du/RC heap oracle: committed delete stands (count 0)") + expect.num(c_cnt, h_cnt, "du/RC columnar count matches heap oracle") + expect.text(c_ss, "40001", "du/RC columnar loser gets serialization_failure") + + +def test_a_row_updated_twice_by_one_statement(race, expect): + """UPDATE ... FROM (VALUES (1), (1)) matches id=1 twice. A heap raises "tuple to be + updated was already modified by an operation triggered by the current command" + and keeps one row; columnar reports TM_SelfModified, so core does the same.""" + def self_update(table): + race.seed(table) + try: + race.watch.execute(f"UPDATE {table} t SET v = v + 1 FROM (VALUES (1), (1)) s(id) WHERE t.id = s.id") + except psycopg.Error: + pass + return race.count(table) + expect.num(self_update("t_heap"), 1, "self-update heap oracle keeps one row") + expect.num(self_update("t_col"), 1, "self-update columnar keeps one row (no self-duplicate)") + + +def test_overlapping_multi_row_updates(race, expect): + """s1 updates ids 1..120, s2 ids 80..200: 41 rows overlap.""" + def multirow(table): + race.seed(table, 200) + code = race.overlap("READ COMMITTED", f"UPDATE {table} SET v = v + 1 WHERE id BETWEEN 1 AND 120", + f"UPDATE {table} SET v = v + 1 WHERE id BETWEEN 80 AND 200") + return race.one(f"SELECT count(*) FROM {table}"), code + h_tot, _ = multirow("t_heap") + c_tot, c_ss = multirow("t_col") + expect.num(h_tot, 200, "multirow heap oracle total unchanged (200)") + expect.num(c_tot, h_tot, "multirow columnar total matches heap (no per-row duplicates)") + expect.text(c_ss, "40001", "multirow columnar loser gets serialization_failure") + + +def test_serial_updates_control(race, expect): + """The same two updates, s1 committed before s2 begins: no overlap, so a red above + is the overlap's and not a broken update.""" + race.seed("t_col") + for sess, v in ((race.s1, 10), (race.s2, 20)): + race.run(sess, "BEGIN") + race.run(sess, f"UPDATE t_col SET v = {v} WHERE id = 1") + race.run(sess, "COMMIT") + expect.num(race.count("t_col"), 1, + "CONTROL serial columnar keeps one row (GREEN today; proves the RED is the overlap)") + expect.text(race.values("t_col"), "20", "CONTROL serial columnar keeps the last writer's value") + + +def test_a_covering_unique_index(race, expect): + """The loser gets 40001 here too, as docs/limitations.md promises. It used to get + 23505: its new version's unique-key lock waited on the winner's identical key, and + the unique check fired before the flush that raises 40001 (#1421).""" + _, _, h_ss = race.uu("t_heapu", "READ COMMITTED") + c_cnt, _, c_ss = race.uu("t_colu", "READ COMMITTED") + expect.text(h_ss, "00000", "unique/RC heap composes with no error (sqlstate 00000)") + expect.text(f"{c_ss}|{c_cnt}", "40001|1", + "unique/RC columnar loser gets serialization_failure (40001), as without a unique index") + + +def _id1(race, table): + return race.one(f"SELECT count(*) || '/' || coalesce(string_agg(v::text, ',' ORDER BY v), '') " + f"FROM {table} WHERE id = 1") + + +def test_a_savepoint_rolled_back(race, expect): + """The inner UPDATE completes, so its marks and its new row are flushed under the + inner subtransaction, and its abort hides them.""" + def sp(table): + race.seed(table) + c = race.watch + c.execute("BEGIN") + c.execute(f"UPDATE {table} SET v = 10 WHERE id = 1") + c.execute("SAVEPOINT sp") + c.execute(f"UPDATE {table} SET v = 99 WHERE id = 1") + c.execute("ROLLBACK TO sp") + c.execute("COMMIT") + return _id1(race, table) + h_sp = sp("t_heap") + c_sp = sp("t_col") + expect.text(c_sp, h_sp, "savepoint columnar final state matches heap oracle") + expect.text(c_sp, "1/10", "savepoint columnar kept the pre-savepoint value, no duplicate (1/10)") + + +def test_a_statement_failing_inside_a_savepoint(race, expect): + """The inner UPDATE changes id=1 and then fails on id=3, so no executor end runs + and its delete mark and new row are still buffered at ROLLBACK TO. Only the + subtransaction discard keeps them from being written at COMMIT; the arm above + cannot reach it (#1341). The premise is the scan order: id=1 is read, and + updated, before id=3 fails the statement.""" + def sp_fail(table): + race.seed(table) + c = race.watch + c.execute("BEGIN") + c.execute(f"UPDATE {table} SET v = 10 WHERE id = 1") + c.execute(f"INSERT INTO {table} VALUES (3, 0)") + order = c.execute(f"SELECT string_agg(id::text, ',') FROM {table}").fetchone()[0] + c.execute("SAVEPOINT sp") + try: + c.execute(f"UPDATE {table} SET v = CASE WHEN id = 3 THEN 1 / (id - 3) ELSE 99 END") + except psycopg.errors.DivisionByZero: + pass + c.execute("ROLLBACK TO sp") + c.execute("COMMIT") + return order, f"{_id1(race, table)}|{race.one(f'SELECT count(*) FROM {table}')}" + _, h_after = sp_fail("t_heap") + c_order, c_after = sp_fail("t_col") + expect.text(c_order, "1,3", "premise: the failing statement reads id=1 before id=3 on the columnar table") + expect.text(c_after, h_after, "savepoint, failed statement: columnar final state matches heap oracle") + expect.text(c_after, "1/10|2", + "savepoint, failed statement: columnar kept the pre-savepoint value, no duplicate (1/10, 2 rows)") diff --git a/test/suites_without_accounting.txt b/test/suites_without_accounting.txt index be55a73e2..515d4b41a 100644 --- a/test/suites_without_accounting.txt +++ b/test/suites_without_accounting.txt @@ -23,4 +23,3 @@ phase4 phase5 phase6 smoke -update_conc diff --git a/test/update_conc.sh b/test/update_conc.sh index 7243146cd..e81c1b9dc 100755 --- a/test/update_conc.sh +++ b/test/update_conc.sh @@ -27,6 +27,10 @@ # in whether the losing writer errors, which is exactly what the heap oracle # measures. # +# THE FIX HAS LANDED: src/columnar_row_lock.c serializes the row identity, and +# every check here passes. The paragraphs above and below describe the code the +# suite was written against. Its pytest twin is test/pytest/test_update_conc.py. +# # This suite is RED-first: the checks encode the CORRECT (post-fix) behavior, so # every columnar arm FAILS on today's code and PASSES once same-row writes # serialize. A heap table runs the byte-identical interleaving as the oracle, and @@ -75,16 +79,19 @@ # failed. That is worse than invisibility, because a missing number can be noticed # and a balancing one cannot. Induced and measured rather than argued. # -# THE NAME IS FIXED PER WAIT KIND, with the session and sentinel in pgc_record's -# REASON field. The ledger is keyed on (suite, part, name), so interpolating +# THE NAME IS FIXED PER WAIT KIND, with the session and sentinel in pgc_fail's +# DETAIL. The ledger is keyed on (suite, part, name), so interpolating # "$name/$label" into the name would mint rows nobody can enumerate and therefore # nobody can seed. # # NO GREEN RUN EXECUTES THIS. It records only on the timeout path, so a passing # suite emits nothing here and the record stream is unchanged. +# +# lib.sh's pgc_fail, not a direct pgc_record (#1341): the pytest twin's grading +# reads names through lib.sh's recorders, and a suite that records on its own is +# one the twin check cannot read. wait_timeout() { # wait_timeout FIXED-NAME DETAIL - pgc_record FAIL "$1" "FAIL $1 (timed out waiting for $2)" "$2" - fail=1 + pgc_fail "$1" "timed out waiting for $2" } @@ -190,35 +197,11 @@ PSQL="psql -h '$WORKDIR' -p $PORT -d upconc -qAtX -v ON_ERROR_STOP=1" SPSQL="psql -h '$WORKDIR' -p $PORT -d upconc -qAtX" ctl_q() { run_pg "$PSQL -c \"$1\""; } -fail=0 -# RECORDS RATHER THAN ONLY PRINTING (#965). This suite emitted human PASS lines -# and no RESULT records, so every mechanism built on the record vocabulary -- the -# ledger, the census, checks_never_observed_red, the red-observation record, -# duplicate-name detection -- was blind to all of them. The ledger did not merely -# return nothing on this log, it REFUSED it: "no RESULT records, so there is -# nothing to reconcile". -# -# `pgc_record` takes the DISPLAY whole, so the human output below is byte-for-byte -# what it was. NOT lib.sh's own `check`: that composes its own display and would -# drop the `: $got` suffix, which is the measured value rather than a label. -# -# `fail` is still set, so this suite's exit logic is untouched. Its verdict line -# stays for the same reason: under `set -euo pipefail` a failing command aborts the -# suite, and the verdict is what distinguishes finished from stopped. -# -# The `checks run:` line is READ BY NOTHING YET. The matrix gates reconciliation on -# the ACCOUNTING line via `pgc_log_shows_accounting`, and this suite emits none -# because it emits no accounting line. The line is still correct and wanted -- -# it is the total the records reconcile against -- so the remaining step is a gate -# flip rather than new work (@jdatcmd, #969 review). -check() { # name got want - if [ "$2" = "$3" ]; then - pgc_record PASS "$1" "PASS $1: $2" - else - pgc_record FAIL "$1" "FAIL $1: got [$2] want [$3]" - fail=1 - fi -} +# lib.sh's `check` records every result (#965), and since #1341 this suite uses +# it rather than a local wrapper around pgc_record. The local one printed the +# measured value on a PASS too. lib.sh's prints it on a FAIL, which is when it is +# read, and the twin check can read the names, which it could not before. +# pgc_summary at the end prints the accounting line the matrix reconciles. # --- persistent interactive sessions over FIFOs (as in test/concurrency.sh) --- start_session() { # name @@ -504,15 +487,42 @@ echo "-- heap=$H_SP ; col=$C_SP" check "savepoint columnar final state matches heap oracle" "$C_SP" "$H_SP" check "savepoint columnar kept the pre-savepoint value, no duplicate (1/10)" "$C_SP" "1/10" +# --------------------------------------------------------------------------- +# savepoint, a statement that FAILS inside it (#1341). The arm above cannot reach +# the subtransaction discard: its inner UPDATE completes, and executor-end flushes +# its marks and rows under the inner subtransaction, whose abort then hides them. +# Measured: with either discard in pgcolumnar_subxact_callback removed, both arms +# above stay green. Here the inner UPDATE changes id=1 and then fails on id=3, so +# no executor-end runs, and its delete mark and new row are still buffered when +# ROLLBACK TO arrives. Only the discard keeps them from being written at COMMIT. +# The premise is the scan order: id=1 is read, and updated, before id=3 fails it. +# --------------------------------------------------------------------------- +sp_fail_arm() { # tbl -> "scan order|count/values for id=1|rows" after the rollback + local order + seed1 "$1" + # One session, so the scan order is read inside the transaction that then + # fails. Without ON_ERROR_STOP the script goes on past the failed UPDATE. + order="$(run_pg "$SPSQL" 2>/dev/null <<-SQL + BEGIN; + UPDATE $1 SET v=10 WHERE id=1; + INSERT INTO $1 VALUES (3,0); + SELECT string_agg(id::text, ',') FROM $1; + SAVEPOINT sp; + UPDATE $1 SET v = CASE WHEN id=3 THEN 1/(id-3) ELSE 99 END; + ROLLBACK TO sp; + COMMIT; + SQL + )" + echo "$order|$(ctl_q "SELECT count(*)||'/'||coalesce(string_agg(v::text, ',' ORDER BY v),'') FROM $1 WHERE id=1;")|$(ctl_q "SELECT count(*) FROM $1;")" +} +echo; echo "### savepoint: a statement failing inside it leaves nothing behind" +H_SPF="$(sp_fail_arm t_heap)"; C_SPF="$(sp_fail_arm t_col)" +echo "-- heap=$H_SPF ; col=$C_SPF" +check "premise: the failing statement reads id=1 before id=3 on the columnar table" "${C_SPF%%|*}" "1,3" +check "savepoint, failed statement: columnar final state matches heap oracle" "${C_SPF#*|}" "${H_SPF#*|}" +check "savepoint, failed statement: columnar kept the pre-savepoint value, no duplicate (1/10, 2 rows)" "${C_SPF#*|}" "1/10|2" + send s1 "\\q" send s2 "\\q" -echo -if [ "$fail" = 0 ]; then - echo "checks run: $PGC_CHECKS" - echo "UPDATE CONCURRENCY TEST PASSED" -else - echo "checks run: $PGC_CHECKS" - echo "UPDATE CONCURRENCY TEST FAILED" -fi -exit "$fail" +pgc_summary From af72e6ae18172848f496fcd893f0bbf2c1e07c4b Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Thu, 15 Oct 2026 23:33:48 +0000 Subject: [PATCH 6/6] test: update_conc seeds the ledger, 14 of 28 arms dated by seven mutations (#1341) 28 rows from five-major runs (28/28 on each of 15-19). Dated on PG18 by, in columnar_row_lock.c: the flush-time serialization skipped, the committed-delete conflict never raised, and the re-read from the statement snapshot (10 arms each, the same 10); the row lock skipped during a unique-index UPDATE (1); the self-modify guard removed (1); and in pgcolumnar_subxact_callback, either discard skipped (the 2 failed-statement savepoint arms). suites_not_covered 223 -> 222; the census is 3535 rows, 2621 never, 914 dated, restated on main 45eaa19d. Co-Authored-By: Claude Opus 5.5 --- test/check_ledger.tsv | 28 ++++++++++++++++++++++++++++ test/check_ledger_budget.txt | 24 ++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 7f25d1ba7..7cc408843 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -3428,6 +3428,34 @@ unique_conc unique_conc 8 after first aborts, second insert succeeds; one row 15 unique_conc unique_conc 8 second inserter blocks behind uncommitted first 15;16;17;18;19 never - unique_conc unique_conc the bucket count cannot be changed per session 15;16;17;18;19 never - unique_conc unique_conc the cluster runs the bucket count the suite needs 15;16;17;18;19 never - +update_conc update_conc CONTROL serial columnar keeps one row (GREEN today; proves the RED is the overlap) 15;16;17;18;19 never - +update_conc update_conc CONTROL serial columnar keeps the last writer's value 15;16;17;18;19 never - +update_conc update_conc du/RC columnar count matches heap oracle 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc du/RC columnar loser gets serialization_failure 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc du/RC heap oracle: committed delete stands (count 0) 15;16;17;18;19 never - +update_conc update_conc multirow columnar loser gets serialization_failure 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc multirow columnar total matches heap (no per-row duplicates) 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc multirow heap oracle total unchanged (200) 15;16;17;18;19 never - +update_conc update_conc premise: the failing statement reads id=1 before id=3 on the columnar table 15;16;17;18;19 never - +update_conc update_conc savepoint columnar final state matches heap oracle 15;16;17;18;19 never - +update_conc update_conc savepoint columnar kept the pre-savepoint value, no duplicate (1/10) 15;16;17;18;19 never - +update_conc update_conc savepoint, failed statement: columnar final state matches heap oracle 15;16;17;18;19 2026-10-15 an aborted subtransaction keeps its buffered delete marks;an aborted subtransaction keeps its buffered rows +update_conc update_conc savepoint, failed statement: columnar kept the pre-savepoint value, no duplicate (1/10, 2 rows) 15;16;17;18;19 2026-10-15 an aborted subtransaction keeps its buffered delete marks;an aborted subtransaction keeps its buffered rows +update_conc update_conc self-update columnar keeps one row (no self-duplicate) 15;16;17;18;19 2026-10-15 a row this command already updated is not reported self-modified +update_conc update_conc self-update heap oracle keeps one row 15;16;17;18;19 never - +update_conc update_conc ud/RC columnar loser gets serialization_failure 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc ud/RC heap oracle: delete applies to the live row (count 0) 15;16;17;18;19 never - +update_conc update_conc unique/RC columnar loser gets serialization_failure (40001), as without a unique index 15;16;17;18;19 2026-10-15 a table with a unique index skips the row lock during the UPDATE +update_conc update_conc unique/RC heap composes with no error (sqlstate 00000) 15;16;17;18;19 never - +update_conc update_conc uu/RC columnar keeps a single row (no duplicate) 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc uu/RC columnar loser gets serialization_failure 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc uu/RC heap oracle composes both updates (one row) 15;16;17;18;19 never - +update_conc update_conc uu/RR columnar count matches heap oracle 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc uu/RR columnar loser gets serialization_failure 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc uu/RR columnar value-set matches heap oracle 15;16;17;18;19 2026-10-15 a row committed as deleted by a concurrent transaction never raises 40001 at the flush;the flush re-reads the statement snapshot, not the latest committed state;the row-identity serialization at the delete-vector flush skipped +update_conc update_conc uu/RR heap oracle: loser serialization_failure 15;16;17;18;19 never - +update_conc update_conc uu/SER columnar count matches heap oracle 15;16;17;18;19 never - +update_conc update_conc uu/SER columnar loser gets serialization_failure 15;16;17;18;19 never - vacuum_lock_privilege vacuum_lock_privilege add_projection refuses a non-owner before taking its lock (#749) 15;16;17;18;19 2026-10-14 every maintenance entry point checks ownership after taking its lock vacuum_lock_privilege vacuum_lock_privilege cluster refuses a non-owner before taking the exclusive lock 15;16;17;18;19 2026-10-14 every maintenance entry point checks ownership after taking its lock vacuum_lock_privilege vacuum_lock_privilege compact does the same, which is the behaviour drop_projection now matches 15;16;17;18;19 never - diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 70685e939..c6ee264be 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -15,7 +15,7 @@ # # Adding a check to a suite that is already covered does not move it, which is # what makes it safe to bound. -suites_not_covered 223 +suites_not_covered 222 # # checks_never_observed_red -- A CENSUS. NOT a ceiling, and it must not become # one. @@ -2834,4 +2834,24 @@ suites_not_covered 223 # never 2601 + 6 = 2607 # dated 873 + 27 = 900 # 2607 + 900 == 3507 -checks_never_observed_red 2607 +# +# #1341, update_conc seeded, with its twin (OffgridwithJD): 2607 -> 2621, and +# suites_not_covered 223 -> 222. The suite had no rows: its local check() recorded +# through pgc_record with no accounting line, and it now uses lib.sh's recorders. +# It enters with all 28 checks from five-major runs (28/28 on each of 15-19). 14 are +# dated by seven mutations: in columnar_row_lock.c, the flush-time serialization +# skipped, the committed-delete conflict never raised, and the re-read taken from +# the statement snapshot (each the same 10 duplicate-row and 40001 arms at RC and +# RR, update-vs-delete, delete-vs-update and multirow); the row lock skipped during +# the UPDATE of a table with a unique index (the unique/RC arm); the self-modify +# guard removed (the self-update arm); and in pgcolumnar_subxact_callback, either +# discard skipped (the new failed-statement savepoint arms, both). Fourteen arrive +# never: the seven heap-oracle arms, the two SERIALIZABLE guards, which held before +# the row lock existed, the two serial controls, the scan-order premise, and the +# completed-UPDATE savepoint arm's two, which neither discard alone reddens. +# +# ledger rows 3507 + 28 = 3535 +# never 2607 + 14 = 2621 +# dated 900 + 14 = 914 +# 2621 + 914 == 3535 +checks_never_observed_red 2621