diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db731a92..2f86e386f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -416,6 +416,12 @@ true until the next version shipped. over every row, so a caller limited by a policy could read values from rows it cannot see. It now raises `row-level security is in force`, as the other functions that read storage directly do. Found by OffgridwithJD in review. +- **An ungrouped aggregate on a columnar table applies a WHERE that references no + column.** `SELECT count(*), min(v) FROM t WHERE (SELECT false)`, or a WHERE on + `now()` or `current_user`, returned every row's aggregates instead of none. The + vectorized aggregate answered from the zone maps and never applied the one-time + filter. It now leaves such a query to the ordinary plan. Found by OffgridwithJD in + review (#1435). `test/native_agg.sh` and `test_native_agg.py`. - **A concurrent `UPDATE` of the same row now fails with 40001 on a table with a unique index, as documented, not 23505 (#1421).** Two sessions updating one row of diff --git a/src/columnar_vector.c b/src/columnar_vector.c index 3eb72b5b9..87b795118 100644 --- a/src/columnar_vector.c +++ b/src/columnar_vector.c @@ -1562,6 +1562,21 @@ PgColumnarCreateUpperPaths(PlannerInfo *root, UpperRelationKind stage, * zone-map answerable. A filter, or a sum/avg over int8/float/numeric, needs * the scan-fold path instead (#289). */ + /* + * A pseudoconstant (gating) qual -- a WHERE that references no column, such as + * (SELECT false) or now() < '2000-01-01' -- is a one-time filter, and + * extract_actual_clauses(..., false) leaves it out of quals. Refused HERE, + * before needsScan is decided: with no other qual the node answers from the + * zone maps and never reaches the refusal in the scan-fold branch below, so a + * false gate returned every row's aggregates (#1435). + */ + { + ListCell *gc; + + foreach(gc, factRel->baserestrictinfo) + if (lfirst_node(RestrictInfo, gc)->pseudoconstant) + return; + } if (pgcolumnar_quals_could_leak(factRel->baserestrictinfo)) return; quals = extract_actual_clauses(factRel->baserestrictinfo, false); diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 560957880..7ec113978 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -2186,6 +2186,9 @@ maintenance_rollback maintenance_rollback premise: the table holds the rows it w maintenance_rollback maintenance_rollback recluster changed the row groups inside its transaction 15;16;17;18;19 never - maintenance_rollback maintenance_rollback vacuum changed the row groups inside its transaction 15;16;17;18;19 never - maintenance_rollback maintenance_rollback vacuum_sorted changed the row groups inside its transaction 15;16;17;18;19 never - +native_agg native_agg a WHERE (SELECT false) with no column in it gives heap's answer 15;16;17;18;19 2026-10-15 the ungrouped vectorized aggregate takes a query with a gating qual and answers from the zone maps (the tree before #1435) +native_agg native_agg a WHERE on current_user with no column in it gives heap's answer 15;16;17;18;19 2026-10-15 the ungrouped vectorized aggregate takes a query with a gating qual and answers from the zone maps (the tree before #1435) +native_agg native_agg a WHERE on now() with no column in it gives heap's answer 15;16;17;18;19 2026-10-15 the ungrouped vectorized aggregate takes a query with a gating qual and answers from the zone maps (the tree before #1435) native_agg native_agg an aggregate that needs zone maps reads them 15;16;17;18;19 never - native_agg native_agg avg(int4,nulls) 15;16;17;18;19 never - native_agg native_agg control: decoding ts reads the table's blocks 15;16;17;18;19 never - @@ -2215,6 +2218,8 @@ native_agg native_agg min(ts) reads under a tenth of the blocks a decode of ts r native_agg native_agg multi-agg 15;16;17;18;19 never - native_agg native_agg multi-agg beats a parallel plan 15;16;17;18;19 never - native_agg native_agg multi-agg plan has no Gather 15;16;17;18;19 never - +native_agg native_agg premise: count, min and max with no WHERE are the vectorized aggregate 15;16;17;18;19 never - +native_agg native_agg premise: heap's answer to a false gate is no rows 15;16;17;18;19 never - native_agg native_agg premise: the timestamptz table holds every row inserted 15;16;17;18;19 never - native_agg native_agg sum(bigint) fallback parity 15;16;17;18;19 never - native_agg native_agg sum(int4) 15;16;17;18;19 never - @@ -3325,6 +3330,63 @@ ttl_expire ttl_expire the retention is recorded where the other options live 15; ttl_expire ttl_expire with the extent, expire retires the same groups 15;16;17;18;19 2026-10-13 the early exit compares the extent's maximum to the cutoff instead of its minimum ttl_expire ttl_expire without the extent the same expire probes every group's zone map 15;16;17;18;19 never - ttl_expire ttl_expire without the extent, expire retires the aged groups 15;16;17;18;19 2026-10-14 expire returning 0 whatever it retired +type_matrix type_matrix a range on bt finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on bt holds rows on heap, so its comparison reads something 15;16;17;18;19 2026-10-15 the range bounds cast with pg_typeof, which drops the typmod, so bt's become bit(1) and its range finds no rows +type_matrix type_matrix a range on bt lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on cd finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on cd holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on cd skips no chunk groups: a cidr key is treated as cross-type against inet and dropped (#1433) 15;16;17;18;19 never - +type_matrix type_matrix a range on ch finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on ch holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on ch skips no chunk groups: every row group holds every value, since the column cycles through 26 letters 15;16;17;18;19 never - +type_matrix type_matrix a range on d finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on d holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on d lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on dom finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on dom holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on dom lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on e finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on e holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on e skips no chunk groups: every row group holds every value, since the column cycles through the 4 labels 15;16;17;18;19 never - +type_matrix type_matrix a range on ip finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on ip holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on ip lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on mac finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on mac holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on mac lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on mac8 finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on mac8 holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on mac8 lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on mo finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on mo holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on mo lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on n finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on n holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on n skips no chunk groups: every row group holds NaN and both infinities, so each zone map spans every value 15;16;17;18;19 never - +type_matrix type_matrix a range on nm finds heap's rows 15;16;17;18;19 2026-10-15 a by-reference fixed-width value over 8 bytes is stored as its first 8 bytes and zero padding (PgColumnarEncodeValueByLen) +type_matrix type_matrix a range on nm holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on nm lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 a by-reference fixed-width value over 8 bytes is stored as its first 8 bytes and zero padding (PgColumnarEncodeValueByLen);the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on tm finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on tm holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on tm lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on tsi finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on tsi holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on tsi lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on tz finds heap's rows 15;16;17;18;19 2026-10-15 a by-reference fixed-width value over 8 bytes is stored as its first 8 bytes and zero padding (PgColumnarEncodeValueByLen) +type_matrix type_matrix a range on tz holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on tz lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a range on vb finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix a range on vb holds rows on heap, so its comparison reads something 15;16;17;18;19 never - +type_matrix type_matrix a range on vb lets the zone maps skip chunk groups 15;16;17;18;19 2026-10-15 the scan never skips a row group by zone map (pgcolumnar_native_group_can_match ignored) +type_matrix type_matrix a tsvector match finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix each column's count and NULL count is heap's 15;16;17;18;19 2026-10-15 a by-reference fixed-width value over 8 bytes is stored as its first 8 bytes and zero padding (PgColumnarEncodeValueByLen) +type_matrix type_matrix equality on json, as text, finds heap's rows 15;16;17;18;19 never - +type_matrix type_matrix every row, as text, is heap's 15;16;17;18;19 2026-10-15 a by-reference fixed-width value over 8 bytes is stored as its first 8 bytes and zero padding (PgColumnarEncodeValueByLen) +type_matrix type_matrix point equality (~=) finds heap's rows 15;16;17;18;19 2026-10-15 a by-reference fixed-width value over 8 bytes is stored as its first 8 bytes and zero padding (PgColumnarEncodeValueByLen) +type_matrix type_matrix premise: both tables hold 12000 rows, the columnar one in at least twelve row groups 15;16;17;18;19 never - +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 - 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 a3960011a..8bbe8de29 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -2760,4 +2760,35 @@ suites_not_covered 224 # never 2544 + 13 = 2557 # dated 836 + 11 = 847 # 2557 + 847 == 3404 -checks_never_observed_red 2557 +# +# #1341, types the differential matrix never stored answer as heap (jdatcmd): +# 2557 -> 2596. type_matrix enters with all 57 checks from five-major runs (57/57 on +# each of 15-19). 17 are dated on PG17 by two mutants (OffgridwithJD, reviews of +# #1426): +# - a by-reference fixed-width value over 8 bytes stored as its first 8 bytes and +# zero padding: every row, the counts, the tz range (its zone offset varies, so +# bytes 8-11 matter), the nm range and its pruning, and point equality; +# - the scan never skipping a row group by zone map: the 12 range arms that must +# skip chunk groups, varbit's included now that its values increase with the row. +# A third, the range bounds cast with pg_typeof (which drops the typmod), dates bt's +# heap-not-empty arm: its range then finds no rows. Each of the 16 range arms has +# one, so no comparison can pass on two empty answers (review of #1426). +# The rest arrive never, among them the four range arms pinned at 0 skipped groups, +# with the reason in each name (cidr's names #1433), and the libxml premise. +# +# ledger rows 3404 + 57 = 3461 +# never 2557 + 39 = 2596 +# dated 847 + 18 = 865 +# 2596 + 865 == 3461 +# +# #1435, the ungrouped vectorized aggregate ignored a WHERE that references no column +# (jdatcmd): 2596 -> 2598. native_agg gains five checks from five-major runs (40/40 on +# each of 15-19). The three gating arms are dated on PG17 by the tree before the fix, +# where each false gate returned every row's aggregates (10000/1/10000 against +# heap's 0/-/-); the two premises arrive never. +# +# ledger rows 3461 + 5 = 3466 +# never 2596 + 2 = 2598 +# dated 865 + 3 = 868 +# 2598 + 868 == 3466 +checks_never_observed_red 2598 diff --git a/test/native_agg.sh b/test/native_agg.sh index 299d955fa..bd738b2a4 100755 --- a/test/native_agg.sh +++ b/test/native_agg.sh @@ -76,6 +76,20 @@ check "count(*) uses the metadata agg node" \ check "sum/min/max uses the metadata agg node" \ "$(has_aggnode "$(plan 'SELECT sum(id), min(id), max(id) FROM n')")" "yes" +# A WHERE that references no column is a gating qual: planned as a one-time +# filter, not part of the per-row quals. The node answered from the zone maps alone +# and ignored it, so a false gate returned every row's aggregates (#1435). +GATED="SELECT count(*) || '/' || coalesce(min(id)::text, '-') || '/' || coalesce(max(id)::text, '-') FROM" +check "premise: count, min and max with no WHERE are the vectorized aggregate" \ + "$(has_aggnode "$(plan 'SELECT count(*), min(id), max(id) FROM n')")" "yes" +check "a WHERE (SELECT false) with no column in it gives heap's answer" \ + "$(q "$GATED n WHERE (SELECT false)")" "$(q "$GATED h WHERE (SELECT false)")" +check "a WHERE on now() with no column in it gives heap's answer" \ + "$(q "$GATED n WHERE now() < '2000-01-01'")" "$(q "$GATED h WHERE now() < '2000-01-01'")" +check "a WHERE on current_user with no column in it gives heap's answer" \ + "$(q "$GATED n WHERE current_user = 'pgc_nobody'")" "$(q "$GATED h WHERE current_user = 'pgc_nobody'")" +check "premise: heap's answer to a false gate is no rows" "$(q "$GATED h WHERE (SELECT false)")" "0/-/-" + # Supported aggregates answered from zone maps must equal the heap oracle. check "count(*)" "$(q 'SELECT count(*) FROM n;')" "$(q 'SELECT count(*) FROM h;')" check "count(col,nulls)" "$(q 'SELECT count(s) FROM n;')" "$(q 'SELECT count(s) FROM h;')" diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 2e32bcdcb..314004377 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -176,6 +176,7 @@ behaviour, the source of that number is named. - [128. test_objstore_userinfo.py: userinfo in a URL is refused on every entry point](#128-test_objstore_userinfopy-userinfo-in-a-url-is-refused-on-every-entry-point) - [129. test_savepoint_flush.py: a savepoint rolled back after a flush](#129-test_savepoint_flushpy-a-savepoint-rolled-back-after-a-flush) - [130. test_rls_query_paths.py: row-level security holds on every read path](#130-test_rls_query_pathspy-row-level-security-holds-on-every-read-path) +- [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) ## 1. How to read a test in here @@ -6486,6 +6487,7 @@ Not ported, and declared INCOMPLETE in `test_compare_to_bash.py`: the `int4`/`te | test | what it holds | | --- | --- | | `test_min_max_over_timestamptz_read_no_data` | the table holds every row; `max(ts)` plans the metadata node; a decode reads at least 10 blocks; `max` and `min` each read under a tenth of that; parity with heap before and after deleting rows at both ends | +| `test_a_where_with_no_column_in_it_is_applied` | a WHERE that references no column, such as `(SELECT false)`, `now() < ...` or `current_user = ...`, gives heap's answer, with a premise that the same aggregate with no WHERE is the vectorized node (#1435) | ## 86. test_format_downgrade.py: an alpha4 binary refuses what this build wrote @@ -7175,3 +7177,13 @@ The pytest twin of `test/rls_query_paths.sh` (#1341), every check by the shell's | test | what it holds | | --- | --- | | `test_row_level_security_holds_on_every_read_path` | the read paths against heap (the custom scan, both vectorized aggregates, parallel, index-only, the covering projection), their plan premises, and the leak arms, including one through each vectorized node | + +## 131. test_type_matrix.py: types the differential matrix never stored answer as heap + +The pytest twin of `test/type_matrix.sh` (#1341), every check by the shell's name. The encoder has fast paths for fixed widths 1, 2, 4 and 8, and every other width and every varlena takes a generic path. This matrix stores what no test had: by-reference fixed widths 6, 8, 12, 16 and 64 (macaddr, macaddr8, timetz, point, name), the 1-byte `"char"`, time, money, inet, cidr, bit, varbit, tsvector, xml, json, an enum, a composite, a domain, and numeric NaN and ±Infinity with timestamp and date ±infinity. 12000 rows in row groups of 1000, each column NULL on its own pattern, are generated once into heap and copied. Every row as text, each column's counts, a range on every ordered column with literal bounds read from heap, json, point and tsvector matches, the special-value aggregates, and the composite's fields must all equal heap's. + +### Every test + +| test | what it holds | +| --- | --- | +| `test_types_the_matrix_never_stored_answer_as_heap` | the 21 columns against heap: rows, counts, sixteen ranges, the three non-btree matches, and the special values | diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index e230ed616..f1b0fbcf0 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -724,4 +724,7 @@ guard_tests 438 # cluster_tests 599 -> 600 for #1341's test_savepoint_flush.py, one test, by collection # cluster_tests 600 -> 601 for #1341's test_rls_query_paths.py, one test, by collection # over the cluster files on this tree. -cluster_tests 601 +# cluster_tests 601 -> 602 for #1341's test_type_matrix.py, one test, by collection +# cluster_tests 602 -> 603 for #1435's test_a_where_with_no_column_in_it_is_applied in +# test_native_agg.py, by collection over the cluster files on this tree. +cluster_tests 603 diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py index 267066828..43943a462 100644 --- a/test/pytest/test_compare_to_bash.py +++ b/test/pytest/test_compare_to_bash.py @@ -164,6 +164,7 @@ "tier_partition", "timeseries_recipes", "ttl_expire", + "type_matrix", "vacuum_lock_privilege", "validity_elision", "vm_privilege", diff --git a/test/pytest/test_native_agg.py b/test/pytest/test_native_agg.py index 276f81762..bad469083 100644 --- a/test/pytest/test_native_agg.py +++ b/test/pytest/test_native_agg.py @@ -116,3 +116,29 @@ def both(sql): expect.text(got, want, "max(timestamptz) parity after deleting the newest rows") got, want = both("SELECT min(at) FROM {t}") expect.text(got, want, "min(timestamptz) parity after deleting the oldest rows") + + +GATED = "SELECT count(*) || '/' || coalesce(min(id)::text, '-') || '/' || coalesce(max(id)::text, '-') FROM" + + +def test_a_where_with_no_column_in_it_is_applied(pgc_conn, expect): + """A WHERE that references no column is a gating qual: planned as a one-time + filter, not part of the per-row quals. The ungrouped vectorized aggregate + answered from the zone maps alone and ignored it, so a false gate returned every + row's aggregates (#1435). Each arm is compared with heap.""" + with pgc_conn.cursor() as cur: + cur.execute("CREATE TABLE gn (id int) USING pgcolumnar") + cur.execute("CREATE TABLE gh (id int) USING heap") + cur.execute("SELECT pgcolumnar.set_options('gn', stripe_row_limit => 1000)") + cur.execute("INSERT INTO gn SELECT g FROM generate_series(1, 5000) g") + cur.execute("INSERT INTO gh SELECT g FROM generate_series(1, 5000) g") + cur.execute("EXPLAIN (COSTS OFF) SELECT count(*), min(id), max(id) FROM gn") + plan = "\n".join(r[0] for r in cur.fetchall()) + expect.text("yes" if "Columnar Vectorized Aggregates" in plan else f"no ({plan})", "yes", + "premise: count, min and max with no WHERE are the vectorized aggregate") + for name, where in (("(SELECT false)", "(SELECT false)"), ("now()", "now() < '2000-01-01'"), + ("current_user", "current_user = 'pgc_nobody'")): + expect.text(_one(pgc_conn, f"{GATED} gn WHERE {where}"), _one(pgc_conn, f"{GATED} gh WHERE {where}"), + f"a WHERE {'' if name.startswith('(') else 'on '}{name} with no column in it gives heap's answer") + expect.text(_one(pgc_conn, f"{GATED} gh WHERE (SELECT false)"), "0/-/-", + "premise: heap's answer to a false gate is no rows") diff --git a/test/pytest/test_type_matrix.py b/test/pytest/test_type_matrix.py new file mode 100644 index 000000000..999c7bd23 --- /dev/null +++ b/test/pytest/test_type_matrix.py @@ -0,0 +1,144 @@ +"""Types the differential matrix never stored, against heap (#1341). The pytest twin +of `test/type_matrix.sh`, every check by the shell's name. + +The encoder has fast paths for fixed widths 1, 2, 4 and 8; every other width and every +varlena takes a generic path, and a zone map is built for each column whose type has a +btree ordering. These columns cover widths 6, 12, 16 and 64 by reference, the 1-byte +"char", time, money, inet, cidr, bit, varbit, tsvector, xml, json, an enum, a composite, +a domain, and numeric, timestamp and date special values. 12000 rows in row groups of +1000, each column NULL on its own pattern, generated once into heap and copied. + +Each comparison is of the rows themselves (`expect.row_set`), so a failure names the rows +that differ. The range bounds are the values heap holds at ids 3005 and 9005, written as +literals so the columnar scan can prune row groups by zone map (not 3001, which holds n's +NaN). Each range arm also says whether the zone maps skipped chunk groups, from EXPLAIN +ANALYZE; four that measure 0 are pinned at 0 with the reason. timetz's zone offset varies +with the row, so its 12-byte width is used past byte 8. +""" +import re + +import psycopg + +COLS = """id int, tz timetz, mac macaddr, mac8 macaddr8, nm name, pt point, ch "char", + tm time, mo money, ip inet, cd cidr, bt bit(16), vb varbit, tsv tsvector, x xml, + j json, e tm_mood, cmp tm_pair, dom tm_pos, n numeric, tsi timestamp, d date""" + +LOAD = """SELECT g, + CASE WHEN g % 7 = 0 THEN NULL ELSE ((('00:00'::time + (g * 7) * interval '1 s')::text || '+' || lpad((g % 13)::text, 2, '0'))::timetz) END, + CASE WHEN g % 11 = 0 THEN NULL ELSE ('08:00:2b:' || lpad(to_hex(g / 256 % 256), 2, '0') || ':' || lpad(to_hex(g % 256), 2, '0') || ':01')::macaddr END, + CASE WHEN g % 13 = 0 THEN NULL ELSE ('08:00:2b:01:02:03:' || lpad(to_hex(g / 256 % 256), 2, '0') || ':' || lpad(to_hex(g % 256), 2, '0'))::macaddr8 END, + CASE WHEN g % 17 = 0 THEN NULL ELSE ('name_' || lpad(g::text, 6, '0'))::name END, + CASE WHEN g % 19 = 0 THEN NULL ELSE point(g, -g) END, + CASE WHEN g % 23 = 0 THEN NULL ELSE chr(65 + g % 26)::"char" END, + CASE WHEN g % 29 = 0 THEN NULL ELSE ('00:00'::time + g * interval '7 s') END, + CASE WHEN g % 31 = 0 THEN NULL ELSE (g * 1.25)::numeric::money END, + CASE WHEN g % 37 = 0 THEN NULL ELSE ('10.' || g / 256 % 256 || '.' || g % 256 || '.' || g % 7 || '/32')::inet END, + CASE WHEN g % 41 = 0 THEN NULL ELSE ('10.' || g / 256 % 256 || '.' || g % 256 || '.0/24')::cidr END, + CASE WHEN g % 43 = 0 THEN NULL ELSE g::bit(16) END, + CASE WHEN g % 47 = 0 THEN NULL ELSE (g::bit(16) || (g % 7)::bit(3))::varbit END, + CASE WHEN g % 53 = 0 THEN NULL ELSE to_tsvector('simple', 'word' || g || ' common term' || g % 9) END, + CASE WHEN g % 59 = 0 THEN NULL ELSE ('' || md5(g::text) || '')::xml END, + CASE WHEN g % 61 = 0 THEN NULL ELSE json_build_object('g', g, 's', md5(g::text)) END, + CASE WHEN g % 67 = 0 THEN NULL ELSE (enum_range(NULL::tm_mood))[1 + g % 4] END, + CASE WHEN g % 71 = 0 THEN NULL ELSE ROW(g, 'p' || g)::tm_pair END, + CASE WHEN g % 73 = 0 THEN NULL ELSE (g)::tm_pos END, + CASE WHEN g % 79 = 0 THEN NULL WHEN g % 1000 = 1 THEN 'NaN'::numeric WHEN g % 1000 = 2 THEN 'Infinity'::numeric + WHEN g % 1000 = 3 THEN '-Infinity'::numeric ELSE g / 7.0 END, + CASE WHEN g % 83 = 0 THEN NULL WHEN g = 500 THEN 'infinity'::timestamp WHEN g = 501 THEN '-infinity'::timestamp + ELSE '2026-01-01'::timestamp + g * interval '1 min' END, + CASE WHEN g % 89 = 0 THEN NULL WHEN g = 600 THEN 'infinity'::date WHEN g = 601 THEN '-infinity'::date + ELSE '2026-01-01'::date + g END + FROM generate_series(1, 12000) g""" + +ALL = ("id, tz::text, mac::text, mac8::text, nm::text, pt::text, ch::text, tm::text, mo::text, ip::text, " + "cd::text, bt::text, vb::text, tsv::text, x::text, j::text, e::text, cmp::text, dom::text, n::text, " + "tsi::text, d::text") + + +def _rows(conn, sql): + with conn.cursor() as cur: + cur.execute(sql) + return cur.fetchall() + + +def _both(conn, sql): + return _rows(conn, sql.replace("%T", "t_col")), _rows(conn, sql.replace("%T", "t_heap")) + + +NO_PRUNE = { + "ch": "every row group holds every value, since the column cycles through 26 letters", + "e": "every row group holds every value, since the column cycles through the 4 labels", + "n": "every row group holds NaN and both infinities, so each zone map spans every value", + "cd": "a cidr key is treated as cross-type against inet and dropped (#1433)", +} + + +def _removed(conn, sql): + plan = "\n".join(r[0] for r in _rows(conn, "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) " + + sql.replace("%T", "t_col"))) + m = re.search(r"Columnar Chunk Groups Removed by Filter: (\d+)", plan) + return m.group(1) if m else "none" + + +def test_types_the_matrix_never_stored_answer_as_heap(pgc_conn, expect): + # xml needs a server built with libxml; without it every arm after the load would + # fail for that reason, so this says so first. + try: + xml_ok = "yes" if _rows(pgc_conn, "SELECT ''::xml IS NOT NULL")[0][0] else "no" + except psycopg.Error as exc: + xml_ok = f"no ({str(exc).splitlines()[0]})" + expect.text(xml_ok, "yes", "premise: the server supports xml (built with libxml)") + with pgc_conn.cursor() as cur: + cur.execute("CREATE TYPE tm_mood AS ENUM ('sad', 'ok', 'happy', 'elated')") + cur.execute("CREATE TYPE tm_pair AS (a int, b text)") + cur.execute("CREATE DOMAIN tm_pos AS int CHECK (VALUE > 0)") + cur.execute(f"CREATE TABLE t_heap ({COLS})") + cur.execute(f"CREATE TABLE t_col ({COLS}) USING pgcolumnar") + cur.execute("SELECT pgcolumnar.set_options('t_col', stripe_row_limit => 1000)") + cur.execute(f"INSERT INTO t_heap {LOAD}") + cur.execute("INSERT INTO t_col SELECT * FROM t_heap") + groups = _rows(pgc_conn, "SELECT count(*) FROM pgcolumnar.row_group " + "WHERE storage_id = pgcolumnar.get_storage_id('t_col')")[0][0] + n_heap = _rows(pgc_conn, "SELECT count(*) FROM t_heap")[0][0] + n_col = _rows(pgc_conn, "SELECT count(*) FROM t_col")[0][0] + expect.text(f"{n_heap}|{n_col}|{'many' if groups >= 12 else groups}", "12000|12000|many", + "premise: both tables hold 12000 rows, the columnar one in at least twelve row groups") + + got, want = _both(pgc_conn, f"SELECT {ALL} FROM %T") + expect.row_set(got, want, "every row, as text, is heap's") + counts = ", ".join(f"count({c}), count(*) FILTER (WHERE {c} IS NULL)" + for c in "tz mac mac8 nm pt ch tm mo ip cd bt vb tsv x j e cmp dom n tsi d".split()) + got, want = _both(pgc_conn, f"SELECT {counts} FROM %T") + expect.row_set(got, want, "each column's count and NULL count is heap's") + + for c in ("tz", "mac", "mac8", "nm", "ch", "tm", "mo", "ip", "cd", "bt", "vb", "e", "dom", "n", "tsi", "d"): + # format_type keeps the typmod: pg_typeof would make bt's bounds bit(1). + ty = _rows(pgc_conn, f"SELECT format_type(atttypid, atttypmod) FROM pg_attribute " + f"WHERE attrelid = 't_heap'::regclass AND attname = '{c}'")[0][0] + lo, hi = (_rows(pgc_conn, f"SELECT quote_literal({c}::text) || '::' || '{ty}' " + f"FROM t_heap WHERE id = {i}")[0][0] for i in (3005, 9005)) + sql = f"SELECT id FROM %T WHERE {c} BETWEEN least({lo}, {hi}) AND greatest({lo}, {hi})" + # Heap's answer must not be empty: wrong bounds would make both answers empty. + n = _rows(pgc_conn, f"SELECT count(*) FROM ({sql.replace('%T', 't_heap')}) x")[0][0] + expect.text("yes" if n > 0 else f"no ({n} rows)", "yes", + f"a range on {c} holds rows on heap, so its comparison reads something") + got, want = _both(pgc_conn, sql) + expect.row_set(got, want, f"a range on {c} finds heap's rows") + r = _removed(pgc_conn, sql) + if c in NO_PRUNE: + expect.text(r, "0", f"a range on {c} skips no chunk groups: {NO_PRUNE[c]}") + else: + expect.text("skips" if r.isdigit() and int(r) >= 1 else f"removed {r}", "skips", + f"a range on {c} lets the zone maps skip chunk groups") + got, want = _both(pgc_conn, "SELECT id FROM %T WHERE j::text = (SELECT j::text FROM t_heap WHERE id = 4242)") + expect.row_set(got, want, "equality on json, as text, finds heap's rows") + got, want = _both(pgc_conn, "SELECT id FROM %T WHERE pt ~= point(4242, -4242)") + expect.row_set(got, want, "point equality (~=) finds heap's rows") + got, want = _both(pgc_conn, "SELECT id FROM %T WHERE tsv @@ to_tsquery('simple', 'term3')") + expect.row_set(got, want, "a tsvector match finds heap's rows") + got, want = _both(pgc_conn, "SELECT min(n)::text, max(n)::text, count(*) FILTER (WHERE n = 'NaN'), " + "count(*) FILTER (WHERE n = 'Infinity'), count(*) FILTER (WHERE n = '-Infinity'), " + "min(tsi)::text, max(tsi)::text, min(d)::text, max(d)::text FROM %T") + expect.row_set(got, want, "the aggregates over numeric NaN and Infinity and timestamp and date infinity are heap's") + got, want = _both(pgc_conn, "SELECT (cmp).a, (cmp).b, e::text, e > 'ok' FROM %T WHERE id % 97 = 0") + expect.row_set(got, want, "the composite's fields and the enum's order read back as heap's") diff --git a/test/run_all_versions.sh b/test/run_all_versions.sh index dbd03a1c3..4466359f9 100755 --- a/test/run_all_versions.sh +++ b/test/run_all_versions.sh @@ -319,6 +319,7 @@ SUITES=( timeseries_recipes truncate_cleanup ttl_expire + type_matrix ungrouped_vector_agg unique_conc update_conc diff --git a/test/type_matrix.sh b/test/type_matrix.sh new file mode 100755 index 000000000..392f2dc4e --- /dev/null +++ b/test/type_matrix.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# Types the differential matrix never stored, against heap (#1341). The encoder has +# fast paths for fixed widths 1, 2, 4 and 8; every other width and every varlena +# takes a generic path, and a zone map is built for each column whose type has a +# btree ordering. None of these types had been written to a columnar table: +# +# - fixed width, by reference, at unusual widths: timetz (12), macaddr (6), +# macaddr8 (8), name (64), point (16, no btree ordering); +# - the 1-byte "char"; +# - time, money, inet, cidr, bit, varbit, tsvector, xml, json (no equality); +# - an enum, a composite, a domain over int; +# - numeric NaN and +-Infinity, timestamp and date +-infinity. +# +# 12000 rows in row groups of 1000, each column NULL on its own pattern. The same +# rows go into heap and are copied to columnar. Then: every row, as text; each +# column's count and NULL count; a range on every ordered column, with constants +# read from heap so the zone maps can prune; equality on json (as text) and point +# (~=); and the aggregates over the special values. +# +# Usage: test/type_matrix.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +psql_run "CREATE TYPE tm_mood AS ENUM ('sad', 'ok', 'happy', 'elated'); + CREATE TYPE tm_pair AS (a int, b text); + CREATE DOMAIN tm_pos AS int CHECK (VALUE > 0);" +COLS="id int, tz timetz, mac macaddr, mac8 macaddr8, nm name, pt point, ch \"char\", + tm time, mo money, ip inet, cd cidr, bt bit(16), vb varbit, tsv tsvector, x xml, + j json, e tm_mood, cmp tm_pair, dom tm_pos, n numeric, tsi timestamp, d date" +# xml needs a server built with libxml. Without it the load below fails and every +# arm after it would fail for that reason, so the suite says so and stops +# (OffgridwithJD, review of #1426). +xml_ok="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" -XAtq \ + -c "SELECT ''::xml IS NOT NULL" 2>&1 | tail -1)" # 2>&1, so a missing libxml is named +check "premise: the server supports xml (built with libxml)" "$([ "$xml_ok" = t ] && echo yes || echo "no ($xml_ok)")" "yes" +[ "$xml_ok" = t ] || pgc_summary +make_pair "$COLS" +q "SELECT pgcolumnar.set_options('t_col', stripe_row_limit => 1000)" >/dev/null +# g % k = 0 makes the column NULL; each column has its own k. +load_pair "SELECT g, + CASE WHEN g % 7 = 0 THEN NULL ELSE ((('00:00'::time + (g * 7) * interval '1 s')::text || '+' || lpad((g % 13)::text, 2, '0'))::timetz) END, + CASE WHEN g % 11 = 0 THEN NULL ELSE ('08:00:2b:' || lpad(to_hex(g / 256 % 256), 2, '0') || ':' || lpad(to_hex(g % 256), 2, '0') || ':01')::macaddr END, + CASE WHEN g % 13 = 0 THEN NULL ELSE ('08:00:2b:01:02:03:' || lpad(to_hex(g / 256 % 256), 2, '0') || ':' || lpad(to_hex(g % 256), 2, '0'))::macaddr8 END, + CASE WHEN g % 17 = 0 THEN NULL ELSE ('name_' || lpad(g::text, 6, '0'))::name END, + CASE WHEN g % 19 = 0 THEN NULL ELSE point(g, -g) END, + CASE WHEN g % 23 = 0 THEN NULL ELSE chr(65 + g % 26)::\"char\" END, + CASE WHEN g % 29 = 0 THEN NULL ELSE ('00:00'::time + g * interval '7 s') END, + CASE WHEN g % 31 = 0 THEN NULL ELSE (g * 1.25)::numeric::money END, + CASE WHEN g % 37 = 0 THEN NULL ELSE ('10.' || g / 256 % 256 || '.' || g % 256 || '.' || g % 7 || '/32')::inet END, + CASE WHEN g % 41 = 0 THEN NULL ELSE ('10.' || g / 256 % 256 || '.' || g % 256 || '.0/24')::cidr END, + CASE WHEN g % 43 = 0 THEN NULL ELSE g::bit(16) END, + CASE WHEN g % 47 = 0 THEN NULL ELSE (g::bit(16) || (g % 7)::bit(3))::varbit END, + CASE WHEN g % 53 = 0 THEN NULL ELSE to_tsvector('simple', 'word' || g || ' common term' || g % 9) END, + CASE WHEN g % 59 = 0 THEN NULL ELSE ('' || md5(g::text) || '')::xml END, + CASE WHEN g % 61 = 0 THEN NULL ELSE json_build_object('g', g, 's', md5(g::text)) END, + CASE WHEN g % 67 = 0 THEN NULL ELSE (enum_range(NULL::tm_mood))[1 + g % 4] END, + CASE WHEN g % 71 = 0 THEN NULL ELSE ROW(g, 'p' || g)::tm_pair END, + CASE WHEN g % 73 = 0 THEN NULL ELSE (g)::tm_pos END, + CASE WHEN g % 79 = 0 THEN NULL WHEN g % 1000 = 1 THEN 'NaN'::numeric WHEN g % 1000 = 2 THEN 'Infinity'::numeric + WHEN g % 1000 = 3 THEN '-Infinity'::numeric ELSE g / 7.0 END, + CASE WHEN g % 83 = 0 THEN NULL WHEN g = 500 THEN 'infinity'::timestamp WHEN g = 501 THEN '-infinity'::timestamp + ELSE '2026-01-01'::timestamp + g * interval '1 min' END, + CASE WHEN g % 89 = 0 THEN NULL WHEN g = 600 THEN 'infinity'::date WHEN g = 601 THEN '-infinity'::date + ELSE '2026-01-01'::date + g END + FROM generate_series(1, 12000) g" + +check "premise: both tables hold 12000 rows, the columnar one in at least twelve row groups" \ + "$(q "SELECT count(*) FROM t_heap")|$(q "SELECT count(*) FROM t_col")|$(q "SELECT CASE WHEN count(*) >= 12 THEN 'many' ELSE count(*)::text END + FROM pgcolumnar.row_group WHERE storage_id = pgcolumnar.get_storage_id('t_col')" 2>/dev/null | tail -1)" "12000|12000|many" + +ALL="id, tz::text, mac::text, mac8::text, nm::text, pt::text, ch::text, tm::text, mo::text, ip::text, cd::text, + bt::text, vb::text, tsv::text, x::text, j::text, e::text, cmp::text, dom::text, n::text, tsi::text, d::text" +diff_query "every row, as text, is heap's" "SELECT $ALL FROM %T" +parts=() +for c in tz mac mac8 nm pt ch tm mo ip cd bt vb tsv x j e cmp dom n tsi d; do + parts+=("count($c), count(*) FILTER (WHERE $c IS NULL)") +done +COUNTS="$(IFS=,; echo "${parts[*]}")" +diff_query "each column's count and NULL count is heap's" "SELECT $COUNTS FROM %T" + +# A range on every ordered column. The bounds are the values heap holds at ids 3005 +# and 9005, as literals, so the columnar scan can prune row groups by zone map. Not +# 3001 and 9001: id 3001 holds n's NaN, which made n's "range" NaN..NaN (OffgridwithJD, +# review of #1426). Neither id is NULL in any column. +# +# Each arm also says whether the zone maps skipped chunk groups, from EXPLAIN ANALYZE, +# so an arm that should prune cannot pass on a scan that read everything. Four measure +# 0 of 12 on PG17 and PG18, and are pinned at 0 with the reason: +declare -A NO_PRUNE=( + [ch]="every row group holds every value, since the column cycles through 26 letters" + [e]="every row group holds every value, since the column cycles through the 4 labels" + [n]="every row group holds NaN and both infinities, so each zone map spans every value" + [cd]="a cidr key is treated as cross-type against inet and dropped (#1433)") +removed() { # removed SQL -> the columnar scan's "Chunk Groups Removed by Filter" + q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) ${1//%T/t_col}" | + sed -n 's/.*Columnar Chunk Groups Removed by Filter: \([0-9]*\).*/\1/p' | head -1 +} +for c in tz mac mac8 nm ch tm mo ip cd bt vb e dom n tsi d; do + # format_type keeps the typmod: pg_typeof would make bt's bounds bit(1) + # (OffgridwithJD, review of #1426). + ty="$(q "SELECT format_type(atttypid, atttypmod) FROM pg_attribute WHERE attrelid = 't_heap'::regclass AND attname = '$c'" | tail -1)" + lo="$(q "SELECT quote_literal($c::text) || '::' || '$ty' FROM t_heap WHERE id = 3005" | tail -1)" + hi="$(q "SELECT quote_literal($c::text) || '::' || '$ty' FROM t_heap WHERE id = 9005" | tail -1)" + sql="SELECT id FROM %T WHERE $c BETWEEN least($lo, $hi) AND greatest($lo, $hi)" + # Heap's answer must not be empty: bounds that came out wrong (a lost typmod made + # bt's BETWEEN '0' AND '0') would make both answers empty, and the comparison and + # the skip arm would pass on nothing (OffgridwithJD, review of #1426). + n="$(q "SELECT count(*) FROM (${sql//%T/t_heap}) x" | tail -1)" + check "a range on $c holds rows on heap, so its comparison reads something" \ + "$([ -n "$n" ] && [ "$n" -gt 0 ] && echo yes || echo "no (${n:-none} rows)")" "yes" + diff_query "a range on $c finds heap's rows" "$sql" + r="$(removed "$sql")" + if [ -n "${NO_PRUNE[$c]:-}" ]; then + check "a range on $c skips no chunk groups: ${NO_PRUNE[$c]}" "${r:-none}" "0" + else + check "a range on $c lets the zone maps skip chunk groups" \ + "$([ -n "$r" ] && [ "$r" -ge 1 ] && echo skips || echo "removed ${r:-none}")" "skips" + fi +done +diff_query "equality on json, as text, finds heap's rows" \ + "SELECT id FROM %T WHERE j::text = (SELECT j::text FROM t_heap WHERE id = 4242)" +diff_query "point equality (~=) finds heap's rows" "SELECT id FROM %T WHERE pt ~= point(4242, -4242)" +diff_query "a tsvector match finds heap's rows" "SELECT id FROM %T WHERE tsv @@ to_tsquery('simple', 'term3')" +diff_query "the aggregates over numeric NaN and Infinity and timestamp and date infinity are heap's" \ + "SELECT min(n)::text, max(n)::text, count(*) FILTER (WHERE n = 'NaN'), count(*) FILTER (WHERE n = 'Infinity'), + count(*) FILTER (WHERE n = '-Infinity'), min(tsi)::text, max(tsi)::text, min(d)::text, max(d)::text FROM %T" +diff_query "the composite's fields and the enum's order read back as heap's" \ + "SELECT (cmp).a, (cmp).b, e, e > 'ok' FROM %T WHERE id % 97 = 0" + +pgc_summary