diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f86e386f..2bc9814d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -422,6 +422,14 @@ true until the next version shipped. 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 join to a columnar table with a WHERE that names no column no longer fails.** + `SELECT count(*) FROM fact JOIN dim USING (k) WHERE (SELECT false)`, or a gate on + `now()`, failed with `pgcolumnar runtime filter expected a core Hash Join`, EXPLAIN + included, and on a build with assertions it aborted the backend. The planner puts a + gating Result over such a join, and the join runtime filter expected the Hash Join + itself. The runtime filter now leaves such a join to the ordinary plan. Found by + OffgridwithJD (#1437). `test/native_join_runtime_filter.sh` and + `test_join_runtime_filter.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_runtime_filter.c b/src/columnar_runtime_filter.c index 7d979ebf1..ef16c5716 100644 --- a/src/columnar_runtime_filter.c +++ b/src/columnar_runtime_filter.c @@ -86,6 +86,7 @@ typedef struct PgColumnarRuntimeTapState static set_join_pathlist_hook_type previous_set_join_pathlist_hook = NULL; +static bool PgColumnarHasGatingQual(List *restrictinfos); static Plan *PgColumnarPlanRuntimeFilterPath(PlannerInfo *root, RelOptInfo *rel, CustomPath *bestPath, @@ -339,7 +340,8 @@ PgColumnarSetJoinPathlist(PlannerInfo *root, continue; hashPath = (HashPath *) path; - if (path->param_info != NULL || + if (PgColumnarHasGatingQual(hashPath->jpath.joinrestrictinfo) || + path->param_info != NULL || path->parallel_aware || path->parallel_workers != 0 || hashPath->jpath.innerjoinpath->param_info != NULL || @@ -430,6 +432,27 @@ PgColumnarSetJoinPathlist(PlannerInfo *root, add_path(joinrel, &customPath->path); } +/* + * A gating qual is a pseudoconstant clause, one that names no column of the + * join, such as WHERE (SELECT false) or WHERE now() < '2000-01-01'. The planner + * puts a Result with a One-Time Filter over a join that carries one, so the + * coordinator's child would be that Result rather than the Hash Join it wraps + * (#1437). The path declines such a join, as the vectorized aggregates do. + * has_pseudoconstant_clauses() would say the same, but PG17 removed it. + */ +static bool +PgColumnarHasGatingQual(List *restrictinfos) +{ + ListCell *cell; + + foreach(cell, restrictinfos) + { + if (lfirst_node(RestrictInfo, cell)->pseudoconstant) + return true; + } + return false; +} + static AttrNumber PgColumnarFindKeyResno(Plan *plan, Index varno, AttrNumber attno) { @@ -469,7 +492,9 @@ PgColumnarPlanRuntimeFilterPath(PlannerInfo *root, if (list_length(customPlans) != 1) elog(ERROR, "pgcolumnar runtime filter expected one core join plan"); - joinPlan = linitial_node(HashJoin, customPlans); + /* linitial, not linitial_node: a cast that fails is an assertion abort on + * an assert build, and this check is what must report the wrong shape */ + joinPlan = (HashJoin *) linitial(customPlans); if (!IsA(joinPlan, HashJoin) || !IsA(innerPlan(joinPlan), Hash)) elog(ERROR, "pgcolumnar runtime filter expected a core Hash Join"); diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv index 7ec113978..319cce7f4 100644 --- a/test/check_ledger.tsv +++ b/test/check_ledger.tsv @@ -3464,3 +3464,11 @@ 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 8bbe8de29..4e15196c1 100644 --- a/test/check_ledger_budget.txt +++ b/test/check_ledger_budget.txt @@ -2791,4 +2791,16 @@ suites_not_covered 224 # never 2596 + 2 = 2598 # dated 865 + 3 = 868 # 2598 + 868 == 3466 -checks_never_observed_red 2598 +# +# #1437, a join with a WHERE that names no column failed in the join runtime filter +# (jdatcmd): 2598 -> 2602. native_join_runtime_filter gains eight checks from +# five-major runs (55/55 on each of 15-19). The four gate arms are dated on PG17 by +# the tree before the fix with only the asserting cast removed, where each failed +# with 'expected a core Hash Join' against heap's 0|| and 200|639900|25600 (main's +# own tree aborts the backend at the first one). The four premises arrive never. +# +# ledger rows 3466 + 8 = 3474 +# never 2598 + 4 = 2602 +# dated 868 + 4 = 872 +# 2602 + 872 == 3474 +checks_never_observed_red 2602 diff --git a/test/native_join_runtime_filter.sh b/test/native_join_runtime_filter.sh index 6deec176a..71cba4fc1 100755 --- a/test/native_join_runtime_filter.sh +++ b/test/native_join_runtime_filter.sh @@ -314,4 +314,42 @@ SQLFQ="SELECT count(*),sum(fact_fq.k),sum(fact_fq.extra) FROM fact_fq JOIN dim_f onfq="$(pc "${rf_on}SET max_parallel_workers_per_gather=0;SET enable_nestloop=off;SET enable_mergejoin=off;EXPLAIN(ANALYZE,TIMING off,SUMMARY off)$SQLFQ")" check "fact-qual plan has coordinator" "$(grep -c 'Columnar Runtime Filter Coordinator' <<<"$onfq")" 1 check "fact-qual conjunction equals heap" "$(pc "${rf_on}$SQLFQ"|tail -1)" "$(q "SELECT count(*),sum(heap_fq.k),sum(heap_fq.extra) FROM heap_fq JOIN dim_fq ON heap_fq.k=dim_fq.k WHERE heap_fq.k > 100 AND heap_fq.extra > 40")" +# A gating qual, a WHERE that names no column (#1437). The planner puts a +# Result with a One-Time Filter over the join, so the coordinator's child was a +# Result rather than the Hash Join it expects, and every such query failed with +# "expected a core Hash Join", EXPLAIN included. A true gate must give heap's +# rows and a false one none, over a heap and a columnar dimension. The premises +# show the same join with no gate takes the coordinator, so the gate is what the +# arms vary. Independent of the pytest twin: different names, key range, and +# row count. +q "$(cat <<'SQL' +CREATE TABLE dim_gh(k int); +INSERT INTO dim_gh SELECT g FROM generate_series(3100, 3299) g; +CREATE TABLE dim_gc(k int) USING pgcolumnar; +INSERT INTO dim_gc SELECT g FROM generate_series(3100, 3299) g; +CREATE TABLE fact_g(k int, payload text) USING pgcolumnar; +SELECT pgcolumnar.set_options($t$fact_g$t$, stripe_row_limit => 1000); +INSERT INTO fact_g SELECT g, repeat(md5(g::text), 4) FROM generate_series(1, 15000) g; +CREATE TABLE heap_g AS SELECT * FROM fact_g; +ANALYZE dim_gh; +ANALYZE dim_gc; +ANALYZE fact_g; +SQL +)" >/dev/null +for dim in dim_gh dim_gc; do + kind=heap; [ "$dim" = dim_gc ] && kind=columnar + SQLG="SELECT count(*),sum(fact_g.k),sum(length(fact_g.payload)) FROM fact_g JOIN $dim ON fact_g.k=$dim.k" + ong="$(pc "${rf_on}SET max_parallel_workers_per_gather=0;SET enable_nestloop=off;SET enable_mergejoin=off;EXPLAIN(ANALYZE,TIMING off,SUMMARY off)$SQLG")" + check "premise: with no gate, the $kind-dimension join has the coordinator" \ + "$(grep -c 'Columnar Runtime Filter Coordinator' <<<"$ong")" 1 + # the reference must hold rows, or a server that answered nothing on both + # sides would pass the comparisons below as equal + check "premise: heap's answer under a true gate over a $kind dimension holds 200 rows" \ + "$(q "SELECT count(*) FROM heap_g JOIN $dim ON heap_g.k=$dim.k WHERE (SELECT true)")" 200 + for gate in false true; do + check "a (SELECT $gate) gate over a $kind dimension equals heap" \ + "$(pc "${rf_on}SET max_parallel_workers_per_gather=0;SET enable_nestloop=off;SET enable_mergejoin=off;$SQLG WHERE (SELECT $gate)" | tail -1)" \ + "$(q "SELECT count(*),sum(heap_g.k),sum(length(heap_g.payload)) FROM heap_g JOIN $dim ON heap_g.k=$dim.k WHERE (SELECT $gate)")" + done +done pgc_summary diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 314004377..56d964b91 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -3023,6 +3023,15 @@ A non-key fact-table qual with late materialization off. The attach used to force the two-pass path with only the join key decoded, so the qual dropped every row. Heap is the oracle. Independent of the shell conjunction arm. +### `test_a_gating_qual_over_the_join_matches_heap` + +A WHERE that names no column, `(SELECT false)` or `(SELECT true)`, over a join to a +heap and to a columnar dimension (#1437). The planner puts a gating Result over such +a join, and the coordinator expected a Hash Join there: a release build raised an +ERROR and an assert build aborted the backend. Each answer must be heap's, with +premises that the same join with no gate takes the coordinator and that heap's +true-gate answer holds rows. Independent of the shell arms. + ## 26. test_check_records.py: every counted assertion is a record #937, first phase. The shell harness makes counting and recording the same call, diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index f1b0fbcf0..316f4f430 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -727,4 +727,6 @@ guard_tests 438 # 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 +# cluster_tests 603 -> 604 for #1437's test_a_gating_qual_over_the_join_matches_heap in +# test_join_runtime_filter.py, by collection over the cluster files on this tree. +cluster_tests 604 diff --git a/test/pytest/test_join_runtime_filter.py b/test/pytest/test_join_runtime_filter.py index 5ddbd8720..40b822741 100644 --- a/test/pytest/test_join_runtime_filter.py +++ b/test/pytest/test_join_runtime_filter.py @@ -719,3 +719,53 @@ def test_fact_qual_with_late_mat_off_matches_heap(pgc_conn, expect): name="late-mat-off fact qual still uses the runtime coordinator", ) expect.rows([got], [heap], "late-mat-off fact qual equals heap") + + +def test_a_gating_qual_over_the_join_matches_heap(pgc_conn, expect): + """A WHERE that names no column puts a gating Result over the join (#1437). + + The coordinator expected its child to be the Hash Join, so the Result made + every such query fail, EXPLAIN included: an ERROR on a release build, and a + failed cast assertion that aborted the backend on an assert build. A true + gate must give heap's rows and a false one none, over a heap and a columnar + dimension. The premises show the same join with no gate takes the + coordinator. Independently of native_join_runtime_filter.sh: different + names, key range and row count. + """ + with pgc_conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE dgh(k int); + INSERT INTO dgh SELECT g FROM generate_series(6400, 6549) g; + CREATE TABLE dgc(k int) USING pgcolumnar; + INSERT INTO dgc SELECT g FROM generate_series(6400, 6549) g; + CREATE TABLE fg(k int, payload text) USING pgcolumnar; + SELECT pgcolumnar.set_options($t$fg$t$, stripe_row_limit => 1000); + INSERT INTO fg SELECT g, repeat(md5(g::text), 3) + FROM generate_series(1, 13000) g; + CREATE TABLE hg AS SELECT * FROM fg; + ANALYZE dgh; + ANALYZE dgc; + ANALYZE fg + """ + ) + for dim, kind in (("dgh", "heap"), ("dgc", "columnar")): + sql = (f"SELECT count(*), sum(fg.k), sum(length(fg.payload)) " + f"FROM fg JOIN {dim} ON fg.k = {dim}.k") + expect.num( + _plan(pgc_conn, sql, on=True).count("Columnar Runtime Filter Coordinator"), 1, + f"premise: with no gate, the {kind}-dimension join has the coordinator") + with pgc_conn.cursor() as cur: + cur.execute(f"SELECT count(*) FROM hg JOIN {dim} ON hg.k = {dim}.k WHERE (SELECT true)") + expect.num(cur.fetchone()[0], 150, + f"premise: heap's answer under a true gate over a {kind} dimension holds 150 rows") + for gate in ("false", "true"): + with pgc_conn.cursor() as cur: + cur.execute(f"{sql} WHERE (SELECT {gate})") + got = cur.fetchone() + cur.execute( + f"SELECT count(*), sum(hg.k), sum(length(hg.payload)) " + f"FROM hg JOIN {dim} ON hg.k = {dim}.k WHERE (SELECT {gate})") + heap = cur.fetchone() + expect.rows([got], [heap], + f"a (SELECT {gate}) gate over a {kind} dimension equals heap")