Skip to content

Commit 866aa75

Browse files
jensensclaude
andcommitted
docs: incremental flush for cache warmer recording (#48)
Flush every 1000 OIDs instead of all-at-once. Max data loss on pod kill: 1000 OIDs. Decay applied only on first flush per startup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 215f605 commit 866aa75

1 file changed

Lines changed: 47 additions & 20 deletions

File tree

docs/plans/2026-04-03-learning-cache-warmer-design.md

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,41 @@ Recording (first N loads after startup):
4545

4646
```python
4747
class CacheWarmer:
48-
def __init__(self, conn, warm_pct=10, decay=0.8):
48+
def __init__(self, conn, warm_pct=10, decay=0.8, flush_interval=1000):
4949
self.recording = True
50+
self._warming_done = False
5051
self._recorded = set()
51-
self._target_count = 0 # set from cache size * warm_pct / 100
52-
self._warm_cache = {} # zoid → (pickle_bytes, tid_bytes)
52+
self._pending = set() # unflushed OIDs
53+
self._target_count = 0 # set from cache size * warm_pct / 100
54+
self._flush_interval = flush_interval
55+
self._decayed = False # decay applied once per startup
56+
self._warm_cache = {} # zoid → (pickle_bytes, tid_bytes)
5357
self._decay = decay
54-
self._conn = conn # main storage PG connection
58+
self._conn = conn # main storage PG connection
5559

5660
def record(self, zoid):
57-
"""Called from Instance.load() during recording phase."""
61+
"""Called from Instance.load() during recording phase.
62+
63+
Flushes to PG every _flush_interval OIDs to limit data loss
64+
if the pod is killed. Decay is applied only on the first flush.
65+
"""
5866
if zoid in self._recorded:
5967
return
6068
self._recorded.add(zoid)
69+
self._pending.add(zoid)
70+
if len(self._pending) >= self._flush_interval:
71+
self._flush(decay=not self._decayed)
72+
self._decayed = True
6173
if len(self._recorded) >= self._target_count:
6274
self.recording = False
63-
self._persist()
75+
if self._pending:
76+
self._flush(decay=not self._decayed)
77+
78+
def _flush(self, decay=False):
79+
"""Write pending OIDs to cache_warm_stats. Called every N OIDs."""
6480

6581
def _persist(self):
66-
"""Decay old scores + insert new OIDs. Runs once per startup."""
82+
"""Deprecated — replaced by incremental _flush()."""
6783

6884
def warm(self, load_multiple_fn):
6985
"""Load top-N ZOIDs into _warm_cache. Runs in background thread.
@@ -104,23 +120,34 @@ CREATE TABLE IF NOT EXISTS cache_warm_stats (
104120
);
105121
```
106122

107-
### Persist algorithm
123+
### Flush algorithm (incremental persist)
108124

109-
```sql
110-
-- 1. Decay all existing scores
111-
UPDATE cache_warm_stats SET score = score * %(decay)s;
125+
Recording flushes every `_flush_interval` OIDs (default 1000).
126+
At 7000 target that's ~7 flushes. Max data loss on pod kill: 1000 OIDs.
112127

113-
-- 2. Upsert recorded ZOIDs
114-
INSERT INTO cache_warm_stats (zoid, score)
115-
VALUES (unnest(%(zoids)s::bigint[]), 1.0)
116-
ON CONFLICT (zoid) DO UPDATE SET score = cache_warm_stats.score + 1.0;
117-
118-
-- 3. Prune negligible scores
119-
DELETE FROM cache_warm_stats WHERE score < 0.01;
128+
```python
129+
def _flush(self, decay=False):
130+
"""Write pending OIDs to PG. Decay only on first flush per startup."""
131+
zoids = list(self._pending)
132+
self._pending.clear()
133+
with self._conn.cursor() as cur:
134+
if decay:
135+
cur.execute(
136+
"UPDATE cache_warm_stats SET score = score * %(d)s",
137+
{"d": self._decay},
138+
)
139+
cur.execute(
140+
"INSERT INTO cache_warm_stats (zoid, score) "
141+
"VALUES (unnest(%(z)s::bigint[]), 1.0) "
142+
"ON CONFLICT (zoid) DO UPDATE "
143+
"SET score = cache_warm_stats.score + 1.0",
144+
{"z": zoids},
145+
)
146+
if decay:
147+
cur.execute("DELETE FROM cache_warm_stats WHERE score < 0.01")
148+
self._conn.commit()
120149
```
121150

122-
All three in one transaction — fast, ~7000 rows.
123-
124151
### Warm algorithm
125152

126153
```sql

0 commit comments

Comments
 (0)