From b03cc29f3e4ee29b74b8393f581ecfac5d5b0ff6 Mon Sep 17 00:00:00 2001 From: Ragnar Date: Mon, 6 Oct 2025 11:09:25 +0200 Subject: [PATCH 1/2] Update service.go --- nodebuilder/header/service.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nodebuilder/header/service.go b/nodebuilder/header/service.go index e916fe38d9..4d62a5b601 100644 --- a/nodebuilder/header/service.go +++ b/nodebuilder/header/service.go @@ -86,13 +86,20 @@ func (s *Service) GetByHeight(ctx context.Context, height uint64) (*header.Exten switch { case err != nil: return nil, fmt.Errorf("store tail: %w", err) + case height == tail.Height(): + return tail, nil case height < tail.Height(): + // Try to get the header from store first, as it might still be available + // even if it's below the current tail due to pruning race conditions + header, err := s.store.GetByHeight(ctx, height) + if err == nil { + return header, nil + } + // If not found in store, then it's truly below the tail log.Warnf(`requested header (%d) is below Tail (%d) lazy fetching (https://github.com/celestiaorg/go-header/issues/334) is not currently supported make sure to set SyncFromHeight value in config covering desired header height`, height, tail.Height()) return nil, fmt.Errorf("requested header (%d) is below Tail (%d)", height, tail.Height()) - case height == tail.Height(): - return tail, nil } head, err = s.store.Head(ctx) From 8f6c32a51e69d47d3591867699c8ff4afd49cc9d Mon Sep 17 00:00:00 2001 From: Ragnar Date: Mon, 6 Oct 2025 11:09:36 +0200 Subject: [PATCH 2/2] Update service_test.go --- nodebuilder/header/service_test.go | 175 +++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/nodebuilder/header/service_test.go b/nodebuilder/header/service_test.go index 14d5ada87d..a84857766e 100644 --- a/nodebuilder/header/service_test.go +++ b/nodebuilder/header/service_test.go @@ -1,11 +1,13 @@ package header import ( + "bytes" "context" "fmt" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" libhead "github.com/celestiaorg/go-header" "github.com/celestiaorg/go-header/sync" @@ -39,3 +41,176 @@ func (d *errorSyncer[H]) State() sync.State { func (d *errorSyncer[H]) SyncWait(context.Context) error { return fmt.Errorf("dummy error") } + +// TestGetByHeightBelowTailButAvailable tests the fix for issue #4603 +// where blocks above tail but within prune window should be accessible +func TestGetByHeightBelowTailButAvailable(t *testing.T) { + // Create a mock store that has a header available even though it's below the tail + mockStore := &mockStore{ + tailHeight: 100, + availableHeaders: map[uint64]*header.ExtendedHeader{ + 95: createMockHeader(95), + }, + } + + serv := Service{ + syncer: &mockSyncer[*header.ExtendedHeader]{ + head: createMockHeader(110), + }, + store: mockStore, + } + + // Test that we can get a header that's below tail but still available + h, err := serv.GetByHeight(context.Background(), 95) + require.NoError(t, err) + require.NotNil(t, h) + assert.Equal(t, uint64(95), h.Height()) +} + +// TestGetByHeightBelowTailNotAvailable tests that we still get an error +// when a header is truly below tail and not available +func TestGetByHeightBelowTailNotAvailable(t *testing.T) { + // Create a mock store that doesn't have the requested header + mockStore := &mockStore{ + tailHeight: 100, + availableHeaders: map[uint64]*header.ExtendedHeader{ + // No header at height 90 + }, + } + + serv := Service{ + syncer: &mockSyncer[*header.ExtendedHeader]{ + head: createMockHeader(110), + }, + store: mockStore, + } + + // Test that we get an error for a header that's truly below tail + h, err := serv.GetByHeight(context.Background(), 90) + assert.Error(t, err) + assert.Nil(t, h) + assert.Contains(t, err.Error(), "requested header (90) is below Tail (100)") +} + +// Mock implementations for testing +type mockStore struct { + tailHeight uint64 + availableHeaders map[uint64]*header.ExtendedHeader +} + +func (m *mockStore) Tail(ctx context.Context) (*header.ExtendedHeader, error) { + return createMockHeader(m.tailHeight), nil +} + +func (m *mockStore) Head(ctx context.Context, opts ...libhead.HeadOption[*header.ExtendedHeader]) (*header.ExtendedHeader, error) { + // Return the highest available header + var maxHeight uint64 + for height := range m.availableHeaders { + if height > maxHeight { + maxHeight = height + } + } + return createMockHeader(maxHeight), nil +} + +func (m *mockStore) GetByHeight(ctx context.Context, height uint64) (*header.ExtendedHeader, error) { + if header, exists := m.availableHeaders[height]; exists { + return header, nil + } + return nil, fmt.Errorf("header not found at height %d", height) +} + +func (m *mockStore) Get(ctx context.Context, hash libhead.Hash) (*header.ExtendedHeader, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockStore) GetRangeByHeight(ctx context.Context, from *header.ExtendedHeader, to uint64) ([]*header.ExtendedHeader, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockStore) Append(ctx context.Context, headers ...*header.ExtendedHeader) error { + // Mock implementation - just store the headers + for _, h := range headers { + m.availableHeaders[h.Height()] = h + } + return nil +} + +func (m *mockStore) DeleteTo(ctx context.Context, height uint64) error { + // Mock implementation - remove headers up to the given height + for h := range m.availableHeaders { + if h <= height { + delete(m.availableHeaders, h) + } + } + return nil +} + +func (m *mockStore) GetRange(ctx context.Context, from uint64, amount uint64) ([]*header.ExtendedHeader, error) { + // Mock implementation - return headers in range + var result []*header.ExtendedHeader + for i := uint64(0); i < amount; i++ { + height := from + i + if header, exists := m.availableHeaders[height]; exists { + result = append(result, header) + } + } + return result, nil +} + +func (m *mockStore) Has(ctx context.Context, hash libhead.Hash) (bool, error) { + // Mock implementation - check if header exists by hash + for _, header := range m.availableHeaders { + if bytes.Equal(header.Hash(), hash) { + return true, nil + } + } + return false, nil +} + +func (m *mockStore) HasAt(ctx context.Context, height uint64) bool { + // Mock implementation - check if header exists at height + _, exists := m.availableHeaders[height] + return exists +} + +func (m *mockStore) Height() uint64 { + // Mock implementation - return the highest available height + var maxHeight uint64 + for height := range m.availableHeaders { + if height > maxHeight { + maxHeight = height + } + } + return maxHeight +} + +func (m *mockStore) OnDelete(fn func(context.Context, uint64) error) { + // Mock implementation - no-op for testing +} + +type mockSyncer[H libhead.Header[H]] struct { + head H +} + +func (m *mockSyncer[H]) Head(context.Context, ...libhead.HeadOption[H]) (H, error) { + return m.head, nil +} + +func (m *mockSyncer[H]) State() sync.State { + return sync.State{} +} + +func (m *mockSyncer[H]) SyncWait(context.Context) error { + return nil +} + +func createMockHeader(height uint64) *header.ExtendedHeader { + // Create a minimal mock header for testing + // We need to set the RawHeader.Height field for the Height() method to work + return &header.ExtendedHeader{ + RawHeader: header.RawHeader{ + Height: int64(height), + }, + } +}