Skip to content

Commit b4ac2ed

Browse files
committed
updates
1 parent aa847fc commit b4ac2ed

File tree

5 files changed

+290
-196
lines changed

5 files changed

+290
-196
lines changed

block/internal/cache/generic_cache.go

Lines changed: 56 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -22,78 +22,59 @@ const (
2222
DefaultDAIncludedCacheSize = 200_000
2323
)
2424

25-
// snapshotEntry is one record in the persisted in-flight window snapshot.
26-
// Encoded as 16 contiguous bytes: [blockHeight uint64 LE][daHeight uint64 LE].
25+
// snapshotEntry is one record in the persisted snapshot.
26+
// Encoded as 16 bytes: [blockHeight uint64 LE][daHeight uint64 LE].
2727
type snapshotEntry struct {
2828
blockHeight uint64
2929
daHeight uint64
3030
}
3131

3232
const snapshotEntrySize = 16 // bytes per snapshotEntry
3333

34-
// Cache is a generic cache that maintains items that are seen and hard confirmed.
35-
// Uses bounded thread-safe LRU caches to prevent unbounded memory growth.
34+
// Cache tracks seen blocks and DA inclusion status using bounded LRU caches.
3635
//
37-
// # Persistence strategy (O(1) restore)
36+
// Persistence: on every DA inclusion mutation a single snapshot key
37+
// (storeKeyPrefix+"__snap") is rewritten with all current in-flight
38+
// [blockHeight, daHeight] pairs. RestoreFromStore reads that one key on
39+
// startup — O(1) regardless of chain length.
3840
//
39-
// Rather than persisting one store key per DA-included hash (which required an
40-
// O(n) prefix scan on restore), the Cache maintains a single *window snapshot*
41-
// key in the store. The snapshot encodes the full set of currently in-flight
42-
// entries as a compact byte slice:
43-
//
44-
// [ blockHeight₀ uint64-LE | daHeight₀ uint64-LE ]
45-
// [ blockHeight₁ uint64-LE | daHeight₁ uint64-LE ]
46-
// …
47-
//
48-
// Every call to setDAIncluded, removeDAIncluded, or deleteAllForHeight
49-
// rewrites this single key atomically. On startup RestoreFromStore issues
50-
// exactly one GetMetadata call and deserialises the slice — O(1) regardless
51-
// of chain height or node type.
52-
//
53-
// The snapshot key is: storeKeyPrefix + "__snap"
41+
// After a restart, real content hashes are not available until the DA
42+
// retriever re-fires. In the meantime, placeholder keys indexed by height
43+
// allow lookups to succeed via getDAIncludedByHeight.
5444
type Cache[T any] struct {
55-
// itemsByHeight stores items keyed by uint64 height.
56-
// Mutex needed for atomic get-and-remove in getNextItem.
5745
itemsByHeight *lru.Cache[uint64, *T]
5846
itemsByHeightMu sync.Mutex
5947

60-
// hashes tracks whether a given hash has been seen
6148
hashes *lru.Cache[string, bool]
6249

63-
// daIncluded tracks the DA inclusion height for a given hash
50+
// daIncluded maps hash → daHeight. Hash may be a real content hash or a
51+
// height placeholder (see HeightPlaceholderKey) immediately after restore.
6452
daIncluded *lru.Cache[string, uint64]
6553

66-
// hashByHeight tracks the hash associated with each height for pruning.
67-
// Mutex needed for atomic operations in deleteAllForHeight.
54+
// hashByHeight maps blockHeight → hash, used for pruning and height-based
55+
// lookups. Protected by hashByHeightMu only in deleteAllForHeight where a
56+
// read-then-remove must be atomic.
6857
hashByHeight *lru.Cache[uint64, string]
6958
hashByHeightMu sync.Mutex
7059

71-
// maxDAHeight tracks the maximum DA height seen
7260
maxDAHeight *atomic.Uint64
7361

74-
// store is used for persisting the window snapshot (optional, nil = ephemeral).
75-
store store.Store
76-
// storeKeyPrefix is the prefix used for store keys (header or data).
62+
store store.Store // nil = ephemeral, no persistence
7763
storeKeyPrefix string
7864
}
7965

80-
// snapshotKey returns the single metadata key used to persist the in-flight window.
8166
func (c *Cache[T]) snapshotKey() string {
8267
return c.storeKeyPrefix + "__snap"
8368
}
8469

85-
// NewCache returns a new Cache struct with default sizes.
86-
//
87-
// When store and keyPrefix are non-empty, setDAIncluded / removeDAIncluded /
88-
// deleteAllForHeight persist a compact window snapshot under a single metadata
89-
// key so that RestoreFromStore can recover the in-flight state with one store
90-
// read (O(1)).
70+
// NewCache creates a Cache. When store and keyPrefix are set, mutations
71+
// persist a snapshot so RestoreFromStore can recover in-flight state.
72+
// The third argument (heightKeyFn) is retained for API compatibility but unused.
9173
func NewCache[T any](s store.Store, keyPrefix string, _ func(uint64) string) *Cache[T] {
9274
// LRU cache creation only fails if size <= 0, which won't happen with our defaults
9375
itemsCache, _ := lru.New[uint64, *T](DefaultItemsCacheSize)
9476
hashesCache, _ := lru.New[string, bool](DefaultHashesCacheSize)
9577
daIncludedCache, _ := lru.New[string, uint64](DefaultDAIncludedCacheSize)
96-
// hashByHeight must be at least as large as hashes cache to ensure proper pruning.
9778
hashByHeightCache, _ := lru.New[uint64, string](DefaultHashesCacheSize)
9879

9980
return &Cache[T]{
@@ -121,7 +102,7 @@ func (c *Cache[T]) setItem(height uint64, item *T) {
121102
c.itemsByHeight.Add(height, item)
122103
}
123104

124-
// getNextItem returns the item at the specified height and removes it from cache if found.
105+
// getNextItem returns and removes the item at height, or nil if absent.
125106
func (c *Cache[T]) getNextItem(height uint64) *T {
126107
c.itemsByHeightMu.Lock()
127108
defer c.itemsByHeightMu.Unlock()
@@ -137,10 +118,7 @@ func (c *Cache[T]) getNextItem(height uint64) *T {
137118
// isSeen returns true if the hash has been seen.
138119
func (c *Cache[T]) isSeen(hash string) bool {
139120
seen, ok := c.hashes.Get(hash)
140-
if !ok {
141-
return false
142-
}
143-
return seen
121+
return ok && seen
144122
}
145123

146124
// setSeen sets the hash as seen and tracks its height for pruning.
@@ -151,17 +129,27 @@ func (c *Cache[T]) setSeen(hash string, height uint64) {
151129

152130
// getDAIncluded returns the DA height if the hash has been DA-included.
153131
func (c *Cache[T]) getDAIncluded(hash string) (uint64, bool) {
154-
daHeight, ok := c.daIncluded.Get(hash)
132+
return c.daIncluded.Get(hash)
133+
}
134+
135+
// getDAIncludedByHeight resolves DA height via the height→hash index.
136+
// Works for both real hashes (steady state) and snapshot placeholders
137+
// (post-restart, before the DA retriever re-fires the real hash).
138+
func (c *Cache[T]) getDAIncludedByHeight(blockHeight uint64) (uint64, bool) {
139+
hash, ok := c.hashByHeight.Get(blockHeight)
155140
if !ok {
156141
return 0, false
157142
}
158-
return daHeight, true
143+
return c.getDAIncluded(hash)
159144
}
160145

161-
// setDAIncluded sets the hash as DA-included with the given DA height and
162-
// tracks block height for pruning. It also rewrites the window snapshot so
163-
// the in-flight state survives a crash/restart.
146+
// setDAIncluded records DA inclusion and persists the snapshot.
147+
// If a previous entry already exists at blockHeight (e.g. a placeholder from
148+
// RestoreFromStore), it is evicted from daIncluded to avoid orphan leaks.
164149
func (c *Cache[T]) setDAIncluded(hash string, daHeight uint64, blockHeight uint64) {
150+
if prev, ok := c.hashByHeight.Get(blockHeight); ok && prev != hash {
151+
c.daIncluded.Remove(prev)
152+
}
165153
c.daIncluded.Add(hash, daHeight)
166154
c.hashByHeight.Add(blockHeight, hash)
167155
c.setMaxDAHeight(daHeight)
@@ -220,26 +208,14 @@ func (c *Cache[T]) deleteAllForHeight(height uint64) {
220208
c.persistSnapshot(context.Background())
221209
}
222210

223-
// persistSnapshot serialises all current daIncluded entries into a compact
224-
// byte slice and writes it to the store under the single snapshot key.
225-
//
226-
// Format: N × 16 bytes where each record is:
227-
//
228-
// [blockHeight uint64 LE][daHeight uint64 LE]
229-
//
230-
// We iterate hashByHeight (height→hash) rather than daIncluded (hash→daH)
231-
// because hashByHeight gives us the blockHeight we need to include in the
232-
// record without an inverse lookup.
233-
//
234-
// This write happens on every mutation so the store always reflects the exact
235-
// current in-flight window. The payload is small (typically < 10 entries ×
236-
// 16 bytes = 160 bytes), so the cost is negligible.
211+
// persistSnapshot writes all current in-flight [blockHeight, daHeight] pairs
212+
// to the store under a single key. Called on every mutation; payload is tiny
213+
// (typically <10 entries × 16 bytes).
237214
func (c *Cache[T]) persistSnapshot(ctx context.Context) {
238215
if c.store == nil || c.storeKeyPrefix == "" {
239216
return
240217
}
241218

242-
// Collect all height→daHeight pairs that are still in daIncluded.
243219
heights := c.hashByHeight.Keys()
244220
entries := make([]snapshotEntry, 0, len(heights))
245221
for _, h := range heights {
@@ -254,8 +230,7 @@ func (c *Cache[T]) persistSnapshot(ctx context.Context) {
254230
entries = append(entries, snapshotEntry{blockHeight: h, daHeight: daH})
255231
}
256232

257-
buf := encodeSnapshot(entries)
258-
_ = c.store.SetMetadata(ctx, c.snapshotKey(), buf)
233+
_ = c.store.SetMetadata(ctx, c.snapshotKey(), encodeSnapshot(entries))
259234
}
260235

261236
// encodeSnapshot serialises a slice of snapshotEntry values into a byte slice.
@@ -275,8 +250,7 @@ func decodeSnapshot(buf []byte) []snapshotEntry {
275250
if len(buf) == 0 || len(buf)%snapshotEntrySize != 0 {
276251
return nil
277252
}
278-
n := len(buf) / snapshotEntrySize
279-
entries := make([]snapshotEntry, n)
253+
entries := make([]snapshotEntry, len(buf)/snapshotEntrySize)
280254
for i := range entries {
281255
off := i * snapshotEntrySize
282256
entries[i].blockHeight = binary.LittleEndian.Uint64(buf[off:])
@@ -285,58 +259,36 @@ func decodeSnapshot(buf []byte) []snapshotEntry {
285259
return entries
286260
}
287261

288-
// RestoreFromStore recovers the in-flight DA inclusion window from the store.
289-
//
290-
// # Complexity: O(1)
291-
//
292-
// A single GetMetadata call retrieves the window snapshot written by
293-
// persistSnapshot. The snapshot encodes the complete set of in-flight entries
294-
// as a compact byte slice, so no iteration, prefix scan, or per-height lookup
295-
// is required.
296-
//
297-
// If the snapshot key is absent (brand-new node, or node upgraded from an
298-
// older version that did not write snapshots) the function is a no-op; the
299-
// in-flight state will be reconstructed naturally as the submitter / DA
300-
// retriever re-processes blocks.
262+
// RestoreFromStore loads the in-flight snapshot with a single store read.
263+
// Each entry is installed as a height placeholder; real hashes replace them
264+
// once the DA retriever re-fires SetHeaderDAIncluded after startup.
265+
// Missing snapshot key is treated as a no-op (fresh node or pre-snapshot version).
301266
func (c *Cache[T]) RestoreFromStore(ctx context.Context) error {
302267
if c.store == nil || c.storeKeyPrefix == "" {
303268
return nil
304269
}
305270

306271
buf, err := c.store.GetMetadata(ctx, c.snapshotKey())
307272
if err != nil {
308-
// Key absent — nothing to restore (new node or pre-snapshot version).
309-
return nil //nolint:nilerr // ok to ignore
273+
return nil //nolint:nilerr // key absent = nothing to restore
310274
}
311275

312-
entries := decodeSnapshot(buf)
313-
for _, e := range entries {
314-
syntheticHash := HeightPlaceholderKey(c.storeKeyPrefix, e.blockHeight)
315-
c.daIncluded.Add(syntheticHash, e.daHeight)
316-
c.hashByHeight.Add(e.blockHeight, syntheticHash)
276+
for _, e := range decodeSnapshot(buf) {
277+
placeholder := HeightPlaceholderKey(c.storeKeyPrefix, e.blockHeight)
278+
c.daIncluded.Add(placeholder, e.daHeight)
279+
c.hashByHeight.Add(e.blockHeight, placeholder)
317280
c.setMaxDAHeight(e.daHeight)
318281
}
319282

320283
return nil
321284
}
322285

323-
// HeightPlaceholderKey returns a deterministic, unique key used to index an
324-
// in-flight DA inclusion entry by block height when the real content hash is
325-
// not available during store restoration.
326-
//
327-
// Format: "<storeKeyPrefix>__h/<height_big_endian_hex>"
328-
//
329-
// The "__h/" infix cannot collide with real content hashes because real
330-
// hashes are hex-encoded 32-byte digests (64 chars) whereas height values
331-
// are at most 16 hex digits.
332-
//
333-
// This is exported so that callers (e.g. the submitter's IsHeightDAIncluded)
334-
// can perform a height-based fallback lookup immediately after a restart,
335-
// before the DA retriever has had a chance to re-fire SetHeaderDAIncluded with
336-
// the real content hash.
286+
// HeightPlaceholderKey returns a store key for a height-indexed DA inclusion
287+
// entry used when the real content hash is unavailable (e.g. after restore).
288+
// Format: "<prefix>__h/<height_hex_16>" — cannot collide with real 64-char hashes.
337289
func HeightPlaceholderKey(prefix string, height uint64) string {
338290
const hexDigits = "0123456789abcdef"
339-
buf := make([]byte, len(prefix)+4+16) // prefix + "__h/" + 16 hex chars
291+
buf := make([]byte, len(prefix)+4+16)
340292
n := copy(buf, prefix)
341293
n += copy(buf[n:], "__h/")
342294
for i := 15; i >= 0; i-- {
@@ -346,8 +298,7 @@ func HeightPlaceholderKey(prefix string, height uint64) string {
346298
return string(buf)
347299
}
348300

349-
// SaveToStore persists all current DA inclusion entries to the store by
350-
// rewriting the window snapshot. This is a no-op if no store is configured.
301+
// SaveToStore flushes the current snapshot to the store.
351302
func (c *Cache[T]) SaveToStore(ctx context.Context) error {
352303
if c.store == nil {
353304
return nil
@@ -356,12 +307,11 @@ func (c *Cache[T]) SaveToStore(ctx context.Context) error {
356307
return nil
357308
}
358309

359-
// ClearFromStore removes the window snapshot from the store.
310+
// ClearFromStore deletes the snapshot key from the store.
360311
func (c *Cache[T]) ClearFromStore(ctx context.Context, _ []string) error {
361312
if c.store == nil {
362313
return nil
363314
}
364-
// Delete the snapshot key; ignore not-found.
365315
_ = c.store.DeleteMetadata(ctx, c.snapshotKey())
366316
return nil
367317
}

block/internal/cache/generic_cache_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,87 @@ func TestHeightPlaceholderKey(t *testing.T) {
396396
// Different prefixes must not collide.
397397
assert.NotEqual(t, HeightPlaceholderKey("a/", 1), HeightPlaceholderKey("b/", 1))
398398
}
399+
400+
// TestCache_NoPlaceholderLeakAfterRefire verifies that when the DA retriever
401+
// re-fires setDAIncluded with the real content hash after a restart, the
402+
// snapshot placeholder that RestoreFromStore installed is evicted from
403+
// daIncluded. Without the eviction in setDAIncluded, every restart cycle
404+
// would leak one orphaned placeholder key per in-flight block.
405+
func TestCache_NoPlaceholderLeakAfterRefire(t *testing.T) {
406+
st := testMemStore(t)
407+
ctx := context.Background()
408+
409+
// Step 1: initial run — write a real hash for height 3.
410+
c1 := NewCache[testItem](st, "pfx/", testKeyFn("da/"))
411+
c1.setDAIncluded("realHash3", 99, 3)
412+
// snapshot now contains [{blockHeight:3, daHeight:99}]
413+
414+
// Step 2: restart — placeholder installed for height 3.
415+
c2 := NewCache[testItem](st, "pfx/", testKeyFn("da/"))
416+
require.NoError(t, c2.RestoreFromStore(ctx))
417+
418+
placeholder := HeightPlaceholderKey("pfx/", 3)
419+
_, placeholderPresent := c2.daIncluded.Get(placeholder)
420+
require.True(t, placeholderPresent, "placeholder must be present immediately after restore")
421+
assert.Equal(t, 1, c2.daIncluded.Len(), "only one entry expected before re-fire")
422+
423+
// Step 3: DA retriever re-fires with the real hash.
424+
c2.setDAIncluded("realHash3", 99, 3)
425+
426+
// The real hash must be present.
427+
daH, ok := c2.getDAIncluded("realHash3")
428+
require.True(t, ok, "real hash must be present after re-fire")
429+
assert.Equal(t, uint64(99), daH)
430+
431+
// The placeholder must be gone — no orphan leak.
432+
_, placeholderPresent = c2.daIncluded.Get(placeholder)
433+
assert.False(t, placeholderPresent, "placeholder must be evicted after real hash is written")
434+
435+
// Total entries must still be exactly one.
436+
assert.Equal(t, 1, c2.daIncluded.Len(), "exactly one daIncluded entry after re-fire — no orphan")
437+
}
438+
439+
// TestCache_RestartIdempotent verifies that multiple successive restarts all
440+
// yield a correctly functioning cache — i.e. the snapshot written after a
441+
// re-fire is identical in semantics to the original, so a second (or third)
442+
// restart still loads the right DA height via the placeholder fallback and the
443+
// snapshot never grows stale or accumulates phantom entries.
444+
func TestCache_RestartIdempotent(t *testing.T) {
445+
st := testMemStore(t)
446+
ctx := context.Background()
447+
448+
const realHash = "realHashH5"
449+
const blockH = uint64(5)
450+
const daH = uint64(42)
451+
452+
// ── Run 1: normal operation, height 5 in-flight ──────────────────────────
453+
c1 := NewCache[testItem](st, "pfx/", testKeyFn("da/"))
454+
c1.setDAIncluded(realHash, daH, blockH)
455+
// snapshot: [{5, 42}]
456+
457+
for restart := 1; restart <= 3; restart++ {
458+
// ── Restart N: restore from snapshot
459+
cR := NewCache[testItem](st, "pfx/", testKeyFn("da/"))
460+
require.NoError(t, cR.RestoreFromStore(ctx), "restart %d: RestoreFromStore", restart)
461+
462+
assert.Equal(t, 1, cR.daIncluded.Len(), "restart %d: one placeholder entry", restart)
463+
assert.Equal(t, daH, cR.daHeight(), "restart %d: daHeight correct", restart)
464+
465+
// Fallback lookup by height must work.
466+
gotDAH, ok := cR.getDAIncludedByHeight(blockH)
467+
require.True(t, ok, "restart %d: height-based lookup must succeed", restart)
468+
assert.Equal(t, daH, gotDAH, "restart %d: height-based DA height correct", restart)
469+
470+
// ── DA retriever re-fires with the real hash
471+
cR.setDAIncluded(realHash, daH, blockH)
472+
473+
// After re-fire: real hash present, no orphan, snapshot updated.
474+
_, realPresent := cR.daIncluded.Get(realHash)
475+
assert.True(t, realPresent, "restart %d: real hash present after re-fire", restart)
476+
assert.Equal(t, 1, cR.daIncluded.Len(), "restart %d: no orphan after re-fire", restart)
477+
478+
// The snapshot rewritten by re-fire must still encode the right data
479+
// so the next restart can load it correctly.
480+
// (persistSnapshot fires inside setDAIncluded, so the store is up to date.)
481+
}
482+
}

0 commit comments

Comments
 (0)