Hi hackers,

I found a case where Memoize rescans its inner plan even though the complete result for the requested key is already cached.

When the cache exceeds its memory limit, Memoize removes older entries. Removing an entry from the hash table can move other entries to different buckets, including the entry currently being filled.

The code that stores a tuple accounts for this movement and updates node->entry. However, its caller then marks the entry as complete using the old local pointer:

```
entry->complete = node->singlerow;
```

This writes to the old bucket and leaves the actual entry marked incomplete. When the same key is requested again, Memoize discards the cached tuples and rescans the inner plan.

Example
```
-- Force a nested loop join  with Memoize and keep the cache small enough to trigger evictions
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET work_mem = '64kB';
SET hash_mem_multiplier = 1;

CREATE TEMP TABLE a AS SELECT i / 2 AS k FROM generate_series(0, 1999) i;

ALTER TABLE a ALTER k SET (n_distinct = 10);
ANALYZE a;

CREATE TEMP TABLE b (k int PRIMARY KEY, v text);

-- Underestimate the number of distinct keys to make the planner choose Memoize despite the small cache.
INSERT INTO b SELECT i, repeat('x', 1200) FROM generate_series(0, 999) i;
ANALYZE b;

EXPLAIN ANALYZE
SELECT sum(length(v)) FROM a JOIN b USING (k);

--- master
                                                             QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=102.96..102.97 rows=1 width=8) (actual time=6.359..6.360 rows=1.00 loops=1)
   Buffers: local hit=2554 dirtied=2 written=5
   ->  Nested Loop  (cost=0.29..92.96 rows=2000 width=1204) (actual time=0.246..2.549 rows=2000.00 loops=1)
         Buffers: local hit=2554 dirtied=2 written=5
         ->  Seq Scan on a  (cost=0.00..36.00 rows=2000 width=4) (actual time=0.172..0.258 rows=2000.00 loops=1)
               Buffers: local hit=23 dirtied=1 written=4
         ->  Memoize  (cost=0.29..0.65 rows=1 width=1208) (actual time=0.001..0.001 rows=1.00 loops=2000)
               Cache Key: a.k
               Cache Mode: logical
               Estimates: capacity=10 distinct keys=10 lookups=2000 hit percent=99.50%
*Hits: 901  Misses: 1099*  Evictions: 950 Overflows: 0  Memory Usage: 65kB
               Buffers: local hit=2531 dirtied=1 written=1
               ->  Index Scan using b_pkey on b (cost=0.28..0.64 rows=1 width=1208) (actual time=0.001..0.001 rows=1.00 loops=1099)
                     Index Cond: (k = a.k)
                     Index Searches: 1099
                     Buffers: local hit=2531 dirtied=1 written=1
 Planning:
   Buffers: shared hit=37 read=1, local hit=1
 Planning Time: 0.211 ms
 Execution Time: 6.412 ms
(20 rows)

--- v1 patch
                                                             QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=102.96..102.97 rows=1 width=8) (actual time=6.138..6.139 rows=1.00 loops=1)
   Buffers: local hit=2356 dirtied=2 written=5
   ->  Nested Loop  (cost=0.29..92.96 rows=2000 width=1204) (actual time=0.277..2.442 rows=2000.00 loops=1)
         Buffers: local hit=2356 dirtied=2 written=5
         ->  Seq Scan on a  (cost=0.00..36.00 rows=2000 width=4) (actual time=0.192..0.276 rows=2000.00 loops=1)
               Buffers: local hit=23 dirtied=1 written=4
         ->  Memoize  (cost=0.29..0.65 rows=1 width=1208) (actual time=0.001..0.001 rows=1.00 loops=2000)
               Cache Key: a.k
               Cache Mode: logical
               Estimates: capacity=10 distinct keys=10 lookups=2000 hit percent=99.50%
*Hits: 1000  Misses: 1000*  Evictions: 950 Overflows: 0  Memory Usage: 65kB
               Buffers: local hit=2333 dirtied=1 written=1
               ->  Index Scan using b_pkey on b (cost=0.28..0.64 rows=1 width=1208) (actual time=0.001..0.001 rows=1.00 loops=1000)
                     Index Cond: (k = a.k)
                     Index Searches: 1000
                     Buffers: local hit=2333 dirtied=1 written=1
 Planning:
   Buffers: shared hit=32 read=5, local hit=1
 Planning Time: 0.272 ms
 Execution Time: 6.222 ms
(20 rows)
```

                  Hits    Misses
Before the fix    901   1099
After the fix    1000    1000

Using the updated pointer fixes this:

- entry->complete = node->singlerow;
+ node->entry->complete = node->singlerow;

Thoughts?

--
Best regards,
Ilia Evdokimov,
Tantor Labs LLC,
https://tantorlabs.com/
From 2031d56bd5fcc5f5217ca8361192982887b620ab Mon Sep 17 00:00:00 2001
From: Evdokimov Ilia <[email protected]>
Date: Wed, 11 Nov 2026 15:12:24 +0300
Subject: [PATCH v1] Fix stale entry pointer in Memoize after cache eviction

Storing a tuple can trigger cache eviction, which may move the current
hash table entry to another bucket. cache_store_tuple() updates
node->entry when this happens, but ExecMemoize() still uses its local
entry pointer to set the complete flag.

Use node->entry instead, so that singlerow entries are correctly marked
complete. Otherwise, subsequent lookups can unnecessarily discard the
cached tuples and rescan the inner plan.
---
 src/backend/executor/nodeMemoize.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/backend/executor/nodeMemoize.c b/src/backend/executor/nodeMemoize.c
index 74f79260fc3..42287c19514 100644
--- a/src/backend/executor/nodeMemoize.c
+++ b/src/backend/executor/nodeMemoize.c
@@ -868,7 +868,7 @@ ExecMemoize(PlanState *pstate)
 					 * cache lookups to work even when the scan has not been
 					 * executed to completion.
 					 */
-					entry->complete = node->singlerow;
+					node->entry->complete = node->singlerow;
 					node->mstatus = MEMO_FILLING_CACHE;
 				}
 
-- 
2.53.0

Reply via email to