From 99e277b8afe18a8e8c850fb9c8ba53b418ef6297 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Fri, 4 Sep 2026 15:25:03 -0700
Subject: [PATCH v1] Fix memory leak in FOR PORTION OF domain lookup

ExecForPortionOfLeftovers() calls domain_check() on every row (actually every
temporal leftover) with NULL for the "extra" parameter, which can be used to
cache the DomainIOData. Without a cache, all the DomainIODatas accumulate and
never get freed.

This commit adds a field to ForPortionOfState to hold the DomainIOData. We pass
that and the query memory context to domain_check, so that we now build the
domain data once.

The test here doesn't exercise the memory leak, but it makes sure that a
multi-row UPDATE FOR PORTION OF with domains isn't broken by the caching.

Reported-by: Tomas Vondra <tomas@vondra.me>
Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
Backpatch-through: 19
---
 src/backend/executor/nodeModifyTable.c       |  3 +-
 src/include/nodes/execnodes.h                |  1 +
 src/test/regress/expected/for_portion_of.out | 50 ++++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 26 ++++++++++
 4 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 5681505d31c..f12e30573be 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -1554,7 +1554,8 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		 * anything here for those.
 		 */
 		if (forPortionOf->isDomain)
-			domain_check(leftover, false, forPortionOf->rangeVar->vartype, NULL, NULL);
+			domain_check(leftover, false, forPortionOf->rangeVar->vartype,
+						 &fpoState->fp_domaininfo, estate->es_query_cxt);
 
 		if (!didInit)
 		{
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e95ac3eda35..1efb1f871b5 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -483,6 +483,7 @@ typedef struct ForPortionOfState
 	TypeCacheEntry *fp_leftoverstypcache;	/* type cache entry of the range */
 	TupleTableSlot *fp_Existing;	/* slot to store old tuple */
 	TupleTableSlot *fp_Leftover;	/* slot to store leftover */
+	void	   *fp_domaininfo;	/* cache space for domain_check() */
 } ForPortionOfState;
 
 /*
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 64789d1777b..8937f84c157 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -1176,6 +1176,56 @@ SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
   2 | [2010-01-09,2020-01-01) | two
 (3 rows)
 
+DROP TABLE for_portion_of_test2;
+-- The domain is checked for every leftover, not just the first one.  The
+-- lookup is cached across rows, so make sure a violation is still caught on a
+-- row after one that passed.
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at daterange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2006-01-01,2020-01-01)', 'one'),
+  (2, '[2000-01-01,2020-01-01)', 'two'),
+  (3, '[2000-01-01,2020-01-01)', 'three');
+-- Every row's leftovers are fine here: several rows, several checks.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2011-01-01'
+  SET name = name || '!';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+ id |        valid_at         |  name  
+----+-------------------------+--------
+  1 | [2006-01-01,2010-01-01) | one
+  1 | [2010-01-01,2011-01-01) | one!
+  1 | [2011-01-01,2020-01-01) | one
+  2 | [2000-01-01,2010-01-01) | two
+  2 | [2010-01-01,2011-01-01) | two!
+  2 | [2011-01-01,2020-01-01) | two
+  3 | [2000-01-01,2010-01-01) | three
+  3 | [2010-01-01,2011-01-01) | three!
+  3 | [2011-01-01,2020-01-01) | three
+(9 rows)
+
+-- Now the first row's leftovers pass but the second row's violate the domain.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2007-01-01'
+  SET name = 'nope';
+ERROR:  value for domain daterange_d violates check constraint "daterange_d_check"
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+ id |        valid_at         |  name  
+----+-------------------------+--------
+  1 | [2006-01-01,2010-01-01) | one
+  1 | [2010-01-01,2011-01-01) | one!
+  1 | [2011-01-01,2020-01-01) | one
+  2 | [2000-01-01,2010-01-01) | two
+  2 | [2010-01-01,2011-01-01) | two!
+  2 | [2011-01-01,2020-01-01) | two
+  3 | [2000-01-01,2010-01-01) | three
+  3 | [2010-01-01,2011-01-01) | three!
+  3 | [2011-01-01,2020-01-01) | three
+(9 rows)
+
 DROP TABLE for_portion_of_test2;
 -- With a domain on a multirangetype
 CREATE FUNCTION multirange_lowers(mr anymultirange) RETURNS anyarray LANGUAGE sql AS $$
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index b61fe10478e..315921cf872 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -764,6 +764,32 @@ ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
 SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
 DROP TABLE for_portion_of_test2;
 
+-- The domain is checked for every leftover, not just the first one.  The
+-- lookup is cached across rows, so make sure a violation is still caught on a
+-- row after one that passed.
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at daterange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2006-01-01,2020-01-01)', 'one'),
+  (2, '[2000-01-01,2020-01-01)', 'two'),
+  (3, '[2000-01-01,2020-01-01)', 'three');
+
+-- Every row's leftovers are fine here: several rows, several checks.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2011-01-01'
+  SET name = name || '!';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+
+-- Now the first row's leftovers pass but the second row's violate the domain.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2007-01-01'
+  SET name = 'nope';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+DROP TABLE for_portion_of_test2;
+
 -- With a domain on a multirangetype
 CREATE FUNCTION multirange_lowers(mr anymultirange) RETURNS anyarray LANGUAGE sql AS $$
   SELECT array_agg(lower(r)) FROM UNNEST(mr) u(r);
-- 
2.47.3

