Hi Hackers,
I received an off-list memory leak report from Tomas Vondra. Details below:
> CREATE DOMAIN leakdom AS daterange CHECK (VALUE IS NULL OR NOT
> isempty(VALUE));
>
> CREATE TABLE leak (id int, valid_at leakdom, name text);
> --CREATE TABLE leak (id int, valid_at daterange, name text);
>
> INSERT INTO leak
> SELECT g, daterange('2000-01-01','2010-01-01'), 'x'
> FROM generate_series(1, 200_000) g;
>
> SET log_executor_stats = on;
>
> UPDATE leak FOR PORTION OF valid_at FROM '2004-01-01' TO '2005-01-01'
> SET name = 'y';
> ---
>
> With the domain, I get this:
>
> DETAIL: ! system usage stats:
> ! 6.003382 s user, 1.508554 s system, 7.633776 s elapsed
> ! [6.724448 s user, 1.533475 s system total]
> ! 2633248 kB max resident size
> ! 0/74240 [0/114192] filesystem blocks in/out
> ! 0/646011 [0/651587] page faults/reclaims, 0 [0] swaps
> ! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
> ! 13/309 [31/354] voluntary/involuntary context switches
>
> while with the plain daterange I get
>
> DETAIL: ! system usage stats:
> ! 3.857666 s user, 0.071827 s system, 4.046457 s elapsed
> ! [4.517879 s user, 0.114015 s system total]
> ! 79060 kB max resident size
> ! 0/92096 [0/149952] filesystem blocks in/out
> ! 0/7674 [0/14166] page faults/reclaims, 0 [0] swaps
> ! 0 [0] signals rcvd, 0/0 [0/0] messages rcvd/sent
> ! 23/130 [39/159] voluntary/involuntary context switches
>
> That's ~2.6GB vs. ~80MB for the RSS, which seems like a lot. It can be
> made worse by using more rows in the table.
>
> AFAIK the issues is in nodeModifyTable.c, which does this:
>
> /*
> * Does the new Datum violate domain checks? Row-level CHECK
> * constraints are validated by ExecInsert, so we don't need to do
> * anything here for those.
> */
> if (forPortionOf->isDomain)
> domain_check(leftover, false, forPortionOf->rangeVar->vartype,
> NULL, NULL);
>
> where the NULLs mean it's running with CurrentMemoryContext, which is
> es_query_ctx. And hence the query-wide leak.
>
> domain_check() has a way to cache stuff once - that's what the extra
> argument is about. I don't know enough about this code, maybe it could
> be a local scratch space, but maybe it'd be better to add it to the FPO
> executor state:
>
> /* src/include/nodes/execnodes.h — ForPortionOfState */
> void *fp_domaininfo; /* cache space for domain_check() */
>
> and call it like this:
>
> if (forPortionOf->isDomain)
> domain_check(leftover, false, forPortionOf->rangeVar->vartype,
> &fpoState->fp_domaininfo, NULL);
I agree that is clearly a leak. His fix works and makes the memory
usage consistent between the domain and non-domain cases. My only
change to the above is to pass estate->es_query_cxt explicitly instead
of assuming it's already the CurrentMemoryContext. I also added a test
with a multi-row domain FOR PORTION OF update to validate that the
caching functions correctly.
Yours,
--
Paul ~{:-)
[email protected]
From 99e277b8afe18a8e8c850fb9c8ba53b418ef6297 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email protected]>
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 <[email protected]>
Author: Paul A. Jungwirth <[email protected]>
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