From d820f841448fe628cf0ca0e50a4cf5aa6d70181c Mon Sep 17 00:00:00 2001
From: John Naylor <john.naylor@postgresql.org>
Date: Sat, 4 Jul 2026 12:14:48 +0700
Subject: [PATCH v3] Demo full coverage for COUNT's window run condition
 support

XXX vibe-coded, and the regression tests still need thought.

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.  Whether monotonicity survives an EXCLUDE clause depends
on where the moving frame bound can be relative to the current row and
its peer group, and, since COUNT(ANY) skips NULL arguments, on whether
the excluded rows' contribution to the count can vary from row to row.
Add that analysis, applying it to the frame end for the monotonically
increasing case and, as a mirror image, to the frame start for the
monotonically decreasing case.

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         | 234 ++++++++++++++++--
 src/test/regress/expected/window.out | 343 +++++++++++++++++++++++++++
 src/test/regress/sql/window.sql      | 178 ++++++++++++++
 3 files changed, 737 insertions(+), 18 deletions(-)

diff --git a/src/backend/utils/adt/int8.c b/src/backend/utils/adt/int8.c
index 9b429da86d9..a97f2d41704 100644
--- a/src/backend/utils/adt/int8.c
+++ b/src/backend/utils/adt/int8.c
@@ -780,6 +780,177 @@ int8dec_any(PG_FUNCTION_ARGS)
 	return int8dec(fcinfo);
 }
 
+/*
+ * count_wfunc_offset_matches
+ *		Check that a ROWS or GROUPS mode frame offset expression is a
+ *		non-NULL Const and that its value is zero (when positive is false)
+ *		or greater than zero (when positive is true).
+ *
+ * This must not be used for RANGE mode offsets, whose type depends on the
+ * ORDER BY column.
+ */
+static bool
+count_wfunc_offset_matches(Node *offset, bool positive)
+{
+	Const	   *cnst;
+
+	offset = eval_const_expressions(NULL, offset);
+
+	if (!IsA(offset, Const))
+		return false;
+
+	cnst = castNode(Const, offset);
+	if (cnst->constisnull)
+		return false;
+
+	if (positive)
+		return DatumGetInt64(cnst->constvalue) > 0;
+	else
+		return DatumGetInt64(cnst->constvalue) == 0;
+}
+
+/*
+ * count_wfunc_exclusion_ok
+ *		Determine whether the window frame's EXCLUDE clause preserves the
+ *		monotonicity of COUNT in one direction.
+ *
+ * COUNT is monotonically increasing when the frame start is pinned to the
+ * partition start and monotonically decreasing when the frame end is pinned
+ * to the partition end, since frame bounds only ever move forwards as the
+ * current row advances through the partition.  An EXCLUDE clause can break
+ * this by removing rows from within the frame in a way that varies from row
+ * to row: rows excluded for one current row can count again for a later
+ * one, and vice versa.  Whether that can happen depends only on where the
+ * moving frame bound can be relative to the current row and its peer group,
+ * so this function inspects the frame end when checking the increasing case
+ * (check_start is false), and the frame start when checking the decreasing
+ * case (check_start is true).  The two cases are exact mirror images, so
+ * the comments below describe the increasing case only.
+ *
+ * count_any must be true for COUNT(any) and false for COUNT(*).  The
+ * difference matters because COUNT(any) skips rows with a NULL argument, so
+ * excluding or re-admitting a particular row can change that count by a
+ * row-dependent amount.
+ */
+static bool
+count_wfunc_exclusion_ok(WindowClause *wc, bool count_any, bool check_start)
+{
+	int			frameOptions = wc->frameOptions;
+	Node	   *offset;
+	bool		bound_current;
+	bool		bound_shrinks;
+	bool		bound_grows;
+	bool		within_group;
+	bool		before_current;
+
+	if (check_start)
+	{
+		/* frame start, for the monotonically decreasing case */
+		bound_current = (frameOptions & FRAMEOPTION_START_CURRENT_ROW) != 0;
+		bound_shrinks = (frameOptions & FRAMEOPTION_START_OFFSET_FOLLOWING) != 0;
+		bound_grows = (frameOptions & FRAMEOPTION_START_OFFSET_PRECEDING) != 0;
+		offset = wc->startOffset;
+	}
+	else
+	{
+		/* frame end, for the monotonically increasing case */
+		bound_current = (frameOptions & FRAMEOPTION_END_CURRENT_ROW) != 0;
+		bound_shrinks = (frameOptions & FRAMEOPTION_END_OFFSET_PRECEDING) != 0;
+		bound_grows = (frameOptions & FRAMEOPTION_END_OFFSET_FOLLOWING) != 0;
+		offset = wc->endOffset;
+	}
+
+	/*
+	 * within_group: the frame end can never pass beyond the current row's
+	 * peer group, so the frame never contains rows after the peer group.
+	 * This holds for CURRENT ROW (the peer group's edge in RANGE and GROUPS
+	 * mode, the current row itself in ROWS mode), for any PRECEDING offset
+	 * (the executor ensures offsets aren't negative), and for a constant
+	 * zero FOLLOWING offset in ROWS or GROUPS mode, where it's equivalent
+	 * to CURRENT ROW.  If the frame end is UNBOUNDED FOLLOWING or can
+	 * otherwise pass the peer group, all of these are false and only
+	 * exclusions that are unconditionally safe are allowed below.
+	 */
+	within_group = bound_current || bound_shrinks ||
+		(bound_grows &&
+		 (frameOptions & (FRAMEOPTION_ROWS | FRAMEOPTION_GROUPS)) &&
+		 count_wfunc_offset_matches(offset, false));
+
+	/*
+	 * before_current: the current row itself can never be in the frame,
+	 * which requires a constant PRECEDING offset greater than zero.  We
+	 * cannot verify RANGE mode offsets, whose type depends on the ORDER BY
+	 * column.  Note that in ROWS mode earlier peers of the current row may
+	 * still be in the frame.
+	 */
+	before_current = bound_shrinks &&
+		(frameOptions & (FRAMEOPTION_ROWS | FRAMEOPTION_GROUPS)) &&
+		count_wfunc_offset_matches(offset, true);
+
+	if (frameOptions & FRAMEOPTION_EXCLUDE_CURRENT_ROW)
+	{
+		/*
+		 * For COUNT(*), excluding the current row is always safe: the count
+		 * is the frame size, less one when the current row is within the
+		 * frame.  For the current row to newly enter the frame, the frame
+		 * end must advance past it, growing the frame by at least the row
+		 * that the exclusion removes, so the count never goes down.
+		 *
+		 * For COUNT(any), the current row's argument may be NULL, making
+		 * the count's dependence on the current row vary from row to row.
+		 * That's safe when the current row can never be in the frame, so
+		 * the exclusion never does anything (before_current); or in ROWS
+		 * mode when the frame cannot extend past the current row
+		 * (within_group), where the result is simply the count over the
+		 * rows before the current row.  It is not safe in RANGE or GROUPS
+		 * mode with the frame end at CURRENT ROW: there the frame covers
+		 * the whole peer group, so the count changes with each row's
+		 * argument as the current row moves through the group.
+		 */
+		if (!count_any)
+			return true;
+		if (frameOptions & FRAMEOPTION_ROWS)
+			return within_group;
+		return before_current;
+	}
+	else if (frameOptions & FRAMEOPTION_EXCLUDE_GROUP)
+	{
+		/*
+		 * Excluding the whole peer group is safe as long as the frame end
+		 * cannot pass beyond it: the result is then the count over the rows
+		 * before the peer group (or before the frame end, whichever comes
+		 * first), and both of those bounds only move forwards.  If the
+		 * frame end can pass the peer group, rows beyond the group are
+		 * counted too, and their number can shrink again when the current
+		 * row moves into a larger peer group.
+		 */
+		return within_group;
+	}
+	else
+	{
+		Assert(frameOptions & FRAMEOPTION_EXCLUDE_TIES);
+
+		/*
+		 * EXCLUDE TIES removes the current row's peers, but not the current
+		 * row itself.  For COUNT(*) this is safe under the same condition
+		 * as EXCLUDE GROUP: with the frame end confined to the peer group,
+		 * the count is the number of rows before the peer group, plus one
+		 * if the current row is within the frame.  The latter is the same
+		 * for every row of a peer group, and crossing into the next peer
+		 * group grows the former by the size of the group just left, so
+		 * the count never goes down.
+		 *
+		 * For COUNT(any) the current row stays in the frame with its
+		 * possibly-NULL argument while its earlier peers are excluded, so
+		 * the count can move both ways as the current row moves through a
+		 * peer group.  It's only safe when the current row can never be in
+		 * the frame, making the result the count of frame rows before the
+		 * peer group.
+		 */
+		return count_any ? before_current : within_group;
+	}
+}
+
 /*
  * int8inc_support
  *		prosupport function for int8inc() and int8inc_any()
@@ -793,29 +964,56 @@ int8inc_support(PG_FUNCTION_ARGS)
 	{
 		SupportRequestWFuncMonotonic *req = (SupportRequestWFuncMonotonic *) rawreq;
 		MonotonicFunction monotonic = MONOTONICFUNC_NONE;
-		int			frameOptions = req->window_clause->frameOptions;
+		WindowClause *wc = req->window_clause;
+		int			frameOptions = wc->frameOptions;
+		bool		increasing;
+		bool		decreasing;
 
-		/* No ORDER BY clause then all rows are peers */
-		if (req->window_clause->orderClause == NIL)
-			monotonic = MONOTONICFUNC_BOTH;
-		else
+		/*
+		 * Frame bounds only ever move forwards as the current row advances
+		 * through the partition, so the count is monotonically increasing
+		 * whenever the frame start is pinned to the partition start, and
+		 * monotonically decreasing whenever the frame end is pinned to the
+		 * partition end.
+		 */
+		increasing = (frameOptions & FRAMEOPTION_START_UNBOUNDED_PRECEDING) != 0;
+		decreasing = (frameOptions & FRAMEOPTION_END_UNBOUNDED_FOLLOWING) != 0;
+
+		/*
+		 * When there's no ORDER BY clause, all rows are peers of one
+		 * another, so in RANGE mode every permitted frame bound degenerates
+		 * to a partition edge 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 are handled by
+		 * the frame bound checks above.  (GROUPS mode requires an ORDER BY
+		 * clause.)
+		 */
+		if (wc->orderClause == NIL && frameOptions & FRAMEOPTION_RANGE)
+			increasing = decreasing = true;
+
+		/*
+		 * A frame EXCLUDE clause can break the monotonicity established
+		 * above, since rows excluded from the count for one current row may
+		 * be counted again for a later one, and vice versa.  Each direction
+		 * must be re-checked separately.
+		 */
+		if (frameOptions & FRAMEOPTION_EXCLUSION)
 		{
-			/*
-			 * Otherwise take into account the frame options.  When the frame
-			 * bound is the start of the window then the resulting value can
-			 * never decrease, therefore is monotonically increasing
-			 */
-			if (frameOptions & FRAMEOPTION_START_UNBOUNDED_PRECEDING)
-				monotonic |= MONOTONICFUNC_INCREASING;
+			bool		count_any;
 
-			/*
-			 * Likewise, if the frame bound is the end of the window then the
-			 * resulting value can never decrease.
-			 */
-			if (frameOptions & FRAMEOPTION_END_UNBOUNDED_FOLLOWING)
-				monotonic |= MONOTONICFUNC_DECREASING;
+			count_any = req->window_func->winfnoid == F_COUNT_ANY;
+
+			if (increasing)
+				increasing = count_wfunc_exclusion_ok(wc, count_any, false);
+			if (decreasing)
+				decreasing = count_wfunc_exclusion_ok(wc, count_any, true);
 		}
 
+		if (increasing)
+			monotonic |= MONOTONICFUNC_INCREASING;
+		if (decreasing)
+			monotonic |= MONOTONICFUNC_DECREASING;
+
 		req->monotonic = monotonic;
 		PG_RETURN_POINTER(req);
 	}
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 90d9f953b81..a7f689362b4 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4436,6 +4436,349 @@ WHERE c = 1;
                ->  Seq Scan on empsalary
 (9 rows)
 
+--
+-- Tests for run condition pushdown with EXCLUDE clauses in the frame
+--
+-- Excluding the current row keeps COUNT(*) monotonic with any frame bounds
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 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 UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1,10) FOLLOWING 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 random(1, 10) FOLLOWING EXCLUDE CURRENT ROW)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- but not COUNT(any), whose argument for the excluded row may be NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 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 UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- COUNT(any) with EXCLUDE CURRENT ROW is fine in ROWS mode when the frame
+-- cannot extend past the current row
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS 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 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- but not in RANGE or GROUPS mode, where the frame covers the whole peer
+-- group while the current row moves through it
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 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 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)
+         ->  Sort
+               Sort Key: empsalary.salary
+               ->  Seq Scan on empsalary
+(7 rows)
+
+-- EXCLUDE GROUP is fine as long as the frame end cannot pass the peer group
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) 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 GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+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                                                       
+------------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE GROUP)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- with a frame end beyond the peer group, the count is 10 minus the size of
+-- the current row's peer group, which can both rise and fall
+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 is like EXCLUDE GROUP for COUNT(*) ...
+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                                                      
+---------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary ROWS BETWEEN UNBOUNDED PRECEDING AND '0'::bigint PRECEDING EXCLUDE TIES)
+   Run Condition: (count(*) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- ... but for COUNT(any) the current row stays in the frame with its
+-- possibly-NULL argument while its earlier peers are excluded, so the frame
+-- may not contain the current row at all
+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)
+
+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)
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+                                                      QUERY PLAN                                                       
+-----------------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN UNBOUNDED PRECEDING AND '1'::bigint PRECEDING EXCLUDE TIES)
+   Run Condition: (count(empsalary.empno) OVER w1 <= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- The monotonically decreasing case must apply the same checks to the frame
+-- start.  The count of rows from the start of each peer group to the end of
+-- the partition never increases ...
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 3;
+                                                  QUERY PLAN                                                  
+--------------------------------------------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY empsalary.salary GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP)
+   Run Condition: (count(empsalary.empno) OVER w1 >= 3)
+   ->  Sort
+         Sort Key: empsalary.salary
+         ->  Seq Scan on empsalary
+(6 rows)
+
+-- ... but from an earlier peer group onwards, it can both rise and fall
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN 1 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 GROUPS BETWEEN '1'::bigint PRECEDING AND UNBOUNDED FOLLOWING 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..d79ad51f9db 100644
--- a/src/test/regress/sql/window.sql
+++ b/src/test/regress/sql/window.sql
@@ -1460,6 +1460,184 @@ SELECT * FROM
    FROM empsalary) emp
 WHERE c = 1;
 
+--
+-- Tests for run condition pushdown with EXCLUDE clauses in the frame
+--
+
+-- Excluding the current row keeps COUNT(*) monotonic with any frame bounds
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND random(1,10) FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- but not COUNT(any), whose argument for the excluded row may be NULL
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- COUNT(any) with EXCLUDE CURRENT ROW is fine in ROWS mode when the frame
+-- cannot extend past the current row
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- but not in RANGE or GROUPS mode, where the frame covers the whole peer
+-- group while the current row moves through it
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- EXCLUDE GROUP is fine as long as the frame end cannot pass the peer group
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+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;
+
+-- with a frame end beyond the peer group, the count is 10 minus the size of
+-- the current row's peer group, which can both rise and fall
+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 is like EXCLUDE GROUP for COUNT(*) ...
+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;
+
+-- ... but for COUNT(any) the current row stays in the frame with its
+-- possibly-NULL argument while its earlier peers are excluded, so the frame
+-- may not contain the current row at all
+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;
+
+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;
+
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING EXCLUDE TIES) c
+   FROM empsalary) emp
+WHERE c <= 3;
+
+-- The monotonically decreasing case must apply the same checks to the frame
+-- start.  The count of rows from the start of each peer group to the end of
+-- the partition never increases ...
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(empno) OVER (ORDER BY salary GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING EXCLUDE GROUP) c
+   FROM empsalary) emp
+WHERE c >= 3;
+
+-- ... but from an earlier peer group onwards, it can both rise and fall
+EXPLAIN (COSTS OFF)
+SELECT * FROM
+  (SELECT empno,
+          salary,
+          count(*) OVER (ORDER BY salary GROUPS BETWEEN 1 PRECEDING AND UNBOUNDED FOLLOWING 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

