Memoize: stale entry pointer after cache_store_tuple()

From: Ilia Evdokimov <ilya(dot)evdokimov(at)tantorlabs(dot)com>
To: pgsql-hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Memoize: stale entry pointer after cache_store_tuple()
Date: 2026-11-11 12:15:01
Message-ID: 14118dd4-eefa-42e4-ada7-900d6845d704@tantorlabs.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

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/

Attachment Content-Type Size
v1-0001-Fix-stale-entry-pointer-in-Memoize-after-cache-ev.patch text/x-patch 1.2 KB

Browse pgsql-hackers by date

  From Date Subject
Next Message Kirill Reshke 2026-11-11 12:22:55 Re: Fix GIN pending cleanup race
Previous Message He Kuang 2026-11-11 12:12:07 [PATCH 2/2] Fix hash join costing when the inner side is provably unique