| 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 |
| 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 |