From 6bc09e078a6b81b66c70f0cb5af1f591170a1965 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Fri, 16 Oct 2026 18:09:29 +0000 Subject: [PATCH 1/7] test: native_fetch_bigcap gets its pytest twin, and its narrow and medium projections assert their plans (#1341, #1339) test_native_fetch_bigcap.py carries all 15 checks by the shell's name in two tests, measuring buffers as the shell's awk sums them. Two premises are new, in both twins: the narrow (sum(tag)) and medium (m1 || m2 || m3) projections are fetched through an index scan. Only the payload query's plan was asserted. Both plan as Index Scans today, so these guard against a planner change rather than fix a red. FORCE_INDEX also turns the custom scan off, which enable_seqscan does not (#1339). Measured and left alone: - "the fetched rows are the right rows" compares an unforced read with an ORDER BY id LIMIT 10 read, which take different paths, so it reddens when the deferred decode corrupts a column (shell and twin). Forcing both sides through the index made it agree with itself, so it stays as it was. - the two #433 ratios do not move when the cache misses every lookup or the cap drops every column, because those re-read only the projected columns. #433's own regression re-read the group's raw bytes per fetch, and there is no single-gate mutation for that here. COMPLETE gains native_fetch_bigcap. TESTS.md section 173, cluster_tests 742 -> 744. Co-Authored-By: Claude Opus 5.5 --- test/native_fetch_bigcap.sh | 14 ++- test/pytest/TESTS.md | 12 +++ test/pytest/expected_tests.txt | 4 +- test/pytest/test_compare_to_bash.py | 1 + test/pytest/test_native_fetch_bigcap.py | 138 ++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 test/pytest/test_native_fetch_bigcap.py diff --git a/test/native_fetch_bigcap.sh b/test/native_fetch_bigcap.sh index d91c52091..9e26b7228 100755 --- a/test/native_fetch_bigcap.sh +++ b/test/native_fetch_bigcap.sh @@ -33,8 +33,13 @@ CAP=$((32 * 1024 * 1024)) # COLUMNAR_FETCH_CACHE_MAX_BYTES, src/columnar.h # by its own model, and then nothing measures the fetch path at all. So the index # path is forced, and every measured query asserts it got one. Whether the # planner should choose it is #434, and that is a separate suite. +# +# pgcolumnar.enable_custom_scan=off as well: enable_seqscan alone does not turn +# the columnar custom scan off (#1339). Every measured query asserts its plan, so +# a planner change cannot quietly take the arms off the fetch path. FORCE_INDEX="SET max_parallel_workers_per_gather=0; SET enable_seqscan=off; - SET enable_bitmapscan=off; SET pgcolumnar.enable_index_fetch_penalty=off;" + SET enable_bitmapscan=off; SET pgcolumnar.enable_index_fetch_penalty=off; + SET pgcolumnar.enable_custom_scan=off;" # --------------------------------------------------------------------------- # The fixture, whose premise is its ON-DISK SIZE. @@ -144,6 +149,11 @@ nbufs() { # $1 = number of rows, projecting one narrow column } } END { print t + 0 }' } +# The narrow and medium projections had no plan premise of their own; only the +# payload query's plan was asserted (#1339). They plan as Index Scans today, and +# these keep a planner change from turning their ratios into scan-against-scan. +check_num "premise: the narrow projection is fetched through an index scan" \ + "$(pgc_uses_row_fetch "$FORCE_INDEX" "SELECT sum(tag) FROM bigcap WHERE id BETWEEN 1 AND 10" | grep -c yes)" "1" N1=$(nbufs 1) N10=$(nbufs 10) echo "-- narrow projection buffers: 1 row = ${N1}, 10 rows = ${N10}" @@ -172,6 +182,8 @@ mbufs() { # $1 = number of rows, projecting three medium columns } } END { print t + 0 }' } +check_num "premise: the medium projection is fetched through an index scan" \ + "$(pgc_uses_row_fetch "$FORCE_INDEX" "SELECT count(m1 || m2 || m3) FROM bigcap WHERE id BETWEEN 1 AND 10" | grep -c yes)" "1" M1=$(mbufs 1) M10=$(mbufs 10) echo "-- three medium columns from an oversized group: 1 row = ${M1}, 10 rows = ${M10}" diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index a82be591b..b20401af7 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -218,6 +218,7 @@ behaviour, the source of that number is named. - [170. test_native_parquet_flba.py: uuid and DECIMAL read from fixed-length byte arrays](#170-test_native_parquet_flbapy-uuid-and-decimal-read-from-fixed-length-byte-arrays) - [171. test_native_parquet_multifile.py: directories and globs read as one relation](#171-test_native_parquet_multifilepy-directories-and-globs-read-as-one-relation) - [172. test_native_index.py: index lookups and unique enforcement fetch by row number](#172-test_native_indexpy-index-lookups-and-unique-enforcement-fetch-by-row-number) +- [173. test_native_fetch_bigcap.py: fetching from a row group above the cache cap](#173-test_native_fetch_bigcappy-fetching-from-a-row-group-above-the-cache-cap) ## 1. How to read a test in here @@ -7773,3 +7774,14 @@ The pytest twin of `test/native_index.sh` (ported for #1341), every check by the | --- | --- | | `test_index_lookups_match_the_heap` | four lookups plan as Index Scans and match the heap, and a duplicate primary key is refused with the row count unchanged | | `test_a_standalone_unique_index_enforces` | a standalone unique index refuses a duplicate and admits a new key | + +## 173. test_native_fetch_bigcap.py: fetching from a row group above the cache cap + +The pytest twin of `test/native_fetch_bigcap.sh` (ported for #1341), every check by the shell's name. Above the 32 MB fetch-cache cap, the entry used to be dropped whole after every fetch, so each fetched row re-read the whole group (#433). The measure is buffers, a count rather than a time. The fixture's premise is its on-disk size, read back from `pgcolumnar.row_group`. Every measured query is forced onto the index path with the custom scan off, and asserts its plan. A payload wider than the cap re-decodes per fetch by design (#359). A narrow projection, and columns that fit, must still be cached. + +### Every test + +| test | what it holds | +| --- | --- | +| `test_a_group_above_the_cap_is_read_once_for_what_fits` | the fixture has a group above the cap, every measured query is an index fetch, and ten rows of a narrow or a fitting projection cost under three times one row | +| `test_caching_changes_no_row` | the first ten rows equal the same rows read in key order, and every payload is full length | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index fdbaa4406..cbafe1c5c 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -807,4 +807,6 @@ guard_tests 438 # three tests, by collection over the cluster files on this tree. # cluster_tests 740 -> 742 for #1341's test_native_index.py, the twin of native_index.sh: # two tests, by collection over the cluster files on this tree. -cluster_tests 742 +# cluster_tests 742 -> 744 for #1341's test_native_fetch_bigcap.py, the twin of native_fetch_bigcap.sh: +# two tests, by collection over the cluster files on this tree. +cluster_tests 744 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index 6708c3f05..f2de2f5bc 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -129,6 +129,7 @@ "native_dict_underfill", "native_dml", "native_encdesc_golden", + "native_fetch_bigcap", "native_fetch_cache", "native_fetch_coalesce", "native_fetch_position", diff --git a/test/pytest/test_native_fetch_bigcap.py b/test/pytest/test_native_fetch_bigcap.py new file mode 100644 index 000000000..1ee132908 --- /dev/null +++ b/test/pytest/test_native_fetch_bigcap.py @@ -0,0 +1,138 @@ +"""Fetching from a row group larger than the fetch-cache cap (#433, #1341). The pytest +twin of `test/native_fetch_bigcap.sh`, every check by the shell's name. + +Above the 32 MB cap the hit rate used to be zero by construction: the entry was +populated, used once and dropped whole, so every fetched row re-read the whole group. +The measure is BUFFERS, a count rather than a time. The fixture's premise is its +on-disk size, read back from pgcolumnar.row_group. Every measured query is forced +onto the index path, with the custom scan off, and asserts its plan. A payload wider +than the cap re-decodes per fetch by design (#359); a narrow projection, and columns +that fit, must be cached anyway. Correctness last. +""" +import psycopg +import pytest + +CAP = 32 * 1024 * 1024 +FORCE_INDEX = ("max_parallel_workers_per_gather=0", "enable_seqscan=off", "enable_bitmapscan=off", + "pgcolumnar.enable_index_fetch_penalty=off", "pgcolumnar.enable_custom_scan=off") + + +class _Sets: + def __init__(self, c, sets): + self.c, self.sets = c, sets + + def __enter__(self): + for s in self.sets: + self.c.execute(f"SET {s}") + + def __exit__(self, *exc): + for s in self.sets: + self.c.execute(f"RESET {s.split('=')[0]}") + + +def _one(c, sql): + try: + return c.execute(sql).fetchone()[0] + except psycopg.Error as exc: + return f"error: {exc.diag.message_primary}" + + +def _index_scan(c, sql): + """-> 1 when `sql` plans an Index Scan under FORCE_INDEX, as the shell counts it.""" + with _Sets(c, FORCE_INDEX): + return int(any("Index Scan" in r[0] for r in c.execute(f"EXPLAIN (COSTS OFF) {sql}"))) + + +def _bufs(c, sql): + """-> the buffers EXPLAIN (ANALYZE, BUFFERS) reports for `sql`, after one warming run, + summed as the shell's awk sums them: every shared/read/hit token's digits.""" + total = 0 + with _Sets(c, FORCE_INDEX): + c.execute(f"EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, FORMAT TEXT) {sql}").fetchall() + for (line,) in c.execute(f"EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, FORMAT TEXT) {sql}"): + if "Buffers:" not in line: + continue + for tok in line.split(): + if tok.startswith(("shared", "read", "hit")): + digits = "".join(ch for ch in tok if ch.isdigit()) + if digits: + total += int(digits) + return total + + +def _ratio(a, b, bound): + """-> 'within' when a/b, rounded to two places, is at most `bound`, as check_ratio.""" + if a == 0 or b == 0: + return f"a side of the ratio is zero, so nothing was measured: a=[{a}] b=[{b}]" + r = round(a / b, 2) + return "within" if r <= bound else f"{r:.2f}x exceeds the {bound}x bound (a={a} b={b})" + + +@pytest.fixture(scope="module") +def bigcap(pgc_cluster): + db = "pgc_bigcap" + with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a: + a.execute(f"DROP DATABASE IF EXISTS {db} WITH (FORCE)") + a.execute(f"CREATE DATABASE {db}") + try: + with psycopg.connect(pgc_cluster.dsn(dbname=db), autocommit=True) as c: + c.execute("CREATE EXTENSION pgcolumnar") + c.execute("CREATE TABLE bigcap (id int, tag int, m1 text, m2 text, m3 text, payload text) USING pgcolumnar") + # per-row distinct hex from a subquery correlated on g, the codec off: this + # fixture compressed 17x and 25x in earlier attempts, so its size is asserted + c.execute("SELECT pgcolumnar.set_options('bigcap', stripe_row_limit => 10000, compression => 'none')") + c.execute("""INSERT INTO bigcap + SELECT g, g % 97, md5((g * 7)::text), md5((g * 11)::text), md5((g * 13)::text), + (SELECT string_agg(md5((g * 300 + s)::text), '') FROM generate_series(1, 256) s) + FROM generate_series(1, 20000) g""") + c.execute("CREATE INDEX bigcap_id ON bigcap (id)") + c.execute("ANALYZE bigcap") + yield c + finally: + with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a: + a.execute(f"DROP DATABASE IF EXISTS {db} WITH (FORCE)") + + +def test_a_group_above_the_cap_is_read_once_for_what_fits(bigcap, expect): + c = bigcap + maxg = _one(c, "SELECT max(byte_length) FROM pgcolumnar.row_group " + "WHERE storage_id = pgcolumnar.get_storage_id('bigcap')") + print(f"-- largest row group: {maxg} bytes, cap {CAP}") + expect.at_least(maxg if isinstance(maxg, int) else 0, CAP + 1, + "premise: the fixture really has a row group above the cap") + expect.num(_one(c, "SELECT count(*) FROM bigcap"), 20000, "premise: the table loaded every row") + + payload = "SELECT sum(length(payload)) FROM bigcap WHERE id BETWEEN 1 AND {}" + expect.num(_index_scan(c, payload.format(1)), 1, "premise: one row is fetched through an index scan") + expect.num(_index_scan(c, payload.format(10)), 1, "premise: ten rows are fetched through an index scan") + b1, b10 = _bufs(c, payload.format(1)), _bufs(c, payload.format(10)) + print(f"-- buffers: 1 row = {b1}, 10 rows = {b10}") + # both sides a measurement before any comparison (#418): a failed EXPLAIN reads as 0 + expect.at_least(b1, 101, "premise: fetching one row touched a measurable number of buffers") + expect.at_least(b10, 101, "premise: fetching ten rows touched a measurable number of buffers") + # the payload alone exceeds the cap, so it re-decodes per fetch: #359's design, not #433 + expect.at_least(b10, b1 + 1, "premise: the wide column is the one over the cap, so this arm is #359's") + + narrow = "SELECT sum(tag) FROM bigcap WHERE id BETWEEN 1 AND {}" + expect.num(_index_scan(c, narrow.format(10)), 1, "premise: the narrow projection is fetched through an index scan") + n1, n10 = _bufs(c, narrow.format(1)), _bufs(c, narrow.format(10)) + print(f"-- narrow projection buffers: 1 row = {n1}, 10 rows = {n10}") + expect.at_least(n1, 1, "premise: the narrow projection touched a measurable number of buffers") + expect.text(_ratio(n10, n1, 3), "within", "a narrow projection over a wide group is cached too (#433)") + + medium = "SELECT count(m1 || m2 || m3) FROM bigcap WHERE id BETWEEN 1 AND {}" + expect.num(_index_scan(c, medium.format(10)), 1, "premise: the medium projection is fetched through an index scan") + m1, m10 = _bufs(c, medium.format(1)), _bufs(c, medium.format(10)) + print(f"-- three medium columns from an oversized group: 1 row = {m1}, 10 rows = {m10}") + expect.at_least(m1, 1, "premise: the medium projection touched a measurable number of buffers") + expect.text(_ratio(m10, m1, 3), "within", "columns that fit are cached even when their group does not (#433)") + + +def test_caching_changes_no_row(bigcap, expect): + c = bigcap + row = "md5(string_agg(id || ':' || tag || ':' || m1 || m2 || m3 || ':' || md5(payload), ',' ORDER BY id))" + expect.text(str(_one(c, f"SELECT {row} FROM bigcap WHERE id BETWEEN 1 AND 10")), + str(_one(c, f"SELECT {row} FROM (SELECT * FROM bigcap ORDER BY id LIMIT 10) s")), + "the fetched rows are the right rows") + expect.num(_one(c, "SELECT count(*) FROM bigcap WHERE length(payload) <> 8192"), 0, + "every payload survived the round trip at full length") From 45a8bfa55f496e1062b796a5b81ec28e10f836e8 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Fri, 16 Oct 2026 18:11:17 +0000 Subject: [PATCH 2/7] test: native_fetch_bigcap seeds the ledger, its right-rows arm dated by a deferred-decode mutation (#1341) The suite had no rows. suites_not_covered 193 -> 192; census 4347 rows / 3031 never / 1316 dated. Co-Authored-By: Claude Opus 5.5 --- test/check_ledger.tsv | 15 +++++++++++++++ test/check_ledger_budget.txt | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 2a9ea5c9b..2b17555a8 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -2479,6 +2479,21 @@ native_encdesc_golden native_encdesc_golden int column a rawLen parses as 1200 ( native_encdesc_golden native_encdesc_golden the table reads back all rows 15;16;17;18;19 never - native_encdesc_golden native_encdesc_golden trailing sharedTableLen is 0 (u32 at offset 6+13=19) 15;16;17;18;19 never - native_encdesc_golden native_encdesc_golden vectorCount parses as 1 (at offset 2) 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap a narrow projection over a wide group is cached too (#433) 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap columns that fit are cached even when their group does not (#433) 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap every payload survived the round trip at full length 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: fetching one row touched a measurable number of buffers 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: fetching ten rows touched a measurable number of buffers 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: one row is fetched through an index scan 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: ten rows are fetched through an index scan 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the fixture really has a row group above the cap 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the medium projection is fetched through an index scan 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the medium projection touched a measurable number of buffers 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the narrow projection is fetched through an index scan 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the narrow projection touched a measurable number of buffers 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the table loaded every row 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap premise: the wide column is the one over the cap, so this arm is #359's 15;16;17;18;19 never - +native_fetch_bigcap native_fetch_bigcap the fetched rows are the right rows 15;16;17;18;19 2026-10-16 columnar_tableam.c: the deferred slot decode reads tag as NULL on every 7th row number native_fetch_cache native_fetch_cache a hit re-checks the group's fileOffset against the row group 15;16;17;18;19 2026-10-16 columnar_reader.c: a fetch-cache hit no longer re-checks fileOffset native_fetch_cache native_fetch_cache a hit re-checks the group's firstRowNumber against the row group 15;16;17;18;19 2026-10-16 columnar_reader.c: a fetch-cache hit no longer re-checks firstRowNumber native_fetch_cache native_fetch_cache a hit re-checks the group's natts against the scan's column count 15;16;17;18;19 2026-10-16 columnar_reader.c: a fetch-cache hit no longer re-checks natts diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index affa347e8..78a46d49a 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 193 +suites_not_covered 192 # # checks_never_observed_red -- A CENSUS. NOT a ceiling, and it must not become # one. @@ -3467,4 +3467,17 @@ suites_not_covered 193 # never 3019 + 2 - 4 = 3017 # dated 1311 + 4 = 1315 # 3017 + 1315 == 4332 -checks_never_observed_red 3017 +# +# #1341, native_fetch_bigcap seeded, with its twin (OffgridwithJD): 3017 -> 3031, +# and suites_not_covered 193 -> 192. The suite had no rows. It enters with all 15 +# checks from five-major runs (15/15 on each of 15-19). One is dated: the right-rows +# arm, by the deferred decode reading tag as NULL on every 7th row number. Fourteen +# arrive never: eleven premises; the payload length; and the two #433 ratios, which +# the cache missing every lookup and the cap dropping every column cannot move, +# because each re-reads only the projected columns. +# +# ledger rows 4332 + 15 = 4347 +# never 3017 + 14 = 3031 +# dated 1315 + 1 = 1316 +# 3031 + 1316 == 4347 +checks_never_observed_red 3031 From 2006aff33dc764fd89e2c72d4e52a5ffcdfcec48 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Fri, 16 Oct 2026 18:18:20 +0000 Subject: [PATCH 3/7] test: partition_rows' primary-key arm reads the key it names (#1339) "the primary key finds a moved row at its new key, and not at its old one, as on heap" forced the index with enable_seqscan off alone, which leaves the columnar custom scan on. Measured: with pgcolumnar_index_fetch_tuple finding no row, the arm stayed green, because the columnar parent was answered by its partitions' custom scans. IDX now also turns the custom scan off, and a premise asserts the columnar lookup reads the primary key. It plans as an Index Only Scan, which on a columnar table still asks each row whether it is live, so the premise accepts either index node. With the same mutation the arm is now red, in both twins. Co-Authored-By: Claude Opus 5.5 --- test/partition_rows.sh | 14 ++++++++++++-- test/pytest/test_partition_rows.py | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/test/partition_rows.sh b/test/partition_rows.sh index 9e6be2d15..b282ee991 100755 --- a/test/partition_rows.sh +++ b/test/partition_rows.sh @@ -52,9 +52,19 @@ check "an UPDATE moving 49 rows to the other partition succeeds, as on heap" \ "$(both "UPDATE %p SET id = id + 1000 WHERE id < 50")" "ok|ok" check "and each partition holds what heap's does" "$(answers "$SPLIT")" "950/49|950/49" check "and the parent holds the same rows as heap's" "$(same "$CONTENT")" "same" -IDX="SET enable_seqscan = off; SET enable_bitmapscan = off;" +# The custom scan off too, and a premise that the columnar lookup reads the key: +# enable_seqscan alone leaves the columnar custom scan on. Measured (#1339): with +# the index fetch finding no row, this arm stayed green, because the columnar +# parent was answered by its partitions' custom scans, not by the primary key. +IDX="SET enable_seqscan = off; SET enable_bitmapscan = off; SET pgcolumnar.enable_custom_scan = off;" +# It plans as an Index Only Scan (it reads only id), which on a columnar table still +# asks each row whether it is live; the premise accepts either index node. +KEY_SQL="SELECT count(*) FILTER (WHERE id = 1001) || '/' || count(*) FILTER (WHERE id = 1) FROM %p WHERE id IN (1, 1001)" +check "premise: the columnar key lookup reads the primary key" \ + "$(case "$(q "$IDX EXPLAIN (COSTS OFF) ${KEY_SQL//%p/pr_pgcolumnar}")" in + *"Index Scan using"*|*"Index Only Scan using"*) echo yes ;; *) echo no ;; esac)" yes check "the primary key finds a moved row at its new key, and not at its old one, as on heap" \ - "$(answers "$IDX SELECT count(*) FILTER (WHERE id = 1001) || '/' || count(*) FILTER (WHERE id = 1) FROM %p WHERE id IN (1, 1001)")" \ + "$(answers "$IDX $KEY_SQL")" \ "1/0|1/0" # ---- the key on the parent ---------------------------------------------------------- diff --git a/test/pytest/test_partition_rows.py b/test/pytest/test_partition_rows.py index 533d95257..d5e321ce8 100644 --- a/test/pytest/test_partition_rows.py +++ b/test/pytest/test_partition_rows.py @@ -21,7 +21,9 @@ CONTENT = ("SELECT count(*) || '/' || md5(string_agg(id || ':' || k || ':' || v, ',' ORDER BY id)) " "FROM %p") SPLIT = "SELECT (SELECT count(*) FROM %p_1) || '/' || (SELECT count(*) FROM %p_2)" -IDX = ("SET enable_seqscan = off", "SET enable_bitmapscan = off") +# the custom scan off too: enable_seqscan alone leaves it on, and the columnar parent +# was answered by its partitions' custom scans rather than the primary key (#1339) +IDX = ("SET enable_seqscan = off", "SET enable_bitmapscan = off", "SET pgcolumnar.enable_custom_scan = off") def _connect(cluster, schema, name): @@ -118,6 +120,17 @@ def test_rows_cross_columnar_partitions_and_keys_hold_through_the_parent(pgc_clu expect.text(_answers(pgc_conn, SPLIT), "950/49|950/49", "and each partition holds what heap's does") c, h = _answers(pgc_conn, CONTENT).split("|") expect.text("same" if c == h else f"{c}|{h}", "same", "and the parent holds the same rows as heap's") + with pgc_conn.cursor() as cur: + for s in IDX: + cur.execute(s) + cur.execute("EXPLAIN (COSTS OFF) SELECT count(*) FILTER (WHERE id = 1001) || '/' || " + "count(*) FILTER (WHERE id = 1) FROM pr_pgcolumnar WHERE id IN (1, 1001)") + # an Index Only Scan here (only id is read), which still asks each row whether it is live + lookup = "yes" if any("Index Scan using" in r[0] or "Index Only Scan using" in r[0] + for r in cur.fetchall()) else "no" + for s in IDX: + cur.execute(s.replace("SET ", "RESET ").split(" =")[0]) + expect.text(lookup, "yes", "premise: the columnar key lookup reads the primary key") expect.text(_answers(pgc_conn, "SELECT count(*) FILTER (WHERE id = 1001) || '/' || " "count(*) FILTER (WHERE id = 1) FROM %p WHERE id IN (1, 1001)", IDX), "1/0|1/0", "the primary key finds a moved row at its new key, and not at its old one, as on heap") From 5aed207aaf91daf36d46741d87dafa32c76e76b5 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Fri, 16 Oct 2026 18:18:35 +0000 Subject: [PATCH 4/7] test: partition_rows' key premise joins the ledger, and three duplicate-key arms are dated (#1339) Census 4348 rows / 3029 never / 1319 dated. Co-Authored-By: Claude Opus 5.5 --- test/check_ledger.tsv | 11 ++++++----- test/check_ledger_budget.txt | 12 +++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 2b17555a8..0a7902e8a 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -3258,21 +3258,22 @@ partition_lifecycle partition_lifecycle running the plan leaves only the partiti partition_lifecycle partition_lifecycle the parent now has the partitions it reported 15;16;17;18;19 2026-10-14 partition_create_ahead creating its last partition without attaching it partition_lifecycle partition_lifecycle the plan retires the partitions wholly before the cutoff, in range order 15;16;17;18;19 2026-10-13 a plain DROP, not refusing while the partition is still attached;the cutoff filter removed: every partition planned;the lower bound read where the upper is meant;the plan detaches but drops nothing;the plan detaches without CONCURRENTLY;the plan in catalog order, not range order partition_lifecycle partition_lifecycle they are contiguous, each one width wide 15;16;17;18;19 2026-10-12 partition_create_ahead leaving a minute between partitions -partition_rows partition_rows a duplicate key inserted through the parent is refused (23505), as on heap 15;16;17;18;19 never - -partition_rows partition_rows a duplicate of a moved row's new key is refused in its new partition (23505), as on heap 15;16;17;18;19 never - +partition_rows partition_rows a duplicate key inserted through the parent is refused (23505), as on heap 15;16;17;18;19 2026-10-16 columnar_tableam.c: pgcolumnar_index_fetch_tuple finds no row +partition_rows partition_rows a duplicate of a moved row's new key is refused in its new partition (23505), as on heap 15;16;17;18;19 2026-10-16 columnar_tableam.c: pgcolumnar_index_fetch_tuple finds no row partition_rows partition_rows a key a moved row gave up can be used again, as on heap 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete) -partition_rows partition_rows a second insert of the same new key through the parent waits, then is refused (23505), and one row is left, as on heap 15;16;17;18;19 never - +partition_rows partition_rows a second insert of the same new key through the parent waits, then is refused (23505), and one row is left, as on heap 15;16;17;18;19 2026-10-16 columnar_tableam.c: pgcolumnar_index_fetch_tuple finds no row partition_rows partition_rows a second session moving a row the first is moving waits, then fails with 40001, and the row exists once, as on heap 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete);the tree before #1421: a row moved between partitions is serialized only at flush, after its new version's unique check partition_rows partition_rows an UPDATE in place of a row another session is moving waits, then fails with 40001, as on heap 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete) partition_rows partition_rows an UPDATE moving 25,000 keyed rows to the other partition keeps under 32 MB likewise 15;16;17;18;19 2026-10-15 PgColumnarSerializeRowNow reads the delete vector into the caller's context, keeping a bitmap copy per row (the tree before the #1422 review fix) partition_rows partition_rows an UPDATE moving 49 rows to the other partition succeeds, as on heap 15;16;17;18;19 never - partition_rows partition_rows an UPDATE of 25,000 keyed rows keeps under 32 MB of memory between its first and last row 15;16;17;18;19 2026-10-15 PgColumnarSerializeRowNow reads the delete vector into the caller's context, keeping a bitmap copy per row (the tree before the #1422 review fix) -partition_rows partition_rows and after the races the parents hold the same rows 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete) +partition_rows partition_rows and after the races the parents hold the same rows 15;16;17;18;19 2026-10-16 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete);columnar_tableam.c: pgcolumnar_index_fetch_tuple finds no row partition_rows partition_rows and each UPDATE reached every row once 15;16;17;18;19 never - partition_rows partition_rows and each partition holds what heap's does 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete) partition_rows partition_rows and the parent holds the same rows as heap's 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete) partition_rows partition_rows each keyed table's group carries delete marks before its UPDATE 15;16;17;18;19 never - -partition_rows partition_rows the primary key finds a moved row at its new key, and not at its old one, as on heap 15;16;17;18;19 2026-10-15 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete) +partition_rows partition_rows premise: the columnar key lookup reads the primary key 15;16;17;18;19 never - +partition_rows partition_rows the primary key finds a moved row at its new key, and not at its old one, as on heap 15;16;17;18;19 2026-10-16 a row moving to another partition is not marked deleted in the partition it leaves (src/columnar_tableam.c tuple_delete);columnar_tableam.c: pgcolumnar_index_fetch_tuple finds no row partman_columnar partman_columnar and no row-group row behind 15;16;17;18;19 2026-10-12 the drop hook stops deleting row_group rows, so they outlive their deleted storage row partman_columnar partman_columnar and read back as heap does 15;16;17;18;19 2026-10-12 a sequential scan drops the last row group from its list, so every one-group partition reads empty partman_columnar partman_columnar and the rows land in columnar partitions 15;16;17;18;19 2026-10-12 a sequential scan drops the last row group from its list, so every one-group partition reads empty diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt index 78a46d49a..f20909e8f 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -3480,4 +3480,14 @@ suites_not_covered 192 # never 3017 + 14 = 3031 # dated 1315 + 1 = 1316 # 3031 + 1316 == 4347 -checks_never_observed_red 3031 +# +# #1339, partition_rows' primary-key arm reads the key it names (OffgridwithJD): +# 3031 -> 3029. One premise is new, never. With pgcolumnar_index_fetch_tuple +# finding no row, five arms are red: the three duplicate-key refusals entered never +# and are now dated, and the primary-key and races arms gain it in their history. +# +# ledger rows 4347 + 1 = 4348 +# never 3031 + 1 - 3 = 3029 +# dated 1316 + 3 = 1319 +# 3029 + 1319 == 4348 +checks_never_observed_red 3029 From 30d725193e3b80305536ba7f7bca7e04ad82b0f7 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Fri, 16 Oct 2026 18:27:03 +0000 Subject: [PATCH 5/7] test: sorted_projection gets its pytest twin, its index lookups read the index, and each refusal must be its own (#1341, #1339) test_sorted_projection.py carries all 29 checks by the shell's name in three tests. Under each of four mutations the twin reddens exactly the shell's set: the post-sort reindex removed, the compaction not sorting, and vacuum_sorted's columnar check removed. Three shell changes, each measured red first: - "index point lookup after sort" set only enable_seqscan off. With the reindex removed, so the index points at the old row numbers, it stayed green: it never read the index. The lookups now turn the custom scan off, with a premise each that they read t_col_id_idx (an Index Scan or, for the count, an Index Only Scan). - "reject non-columnar table" counted any error. With vacuum_sorted's columnar check removed, the heap table still raised -- XX001, reading the heap's file as columnar storage -- and the arm stayed green. Each refusal now requires its own SQLSTATE (42809, 42703, 22023, 42883). - expect_error recorded a red as "