From 2c37b35fd65bb665fe2318b5f6d7e3bc0d697d34 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 9 Sep 2025 11:11:15 +0200 Subject: [PATCH 01/20] feat: Add `DeleteFromHead` method to Store --- headertest/store.go | 28 ++++ interface.go | 3 + p2p/server_test.go | 5 + store/store_delete.go | 155 ++++++++++++++++++++ store/store_test.go | 329 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 520 insertions(+) diff --git a/headertest/store.go b/headertest/store.go index 8ce96213..c205264e 100644 --- a/headertest/store.go +++ b/headertest/store.go @@ -99,6 +99,34 @@ func (m *Store[H]) DeleteTo(ctx context.Context, to uint64) error { return nil } +func (m *Store[H]) DeleteFromHead(ctx context.Context, to uint64) error { + // Find the current head height + headHeight := uint64(0) + for h := range m.Headers { + if h > headHeight { + headHeight = h + } + } + + // Delete from head down to (but not including) 'to' + for h := headHeight; h > to; h-- { + _, ok := m.Headers[h] + if !ok { + continue + } + + for _, deleteFn := range m.onDelete { + err := deleteFn(ctx, h) + if err != nil { + return err + } + } + delete(m.Headers, h) // must be after deleteFn + } + + return nil +} + func (m *Store[H]) OnDelete(fn func(context.Context, uint64) error) { m.onDeleteMu.Lock() defer m.onDeleteMu.Unlock() diff --git a/interface.go b/interface.go index 1c719fa6..3f926500 100644 --- a/interface.go +++ b/interface.go @@ -88,6 +88,9 @@ type Store[H Header[H]] interface { // DeleteTo deletes the range [Tail():to). DeleteTo(ctx context.Context, to uint64) error + // DeleteFromHead deletes the range (to:Head()]. + DeleteFromHead(ctx context.Context, to uint64) error + // OnDelete registers given handler to be called whenever a header with the height is being removed. // OnDelete guarantees that the header is accessible for the handler with GetByHeight and is removed // only after the handler terminates with nil error. diff --git a/p2p/server_test.go b/p2p/server_test.go index 0af96b11..cf05546a 100644 --- a/p2p/server_test.go +++ b/p2p/server_test.go @@ -197,4 +197,9 @@ func (timeoutStore[H]) DeleteTo(ctx context.Context, _ uint64) error { return ctx.Err() } +func (timeoutStore[H]) DeleteFromHead(ctx context.Context, _ uint64) error { + <-ctx.Done() + return ctx.Err() +} + func (timeoutStore[H]) OnDelete(fn func(context.Context, uint64) error) {} diff --git a/store/store_delete.go b/store/store_delete.go index 4bea211e..3e793998 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -346,3 +346,158 @@ func (s *Store[H]) deleteParallel(ctx context.Context, from, to uint64) (uint64, ) return highest, missing, nil } + +// DeleteFromHead deletes headers from the current head down to (but not including) the specified height. +// The specified height becomes the new head of the store. +// This is conceptually the opposite of DeleteTo, which deletes from tail up to a height. +func (s *Store[H]) DeleteFromHead(ctx context.Context, to uint64) error { + // ensure all the pending headers are synchronized + err := s.Sync(ctx) + if err != nil { + return err + } + + head, err := s.Head(ctx) + if err != nil { + return fmt.Errorf("header/store: reading head: %w", err) + } + + tail, err := s.Tail(ctx) + if err != nil { + return fmt.Errorf("header/store: reading tail: %w", err) + } + + // validate that 'to' height is within valid bounds + if to < tail.Height() { + return fmt.Errorf( + "header/store: delete from head to %d below current tail(%d)", + to, + tail.Height(), + ) + } + if to > head.Height() { + return fmt.Errorf( + "header/store: delete from head to %d above current head(%d)", + to, + head.Height(), + ) + } + + // if to equals head height, it's a no-op + if to == head.Height() { + return nil + } + + // verify that the target height exists and will become the new head + _, err = s.getByHeight(ctx, to) + if err != nil { + return fmt.Errorf( + "header/store: target height %d not found: %w", + to, + err, + ) + } + + // delete the range (to, head.Height()] + err = s.deleteRangeFromHead(ctx, to+1, head.Height()+1) + if err != nil { + return fmt.Errorf( + "header/store: delete from head to height %d: %w", + to, + err, + ) + } + + return nil +} + +// deleteRangeFromHead deletes [from:to) header range from the store and updates the head. +// This is similar to deleteRange but updates the head pointer instead of tail. +func (s *Store[H]) deleteRangeFromHead(ctx context.Context, from, to uint64) (err error) { + startTime := time.Now() + + var ( + height uint64 + missing int + ) + defer func() { + if err != nil { + if errors.Is(err, errDeleteTimeout) { + log.Warnw("partial delete from head", + "from_height", from, + "expected_to_height", to, + "actual_to_height", height, + "hdrs_not_found", missing, + "took(s)", time.Since(startTime), + ) + } else { + log.Errorw("partial delete from head with error", + "from_height", from, + "expected_to_height", to, + "actual_to_height", height, + "hdrs_not_found", missing, + "took(s)", time.Since(startTime), + "err", err, + ) + } + } else if to-from > 1 { + log.Debugw("deleted headers from head", + "from_height", from, + "to_height", to, + "hdrs_not_found", missing, + "took(s)", time.Since(startTime).Seconds(), + ) + } + + // Set new head to the height just before the deleted range + if from > 0 { + newHeadHeight := from - 1 + if derr := s.setHead(ctx, s.ds, newHeadHeight); derr != nil { + err = errors.Join( + err, + fmt.Errorf("setting head to %d: %w", newHeadHeight, derr), + ) + } + } + }() + + deleteCtx := ctx + if deadline, ok := ctx.Deadline(); ok { + // allocate 95% of caller's set deadline for deletion + // and give leftover to save progress + // this prevents store's state corruption from partial deletion + sub := deadline.Sub(startTime) / 100 * 95 + var cancel context.CancelFunc + deleteCtx, cancel = context.WithDeadlineCause( + ctx, + startTime.Add(sub), + errDeleteTimeout, + ) + defer cancel() + } + + if to-from < deleteRangeParallelThreshold { + height, missing, err = s.deleteSequential(deleteCtx, from, to) + } else { + height, missing, err = s.deleteParallel(deleteCtx, from, to) + } + + return err +} + +// setHead sets the head of the store to the specified height. +// This is similar to setTail but updates the head pointer. +func (s *Store[H]) setHead(ctx context.Context, write datastore.Write, to uint64) error { + newHead, err := s.getByHeight(ctx, to) + if err != nil { + return fmt.Errorf("getting head: %w", err) + } + + // update the contiguous head + s.contiguousHead.Store(&newHead) + if err := writeHeaderHashTo(ctx, write, newHead, headKey); err != nil { + return fmt.Errorf("writing headKey in batch: %w", err) + } + + return nil +} diff --git a/store/store_test.go b/store/store_test.go index f9ffca0c..7a780e60 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -907,3 +907,332 @@ func TestStore_HasAt(t *testing.T) { has = store.HasAt(ctx, 0) assert.False(t, has) } + +func TestStore_DeleteFromHead(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + const count = 100 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + hashes := make(map[uint64]header.Hash, count) + for _, h := range in { + hashes[h.Height()] = h.Hash() + } + + // wait until headers are written + time.Sleep(100 * time.Millisecond) + + tests := []struct { + name string + to uint64 + wantHead uint64 + wantError bool + }{ + { + name: "initial delete from head request", + to: 85, + wantHead: 85, + wantError: false, + }, + { + name: "no-op delete request - to equals current head", + to: 85, // same as previous head + wantError: false, + }, + { + name: "valid delete from head request", + to: 50, + wantHead: 50, + wantError: false, + }, + { + name: "delete to height above current head", + to: 200, + wantError: true, + }, + { + name: "delete to height below tail", + to: 0, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeHead, err := store.Head(ctx) + require.NoError(t, err) + beforeTail, err := store.Tail(ctx) + require.NoError(t, err) + + // manually add something to the pending for assert at the bottom + if idx := beforeHead.Height() - 1; idx < count && idx > 0 { + store.pending.Append(in[idx-1]) + defer store.pending.Reset() + } + + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + err = store.DeleteFromHead(ctx, tt.to) + if tt.wantError { + assert.Error(t, err) + return + } + require.NoError(t, err) + + // check that cache and pending doesn't contain deleted headers + for h := tt.to + 1; h <= beforeHead.Height(); h++ { + hash, ok := hashes[h] + if !ok { + continue // skip heights that weren't in our original set + } + assert.False( + t, + store.cache.Contains(hash.String()), + "height %d should be removed from cache", + h, + ) + assert.False( + t, + store.heightIndex.cache.Contains(h), + "height %d should be removed from height index", + h, + ) + assert.False( + t, + store.pending.Has(hash), + "height %d should be removed from pending", + h, + ) + } + + // verify new head is correct + if tt.wantHead > 0 { + head, err := store.Head(ctx) + require.NoError(t, err) + require.EqualValues(t, tt.wantHead, head.Height()) + } + + // verify tail hasn't changed + tail, err := store.Tail(ctx) + require.NoError(t, err) + require.EqualValues(t, beforeTail.Height(), tail.Height()) + + // verify headers below 'to' still exist + for h := beforeTail.Height(); h <= tt.to; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } + }) + } +} + +func TestStore_DeleteFromHead_EmptyStore(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store, err := NewStore[*headertest.DummyHeader](ds) + require.NoError(t, err) + + err = store.Start(ctx) + require.NoError(t, err) + t.Cleanup(func() { + _ = store.Stop(ctx) + }) + + // wait for store to initialize + time.Sleep(10 * time.Millisecond) + + // should handle empty store gracefully + err = store.DeleteFromHead(ctx, 50) + require.Error(t, err) // should error because store is empty +} + +func TestStore_DeleteFromHead_SingleHeader(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + // Add single header at height 1 (genesis is at 0) + headers := suite.GenDummyHeaders(1) + err := store.Append(ctx, headers...) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + // Should not be able to delete the only header + err = store.DeleteFromHead(ctx, 0) + require.Error(t, err) // should error - would delete below tail +} + +func TestStore_DeleteFromHead_Synchronized(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + err := store.Append(ctx, suite.GenDummyHeaders(50)...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Ensure sync completes + err = store.Sync(ctx) + require.NoError(t, err) + + err = store.DeleteFromHead(ctx, 25) + require.NoError(t, err) + + // Verify head is now at height 25 + head, err := store.Head(ctx) + require.NoError(t, err) + require.EqualValues(t, 25, head.Height()) + + // Verify headers above 25 are gone + for h := uint64(26); h <= 50; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Verify headers at and below 25 still exist + for h := uint64(1); h <= 25; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } +} + +func TestStore_DeleteFromHead_OnDeleteHandlers(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + err := store.Append(ctx, suite.GenDummyHeaders(50)...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Get the actual head height to calculate expected deletions + head, err := store.Head(ctx) + require.NoError(t, err) + + var deletedHeights []uint64 + store.OnDelete(func(ctx context.Context, height uint64) error { + deletedHeights = append(deletedHeights, height) + return nil + }) + + err = store.DeleteFromHead(ctx, 40) + require.NoError(t, err) + + // Verify onDelete was called for each deleted height (from 41 to head height) + var expectedDeleted []uint64 + for h := uint64(41); h <= head.Height(); h++ { + expectedDeleted = append(expectedDeleted, h) + } + assert.ElementsMatch(t, expectedDeleted, deletedHeights) +} + +func TestStore_DeleteFromHead_LargeRange(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(100)) + + // Create a large number of headers to trigger parallel deletion + const count = 15000 + headers := suite.GenDummyHeaders(count) + err := store.Append(ctx, headers...) + require.NoError(t, err) + + time.Sleep(500 * time.Millisecond) // allow time for large batch to write + + // Delete a large range to test parallel deletion path + const keepHeight = 5000 + err = store.DeleteFromHead(ctx, keepHeight) + require.NoError(t, err) + + // Verify new head + head, err := store.Head(ctx) + require.NoError(t, err) + require.EqualValues(t, keepHeight, head.Height()) + + // Spot check that high numbered headers are gone + for h := uint64(keepHeight + 1000); h <= count; h += 1000 { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Spot check that low numbered headers still exist + for h := uint64(1000); h <= keepHeight; h += 1000 { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } +} + +func TestStore_DeleteFromHead_ValidationErrors(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + err := store.Append(ctx, suite.GenDummyHeaders(20)...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + head, err := store.Head(ctx) + require.NoError(t, err) + tail, err := store.Tail(ctx) + require.NoError(t, err) + + tests := []struct { + name string + to uint64 + errMsg string + }{ + { + name: "to below tail", + to: tail.Height() - 1, + errMsg: "below current tail", + }, + { + name: "to above head", + to: head.Height() + 1, + errMsg: "above current head", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.DeleteFromHead(ctx, tt.to) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} From 4c3fc7cb55406c767726d5c8b0d2eeb619dfa0b1 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 9 Sep 2025 13:19:53 +0200 Subject: [PATCH 02/20] refactor --- headertest/store.go | 19 +-- interface.go | 4 +- p2p/server_test.go | 2 +- store/store_delete.go | 236 ++++++++++-------------------- store/store_test.go | 329 ------------------------------------------ 5 files changed, 85 insertions(+), 505 deletions(-) diff --git a/headertest/store.go b/headertest/store.go index c205264e..9c1dd731 100644 --- a/headertest/store.go +++ b/headertest/store.go @@ -99,17 +99,9 @@ func (m *Store[H]) DeleteTo(ctx context.Context, to uint64) error { return nil } -func (m *Store[H]) DeleteFromHead(ctx context.Context, to uint64) error { - // Find the current head height - headHeight := uint64(0) - for h := range m.Headers { - if h > headHeight { - headHeight = h - } - } - - // Delete from head down to (but not including) 'to' - for h := headHeight; h > to; h-- { +func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { + // Delete headers in the range [from:to) + for h := from; h < to; h++ { _, ok := m.Headers[h] if !ok { continue @@ -124,6 +116,11 @@ func (m *Store[H]) DeleteFromHead(ctx context.Context, to uint64) error { delete(m.Headers, h) // must be after deleteFn } + // Update TailHeight if we deleted from the beginning + if from <= m.TailHeight { + m.TailHeight = to + } + return nil } diff --git a/interface.go b/interface.go index 3f926500..d139e719 100644 --- a/interface.go +++ b/interface.go @@ -88,8 +88,8 @@ type Store[H Header[H]] interface { // DeleteTo deletes the range [Tail():to). DeleteTo(ctx context.Context, to uint64) error - // DeleteFromHead deletes the range (to:Head()]. - DeleteFromHead(ctx context.Context, to uint64) error + // DeleteRange deletes the range [from:to). + DeleteRange(ctx context.Context, from, to uint64) error // OnDelete registers given handler to be called whenever a header with the height is being removed. // OnDelete guarantees that the header is accessible for the handler with GetByHeight and is removed diff --git a/p2p/server_test.go b/p2p/server_test.go index cf05546a..e53808c1 100644 --- a/p2p/server_test.go +++ b/p2p/server_test.go @@ -197,7 +197,7 @@ func (timeoutStore[H]) DeleteTo(ctx context.Context, _ uint64) error { return ctx.Err() } -func (timeoutStore[H]) DeleteFromHead(ctx context.Context, _ uint64) error { +func (timeoutStore[H]) DeleteRange(ctx context.Context, _, _ uint64) error { <-ctx.Done() return ctx.Err() } diff --git a/store/store_delete.go b/store/store_delete.go index 3e793998..3c9724f8 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -10,8 +10,6 @@ import ( "time" "github.com/ipfs/go-datastore" - - "github.com/celestiaorg/go-header" ) // OnDelete implements [header.Store] interface. @@ -35,56 +33,14 @@ func (s *Store[H]) OnDelete(fn func(context.Context, uint64) error) { } // DeleteTo implements [header.Store] interface. +// This is a convenience wrapper around DeleteRange that deletes from tail up to a height. func (s *Store[H]) DeleteTo(ctx context.Context, to uint64) error { - // ensure all the pending headers are synchronized - err := s.Sync(ctx) - if err != nil { - return err - } - - head, err := s.Head(ctx) - if err != nil { - return fmt.Errorf("header/store: reading head: %w", err) - } - if head.Height()+1 < to { - _, err := s.getByHeight(ctx, to) - if errors.Is(err, header.ErrNotFound) { - return fmt.Errorf( - "header/store: delete to %d beyond current head(%d)", - to, - head.Height(), - ) - } - if err != nil { - return fmt.Errorf("delete to potential new head: %w", err) - } - - // if `to` is bigger than the current head and is stored - allow delete, making `to` a new head - } - tail, err := s.Tail(ctx) if err != nil { return fmt.Errorf("header/store: reading tail: %w", err) } - if tail.Height() >= to { - return fmt.Errorf("header/store: delete to %d below current tail(%d)", to, tail.Height()) - } - err = s.deleteRange(ctx, tail.Height(), to) - if errors.Is(err, header.ErrNotFound) && head.Height()+1 == to { - // this is the case where we have deleted all the headers - // wipe the store - if err := s.wipe(ctx); err != nil { - return fmt.Errorf("header/store: wipe: %w", err) - } - - return nil - } - if err != nil { - return fmt.Errorf("header/store: delete to height %d: %w", to, err) - } - - return nil + return s.DeleteRange(ctx, tail.Height(), to) } // deleteRangeParallelThreshold defines the threshold for parallel deletion. @@ -94,69 +50,6 @@ var ( errDeleteTimeout = errors.New("delete timeout") ) -// deleteRange deletes [from:to) header range from the store. -// It gracefully handles context and errors attempting to save interrupted progress. -func (s *Store[H]) deleteRange(ctx context.Context, from, to uint64) (err error) { - startTime := time.Now() - - var ( - height uint64 - missing int - ) - defer func() { - if err != nil { - if errors.Is(err, errDeleteTimeout) { - log.Warnw("partial delete", - "from_height", from, - "expected_to_height", to, - "actual_to_height", height, - "hdrs_not_found", missing, - "took(s)", time.Since(startTime), - ) - } else { - log.Errorw("partial delete with error", - "from_height", from, - "expected_to_height", to, - "actual_to_height", height, - "hdrs_not_found", missing, - "took(s)", time.Since(startTime), - "err", err, - ) - } - } else if to-from > 1 { - log.Debugw("deleted headers", - "from_height", from, - "to_height", to, - "hdrs_not_found", missing, - "took(s)", time.Since(startTime).Seconds(), - ) - } - - if derr := s.setTail(ctx, s.ds, height); derr != nil { - err = errors.Join(err, fmt.Errorf("setting tail to %d: %w", height, derr)) - } - }() - - deleteCtx := ctx - if deadline, ok := ctx.Deadline(); ok { - // allocate 95% of caller's set deadline for deletion - // and give leftover to save progress - // this prevents store's state corruption from partial deletion - sub := deadline.Sub(startTime) / 100 * 95 - var cancel context.CancelFunc - deleteCtx, cancel = context.WithDeadlineCause(ctx, startTime.Add(sub), errDeleteTimeout) - defer cancel() - } - - if to-from < deleteRangeParallelThreshold { - height, missing, err = s.deleteSequential(deleteCtx, from, to) - } else { - height, missing, err = s.deleteParallel(deleteCtx, from, to) - } - - return err -} - // deleteSingle deletes a single header from the store, // its caches and indexies, notifying any registered onDelete handlers. func (s *Store[H]) deleteSingle( @@ -347,10 +240,9 @@ func (s *Store[H]) deleteParallel(ctx context.Context, from, to uint64) (uint64, return highest, missing, nil } -// DeleteFromHead deletes headers from the current head down to (but not including) the specified height. -// The specified height becomes the new head of the store. -// This is conceptually the opposite of DeleteTo, which deletes from tail up to a height. -func (s *Store[H]) DeleteFromHead(ctx context.Context, to uint64) error { +// DeleteRange deletes headers in the range [from:to) from the store. +// It intelligently updates head and/or tail pointers based on what range is being deleted. +func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // ensure all the pending headers are synchronized err := s.Sync(ctx) if err != nil { @@ -367,53 +259,90 @@ func (s *Store[H]) DeleteFromHead(ctx context.Context, to uint64) error { return fmt.Errorf("header/store: reading tail: %w", err) } - // validate that 'to' height is within valid bounds - if to < tail.Height() { + // validate range parameters + if from >= to { return fmt.Errorf( - "header/store: delete from head to %d below current tail(%d)", + "header/store: invalid range [%d:%d) - from must be less than to", + from, to, - tail.Height(), ) } - if to > head.Height() { + + if from < tail.Height() { return fmt.Errorf( - "header/store: delete from head to %d above current head(%d)", - to, - head.Height(), + "header/store: delete range from %d below current tail(%d)", + from, + tail.Height(), ) } - // if to equals head height, it's a no-op - if to == head.Height() { + // Note: Allow deletion beyond head to match original DeleteTo behavior + // Missing headers in the range will be handled gracefully by the deletion logic + + // if range is empty within the current store bounds, it's a no-op + if from > head.Height() || to <= tail.Height() { return nil } - // verify that the target height exists and will become the new head - _, err = s.getByHeight(ctx, to) - if err != nil { - return fmt.Errorf( - "header/store: target height %d not found: %w", - to, - err, - ) + // Check if we're deleting all existing headers (making store empty) + // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes + if from <= tail.Height() && to == head.Height()+1 { + // Check if any headers exist at or beyond 'to' + hasHeadersAtOrBeyond := false + for checkHeight := to; checkHeight <= to+10; checkHeight++ { + if _, err := s.getByHeight(ctx, checkHeight); err == nil { + hasHeadersAtOrBeyond = true + break + } + } + + if !hasHeadersAtOrBeyond { + // wipe the entire store + if err := s.wipe(ctx); err != nil { + return fmt.Errorf("header/store: wipe: %w", err) + } + return nil + } } - // delete the range (to, head.Height()] - err = s.deleteRangeFromHead(ctx, to+1, head.Height()+1) + // Determine which pointers need updating + updateTail := from <= tail.Height() + updateHead := to > head.Height() + + // Delete the headers without automatic tail updates + err = s.deleteRangeRaw(ctx, from, to) if err != nil { - return fmt.Errorf( - "header/store: delete from head to height %d: %w", - to, - err, - ) + return fmt.Errorf("header/store: delete range [%d:%d): %w", from, to, err) + } + + // Update tail if we deleted from the beginning + if updateTail { + _, err = s.getByHeight(ctx, to) + if err != nil { + return fmt.Errorf("header/store: new tail height %d not found: %w", to, err) + } + err = s.setTail(ctx, s.ds, to) + if err != nil { + return fmt.Errorf("header/store: setting tail to %d: %w", to, err) + } + } + + // Update head if we deleted from the end + if updateHead && from > tail.Height() { + newHeadHeight := from - 1 + if newHeadHeight >= tail.Height() { + err = s.setHead(ctx, s.ds, newHeadHeight) + if err != nil { + return fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err) + } + } } return nil } -// deleteRangeFromHead deletes [from:to) header range from the store and updates the head. -// This is similar to deleteRange but updates the head pointer instead of tail. -func (s *Store[H]) deleteRangeFromHead(ctx context.Context, from, to uint64) (err error) { +// deleteRangeRaw deletes [from:to) header range without updating head or tail pointers. +func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (err error) { startTime := time.Now() var ( @@ -423,7 +352,7 @@ func (s *Store[H]) deleteRangeFromHead(ctx context.Context, from, to uint64) (er defer func() { if err != nil { if errors.Is(err, errDeleteTimeout) { - log.Warnw("partial delete from head", + log.Warnw("partial delete range", "from_height", from, "expected_to_height", to, "actual_to_height", height, @@ -431,7 +360,7 @@ func (s *Store[H]) deleteRangeFromHead(ctx context.Context, from, to uint64) (er "took(s)", time.Since(startTime), ) } else { - log.Errorw("partial delete from head with error", + log.Errorw("partial delete range with error", "from_height", from, "expected_to_height", to, "actual_to_height", height, @@ -441,38 +370,22 @@ func (s *Store[H]) deleteRangeFromHead(ctx context.Context, from, to uint64) (er ) } } else if to-from > 1 { - log.Debugw("deleted headers from head", + log.Debugw("deleted range", "from_height", from, "to_height", to, "hdrs_not_found", missing, "took(s)", time.Since(startTime).Seconds(), ) } - - // Set new head to the height just before the deleted range - if from > 0 { - newHeadHeight := from - 1 - if derr := s.setHead(ctx, s.ds, newHeadHeight); derr != nil { - err = errors.Join( - err, - fmt.Errorf("setting head to %d: %w", newHeadHeight, derr), - ) - } - } }() deleteCtx := ctx if deadline, ok := ctx.Deadline(); ok { // allocate 95% of caller's set deadline for deletion // and give leftover to save progress - // this prevents store's state corruption from partial deletion sub := deadline.Sub(startTime) / 100 * 95 var cancel context.CancelFunc - deleteCtx, cancel = context.WithDeadlineCause( - ctx, - startTime.Add(sub), - errDeleteTimeout, - ) + deleteCtx, cancel = context.WithDeadlineCause(ctx, startTime.Add(sub), errDeleteTimeout) defer cancel() } @@ -486,7 +399,6 @@ func (s *Store[H]) deleteRangeFromHead(ctx context.Context, from, to uint64) (er } // setHead sets the head of the store to the specified height. -// This is similar to setTail but updates the head pointer. func (s *Store[H]) setHead(ctx context.Context, write datastore.Write, to uint64) error { newHead, err := s.getByHeight(ctx, to) if err != nil { diff --git a/store/store_test.go b/store/store_test.go index 7a780e60..f9ffca0c 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -907,332 +907,3 @@ func TestStore_HasAt(t *testing.T) { has = store.HasAt(ctx, 0) assert.False(t, has) } - -func TestStore_DeleteFromHead(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - - const count = 100 - in := suite.GenDummyHeaders(count) - err := store.Append(ctx, in...) - require.NoError(t, err) - - hashes := make(map[uint64]header.Hash, count) - for _, h := range in { - hashes[h.Height()] = h.Hash() - } - - // wait until headers are written - time.Sleep(100 * time.Millisecond) - - tests := []struct { - name string - to uint64 - wantHead uint64 - wantError bool - }{ - { - name: "initial delete from head request", - to: 85, - wantHead: 85, - wantError: false, - }, - { - name: "no-op delete request - to equals current head", - to: 85, // same as previous head - wantError: false, - }, - { - name: "valid delete from head request", - to: 50, - wantHead: 50, - wantError: false, - }, - { - name: "delete to height above current head", - to: 200, - wantError: true, - }, - { - name: "delete to height below tail", - to: 0, - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - beforeHead, err := store.Head(ctx) - require.NoError(t, err) - beforeTail, err := store.Tail(ctx) - require.NoError(t, err) - - // manually add something to the pending for assert at the bottom - if idx := beforeHead.Height() - 1; idx < count && idx > 0 { - store.pending.Append(in[idx-1]) - defer store.pending.Reset() - } - - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - - err = store.DeleteFromHead(ctx, tt.to) - if tt.wantError { - assert.Error(t, err) - return - } - require.NoError(t, err) - - // check that cache and pending doesn't contain deleted headers - for h := tt.to + 1; h <= beforeHead.Height(); h++ { - hash, ok := hashes[h] - if !ok { - continue // skip heights that weren't in our original set - } - assert.False( - t, - store.cache.Contains(hash.String()), - "height %d should be removed from cache", - h, - ) - assert.False( - t, - store.heightIndex.cache.Contains(h), - "height %d should be removed from height index", - h, - ) - assert.False( - t, - store.pending.Has(hash), - "height %d should be removed from pending", - h, - ) - } - - // verify new head is correct - if tt.wantHead > 0 { - head, err := store.Head(ctx) - require.NoError(t, err) - require.EqualValues(t, tt.wantHead, head.Height()) - } - - // verify tail hasn't changed - tail, err := store.Tail(ctx) - require.NoError(t, err) - require.EqualValues(t, beforeTail.Height(), tail.Height()) - - // verify headers below 'to' still exist - for h := beforeTail.Height(); h <= tt.to; h++ { - has := store.HasAt(ctx, h) - assert.True(t, has, "height %d should still exist", h) - } - }) - } -} - -func TestStore_DeleteFromHead_EmptyStore(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store, err := NewStore[*headertest.DummyHeader](ds) - require.NoError(t, err) - - err = store.Start(ctx) - require.NoError(t, err) - t.Cleanup(func() { - _ = store.Stop(ctx) - }) - - // wait for store to initialize - time.Sleep(10 * time.Millisecond) - - // should handle empty store gracefully - err = store.DeleteFromHead(ctx, 50) - require.Error(t, err) // should error because store is empty -} - -func TestStore_DeleteFromHead_SingleHeader(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - - // Add single header at height 1 (genesis is at 0) - headers := suite.GenDummyHeaders(1) - err := store.Append(ctx, headers...) - require.NoError(t, err) - - time.Sleep(10 * time.Millisecond) - - // Should not be able to delete the only header - err = store.DeleteFromHead(ctx, 0) - require.Error(t, err) // should error - would delete below tail -} - -func TestStore_DeleteFromHead_Synchronized(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - - err := store.Append(ctx, suite.GenDummyHeaders(50)...) - require.NoError(t, err) - - time.Sleep(100 * time.Millisecond) - - // Ensure sync completes - err = store.Sync(ctx) - require.NoError(t, err) - - err = store.DeleteFromHead(ctx, 25) - require.NoError(t, err) - - // Verify head is now at height 25 - head, err := store.Head(ctx) - require.NoError(t, err) - require.EqualValues(t, 25, head.Height()) - - // Verify headers above 25 are gone - for h := uint64(26); h <= 50; h++ { - has := store.HasAt(ctx, h) - assert.False(t, has, "height %d should be deleted", h) - } - - // Verify headers at and below 25 still exist - for h := uint64(1); h <= 25; h++ { - has := store.HasAt(ctx, h) - assert.True(t, has, "height %d should still exist", h) - } -} - -func TestStore_DeleteFromHead_OnDeleteHandlers(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - - err := store.Append(ctx, suite.GenDummyHeaders(50)...) - require.NoError(t, err) - - time.Sleep(100 * time.Millisecond) - - // Get the actual head height to calculate expected deletions - head, err := store.Head(ctx) - require.NoError(t, err) - - var deletedHeights []uint64 - store.OnDelete(func(ctx context.Context, height uint64) error { - deletedHeights = append(deletedHeights, height) - return nil - }) - - err = store.DeleteFromHead(ctx, 40) - require.NoError(t, err) - - // Verify onDelete was called for each deleted height (from 41 to head height) - var expectedDeleted []uint64 - for h := uint64(41); h <= head.Height(); h++ { - expectedDeleted = append(expectedDeleted, h) - } - assert.ElementsMatch(t, expectedDeleted, deletedHeights) -} - -func TestStore_DeleteFromHead_LargeRange(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(100)) - - // Create a large number of headers to trigger parallel deletion - const count = 15000 - headers := suite.GenDummyHeaders(count) - err := store.Append(ctx, headers...) - require.NoError(t, err) - - time.Sleep(500 * time.Millisecond) // allow time for large batch to write - - // Delete a large range to test parallel deletion path - const keepHeight = 5000 - err = store.DeleteFromHead(ctx, keepHeight) - require.NoError(t, err) - - // Verify new head - head, err := store.Head(ctx) - require.NoError(t, err) - require.EqualValues(t, keepHeight, head.Height()) - - // Spot check that high numbered headers are gone - for h := uint64(keepHeight + 1000); h <= count; h += 1000 { - has := store.HasAt(ctx, h) - assert.False(t, has, "height %d should be deleted", h) - } - - // Spot check that low numbered headers still exist - for h := uint64(1000); h <= keepHeight; h += 1000 { - has := store.HasAt(ctx, h) - assert.True(t, has, "height %d should still exist", h) - } -} - -func TestStore_DeleteFromHead_ValidationErrors(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - - err := store.Append(ctx, suite.GenDummyHeaders(20)...) - require.NoError(t, err) - - time.Sleep(100 * time.Millisecond) - - head, err := store.Head(ctx) - require.NoError(t, err) - tail, err := store.Tail(ctx) - require.NoError(t, err) - - tests := []struct { - name string - to uint64 - errMsg string - }{ - { - name: "to below tail", - to: tail.Height() - 1, - errMsg: "below current tail", - }, - { - name: "to above head", - to: head.Height() + 1, - errMsg: "above current head", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := store.DeleteFromHead(ctx, tt.to) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errMsg) - }) - } -} From 41f74467ef6e031ae16708b27016b9a728e3a852 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 9 Sep 2025 15:14:45 +0200 Subject: [PATCH 03/20] add tests --- store/store_delete.go | 13 ++ store/store_test.go | 409 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+) diff --git a/store/store_delete.go b/store/store_delete.go index 3c9724f8..efa051c4 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -284,6 +284,19 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { return nil } + // Validate that deletion won't create gaps in the store + // Only allow deletions that: + // 1. Start from tail (advancing tail forward) + // 2. End at head+1 (moving head backward) + // 3. Delete the entire store + if from > tail.Height() && to <= head.Height() { + return fmt.Errorf( + "header/store: deletion range [%d:%d) would create gaps in the store. "+ + "Only deletion from tail (%d) or to head+1 (%d) is supported", + from, to, tail.Height(), head.Height()+1, + ) + } + // Check if we're deleting all existing headers (making store empty) // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes if from <= tail.Height() && to == head.Height()+1 { diff --git a/store/store_test.go b/store/store_test.go index f9ffca0c..e5902617 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "math/rand" + "strings" stdsync "sync" "testing" "time" @@ -907,3 +908,411 @@ func TestStore_HasAt(t *testing.T) { has = store.HasAt(ctx, 0) assert.False(t, has) } + +func TestStore_DeleteRange(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + t.Cleanup(cancel) + + t.Run("delete range from head down", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + const count = 20 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Genesis is at height 1, GenDummyHeaders(20) creates headers 2-21 + // So head should be at height 21, tail at height 1 + head, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(21), head.Height()) + + // Delete from height 16 to 22 (should delete 16, 17, 18, 19, 20, 21) + err = store.DeleteRange(ctx, 16, 22) + require.NoError(t, err) + + // Verify new head is at height 15 + newHead, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(15), newHead.Height()) + + // Verify deleted headers are gone + for h := uint64(16); h <= 21; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Verify remaining headers still exist + for h := uint64(1); h <= 15; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } + }) + + t.Run("delete range in middle should fail", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + const count = 20 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Try to delete a range in the middle (heights 8-12) which would create gaps + err = store.DeleteRange(ctx, 8, 12) + require.Error(t, err) + assert.Contains(t, err.Error(), "would create gaps in the store") + + // Verify all headers still exist since the operation failed + for h := uint64(1); h <= 21; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist after failed deletion", h) + } + }) + + t.Run("delete range from tail up", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + const count = 20 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + originalHead, err := store.Head(ctx) + require.NoError(t, err) + + // Delete from tail height to height 10 + err = store.DeleteRange(ctx, 1, 10) + require.NoError(t, err) + + // Verify head is unchanged + head, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, originalHead.Height(), head.Height()) + + // Verify tail moved to height 10 + tail, err := store.Tail(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(10), tail.Height()) + + // Verify deleted headers are gone + for h := uint64(1); h < 10; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Verify remaining headers still exist + for h := uint64(10); h <= 21; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } + }) + + t.Run("delete range completely out of bounds", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + const count = 20 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + originalHead, err := store.Head(ctx) + require.NoError(t, err) + originalTail, err := store.Tail(ctx) + require.NoError(t, err) + + // Delete range completely above head - should be no-op + err = store.DeleteRange(ctx, 200, 300) + require.NoError(t, err) + + // Verify head and tail are unchanged + head, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, originalHead.Height(), head.Height()) + + tail, err := store.Tail(ctx) + require.NoError(t, err) + assert.Equal(t, originalTail.Height(), tail.Height()) + + // Verify all original headers still exist + for h := uint64(1); h <= 21; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } + }) + + t.Run("invalid range errors", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + const count = 20 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // from >= to should error + err = store.DeleteRange(ctx, 50, 50) + assert.Error(t, err) + assert.Contains(t, err.Error(), "from must be less than to") + + // from > to should error + err = store.DeleteRange(ctx, 60, 50) + assert.Error(t, err) + assert.Contains(t, err.Error(), "from must be less than to") + + // from below tail should error + err = store.DeleteRange(ctx, 0, 5) + assert.Error(t, err) + assert.Contains(t, err.Error(), "below current tail") + + // middle deletion should error + err = store.DeleteRange(ctx, 10, 15) + assert.Error(t, err) + assert.Contains(t, err.Error(), "would create gaps") + }) +} + +func TestStore_DeleteRange_EmptyStore(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store, err := NewStore[*headertest.DummyHeader](ds) + require.NoError(t, err) + + err = store.Start(ctx) + require.NoError(t, err) + t.Cleanup(func() { + err := store.Stop(ctx) + require.NoError(t, err) + }) + + // wait until headers are written + time.Sleep(10 * time.Millisecond) + + // should fail when trying to delete from empty store because it can't read head/tail + err = store.DeleteRange(ctx, 50, 60) + require.Error(t, err) + assert.Contains(t, err.Error(), "store is empty") + + // invalid range should also error with empty store, but it will hit the empty store error first + err = store.DeleteRange(ctx, 60, 50) + require.Error(t, err) + // Could be either empty store error or range validation error, both are valid + assert.True(t, + strings.Contains(err.Error(), "store is empty") || + strings.Contains(err.Error(), "from must be less than to"), + "Expected either empty store or range validation error, got: %v", err) +} + +func TestStore_DeleteRange_SingleHeader(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + // Add single header at height 1 (genesis is at 0) + headers := suite.GenDummyHeaders(1) + err := store.Append(ctx, headers...) + require.NoError(t, err) + + // Should not be able to delete below tail + err = store.DeleteRange(ctx, 0, 1) + require.Error(t, err) // should error - would delete below tail +} + +func TestStore_DeleteRange_Synchronized(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + err := store.Append(ctx, suite.GenDummyHeaders(50)...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Ensure sync completes + err = store.Sync(ctx) + require.NoError(t, err) + + // Delete from height 26 to head+1 (equivalent to DeleteFromHead(25)) + head, err := store.Head(ctx) + require.NoError(t, err) + + err = store.DeleteRange(ctx, 26, head.Height()+1) + require.NoError(t, err) + + // Verify head is now at height 25 + newHead, err := store.Head(ctx) + require.NoError(t, err) + require.EqualValues(t, 25, newHead.Height()) + + // Verify headers above 25 are gone + for h := uint64(26); h <= 50; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Verify headers at and below 25 still exist + for h := uint64(1); h <= 25; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } +} + +func TestStore_DeleteRange_OnDeleteHandlers(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + err := store.Append(ctx, suite.GenDummyHeaders(50)...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Get the actual head height to calculate expected deletions + head, err := store.Head(ctx) + require.NoError(t, err) + + var deletedHeights []uint64 + store.OnDelete(func(ctx context.Context, height uint64) error { + deletedHeights = append(deletedHeights, height) + return nil + }) + + // Delete from height 41 to head+1 (equivalent to DeleteFromHead(40)) + err = store.DeleteRange(ctx, 41, head.Height()+1) + require.NoError(t, err) + + // Verify onDelete was called for each deleted height (from 41 to head height) + var expectedDeleted []uint64 + for h := uint64(41); h <= head.Height(); h++ { + expectedDeleted = append(expectedDeleted, h) + } + assert.ElementsMatch(t, expectedDeleted, deletedHeights) +} + +func TestStore_DeleteRange_LargeRange(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(100)) + + // Create a large number of headers to trigger parallel deletion + const count = 15000 + headers := suite.GenDummyHeaders(count) + err := store.Append(ctx, headers...) + require.NoError(t, err) + + time.Sleep(500 * time.Millisecond) // allow time for large batch to write + + // Get head height for deletion range + head, err := store.Head(ctx) + require.NoError(t, err) + + // Delete a large range to test parallel deletion path (from 5001 to head+1) + const keepHeight = 5000 + err = store.DeleteRange(ctx, keepHeight+1, head.Height()+1) + require.NoError(t, err) + + // Verify new head + newHead, err := store.Head(ctx) + require.NoError(t, err) + require.EqualValues(t, keepHeight, newHead.Height()) + + // Spot check that high numbered headers are gone + for h := uint64(keepHeight + 1000); h <= count; h += 1000 { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Spot check that low numbered headers still exist + for h := uint64(1000); h <= keepHeight; h += 1000 { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } +} + +func TestStore_DeleteRange_ValidationErrors(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + err := store.Append(ctx, suite.GenDummyHeaders(20)...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + tail, err := store.Tail(ctx) + require.NoError(t, err) + + tests := []struct { + name string + from uint64 + to uint64 + errMsg string + }{ + { + name: "delete from below tail boundary", + from: tail.Height() - 1, + to: tail.Height() + 5, + errMsg: "below current tail", + }, + { + name: "invalid range - from equals to", + from: 10, + to: 10, + errMsg: "from must be less than to", + }, + { + name: "invalid range - from greater than to", + from: 15, + to: 10, + errMsg: "from must be less than to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.DeleteRange(ctx, tt.from, tt.to) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} From cb11b091bf585190233e4bbf4663847f2d2c6110 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 9 Sep 2025 17:15:51 +0200 Subject: [PATCH 04/20] cleanup comments --- store/store_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/store/store_test.go b/store/store_test.go index e5902617..08e231de 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -1160,7 +1160,7 @@ func TestStore_DeleteRange_Synchronized(t *testing.T) { err = store.Sync(ctx) require.NoError(t, err) - // Delete from height 26 to head+1 (equivalent to DeleteFromHead(25)) + // Delete from height 26 to head+1 head, err := store.Head(ctx) require.NoError(t, err) @@ -1209,7 +1209,7 @@ func TestStore_DeleteRange_OnDeleteHandlers(t *testing.T) { return nil }) - // Delete from height 41 to head+1 (equivalent to DeleteFromHead(40)) + // Delete from height 41 to head+1 err = store.DeleteRange(ctx, 41, head.Height()+1) require.NoError(t, err) From a4b1924e220452329f5b37ec86051de91bfb74a7 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 12 Sep 2025 09:46:37 +0200 Subject: [PATCH 05/20] feedback --- headertest/store.go | 25 +++--------- interface.go | 3 -- p2p/server_test.go | 5 --- store/store_delete.go | 15 ------- store/store_test.go | 92 +++++++++++-------------------------------- sync/syncer_tail.go | 2 +- 6 files changed, 28 insertions(+), 114 deletions(-) diff --git a/headertest/store.go b/headertest/store.go index 9c1dd731..b08b8c03 100644 --- a/headertest/store.go +++ b/headertest/store.go @@ -79,26 +79,6 @@ func (m *Store[H]) GetByHeight(_ context.Context, height uint64) (H, error) { return zero, header.ErrNotFound } -func (m *Store[H]) DeleteTo(ctx context.Context, to uint64) error { - for h := m.TailHeight; h < to; h++ { - _, ok := m.Headers[h] - if !ok { - continue - } - - for _, deleteFn := range m.onDelete { - err := deleteFn(ctx, h) - if err != nil { - return err - } - } - delete(m.Headers, h) // must be after deleteFn - } - - m.TailHeight = to - return nil -} - func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Delete headers in the range [from:to) for h := from; h < to; h++ { @@ -121,6 +101,11 @@ func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { m.TailHeight = to } + // Update HeadHeight if we deleted from the end + if to >= m.HeadHeight { + m.HeadHeight = from - 1 + } + return nil } diff --git a/interface.go b/interface.go index d139e719..de7146de 100644 --- a/interface.go +++ b/interface.go @@ -85,9 +85,6 @@ type Store[H Header[H]] interface { // GetRange returns the range [from:to). GetRange(context.Context, uint64, uint64) ([]H, error) - // DeleteTo deletes the range [Tail():to). - DeleteTo(ctx context.Context, to uint64) error - // DeleteRange deletes the range [from:to). DeleteRange(ctx context.Context, from, to uint64) error diff --git a/p2p/server_test.go b/p2p/server_test.go index e53808c1..315359fc 100644 --- a/p2p/server_test.go +++ b/p2p/server_test.go @@ -192,11 +192,6 @@ func (timeoutStore[H]) GetRange(ctx context.Context, _, _ uint64) ([]H, error) { return nil, ctx.Err() } -func (timeoutStore[H]) DeleteTo(ctx context.Context, _ uint64) error { - <-ctx.Done() - return ctx.Err() -} - func (timeoutStore[H]) DeleteRange(ctx context.Context, _, _ uint64) error { <-ctx.Done() return ctx.Err() diff --git a/store/store_delete.go b/store/store_delete.go index efa051c4..a5d5ac85 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -32,17 +32,6 @@ func (s *Store[H]) OnDelete(fn func(context.Context, uint64) error) { }) } -// DeleteTo implements [header.Store] interface. -// This is a convenience wrapper around DeleteRange that deletes from tail up to a height. -func (s *Store[H]) DeleteTo(ctx context.Context, to uint64) error { - tail, err := s.Tail(ctx) - if err != nil { - return fmt.Errorf("header/store: reading tail: %w", err) - } - - return s.DeleteRange(ctx, tail.Height(), to) -} - // deleteRangeParallelThreshold defines the threshold for parallel deletion. // If range is smaller than this threshold, deletion will be performed sequentially. var ( @@ -330,10 +319,6 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Update tail if we deleted from the beginning if updateTail { - _, err = s.getByHeight(ctx, to) - if err != nil { - return fmt.Errorf("header/store: new tail height %d not found: %w", to, err) - } err = s.setTail(ctx, s.ds, to) if err != nil { return fmt.Errorf("header/store: setting tail to %d: %w", to, err) diff --git a/store/store_test.go b/store/store_test.go index 08e231de..6725a63f 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "math/rand" - "strings" stdsync "sync" "testing" "time" @@ -526,7 +525,7 @@ func TestStore_GetRange(t *testing.T) { } } -func TestStore_DeleteTo(t *testing.T) { +func TestStore_DeleteRange_Tail(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) t.Cleanup(cancel) @@ -591,7 +590,7 @@ func TestStore_DeleteTo(t *testing.T) { ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - err := store.DeleteTo(ctx, tt.to) + err := store.DeleteRange(ctx, from, tt.to) if tt.wantError { assert.Error(t, err) return @@ -613,7 +612,7 @@ func TestStore_DeleteTo(t *testing.T) { } } -func TestStore_DeleteTo_EmptyStore(t *testing.T) { +func TestStore_DeleteRange_EmptyStore(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) t.Cleanup(cancel) @@ -630,11 +629,14 @@ func TestStore_DeleteTo_EmptyStore(t *testing.T) { require.NoError(t, err) time.Sleep(10 * time.Millisecond) - err = store.DeleteTo(ctx, 101) + tail, err := store.Tail(ctx) + require.NoError(t, err) + + err = store.DeleteRange(ctx, tail.Height(), 101) require.NoError(t, err) // assert store is empty - tail, err := store.Tail(ctx) + tail, err = store.Tail(ctx) assert.Nil(t, tail) assert.ErrorIs(t, err, header.ErrEmptyStore) head, err := store.Head(ctx) @@ -655,7 +657,7 @@ func TestStore_DeleteTo_EmptyStore(t *testing.T) { assert.ErrorIs(t, err, header.ErrEmptyStore) } -func TestStore_DeleteTo_MoveHeadAndTail(t *testing.T) { +func TestStore_DeleteRange_MoveHeadAndTail(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) t.Cleanup(cancel) @@ -678,11 +680,14 @@ func TestStore_DeleteTo_MoveHeadAndTail(t *testing.T) { require.NoError(t, err) time.Sleep(10 * time.Millisecond) - err = store.DeleteTo(ctx, 111) + tail, err := store.Tail(ctx) + require.NoError(t, err) + + err = store.DeleteRange(ctx, tail.Height(), 111) require.NoError(t, err) // assert store is not empty - tail, err := store.Tail(ctx) + tail, err = store.Tail(ctx) require.NoError(t, err) assert.Equal(t, int(gap[len(gap)-1].Height()+1), int(tail.Height())) head, err := store.Head(ctx) @@ -703,32 +708,6 @@ func TestStore_DeleteTo_MoveHeadAndTail(t *testing.T) { assert.Equal(t, suite.Head().Height(), head.Height()) } -func TestStore_DeleteTo_Synchronized(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - suite := headertest.NewTestSuite(t) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - - err := store.Append(ctx, suite.GenDummyHeaders(50)...) - require.NoError(t, err) - - err = store.Append(ctx, suite.GenDummyHeaders(50)...) - require.NoError(t, err) - - err = store.Append(ctx, suite.GenDummyHeaders(50)...) - require.NoError(t, err) - - err = store.DeleteTo(ctx, 100) - require.NoError(t, err) - - tail, err := store.Tail(ctx) - require.NoError(t, err) - require.EqualValues(t, 100, tail.Height()) -} - func TestStore_OnDelete(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) t.Cleanup(cancel) @@ -759,7 +738,10 @@ func TestStore_OnDelete(t *testing.T) { return nil }) - err = store.DeleteTo(ctx, 101) + tail, err := store.Tail(ctx) + require.NoError(t, err) + + err = store.DeleteRange(ctx, tail.Height(), 101) require.NoError(t, err) assert.Equal(t, 50, deleted) @@ -890,7 +872,10 @@ func TestStore_HasAt(t *testing.T) { require.NoError(t, err) time.Sleep(100 * time.Millisecond) - err = store.DeleteTo(ctx, 50) + tail, err := store.Tail(ctx) + require.NoError(t, err) + + err = store.DeleteRange(ctx, tail.Height(), 50) require.NoError(t, err) has := store.HasAt(ctx, 100) @@ -1090,39 +1075,6 @@ func TestStore_DeleteRange(t *testing.T) { }) } -func TestStore_DeleteRange_EmptyStore(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - t.Cleanup(cancel) - - ds := sync.MutexWrap(datastore.NewMapDatastore()) - store, err := NewStore[*headertest.DummyHeader](ds) - require.NoError(t, err) - - err = store.Start(ctx) - require.NoError(t, err) - t.Cleanup(func() { - err := store.Stop(ctx) - require.NoError(t, err) - }) - - // wait until headers are written - time.Sleep(10 * time.Millisecond) - - // should fail when trying to delete from empty store because it can't read head/tail - err = store.DeleteRange(ctx, 50, 60) - require.Error(t, err) - assert.Contains(t, err.Error(), "store is empty") - - // invalid range should also error with empty store, but it will hit the empty store error first - err = store.DeleteRange(ctx, 60, 50) - require.Error(t, err) - // Could be either empty store error or range validation error, both are valid - assert.True(t, - strings.Contains(err.Error(), "store is empty") || - strings.Contains(err.Error(), "from must be less than to"), - "Expected either empty store or range validation error, got: %v", err) -} - func TestStore_DeleteRange_SingleHeader(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) t.Cleanup(cancel) diff --git a/sync/syncer_tail.go b/sync/syncer_tail.go index 877ff407..c7790c99 100644 --- a/sync/syncer_tail.go +++ b/sync/syncer_tail.go @@ -130,7 +130,7 @@ func (s *Syncer[H]) moveTail(ctx context.Context, from, to H) error { switch { case from.Height() < to.Height(): log.Infof("move tail up from %d to %d, pruning the diff...", from.Height(), to.Height()) - err := s.store.DeleteTo(ctx, to.Height()) + err := s.store.DeleteRange(ctx, from.Height(), to.Height()) if err != nil { return fmt.Errorf( "deleting headers up to newly configured tail(%d): %w", From 34f6358649df0550e0be2b2f12fc9b00a3954f04 Mon Sep 17 00:00:00 2001 From: julienrbrt Date: Tue, 7 Oct 2025 23:06:38 +0200 Subject: [PATCH 06/20] feedback Co-authored-by: rene <41963722+renaynay@users.noreply.github.com> --- interface.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface.go b/interface.go index de7146de..a6537b16 100644 --- a/interface.go +++ b/interface.go @@ -85,7 +85,7 @@ type Store[H Header[H]] interface { // GetRange returns the range [from:to). GetRange(context.Context, uint64, uint64) ([]H, error) - // DeleteRange deletes the range [from:to). + // DeleteRange deletes the range [from:to). It disallows the creation of gaps in the implementation's chain, ensuring contiguity between Tail --> Head. DeleteRange(ctx context.Context, from, to uint64) error // OnDelete registers given handler to be called whenever a header with the height is being removed. From de679005729635cec7ee1b4d1abe8ee082a14f7f Mon Sep 17 00:00:00 2001 From: julienrbrt Date: Tue, 7 Oct 2025 23:06:51 +0200 Subject: [PATCH 07/20] Update store/store_delete.go Co-authored-by: rene <41963722+renaynay@users.noreply.github.com> --- store/store_delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/store_delete.go b/store/store_delete.go index 408c8a71..20379196 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -357,7 +357,7 @@ func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (err err "expected_to_height", to, "actual_to_height", height, "hdrs_not_found", missing, - "took(s)", time.Since(startTime), + "took(s)", time.Since(startTime).Seconds(), ) } else { log.Errorw("partial delete range with error", From 989e0579b1472453723d79de6b898ce1674bc7d2 Mon Sep 17 00:00:00 2001 From: julienrbrt Date: Tue, 7 Oct 2025 23:07:00 +0200 Subject: [PATCH 08/20] Update store/store_delete.go Co-authored-by: rene <41963722+renaynay@users.noreply.github.com> --- store/store_delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/store_delete.go b/store/store_delete.go index 20379196..77b406e7 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -365,7 +365,7 @@ func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (err err "expected_to_height", to, "actual_to_height", height, "hdrs_not_found", missing, - "took(s)", time.Since(startTime), + "took(s)", time.Since(startTime).Seconds(), "err", err, ) } From fd232e03f026bf6163d87a4dfa4de17003937e93 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Tue, 7 Oct 2025 23:11:44 +0200 Subject: [PATCH 09/20] log --- store/store_delete.go | 1 + 1 file changed, 1 insertion(+) diff --git a/store/store_delete.go b/store/store_delete.go index 77b406e7..f4842cc9 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -272,6 +272,7 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // if range is empty within the current store bounds, it's a no-op if from > head.Height() || to <= tail.Height() { + log.Warn("header/store range is empty, nothing needs to be deleted") return nil } From 7afde85d8840fe4ccfd4767bc78fb043d6988a94 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 8 Oct 2025 15:04:53 +0200 Subject: [PATCH 10/20] feedback --- headertest/store.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/headertest/store.go b/headertest/store.go index b08b8c03..99e15e3e 100644 --- a/headertest/store.go +++ b/headertest/store.go @@ -80,6 +80,10 @@ func (m *Store[H]) GetByHeight(_ context.Context, height uint64) (H, error) { } func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { + if from >= to { + return fmt.Errorf("malformed range, from: %d, to: %d", from, to) + } + // Delete headers in the range [from:to) for h := from; h < to; h++ { _, ok := m.Headers[h] From ec2e075655e58e13200bd60ecfec635e9c7d3e2d Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 8 Oct 2025 15:10:26 +0200 Subject: [PATCH 11/20] deleteRangeRaw actual to --- store/store_delete.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/store/store_delete.go b/store/store_delete.go index f4842cc9..955f8b53 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -315,9 +315,15 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { updateHead := to > head.Height() // Delete the headers without automatic tail updates - err = s.deleteRangeRaw(ctx, from, to) + actualTo, _, err := s.deleteRangeRaw(ctx, from, to) if err != nil { - return fmt.Errorf("header/store: delete range [%d:%d): %w", from, to, err) + return fmt.Errorf( + "header/store: delete range [%d:%d) (actual: %d): %w", + from, + to, + actualTo, + err, + ) } // Update tail if we deleted from the beginning @@ -343,14 +349,16 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { } // deleteRangeRaw deletes [from:to) header range without updating head or tail pointers. -func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (err error) { +// Returns the actual highest height processed (actualTo) and the number of missing headers. +func (s *Store[H]) deleteRangeRaw( + ctx context.Context, + from, to uint64, +) (actualTo uint64, missing int, err error) { startTime := time.Now() - var ( - height uint64 - missing int - ) + var height uint64 defer func() { + actualTo = height if err != nil { if errors.Is(err, errDeleteTimeout) { log.Warnw("partial delete range", @@ -396,7 +404,7 @@ func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (err err height, missing, err = s.deleteParallel(deleteCtx, from, to) } - return err + return height, missing, err } // setHead sets the head of the store to the specified height. From dfed637a5527f8b07250e3a4a66195277c0e70cf Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 8 Oct 2025 15:15:04 +0200 Subject: [PATCH 12/20] feedback --- store/store_delete.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/store/store_delete.go b/store/store_delete.go index 955f8b53..005932ba 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -291,7 +291,7 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Check if we're deleting all existing headers (making store empty) // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes - if from <= tail.Height() && to == head.Height()+1 { + if from == tail.Height() && to == head.Height()+1 { // Check if any headers exist at or beyond 'to' hasHeadersAtOrBeyond := false for checkHeight := to; checkHeight <= to+10; checkHeight++ { @@ -350,10 +350,7 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // deleteRangeRaw deletes [from:to) header range without updating head or tail pointers. // Returns the actual highest height processed (actualTo) and the number of missing headers. -func (s *Store[H]) deleteRangeRaw( - ctx context.Context, - from, to uint64, -) (actualTo uint64, missing int, err error) { +func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (actualTo uint64, missing int, err error) { startTime := time.Now() var height uint64 From dcc8f57635b25f096f4e00b17714b00d8f1b7c92 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 8 Oct 2025 15:28:42 +0200 Subject: [PATCH 13/20] pending header fix --- store/store_delete.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/store/store_delete.go b/store/store_delete.go index 005932ba..8685985d 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -292,10 +292,11 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Check if we're deleting all existing headers (making store empty) // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes if from == tail.Height() && to == head.Height()+1 { - // Check if any headers exist at or beyond 'to' + // Check if any headers exist at or beyond 'to' in the pending buffer hasHeadersAtOrBeyond := false - for checkHeight := to; checkHeight <= to+10; checkHeight++ { - if _, err := s.getByHeight(ctx, checkHeight); err == nil { + pendingHeaders := s.pending.GetAll() + for _, h := range pendingHeaders { + if h.Height() >= to { hasHeadersAtOrBeyond = true break } @@ -350,7 +351,10 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // deleteRangeRaw deletes [from:to) header range without updating head or tail pointers. // Returns the actual highest height processed (actualTo) and the number of missing headers. -func (s *Store[H]) deleteRangeRaw(ctx context.Context, from, to uint64) (actualTo uint64, missing int, err error) { +func (s *Store[H]) deleteRangeRaw( + ctx context.Context, + from, to uint64, +) (actualTo uint64, missing int, err error) { startTime := time.Now() var height uint64 From 747c8c192fa8eb9ab4f234a7443e384deff97ac8 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Wed, 8 Oct 2025 15:43:30 +0200 Subject: [PATCH 14/20] feedback --- store/store_delete.go | 29 +++--- store/store_test.go | 228 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 13 deletions(-) diff --git a/store/store_delete.go b/store/store_delete.go index 8685985d..dfe585a3 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -292,23 +292,26 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Check if we're deleting all existing headers (making store empty) // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes if from == tail.Height() && to == head.Height()+1 { - // Check if any headers exist at or beyond 'to' in the pending buffer - hasHeadersAtOrBeyond := false - pendingHeaders := s.pending.GetAll() - for _, h := range pendingHeaders { - if h.Height() >= to { - hasHeadersAtOrBeyond = true - break + if from == tail.Height() && to == head.Height()+1 { + // Check if a header exists exactly at 'to' in the pending buffer + hasHeaderAtTo := false + pendingHeaders := s.pending.GetAll() + for _, h := range pendingHeaders { + if h.Height() == to { + hasHeaderAtTo = true + break + } } - } - if !hasHeadersAtOrBeyond { - // wipe the entire store - if err := s.wipe(ctx); err != nil { - return fmt.Errorf("header/store: wipe: %w", err) + if !hasHeaderAtTo { + // wipe the entire store + if err := s.wipe(ctx); err != nil { + return fmt.Errorf("header/store: wipe: %w", err) + } + return nil } - return nil } + } // Determine which pointers need updating diff --git a/store/store_test.go b/store/store_test.go index 6725a63f..0d520c26 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -3,6 +3,7 @@ package store import ( "bytes" "context" + "errors" "math/rand" stdsync "sync" "testing" @@ -1217,6 +1218,41 @@ func TestStore_DeleteRange_LargeRange(t *testing.T) { } } +func TestStore_DeleteRange_Wipe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + t.Cleanup(cancel) + + suite := headertest.NewTestSuite(t) + + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(100)) + + // Create a large number of headers + const count = 15000 + headers := suite.GenDummyHeaders(count) + err := store.Append(ctx, headers...) + require.NoError(t, err) + + time.Sleep(500 * time.Millisecond) // allow time for large batch to write + + // Get head height for deletion range + head, err := store.Head(ctx) + require.NoError(t, err) + + tail, err := store.Tail(ctx) + require.NoError(t, err) + + // Delete a large range to test parallel deletion path (from 5001 to head+1) + err = store.DeleteRange(ctx, tail.Height(), head.Height()+1) + require.NoError(t, err) + + // Verify new head + _, err = store.Head(ctx) + require.Error(t, err) + _, err = store.Tail(ctx) + require.Error(t, err) +} + func TestStore_DeleteRange_ValidationErrors(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) t.Cleanup(cancel) @@ -1268,3 +1304,195 @@ func TestStore_DeleteRange_ValidationErrors(t *testing.T) { }) } } + +func TestStore_DeleteRange_PartialDelete(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + t.Cleanup(cancel) + + t.Run("partial delete from head with timeout and recovery", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + // Add headers + const count = 1000 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + originalHead, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(1001), originalHead.Height()) + + originalTail, err := store.Tail(ctx) + require.NoError(t, err) + + // Create a context with very short timeout to trigger partial delete + shortCtx, shortCancel := context.WithTimeout(ctx, 1*time.Millisecond) + defer shortCancel() + + // Try to delete from height 500 to head+1 (should partially fail) + err = store.DeleteRange(shortCtx, 500, 1002) + assert.True(t, errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, errDeleteTimeout), + "expected timeout error, got: %v", err) + + // Head should not have been updated after partial delete + head, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, originalHead.Height(), head.Height()) + + // Tail should not have changed + tail, err := store.Tail(ctx) + require.NoError(t, err) + assert.Equal(t, originalTail.Height(), tail.Height()) + + // Some headers may have been deleted, but store should be consistent + // Verify we can still read the head + _, err = store.Get(ctx, head.Hash()) + require.NoError(t, err) + + // Now complete the deletion with proper timeout + err = store.DeleteRange(ctx, 500, 1002) + require.NoError(t, err) + + // After successful deletion, verify state + newHead, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(499), newHead.Height()) + + // Verify deleted headers are gone + for h := uint64(500); h <= 1001; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Verify remaining headers exist + for h := uint64(1); h <= 499; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } + }) + + t.Run("partial delete from tail with timeout and recovery", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + // Add headers + const count = 1000 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + originalHead, err := store.Head(ctx) + require.NoError(t, err) + originalTail, err := store.Tail(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(1), originalTail.Height()) + + // Create a context with very short timeout + shortCtx, shortCancel := context.WithTimeout(ctx, 1*time.Millisecond) + defer shortCancel() + + // Try to delete from tail to height 500 (should partially fail) + err = store.DeleteRange(shortCtx, 1, 500) + assert.True(t, errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, errDeleteTimeout), + "expected timeout error, got: %v", err) + + // Tail should not have been updated after partial delete + tail, err := store.Tail(ctx) + require.NoError(t, err) + assert.Equal(t, originalTail.Height(), tail.Height()) + + // Head should not have changed + head, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, originalHead.Height(), head.Height()) + + // Now complete the deletion with proper timeout + err = store.DeleteRange(ctx, 1, 500) + require.NoError(t, err) + + // After successful deletion, verify state + newTail, err := store.Tail(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(500), newTail.Height()) + + // Head should be unchanged + head, err = store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, originalHead.Height(), head.Height()) + + // Verify deleted headers are gone + for h := uint64(1); h < 500; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + + // Verify remaining headers exist + for h := uint64(500); h <= 1001; h++ { + has := store.HasAt(ctx, h) + assert.True(t, has, "height %d should still exist", h) + } + }) + + t.Run("multiple partial deletes eventually succeed", func(t *testing.T) { + suite := headertest.NewTestSuite(t) + ds := sync.MutexWrap(datastore.NewMapDatastore()) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) + + // Add headers + const count = 800 + in := suite.GenDummyHeaders(count) + err := store.Append(ctx, in...) + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Attempt delete with progressively longer timeouts + from, to := uint64(300), uint64(802) + maxAttempts := 5 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + attemptCtx, attemptCancel := context.WithTimeout(ctx, + time.Duration(attempt*20)*time.Millisecond) + + err = store.DeleteRange(attemptCtx, from, to) + attemptCancel() + + if err == nil { + // Success! + break + } + + // Verify store remains consistent after each failed attempt + head, err := store.Head(ctx) + require.NoError(t, err) + _, err = store.Get(ctx, head.Hash()) + require.NoError(t, err) + + if attempt == maxAttempts { + // Last attempt with full context + err = store.DeleteRange(ctx, from, to) + require.NoError(t, err) + } + } + + // Verify final state + newHead, err := store.Head(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(299), newHead.Height()) + + // Verify deleted range is gone + for h := from; h <= 801; h++ { + has := store.HasAt(ctx, h) + assert.False(t, has, "height %d should be deleted", h) + } + }) +} From 1a5d1ee14054906bff3953bfb756a53d6606cf86 Mon Sep 17 00:00:00 2001 From: Hlib Kanunnikov Date: Thu, 18 Dec 2025 17:13:36 +0400 Subject: [PATCH 15/20] review fixes (#1) --- headertest/store.go | 7 ++- store/store_delete.go | 85 +++++++++++++------------ store/store_test.go | 142 +++++++++++++++++++++++++----------------- 3 files changed, 137 insertions(+), 97 deletions(-) diff --git a/headertest/store.go b/headertest/store.go index 99e15e3e..ad31360b 100644 --- a/headertest/store.go +++ b/headertest/store.go @@ -84,6 +84,10 @@ func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { return fmt.Errorf("malformed range, from: %d, to: %d", from, to) } + if to > m.HeadHeight+1 { + return fmt.Errorf("delete range to %d beyond current head+1(%d)", to, m.HeadHeight+1) + } + // Delete headers in the range [from:to) for h := from; h < to; h++ { _, ok := m.Headers[h] @@ -106,7 +110,8 @@ func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { } // Update HeadHeight if we deleted from the end - if to >= m.HeadHeight { + // Range is [from:to), so head is only affected if to > HeadHeight + if to > m.HeadHeight { m.HeadHeight = from - 1 } diff --git a/store/store_delete.go b/store/store_delete.go index dfe585a3..47d7a5dd 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -11,6 +11,8 @@ import ( "time" "github.com/ipfs/go-datastore" + + "github.com/celestiaorg/go-header" ) // OnDelete implements [header.Store] interface. @@ -267,8 +269,13 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { ) } - // Note: Allow deletion beyond head to match original DeleteTo behavior - // Missing headers in the range will be handled gracefully by the deletion logic + if to > head.Height()+1 { + return fmt.Errorf( + "header/store: delete range to %d beyond current head+1(%d)", + to, + head.Height()+1, + ) + } // if range is empty within the current store bounds, it's a no-op if from > head.Height() || to <= tail.Height() { @@ -292,26 +299,20 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Check if we're deleting all existing headers (making store empty) // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes if from == tail.Height() && to == head.Height()+1 { - if from == tail.Height() && to == head.Height()+1 { - // Check if a header exists exactly at 'to' in the pending buffer - hasHeaderAtTo := false - pendingHeaders := s.pending.GetAll() - for _, h := range pendingHeaders { - if h.Height() == to { - hasHeaderAtTo = true - break - } - } - - if !hasHeaderAtTo { - // wipe the entire store - if err := s.wipe(ctx); err != nil { - return fmt.Errorf("header/store: wipe: %w", err) - } - return nil + // Check if a header exists exactly at 'to' (in pending, cache, or disk) + // If it exists, we can't wipe - there's a header that would become the new tail + _, err := s.getByHeight(ctx, to) + if errors.Is(err, header.ErrNotFound) { + // No header at 'to', safe to wipe the entire store + if err := s.wipe(ctx); err != nil { + return fmt.Errorf("header/store: wipe: %w", err) } + return nil } - + if err != nil { + return fmt.Errorf("header/store: checking header at %d: %w", to, err) + } + // Header exists at 'to', proceed with normal deletion } // Determine which pointers need updating @@ -319,36 +320,40 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { updateHead := to > head.Height() // Delete the headers without automatic tail updates - actualTo, _, err := s.deleteRangeRaw(ctx, from, to) - if err != nil { - return fmt.Errorf( - "header/store: delete range [%d:%d) (actual: %d): %w", - from, - to, - actualTo, - err, - ) - } + actualTo, _, deleteErr := s.deleteRangeRaw(ctx, from, to) - // Update tail if we deleted from the beginning + // Always update pointers to reflect actual progress, even on partial delete. + // This ensures store consistency and allows retries to continue from where we left off. if updateTail { - err = s.setTail(ctx, s.ds, to) - if err != nil { - return fmt.Errorf("header/store: setting tail to %d: %w", to, err) + // For tail-side deletion, update tail to actual progress + if err := s.setTail(ctx, s.ds, actualTo); err != nil { + return errors.Join(deleteErr, fmt.Errorf("header/store: setting tail to %d: %w", actualTo, err)) } } - // Update head if we deleted from the end if updateHead && from > tail.Height() { - newHeadHeight := from - 1 - if newHeadHeight >= tail.Height() { - err = s.setHead(ctx, s.ds, newHeadHeight) - if err != nil { - return fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err) + // For head-side deletion, only update head if we made progress + // actualTo represents the height we processed up to + if actualTo > from { + newHeadHeight := from - 1 + if newHeadHeight >= tail.Height() { + if err := s.setHead(ctx, s.ds, newHeadHeight); err != nil { + return errors.Join(deleteErr, fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err)) + } } } } + if deleteErr != nil { + return fmt.Errorf( + "header/store: delete range [%d:%d) (actual: %d): %w", + from, + to, + actualTo, + deleteErr, + ) + } + return nil } diff --git a/store/store_test.go b/store/store_test.go index 0d520c26..a5a8870d 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -671,29 +671,36 @@ func TestStore_DeleteRange_MoveHeadAndTail(t *testing.T) { err = store.Start(ctx) require.NoError(t, err) + // Append 100 headers (heights 2-101, head becomes 101) err = store.Append(ctx, suite.GenDummyHeaders(100)...) require.NoError(t, err) time.Sleep(10 * time.Millisecond) - gap := suite.GenDummyHeaders(10) - - err = store.Append(ctx, suite.GenDummyHeaders(10)...) + // Append 10 more headers (heights 102-111, head becomes 111) + newHeaders := suite.GenDummyHeaders(10) + err = store.Append(ctx, newHeaders...) require.NoError(t, err) time.Sleep(10 * time.Millisecond) tail, err := store.Tail(ctx) require.NoError(t, err) - err = store.DeleteRange(ctx, tail.Height(), 111) + head, err := store.Head(ctx) + require.NoError(t, err) + + // Delete from tail to head+1 (wipes the store, then we verify behavior) + // Instead, let's delete a portion from tail to keep some headers + deleteTo := uint64(102) // Delete heights 1-101, keep 102-111 + err = store.DeleteRange(ctx, tail.Height(), deleteTo) require.NoError(t, err) // assert store is not empty tail, err = store.Tail(ctx) require.NoError(t, err) - assert.Equal(t, int(gap[len(gap)-1].Height()+1), int(tail.Height())) - head, err := store.Head(ctx) + assert.Equal(t, deleteTo, tail.Height()) + head, err = store.Head(ctx) require.NoError(t, err) - assert.Equal(t, suite.Head().Height(), head.Height()) + assert.Equal(t, newHeaders[len(newHeaders)-1].Height(), head.Height()) // assert that it is still not empty after restart err = store.Stop(ctx) @@ -703,10 +710,10 @@ func TestStore_DeleteRange_MoveHeadAndTail(t *testing.T) { tail, err = store.Tail(ctx) require.NoError(t, err) - assert.Equal(t, gap[len(gap)-1].Height()+1, tail.Height()) + assert.Equal(t, deleteTo, tail.Height()) head, err = store.Head(ctx) require.NoError(t, err) - assert.Equal(t, suite.Head().Height(), head.Height()) + assert.Equal(t, newHeaders[len(newHeaders)-1].Height(), head.Height()) } func TestStore_OnDelete(t *testing.T) { @@ -716,19 +723,11 @@ func TestStore_OnDelete(t *testing.T) { suite := headertest.NewTestSuite(t) ds := sync.MutexWrap(datastore.NewMapDatastore()) - store, err := NewStore[*headertest.DummyHeader](ds) - require.NoError(t, err) - - err = store.Start(ctx) - require.NoError(t, err) - - err = store.Append(ctx, suite.GenDummyHeaders(50)...) - require.NoError(t, err) - // artificial gap - _ = suite.GenDummyHeaders(50) + store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) - err = store.Append(ctx, suite.GenDummyHeaders(50)...) + err := store.Append(ctx, suite.GenDummyHeaders(100)...) require.NoError(t, err) + time.Sleep(100 * time.Millisecond) deleted := 0 store.OnDelete(func(ctx context.Context, height uint64) error { @@ -742,13 +741,24 @@ func TestStore_OnDelete(t *testing.T) { tail, err := store.Tail(ctx) require.NoError(t, err) - err = store.DeleteRange(ctx, tail.Height(), 101) + // Delete a partial range from tail (not the entire store) + // This ensures OnDelete handlers are called for each header + deleteTo := uint64(51) + err = store.DeleteRange(ctx, tail.Height(), deleteTo) require.NoError(t, err) - assert.Equal(t, 50, deleted) + // Should have deleted headers from tail to deleteTo-1 (50 headers) + expectedDeleted := int(deleteTo - tail.Height()) + assert.Equal(t, expectedDeleted, deleted) - hdr, err := store.GetByHeight(ctx, 50) + // Verify deleted headers are gone + hdr, err := store.GetByHeight(ctx, tail.Height()) assert.Error(t, err) assert.Nil(t, hdr) + + // Verify headers at and above deleteTo still exist + hdr, err = store.GetByHeight(ctx, deleteTo) + assert.NoError(t, err) + assert.NotNil(t, hdr) } func TestStorePendingCacheMiss(t *testing.T) { @@ -1005,7 +1015,7 @@ func TestStore_DeleteRange(t *testing.T) { } }) - t.Run("delete range completely out of bounds", func(t *testing.T) { + t.Run("delete range completely out of bounds errors", func(t *testing.T) { suite := headertest.NewTestSuite(t) ds := sync.MutexWrap(datastore.NewMapDatastore()) store := NewTestStore(t, ctx, ds, suite.Head(), WithWriteBatchSize(10)) @@ -1022,9 +1032,10 @@ func TestStore_DeleteRange(t *testing.T) { originalTail, err := store.Tail(ctx) require.NoError(t, err) - // Delete range completely above head - should be no-op + // Delete range completely above head - should error (to > head+1) err = store.DeleteRange(ctx, 200, 300) - require.NoError(t, err) + require.Error(t, err) + assert.Contains(t, err.Error(), "beyond current head+1") // Verify head and tail are unchanged head, err := store.Head(ctx) @@ -1270,6 +1281,9 @@ func TestStore_DeleteRange_ValidationErrors(t *testing.T) { tail, err := store.Tail(ctx) require.NoError(t, err) + head, err := store.Head(ctx) + require.NoError(t, err) + tests := []struct { name string from uint64 @@ -1294,6 +1308,12 @@ func TestStore_DeleteRange_ValidationErrors(t *testing.T) { to: 10, errMsg: "from must be less than to", }, + { + name: "delete to beyond head+1", + from: tail.Height(), + to: head.Height() + 10, + errMsg: "beyond current head+1", + }, } for _, tt := range tests { @@ -1333,32 +1353,36 @@ func TestStore_DeleteRange_PartialDelete(t *testing.T) { shortCtx, shortCancel := context.WithTimeout(ctx, 1*time.Millisecond) defer shortCancel() - // Try to delete from height 500 to head+1 (should partially fail) - err = store.DeleteRange(shortCtx, 500, 1002) - assert.True(t, errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, errDeleteTimeout), - "expected timeout error, got: %v", err) + // Try to delete from height 500 to head+1 (may partially complete or fully complete) + deleteErr := store.DeleteRange(shortCtx, 500, 1002) - // Head should not have been updated after partial delete + // Get current state - head should be updated to reflect progress (set to 499 since from=500) head, err := store.Head(ctx) require.NoError(t, err) - assert.Equal(t, originalHead.Height(), head.Height()) - // Tail should not have changed + // Tail should not have changed (we're deleting from head side) tail, err := store.Tail(ctx) require.NoError(t, err) assert.Equal(t, originalTail.Height(), tail.Height()) - // Some headers may have been deleted, but store should be consistent - // Verify we can still read the head - _, err = store.Get(ctx, head.Hash()) - require.NoError(t, err) + if deleteErr != nil { + // Partial delete occurred - head should be updated to from-1 + assert.True(t, errors.Is(deleteErr, context.DeadlineExceeded) || + errors.Is(deleteErr, errDeleteTimeout), + "expected timeout error, got: %v", deleteErr) - // Now complete the deletion with proper timeout - err = store.DeleteRange(ctx, 500, 1002) - require.NoError(t, err) + // Head should be updated to reflect that deletion started at 500 + assert.Equal(t, uint64(499), head.Height()) + + // Verify we can still read the new head + _, err = store.Get(ctx, head.Hash()) + require.NoError(t, err) - // After successful deletion, verify state + // Since head is already at 499, there's nothing more to delete from the head side + // The partial delete already completed the deletion by setting head to from-1 + } + + // After completion, verify final state newHead, err := store.Head(ctx) require.NoError(t, err) assert.Equal(t, uint64(499), newHead.Height()) @@ -1399,27 +1423,33 @@ func TestStore_DeleteRange_PartialDelete(t *testing.T) { shortCtx, shortCancel := context.WithTimeout(ctx, 1*time.Millisecond) defer shortCancel() - // Try to delete from tail to height 500 (should partially fail) - err = store.DeleteRange(shortCtx, 1, 500) - assert.True(t, errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, errDeleteTimeout), - "expected timeout error, got: %v", err) - - // Tail should not have been updated after partial delete - tail, err := store.Tail(ctx) - require.NoError(t, err) - assert.Equal(t, originalTail.Height(), tail.Height()) + // Try to delete from tail to height 500 (may partially complete or fully complete) + deleteErr := store.DeleteRange(shortCtx, 1, 500) - // Head should not have changed + // Head should not have changed (we're deleting from tail side) head, err := store.Head(ctx) require.NoError(t, err) assert.Equal(t, originalHead.Height(), head.Height()) - // Now complete the deletion with proper timeout - err = store.DeleteRange(ctx, 1, 500) + // Get current tail - it should be updated to reflect progress + tail, err := store.Tail(ctx) require.NoError(t, err) - // After successful deletion, verify state + if deleteErr != nil { + // Partial delete occurred + assert.True(t, errors.Is(deleteErr, context.DeadlineExceeded) || + errors.Is(deleteErr, errDeleteTimeout), + "expected timeout error, got: %v", deleteErr) + + // Tail should be updated to reflect actual progress + assert.Greater(t, tail.Height(), originalTail.Height()) + + // Now complete the deletion with proper timeout - use current tail + err = store.DeleteRange(ctx, tail.Height(), 500) + require.NoError(t, err) + } + + // After completion, verify final state newTail, err := store.Tail(ctx) require.NoError(t, err) assert.Equal(t, uint64(500), newTail.Height()) From 5a174e6db540366221752354ae9dc3768ce15ffb Mon Sep 17 00:00:00 2001 From: Hlib Kanunnikov Date: Thu, 18 Dec 2025 17:42:29 +0400 Subject: [PATCH 16/20] lint fix (#2) --- headertest/store.go | 4 ++-- interface.go | 3 ++- store/store_delete.go | 10 ++++++++-- store/store_test.go | 5 +---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/headertest/store.go b/headertest/store.go index 6de403be..7071189e 100644 --- a/headertest/store.go +++ b/headertest/store.go @@ -83,9 +83,9 @@ func (m *Store[H]) GetByHeight(_ context.Context, height uint64) (H, error) { } func (m *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { - m.HeaderMu.Lock() + m.HeaderMu.Lock() defer m.HeaderMu.Unlock() - + if from >= to { return fmt.Errorf("malformed range, from: %d, to: %d", from, to) } diff --git a/interface.go b/interface.go index a6537b16..26893b79 100644 --- a/interface.go +++ b/interface.go @@ -85,7 +85,8 @@ type Store[H Header[H]] interface { // GetRange returns the range [from:to). GetRange(context.Context, uint64, uint64) ([]H, error) - // DeleteRange deletes the range [from:to). It disallows the creation of gaps in the implementation's chain, ensuring contiguity between Tail --> Head. + // DeleteRange deletes the range [from:to). + // It disallows the creation of gaps in the implementation's chain, ensuring contiguity between Tail --> Head. DeleteRange(ctx context.Context, from, to uint64) error // OnDelete registers given handler to be called whenever a header with the height is being removed. diff --git a/store/store_delete.go b/store/store_delete.go index 1d873273..453c6290 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -327,7 +327,10 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { if updateTail { // For tail-side deletion, update tail to actual progress if err := s.setTail(ctx, s.ds, actualTo); err != nil { - return errors.Join(deleteErr, fmt.Errorf("header/store: setting tail to %d: %w", actualTo, err)) + return errors.Join( + deleteErr, + fmt.Errorf("header/store: setting tail to %d: %w", actualTo, err), + ) } } @@ -338,7 +341,10 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { newHeadHeight := from - 1 if newHeadHeight >= tail.Height() { if err := s.setHead(ctx, s.ds, newHeadHeight); err != nil { - return errors.Join(deleteErr, fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err)) + return errors.Join( + deleteErr, + fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err), + ) } } } diff --git a/store/store_test.go b/store/store_test.go index a5a8870d..ef68a550 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -685,9 +685,6 @@ func TestStore_DeleteRange_MoveHeadAndTail(t *testing.T) { tail, err := store.Tail(ctx) require.NoError(t, err) - head, err := store.Head(ctx) - require.NoError(t, err) - // Delete from tail to head+1 (wipes the store, then we verify behavior) // Instead, let's delete a portion from tail to keep some headers deleteTo := uint64(102) // Delete heights 1-101, keep 102-111 @@ -698,7 +695,7 @@ func TestStore_DeleteRange_MoveHeadAndTail(t *testing.T) { tail, err = store.Tail(ctx) require.NoError(t, err) assert.Equal(t, deleteTo, tail.Height()) - head, err = store.Head(ctx) + head, err := store.Head(ctx) require.NoError(t, err) assert.Equal(t, newHeaders[len(newHeaders)-1].Height(), head.Height()) From f34c9ebae45567910b9238e9408fd4f9e639343d Mon Sep 17 00:00:00 2001 From: rene <41963722+renaynay@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:34:32 +0100 Subject: [PATCH 17/20] fix(store): Cleanup `DeleteRange` and disallow silent err on noop range deletion (#3) * fix(store): Cleanup `DeleteRange` and disallow silent err on noop range deletion * nit --- store/store_delete.go | 100 +++++++++++++++++++++++------------------- store/store_test.go | 10 ++--- 2 files changed, 60 insertions(+), 50 deletions(-) diff --git a/store/store_delete.go b/store/store_delete.go index 453c6290..41fbfd4b 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -242,6 +242,7 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { return err } + // load current head and tail head, err := s.Head(ctx) if err != nil { return fmt.Errorf("header/store: reading head: %w", err) @@ -252,7 +253,7 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { return fmt.Errorf("header/store: reading tail: %w", err) } - // validate range parameters + // sanity check range parameters if from >= to { return fmt.Errorf( "header/store: invalid range [%d:%d) - from must be less than to", @@ -260,45 +261,26 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { to, ) } - - if from < tail.Height() { - return fmt.Errorf( - "header/store: delete range from %d below current tail(%d)", - from, - tail.Height(), - ) - } - - if to > head.Height()+1 { - return fmt.Errorf( - "header/store: delete range to %d beyond current head+1(%d)", - to, - head.Height()+1, - ) - } - // if range is empty within the current store bounds, it's a no-op if from > head.Height() || to <= tail.Height() { - log.Warn("header/store range is empty, nothing needs to be deleted") - return nil + return fmt.Errorf("header/store range [%d,%d) is not present in store, nothing needs to be deleted: "+ + "current head %d, tail %d", + from, to, head.Height(), tail.Height(), + ) } - // Validate that deletion won't create gaps in the store - // Only allow deletions that: + // Determine whether we are moving the head or tail pointers, or wiping the + // store completely: only allow deletions that - // 1. Start from tail (advancing tail forward) // 2. End at head+1 (moving head backward) // 3. Delete the entire store - if from > tail.Height() && to <= head.Height() { - return fmt.Errorf( - "header/store: deletion range [%d:%d) would create gaps in the store. "+ - "Only deletion from tail (%d) or to head+1 (%d) is supported", - from, to, tail.Height(), head.Height()+1, - ) - } - // Check if we're deleting all existing headers (making store empty) - // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes - if from == tail.Height() && to == head.Height()+1 { + updateTail := from == tail.Height() + updateHead := to == head.Height()+1 + + // Attempt to delete (wipe) the entire store + if updateTail && updateHead { + // Only wipe if 'to' is exactly at head+1 (normal case) to avoid accidental wipes // Check if a header exists exactly at 'to' (in pending, cache, or disk) // If it exists, we can't wipe - there's a header that would become the new tail _, err := s.getByHeight(ctx, to) @@ -307,6 +289,7 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { if err := s.wipe(ctx); err != nil { return fmt.Errorf("header/store: wipe: %w", err) } + log.Info("header/store: wiped store") return nil } if err != nil { @@ -315,9 +298,35 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { // Header exists at 'to', proceed with normal deletion } - // Determine which pointers need updating - updateTail := from <= tail.Height() - updateHead := to > head.Height() + switch { + case updateTail: + // we are attempting to move tail forward, so sanity check `to` + if to > head.Height()+1 { + return fmt.Errorf( + "header/store: delete range to %d beyond current head+1(%d)", + to, + head.Height()+1, + ) + } + case updateHead: + // we are attempting to move head backward, so sanity check `from` + if from < tail.Height() { + return fmt.Errorf( + "header/store: delete range from %d below current tail(%d)", + from, + tail.Height(), + ) + } + default: + // disallow deletions that are neither move the tail nor head as this is + // a malformed range and could create a gap within the contiguous chain + // of headers + return fmt.Errorf( + "header/store: delete range [%d:%d) does not move head(%d) or tail(%d) pointers "+ + "and would create gaps in the store. Only deletion from tail or head+1 is supported", + from, to, head.Height(), tail.Height(), + ) + } // Delete the headers without automatic tail updates actualTo, _, deleteErr := s.deleteRangeRaw(ctx, from, to) @@ -334,18 +343,19 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { } } - if updateHead && from > tail.Height() { - // For head-side deletion, only update head if we made progress - // actualTo represents the height we processed up to + if updateHead && !updateTail { + // This means we only receded the head, only update head if we made progress + // `actualTo` represents the height we receded backwards to as deletion + // moves in ascending order from --> to, so if we made any progress deleting headers + // `from` --> `actualTo`, we must always update the head to be one below `from` (which was deleted) + // to preserve contiguity (regardless of whether a partial delete occurred) if actualTo > from { newHeadHeight := from - 1 - if newHeadHeight >= tail.Height() { - if err := s.setHead(ctx, s.ds, newHeadHeight); err != nil { - return errors.Join( - deleteErr, - fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err), - ) - } + if err := s.setHead(ctx, s.ds, newHeadHeight); err != nil { + return errors.Join( + deleteErr, + fmt.Errorf("header/store: setting head to %d: %w", newHeadHeight, err), + ) } } } diff --git a/store/store_test.go b/store/store_test.go index ef68a550..f5b18a34 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -1029,10 +1029,10 @@ func TestStore_DeleteRange(t *testing.T) { originalTail, err := store.Tail(ctx) require.NoError(t, err) - // Delete range completely above head - should error (to > head+1) + // Delete range completely above head - should error (from > head.Height()) err = store.DeleteRange(ctx, 200, 300) require.Error(t, err) - assert.Contains(t, err.Error(), "beyond current head+1") + assert.Contains(t, err.Error(), "is not present in store") // Verify head and tail are unchanged head, err := store.Head(ctx) @@ -1072,10 +1072,10 @@ func TestStore_DeleteRange(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "from must be less than to") - // from below tail should error + // from < tail && to < head+1 should error err = store.DeleteRange(ctx, 0, 5) assert.Error(t, err) - assert.Contains(t, err.Error(), "below current tail") + assert.Contains(t, err.Error(), "Only deletion from tail or head+1 is supported") // middle deletion should error err = store.DeleteRange(ctx, 10, 15) @@ -1291,7 +1291,7 @@ func TestStore_DeleteRange_ValidationErrors(t *testing.T) { name: "delete from below tail boundary", from: tail.Height() - 1, to: tail.Height() + 5, - errMsg: "below current tail", + errMsg: "Only deletion from tail or head+1 is supported", }, { name: "invalid range - from equals to", From b0e9be4990cfd5bc52b1adfeaa5cb269b5bbab42 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 9 Jan 2026 10:55:37 +0100 Subject: [PATCH 18/20] lint --- store/store_delete.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/store/store_delete.go b/store/store_delete.go index 41fbfd4b..ebfaea12 100644 --- a/store/store_delete.go +++ b/store/store_delete.go @@ -263,9 +263,13 @@ func (s *Store[H]) DeleteRange(ctx context.Context, from, to uint64) error { } // if range is empty within the current store bounds, it's a no-op if from > head.Height() || to <= tail.Height() { - return fmt.Errorf("header/store range [%d,%d) is not present in store, nothing needs to be deleted: "+ - "current head %d, tail %d", - from, to, head.Height(), tail.Height(), + return fmt.Errorf( + "header/store range [%d,%d) is not present in store, nothing needs to be deleted: "+ + "current head %d, tail %d", + from, + to, + head.Height(), + tail.Height(), ) } From b06e89fb369f1fbcc53c64092c574cbb0aacc798 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 16 Jan 2026 21:01:16 +0100 Subject: [PATCH 19/20] ci: bump golangci-lint action --- .github/workflows/go-ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 7f1abbd1..e69fc133 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -7,7 +7,6 @@ on: - main pull_request: jobs: - build: runs-on: ubuntu-latest steps: @@ -28,8 +27,8 @@ jobs: file: ./coverage.o - name: Lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: version: latest - name: Go Mod Tidy - run: go mod tidy && git diff --exit-code \ No newline at end of file + run: go mod tidy && git diff --exit-code From a7515462e17612e4f4ae7596db38f77cbcd333c9 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Fri, 16 Jan 2026 21:18:06 +0100 Subject: [PATCH 20/20] lint --- p2p/server.go | 6 +++++- sync/syncer_head.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/p2p/server.go b/p2p/server.go index 4dc02a79..70613fd4 100644 --- a/p2p/server.go +++ b/p2p/server.go @@ -115,7 +115,11 @@ func (serv *ExchangeServer[H]) requestHandler(stream network.Stream) { case *p2p_pb.HeaderRequest_Hash: headers, err = serv.handleRequestByHash(ctx, pbreq.GetHash()) case *p2p_pb.HeaderRequest_Origin: - headers, err = serv.handleRangeRequest(ctx, pbreq.GetOrigin(), pbreq.GetOrigin()+pbreq.Amount) + headers, err = serv.handleRangeRequest( + ctx, + pbreq.GetOrigin(), + pbreq.GetOrigin()+pbreq.Amount, + ) default: log.Warn("server: invalid data type received") stream.Reset() //nolint:errcheck diff --git a/sync/syncer_head.go b/sync/syncer_head.go index 3fb24c63..271a5b07 100644 --- a/sync/syncer_head.go +++ b/sync/syncer_head.go @@ -93,7 +93,11 @@ func (s *Syncer[H]) networkHead(ctx context.Context) (H, bool, error) { return sbjHead, false, nil } // still check if even the newly requested head is not recent - if recent, timeDiff = isRecent(newHead, s.Params.blockTime, s.Params.recencyThreshold); !recent { + if recent, timeDiff = isRecent( + newHead, + s.Params.blockTime, + s.Params.recencyThreshold, + ); !recent { log.Warnw( "non recent head from trusted peers", "height",