From cf184ec77a04bf164692a0c7bcd148f1e96b71f2 Mon Sep 17 00:00:00 2001 From: David Rowley Date: Tue, 7 Jul 2026 23:59:08 +1200 Subject: [PATCH] Fix COUNT's logic for window run condition support 9d9c02ccd added code to allow the executor to stop early when processing WindowAgg nodes where a monotonic window function starts producing values that result in a pushed-down qual no longer matching, and will never match again due to the window function's monotonic properties. That commit requires a SupportRequestWFuncMonotonic to exist on the window function and for it to detect when the function is monotonic. For COUNT(ANY) and COUNT(*), the support function failed to consider some cases where the WindowClause used EXCLUDE to exclude certain rows from being aggregated. Some WindowClause definitions mean we aggregate rows that come after the current row, and when processing those rows later, if we EXCLUDE certain rows, the monotonic property can be broken. Wrongly treating the COUNT(*) or COUNT(ANY) aggregate as monotonic could lead to rows being filtered that should not be filtered from the result set. Another issue was that the support function for the COUNT aggregate mistakenly thought that a WindowClause without an ORDER BY meant that the results would be both monotonically increasing and decreasing, but that's only true when in RANGE mode, where all rows are peers. It is possible to support various cases that do have an EXCLUDE clause, but getting the logic correct for the exact set of cases that are valid is quite complex and would likely better be left for a future project. Here, we mostly disable run condition pushdown when there is an EXCLUDE clause unless the clause is for EXCLUDE CURRENT ROW, uses COUNT(*) (rather than COUNT(ANY)), and the window aggregate has no FILTER clause. Bug: #19533 Reported-by: Qifan Liu Author: Chengpeng Yan Author: David Rowley Reviewed-by: Richard Guo Reviewed-by: John Naylor Discussion: https://postgr.es/m/19533-413a1014e5d0e766@postgresql.org Backpatch-through: 15 --- src/backend/utils/adt/int8.c | 36 ++++- src/test/regress/expected/window.out | 221 ++++++++++++++++++++++++++- src/test/regress/sql/window.sql | 115 +++++++++++++- 3 files changed, 362 insertions(+), 10 deletions(-) diff --git a/src/backend/utils/adt/int8.c b/src/backend/utils/adt/int8.c index 9dd5889f34c..ad6a56b4688 100644 --- a/src/backend/utils/adt/int8.c +++ b/src/backend/utils/adt/int8.c @@ -24,7 +24,7 @@ #include "nodes/supportnodes.h" #include "optimizer/optimizer.h" #include "utils/builtins.h" - +#include "utils/fmgroids.h" typedef struct { @@ -833,8 +833,38 @@ int8inc_support(PG_FUNCTION_ARGS) MonotonicFunction monotonic = MONOTONICFUNC_NONE; int frameOptions = req->window_clause->frameOptions; - /* No ORDER BY clause then all rows are peers */ - if (req->window_clause->orderClause == NIL) + /* + * Because an EXCLUDE clauses in the window definition can exclude + * rows that have previously been included in the aggregate result for + * prior rows, this can break the monotonic properties that might + * otherwise be guaranteed. There's a narrow set of circumstances + * that can be guaranteed, which we check for below. + */ + if (frameOptions & FRAMEOPTION_EXCLUSION) + { + WindowFunc *wfunc = req->window_func; + + /* + * To add handling for all valid monotonic cases with an EXCLUDE + * clause is complex and likely not worth troubling over. For + * now, just bail unless we see EXCLUDE CURRENT ROW with COUNT(*) + * and no FILTER. Excluding the current row is fine when using + * COUNT(*) as this always reduces the count by 1. The same isn't + * true for COUNY(ANY) as a NULL won't be counted, and a + * subsequent non-NULL could make the count decrease. + */ + if ((frameOptions & FRAMEOPTION_EXCLUDE_CURRENT_ROW) == 0 || + wfunc->winfnoid != F_COUNT_ || + wfunc->aggfilter != NULL) + { + req->monotonic = MONOTONICFUNC_NONE; + PG_RETURN_POINTER(req); + } + } + + /* No ORDER BY clause and RANGE mode means all rows are peers. */ + if (req->window_clause->orderClause == NIL && + (frameOptions & FRAMEOPTION_RANGE)) monotonic = MONOTONICFUNC_BOTH; else { diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out index 6ee01f37009..f7bb520402b 100644 --- a/src/test/regress/expected/window.out +++ b/src/test/regress/expected/window.out @@ -4232,23 +4232,59 @@ WHERE c <= 3; (8 rows) -- Ensure we get the correct run condition when the window function is both --- monotonically increasing and decreasing. +-- monotonically increasing and decreasing in RANGE mode without an ORDER BY EXPLAIN (COSTS OFF) SELECT * FROM (SELECT empno, depname, salary, - count(empno) OVER () c + count(empno) OVER (RANGE BETWEEN CURRENT ROW AND CURRENT ROW) c FROM empsalary) emp WHERE c = 1; - QUERY PLAN -------------------------------------------------------- + QUERY PLAN +------------------------------------------------------------- WindowAgg - Window: w1 AS () + Window: w1 AS (RANGE BETWEEN CURRENT ROW AND CURRENT ROW) Run Condition: (count(empsalary.empno) OVER w1 = 1) -> Seq Scan on empsalary (4 rows) +-- As above, but check we detect it's monotonically increasing +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + depname, + salary, + count(empno) OVER (RANGE BETWEEN CURRENT ROW AND CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +------------------------------------------------------------- + WindowAgg + Window: w1 AS (RANGE BETWEEN CURRENT ROW AND CURRENT ROW) + Run Condition: (count(empsalary.empno) OVER w1 <= 3) + -> Seq Scan on empsalary +(4 rows) + +-- Ensure that ROWS mode without an ORDER BY doesn't think it's monotonically +-- decreasing, i.e. don't push down the run condition. +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + depname, + salary, + count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c + FROM empsalary) emp +WHERE c > 1; + QUERY PLAN +------------------------------------------------------------------ + Subquery Scan on emp + Filter: (emp.c > 1) + -> WindowAgg + Window: w1 AS (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) + -> Seq Scan on empsalary +(5 rows) + -- Try another case with a WindowFunc with a byref return type SELECT * FROM (SELECT row_number() OVER (PARTITION BY salary) AS rn, @@ -4436,6 +4472,181 @@ WHERE c = 1; -> Seq Scan on empsalary (9 rows) +-- +-- Ensure we get the correct behavior for run condition pushdown when the +-- frame option has an EXCLUDE clause +-- +-- Ensure pushdown occurs for ROWS BETWEEN UNBOUNDED PRECEDING with EXCLUDE +-- CURRENT ROW with COUNT(*) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------- + WindowAgg + Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE CURRENT ROW) + Run Condition: (count(*) OVER w1 <= 3) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(6 rows) + +-- Ensure pushdown occurs for GROUPS BETWEEN UNBOUNDED PRECEDING with EXCLUDE +-- CURRENT ROW with COUNT(*) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +-------------------------------------------------------------------------------------------------------------------- + WindowAgg + Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) + Run Condition: (count(*) OVER w1 <= 3) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(6 rows) + +-- Ensure pushdown occurs for RANGE BETWEEN UNBOUNDED PRECEDING with EXCLUDE +-- CURRENT ROW with COUNT(*) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +------------------------------------------------------------------------------------------------------------------- + WindowAgg + Window: w1 AS (ORDER BY empsalary.salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) + Run Condition: (count(*) OVER w1 <= 3) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(6 rows) + +-- Ensure we don't get pushdown when a FILTER clause is present +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) FILTER (WHERE salary > 4000) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------- + Subquery Scan on emp + Filter: (emp.c <= 3) + -> WindowAgg + Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE CURRENT ROW) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(7 rows) + +-- Ensure we don't get pushdown with COUNT(ANY) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(salary) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------- + Subquery Scan on emp + Filter: (emp.c <= 3) + -> WindowAgg + Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE CURRENT ROW) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(7 rows) + +-- Ensure we don't get pushdown with EXCLUDE GROUP +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------- + Subquery Scan on emp + Filter: (emp.c <= 3) + -> WindowAgg + Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE GROUP) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(7 rows) + +-- Ensure we don't get pushdown with EXCLUDE TIES +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +--------------------------------------------------------------------------------------------------------------------------- + Subquery Scan on emp + Filter: (emp.c <= 3) + -> WindowAgg + Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE TIES) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(7 rows) + +-- Ensure we don't get pushdown with GROUPS mode and EXCLUDE GROUP +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------ + Subquery Scan on emp + Filter: (emp.c <= 3) + -> WindowAgg + Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE GROUP) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(7 rows) + +-- Ensure we don't get pushdown with GROUPS mode and EXCLUDE TIES +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c + FROM empsalary) emp +WHERE c <= 3; + QUERY PLAN +----------------------------------------------------------------------------------------------------------------------------- + Subquery Scan on emp + Filter: (emp.c <= 3) + -> WindowAgg + Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE TIES) + -> Sort + Sort Key: empsalary.salary + -> Seq Scan on empsalary +(7 rows) + -- Test Sort node collapsing EXPLAIN (COSTS OFF) SELECT * FROM diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql index ff58f45ce26..cbc11c704ab 100644 --- a/src/test/regress/sql/window.sql +++ b/src/test/regress/sql/window.sql @@ -1361,16 +1361,37 @@ SELECT * FROM WHERE c <= 3; -- Ensure we get the correct run condition when the window function is both --- monotonically increasing and decreasing. +-- monotonically increasing and decreasing in RANGE mode without an ORDER BY EXPLAIN (COSTS OFF) SELECT * FROM (SELECT empno, depname, salary, - count(empno) OVER () c + count(empno) OVER (RANGE BETWEEN CURRENT ROW AND CURRENT ROW) c FROM empsalary) emp WHERE c = 1; +-- As above, but check we detect it's monotonically increasing +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + depname, + salary, + count(empno) OVER (RANGE BETWEEN CURRENT ROW AND CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure that ROWS mode without an ORDER BY doesn't think it's monotonically +-- decreasing, i.e. don't push down the run condition. +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + depname, + salary, + count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c + FROM empsalary) emp +WHERE c > 1; + -- Try another case with a WindowFunc with a byref return type SELECT * FROM (SELECT row_number() OVER (PARTITION BY salary) AS rn, @@ -1460,6 +1481,96 @@ SELECT * FROM FROM empsalary) emp WHERE c = 1; +-- +-- Ensure we get the correct behavior for run condition pushdown when the +-- frame option has an EXCLUDE clause +-- + +-- Ensure pushdown occurs for ROWS BETWEEN UNBOUNDED PRECEDING with EXCLUDE +-- CURRENT ROW with COUNT(*) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure pushdown occurs for GROUPS BETWEEN UNBOUNDED PRECEDING with EXCLUDE +-- CURRENT ROW with COUNT(*) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure pushdown occurs for RANGE BETWEEN UNBOUNDED PRECEDING with EXCLUDE +-- CURRENT ROW with COUNT(*) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure we don't get pushdown when a FILTER clause is present +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) FILTER (WHERE salary > 4000) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure we don't get pushdown with COUNT(ANY) +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(salary) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE CURRENT ROW) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure we don't get pushdown with EXCLUDE GROUP +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure we don't get pushdown with EXCLUDE TIES +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure we don't get pushdown with GROUPS mode and EXCLUDE GROUP +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE GROUP) c + FROM empsalary) emp +WHERE c <= 3; + +-- Ensure we don't get pushdown with GROUPS mode and EXCLUDE TIES +EXPLAIN (COSTS OFF) +SELECT * FROM + (SELECT empno, + salary, + count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING EXCLUDE TIES) c + FROM empsalary) emp +WHERE c <= 3; + + -- Test Sort node collapsing EXPLAIN (COSTS OFF) SELECT * FROM