Skip to content

Commit f5af7bc

Browse files
tac0turtleclaude
andcommitted
fix(cache,da): drop finalized snapshot entries on restore and drain DA subscription channel
Two memory fixes for long-running nodes: Cache restore: the DA-inclusion snapshot is only written on graceful shutdown, so after a crash (e.g. OOM kill) the restored snapshot can contain heights already below the persisted DA-included watermark. The inclusion loop never evicts below its watermark, so those placeholder entries leaked for the process lifetime and were re-persisted on every subsequent save, growing the snapshot monotonically across crash/restart cycles. RestoreFromStore now skips entries at or below the persisted DAIncludedHeight; skipped entries still seed maxDAHeight so DaHeight() is unchanged. DA subscription: the Subscribe wrapper goroutine exited on ctx cancellation without draining the underlying jsonrpc channel, leaving the go-jsonrpc delivery goroutine blocked on send — it never observed the cancellation and never closed its channel, leaking one goroutine per watchdog reconnect. The wrapper now drains the raw channel on exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 67b2dbc commit f5af7bc

5 files changed

Lines changed: 84 additions & 16 deletions

File tree

block/internal/cache/generic_cache.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,14 @@ func decodeSnapshot(buf []byte) []snapshotEntry {
191191
// RestoreFromStore loads the in-flight snapshot with a single store read.
192192
// Each entry is installed as a height placeholder; real hashes replace them
193193
// once the DA retriever re-fires SetHeaderDAIncluded after startup.
194-
func (c *Cache) RestoreFromStore(ctx context.Context) error {
194+
//
195+
// Entries at or below finalizedHeight are already DA-included and finalized:
196+
// the snapshot on disk can predate the current DA-included height (it is only
197+
// written on graceful shutdown), and the inclusion loop never revisits heights
198+
// below its watermark, so installing them would leak placeholders for the
199+
// lifetime of the process and re-persist them on the next save. They are
200+
// skipped, contributing only to maxDAHeight.
201+
func (c *Cache) RestoreFromStore(ctx context.Context, finalizedHeight uint64) error {
195202
if c.store == nil || c.storeKeyPrefix == "" {
196203
return nil
197204
}
@@ -208,10 +215,13 @@ func (c *Cache) RestoreFromStore(ctx context.Context) error {
208215
defer c.mu.Unlock()
209216

210217
for _, e := range decodeSnapshot(buf) {
218+
c.setMaxDAHeight(e.daHeight)
219+
if e.blockHeight <= finalizedHeight {
220+
continue
221+
}
211222
placeholder := HeightPlaceholderKey(c.storeKeyPrefix, e.blockHeight)
212223
c.daIncluded[placeholder] = e.daHeight
213224
c.hashByHeight[e.blockHeight] = placeholder
214-
c.setMaxDAHeight(e.daHeight)
215225
}
216226

217227
return nil

block/internal/cache/generic_cache_test.go

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func TestCache_RestoreFromStore_EmptyChain(t *testing.T) {
5757
st := testMemStore(t)
5858

5959
c := NewCache(st, "hdr/")
60-
require.NoError(t, c.RestoreFromStore(context.Background()))
60+
require.NoError(t, c.RestoreFromStore(context.Background(), 0))
6161

6262
assert.Equal(t, 0, c.daIncludedLen(), "no entries expected on empty chain")
6363
assert.Equal(t, uint64(0), c.daHeight())
@@ -75,7 +75,7 @@ func TestCache_RestoreFromStore_FullyFinalized(t *testing.T) {
7575
writeSnapshot(t, st, "hdr/", nil)
7676

7777
c := NewCache(st, "hdr/")
78-
require.NoError(t, c.RestoreFromStore(ctx))
78+
require.NoError(t, c.RestoreFromStore(ctx, 0))
7979

8080
assert.Equal(t, 0, c.daIncludedLen(), "no in-flight entries expected")
8181
assert.Equal(t, uint64(0), c.daHeight(), "no in-flight entries means daHeight is 0")
@@ -93,7 +93,7 @@ func TestCache_RestoreFromStore_InFlightWindow(t *testing.T) {
9393
})
9494

9595
c := NewCache(st, "hdr/")
96-
require.NoError(t, c.RestoreFromStore(ctx))
96+
require.NoError(t, c.RestoreFromStore(ctx, 0))
9797

9898
assert.Equal(t, 2, c.daIncludedLen(), "exactly the in-flight snapshot entries should be loaded")
9999
assert.Equal(t, uint64(14), c.daHeight(), "maxDAHeight should reflect the highest in-flight DA height")
@@ -118,7 +118,7 @@ func TestCache_RestoreFromStore_SingleEntry(t *testing.T) {
118118
})
119119

120120
c := NewCache(st, "hdr/")
121-
require.NoError(t, c.RestoreFromStore(ctx))
121+
require.NoError(t, c.RestoreFromStore(ctx, 0))
122122

123123
assert.Equal(t, 1, c.daIncludedLen(), "one entry should be in-flight")
124124
assert.Equal(t, uint64(20), c.daHeight())
@@ -131,7 +131,7 @@ func TestCache_RestoreFromStore_SingleEntry(t *testing.T) {
131131

132132
func TestCache_RestoreFromStore_NilStore(t *testing.T) {
133133
c := NewCache(nil, "")
134-
require.NoError(t, c.RestoreFromStore(context.Background()))
134+
require.NoError(t, c.RestoreFromStore(context.Background(), 0))
135135
assert.Equal(t, 0, c.daIncludedLen())
136136
}
137137

@@ -147,7 +147,7 @@ func TestCache_RestoreFromStore_PlaceholderOverwrittenByRealHash(t *testing.T) {
147147
})
148148

149149
c := NewCache(st, "hdr/")
150-
require.NoError(t, c.RestoreFromStore(ctx))
150+
require.NoError(t, c.RestoreFromStore(ctx, 0))
151151

152152
assert.Equal(t, 1, c.daIncludedLen(), "one placeholder for height 3")
153153

@@ -176,7 +176,7 @@ func TestCache_RestoreFromStore_RoundTrip(t *testing.T) {
176176
require.NoError(t, c1.SaveToStore(ctx))
177177

178178
c2 := NewCache(st, "rt/")
179-
require.NoError(t, c2.RestoreFromStore(ctx))
179+
require.NoError(t, c2.RestoreFromStore(ctx, 0))
180180

181181
assert.Equal(t, 2, c2.daIncludedLen(), "only non-deleted entries should be restored")
182182
assert.Equal(t, uint64(30), c2.daHeight())
@@ -189,6 +189,42 @@ func TestCache_RestoreFromStore_RoundTrip(t *testing.T) {
189189
assert.True(t, ok, "height 3 placeholder should exist")
190190
}
191191

192+
// TestCache_RestoreFromStore_SkipsFinalizedEntries verifies that snapshot
193+
// entries at or below the finalized height are not installed as placeholders
194+
// (a stale snapshot from a previous graceful shutdown can contain heights the
195+
// inclusion loop has already advanced past), while maxDAHeight still accounts
196+
// for them.
197+
func TestCache_RestoreFromStore_SkipsFinalizedEntries(t *testing.T) {
198+
st := testMemStore(t)
199+
ctx := context.Background()
200+
201+
writeSnapshot(t, st, "hdr/", []snapshotEntry{
202+
{blockHeight: 3, daHeight: 10},
203+
{blockHeight: 4, daHeight: 11},
204+
{blockHeight: 5, daHeight: 12},
205+
})
206+
207+
c := NewCache(st, "hdr/")
208+
require.NoError(t, c.RestoreFromStore(ctx, 4))
209+
210+
assert.Equal(t, 1, c.daIncludedLen(), "only entries above the finalized height should be installed")
211+
assert.Equal(t, uint64(12), c.daHeight(), "maxDAHeight should include skipped entries")
212+
213+
_, ok := c.getDAIncludedByHeight(3)
214+
assert.False(t, ok, "height 3 is finalized, must not be restored")
215+
_, ok = c.getDAIncludedByHeight(4)
216+
assert.False(t, ok, "height 4 is finalized, must not be restored")
217+
daH, ok := c.getDAIncludedByHeight(5)
218+
require.True(t, ok, "height 5 is in-flight, must be restored")
219+
assert.Equal(t, uint64(12), daH)
220+
221+
// A fully-stale snapshot restores nothing but still seeds maxDAHeight.
222+
c2 := NewCache(st, "hdr/")
223+
require.NoError(t, c2.RestoreFromStore(ctx, 10))
224+
assert.Equal(t, 0, c2.daIncludedLen(), "all entries finalized, nothing restored")
225+
assert.Equal(t, uint64(12), c2.daHeight())
226+
}
227+
192228
// ---------------------------------------------------------------------------
193229
// Basic operations
194230
// ---------------------------------------------------------------------------
@@ -306,7 +342,7 @@ func TestCache_NoPlaceholderLeakAfterRefire(t *testing.T) {
306342
require.NoError(t, c1.SaveToStore(ctx))
307343

308344
c2 := NewCache(st, "pfx/")
309-
require.NoError(t, c2.RestoreFromStore(ctx))
345+
require.NoError(t, c2.RestoreFromStore(ctx, 0))
310346

311347
placeholder := HeightPlaceholderKey("pfx/", 3)
312348
_, placeholderPresent := c2.getDAIncluded(placeholder)
@@ -344,7 +380,7 @@ func TestCache_RestartIdempotent(t *testing.T) {
344380

345381
for restart := 1; restart <= 3; restart++ {
346382
cR := NewCache(st, "pfx/")
347-
require.NoError(t, cR.RestoreFromStore(ctx), "restart %d: RestoreFromStore", restart)
383+
require.NoError(t, cR.RestoreFromStore(ctx, 0), "restart %d: RestoreFromStore", restart)
348384

349385
assert.Equal(t, 1, cR.daIncludedLen(), "restart %d: one placeholder entry", restart)
350386
assert.Equal(t, daH, cR.daHeight(), "restart %d: daHeight correct", restart)

block/internal/cache/manager.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,13 +294,22 @@ func (m *implementation) SaveToStore() error {
294294
// RestoreFromStore loads the in-flight snapshot (O(1)) then seeds maxDAHeight
295295
// from the finalized-tip HeightToDAHeight metadata so DaHeight() is correct
296296
// even when the snapshot is empty (all blocks finalized).
297+
// Snapshot entries at or below the persisted DA-included height are dropped:
298+
// the snapshot may predate the watermark (it is only written on graceful
299+
// shutdown) and the inclusion loop never evicts below it.
297300
func (m *implementation) RestoreFromStore() error {
298301
ctx := context.Background()
299302

300-
if err := m.headerCache.RestoreFromStore(ctx); err != nil {
303+
daIncludedHeight, _, err := getMetadataUint64(ctx, m.store, store.DAIncludedHeightKey)
304+
if err != nil {
305+
m.logger.Warn().Err(err).Msg("failed to read DA included height, restoring cache unfiltered")
306+
daIncludedHeight = 0
307+
}
308+
309+
if err := m.headerCache.RestoreFromStore(ctx, daIncludedHeight); err != nil {
301310
return fmt.Errorf("failed to restore header cache from store: %w", err)
302311
}
303-
if err := m.dataCache.RestoreFromStore(ctx); err != nil {
312+
if err := m.dataCache.RestoreFromStore(ctx, daIncludedHeight); err != nil {
304313
return fmt.Errorf("failed to restore data cache from store: %w", err)
305314
}
306315

block/internal/cache/manager_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,13 @@ func TestManager_SaveAndRestoreFromStore(t *testing.T) {
136136

137137
// Height 1 is finalized (DAIncludedHeight = 1): IsHeightDAIncluded returns true
138138
// via the height comparison, so GetHeaderDAIncludedByHash is never consulted for it.
139-
// The cache entry is not restored — this is correct and intentional.
139+
// The cache entry is not restored — this is correct and intentional. Restoring it
140+
// would leak a placeholder for the process lifetime (the inclusion loop never
141+
// evicts below its watermark) and re-persist it on every subsequent save.
142+
_, ok := m2.GetHeaderDAIncludedByHeight(1)
143+
assert.False(t, ok, "finalized height 1 must not be restored from the snapshot")
144+
_, ok = m2.GetDataDAIncludedByHeight(1)
145+
assert.False(t, ok, "finalized height 1 must not be restored from the snapshot")
140146

141147
// Height 2 is in-flight: the window restore loads a placeholder entry keyed by
142148
// height. The real content-hash entry is populated when the submitter re-processes

block/internal/da/client.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,8 +497,8 @@ func (c *client) SupportsSubscribe() bool {
497497
// Subscribe subscribes to blobs in the given namespace via the celestia-node
498498
// Subscribe API. It returns a channel that emits a SubscriptionEvent for every
499499
// DA block containing a matching blob. The channel is closed when ctx is
500-
// cancelled. The caller must drain the channel after cancellation to avoid
501-
// goroutine leaks.
500+
// cancelled; the underlying jsonrpc subscription channel is drained on exit so
501+
// its delivery goroutine is never left blocked on send.
502502
// Timestamps are included from the header if available (celestia-node v0.29.1+§), otherwise
503503
// fetched via a separate call when includeTimestamp is true. Be aware that fetching timestamps
504504
// separately is an additional call to the celestia node for each event.
@@ -516,6 +516,13 @@ func (c *client) Subscribe(ctx context.Context, namespace []byte, includeTimesta
516516
out := make(chan datypes.SubscriptionEvent, 16)
517517
go func() {
518518
defer close(out)
519+
// The jsonrpc layer closes rawCh only after its pending send is
520+
// consumed and the ctx cancellation is processed. Drain it on exit
521+
// so the delivery goroutine is not leaked on reconnect.
522+
defer func() {
523+
for range rawCh { //nolint:revive // draining until producer closes the channel
524+
}
525+
}()
519526
for {
520527
select {
521528
case <-ctx.Done():

0 commit comments

Comments
 (0)