From cc7ca26237e3b55853ef489306423b5d6828feab Mon Sep 17 00:00:00 2001
From: John Naylor <john.naylor@postgresql.org>
Date: Sat, 4 Jul 2026 14:54:36 +0700
Subject: [PATCH v3] Minimal fix for COUNT's window run condition support

XXX vibe-coded, and the regression tests are surely not enough.

9d9c02ccd added code to allow the executor to stop early when
processing WindowAgg nodes where a monotonic window function starts
producing values that fail a pushed-down qual and can never satisfy it
again.  The monotonicity of COUNT(*) and COUNT(ANY) is determined by
int8inc_support(), which got two things wrong:

Firstly, it ignored the frame's EXCLUDE clause.  Excluded rows can
rejoin the count as the frame moves over the partition, so the count
can both rise and fall even when a frame bound is pinned to an edge of
the partition.  Proving monotonicity in the presence of an EXCLUDE
clause requires analyzing both frame bounds relative to the current
row's peer group, the frame mode, the exclusion type, and whether the
COUNT argument can be NULL.  That doesn't seem worth the complexity for
such a rarely-used feature, so simply report COUNT as non-monotonic
whenever the frame has an EXCLUDE clause.

Secondly, it assumed that the absence of an ORDER BY clause means the
count is the same for every row.  That's only true in RANGE mode, where
all rows being peers means every permitted frame covers the whole
partition.  In ROWS mode the frame bounds count individual rows
regardless of peerage, so determine monotonicity from the frame bounds
in the same way as when an ORDER BY is present.

Both errors could result in rows incorrectly missing from the query
result when the qual was pushed down as a run condition.

Bug: #19533
Reported-by: Qifan Liu <imchifan@163.com>
Discussion: https://postgr.es/m/19533-413a1014e5d0e766@postgresql.org
---
 src/backend/utils/adt/int8.c         |  27 +++-
 src/test/regress/expected/window.out | 183 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      | 100 +++++++++++++++
 3 files changed, 308 insertions(+), 2 deletions(-)

diff --git a/src/backend/utils/adt/int8.c b/src/backend/utils/adt/int8.c
index 9b429da86d9..6f0da84b435 100644
--- a/src/backend/utils/adt/int8.c
+++ b/src/backend/utils/adt/int8.c
@@ -795,8 +795,31 @@ 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)
+		/*
+		 * A frame EXCLUDE clause removes rows from within the frame as the
+		 * frame moves over the partition, which can cause the count to both
+		 * rise and fall for the same window definition.  Proving which
+		 * combinations of frame mode, frame bounds, exclusion type and
+		 * COUNT(*) vs COUNT(any) remain monotonic requires analysis of both
+		 * frame bounds relative to the current row's peer group, so we don't
+		 * attempt it; just report the function as non-monotonic.
+		 */
+		if (frameOptions & FRAMEOPTION_EXCLUSION)
+		{
+			req->monotonic = MONOTONICFUNC_NONE;
+			PG_RETURN_POINTER(req);
+		}
+
+		/*
+		 * When there's no ORDER BY clause, all rows are peers of one
+		 * another, so in RANGE mode every permitted frame bound degenerates
+		 * to the edge of the partition and the count is the same for every
+		 * row.  This does not apply in ROWS mode, where frame bounds count
+		 * individual rows regardless of any peerage; those get the ordinary
+		 * treatment below.  (GROUPS mode requires an ORDER BY clause.)
+		 */
+		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 90d9f953b81..b48316e2b53 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4436,6 +4436,189 @@ WHERE c = 1;
                ->  Seq Scan on empsalary
 (9 rows)
 
+--
+-- Ensure we don't push down the run condition when the frame has an EXCLUDE
+-- clause.  The excluded rows can rejoin the count as the frame moves over
+-- the partition, so the count is not guaranteed monotonic.
+--
+-- The count is 10 minus the size of the current row's peer group, which can
+-- both rise and fall, despite the UNBOUNDED PRECEDING start
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 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 UNBOUNDED FOLLOWING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- As above, for the monotonically decreasing claim of the UNBOUNDED
+-- FOLLOWING end
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8;
+                                                        QUERY PLAN                                                        
+--------------------------------------------------------------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c >= 8)
+   ->  WindowAgg
+         Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- Check the results are sane without the pushdown
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8 ORDER BY empno;
+ empno | salary | c 
+-------+--------+---
+     1 |   5000 | 9
+     2 |   3900 | 9
+     3 |   4800 | 8
+     4 |   4800 | 8
+     5 |   3500 | 9
+     7 |   4200 | 9
+     8 |   6000 | 9
+     9 |   4500 | 9
+    10 |   5200 | 8
+    11 |   5200 | 8
+(10 rows)
+
+-- EXCLUDE TIES keeps the current row, so with COUNT(any) the count can go
+-- down when the current row's argument is NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 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 CURRENT ROW EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- As above, spelled 0 FOLLOWING instead of CURRENT ROW
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING 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 FOLLOWING EXCLUDE TIES)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- No pushdown here either, even though this particular case happens to be
+-- monotonic: the excluded peer group can never be within the frame
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 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 '1'::bigint PRECEDING EXCLUDE GROUP)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+--
+-- Frames without an ORDER BY clause.  In RANGE mode all rows are peers, so
+-- the frame always covers the whole partition and the count is constant.
+-- In ROWS mode the frame bounds count rows as usual, so only the frame
+-- bounds determine monotonicity.
+--
+-- Ensure we get a pushdown in ROWS mode when the frame starts at the start
+-- of the partition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Seq Scan on empsalary
+(4 rows)
+
+-- The count here is the row number counted from the end of the partition,
+-- i.e. monotonically decreasing, so ensure we get a pushdown for >=
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) c
+   FROM empsalary) emp
+WHERE c >= 9;
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
+   Run Condition: (count(*) OVER w1 >= 9)
+   ->  Seq Scan on empsalary
+(4 rows)
+
+-- Ensure no pushdown when neither frame bound is the partition edge; the
+-- count is not monotonic in either direction
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                            QUERY PLAN                            
+------------------------------------------------------------------
+ Subquery Scan on emp
+   Filter: (emp.c <= 3)
+   ->  WindowAgg
+         Window: w1 AS (ROWS BETWEEN CURRENT ROW AND CURRENT ROW)
+         ->  Seq Scan on empsalary
+(5 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 5ac3a486e16..c2d7e36bb0c 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1460,6 +1460,106 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE c = 1;
 
+--
+-- Ensure we don't push down the run condition when the frame has an EXCLUDE
+-- clause.  The excluded rows can rejoin the count as the frame moves over
+-- the partition, so the count is not guaranteed monotonic.
+--
+
+-- The count is 10 minus the size of the current row's peer group, which can
+-- both rise and fall, despite the UNBOUNDED PRECEDING start
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- As above, for the monotonically decreasing claim of the UNBOUNDED
+-- FOLLOWING end
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8;
+
+-- Check the results are sane without the pushdown
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 8 ORDER BY empno;
+
+-- EXCLUDE TIES keeps the current row, so with COUNT(any) the count can go
+-- down when the current row's argument is NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- As above, spelled 0 FOLLOWING instead of CURRENT ROW
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 0 FOLLOWING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- No pushdown here either, even though this particular case happens to be
+-- monotonic: the excluded peer group can never be within the frame
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+--
+-- Frames without an ORDER BY clause.  In RANGE mode all rows are peers, so
+-- the frame always covers the whole partition and the count is constant.
+-- In ROWS mode the frame bounds count rows as usual, so only the frame
+-- bounds determine monotonicity.
+--
+
+-- Ensure we get a pushdown in ROWS mode when the frame starts at the start
+-- of the partition
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- The count here is the row number counted from the end of the partition,
+-- i.e. monotonically decreasing, so ensure we get a pushdown for >=
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) c
+   FROM empsalary) emp
+WHERE c >= 9;
+
+-- Ensure no pushdown when neither frame bound is the partition edge; the
+-- count is not monotonic in either direction
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ROWS BETWEEN CURRENT ROW AND CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
 -- Test Sort node collapsing
 EXPLAIN (COSTS OFF)
 SELECT * FROM
-- 
2.54.0

