diff --git a/beacon-chain/blockchain/head_test.go b/beacon-chain/blockchain/head_test.go index dab5f3230ad3..e367bf460646 100644 --- a/beacon-chain/blockchain/head_test.go +++ b/beacon-chain/blockchain/head_test.go @@ -889,7 +889,7 @@ func TestUpdateHead_noSavedChanges(t *testing.T) { bellatrixState, _ := util.DeterministicGenesisStateBellatrix(t, 2) require.NoError(t, beaconDB.SaveState(ctx, bellatrixState, bellatrixBlkRoot)) - service.cfg.StateGen.SaveFinalizedState(0, bellatrixBlkRoot, bellatrixState) + service.cfg.StateGen.SaveFinalizedState(bellatrixBlkRoot, bellatrixState) headRoot := service.headRoot() require.Equal(t, [32]byte{}, headRoot) diff --git a/beacon-chain/blockchain/service.go b/beacon-chain/blockchain/service.go index bb135007de9d..59c37adb05bb 100644 --- a/beacon-chain/blockchain/service.go +++ b/beacon-chain/blockchain/service.go @@ -374,7 +374,7 @@ func (s *Service) saveGenesisData(ctx context.Context, genesisState state.Beacon } s.originBlockRoot = genesisBlkRoot - s.cfg.StateGen.SaveFinalizedState(0 /*slot*/, genesisBlkRoot, genesisState) + s.cfg.StateGen.SaveFinalizedState(genesisBlkRoot, genesisState) s.cfg.ForkChoiceStore.Lock() defer s.cfg.ForkChoiceStore.Unlock() diff --git a/beacon-chain/state/stategen/getter_test.go b/beacon-chain/state/stategen/getter_test.go index f768fb8a0fab..4c86b0c86748 100644 --- a/beacon-chain/state/stategen/getter_test.go +++ b/beacon-chain/state/stategen/getter_test.go @@ -42,7 +42,6 @@ func TestStateByRoot_ColdState(t *testing.T) { beaconDB := testDB.SetupDB(t) service := New(beaconDB, doublylinkedtree.New()) - service.finalizedInfo.slot = 2 service.slotsPerArchivedPoint = 1 b := util.NewBeaconBlock() @@ -96,7 +95,6 @@ func TestStateByRootIfCachedNoCopy_ColdState(t *testing.T) { beaconDB := testDB.SetupDB(t) service := New(beaconDB, doublylinkedtree.New()) - service.finalizedInfo.slot = 2 service.slotsPerArchivedPoint = 1 b := util.NewBeaconBlock() @@ -264,7 +262,6 @@ func TestLoadStateByRoot(t *testing.T) { persistFinalizedStruct := func(r testChain, slot primitives.Slot) { st := r.state(t, slot) r.srv.finalizedInfo.state = st - r.srv.finalizedInfo.slot = st.Slot() r.srv.finalizedInfo.root = r.blockRoot(t, slot) } diff --git a/beacon-chain/state/stategen/migrate.go b/beacon-chain/state/stategen/migrate.go index 608d22483abc..495164669fe7 100644 --- a/beacon-chain/state/stategen/migrate.go +++ b/beacon-chain/state/stategen/migrate.go @@ -16,9 +16,11 @@ import ( "github.com/sirupsen/logrus" ) -// MigrateToCold advances the finalized info in between the cold and hot state sections. -// It moves the recent finalized states from the hot section to the cold section and -// only preserves the ones that are on archived point. +// maxFinalizedRootSearch is for bounding the number of iterations +// when searching for a finalized canonical root below a given slot. +const maxFinalizedRootSearch = 1024 + +// MigrateToCold moves finalized states to cold storage and advances the migration cursor. func (s *State) MigrateToCold(ctx context.Context, fRoot [32]byte) error { ctx, span := trace.StartSpan(ctx, "stateGen.MigrateToCold") defer span.End() @@ -33,9 +35,7 @@ func (s *State) MigrateToCold(ctx context.Context, fRoot [32]byte) error { return s.migrateToColdHdiff(ctx, fRoot) } - s.finalizedInfo.lock.RLock() - oldFSlot := s.finalizedInfo.slot - s.finalizedInfo.lock.RUnlock() + oldFSlot := s.migratedSlot fBlock, err := s.beaconDB.Block(ctx, fRoot) if err != nil { @@ -79,16 +79,10 @@ func (s *State) MigrateToCold(ctx context.Context, fRoot [32]byte) error { aRoot = cached.root aState = cached.state } else { - _, roots, err := s.beaconDB.HighestRootsBelowSlot(ctx, slot) + aRoot, err = s.canonicalRootBelowSlot(ctx, slot) if err != nil { - return err + return fmt.Errorf("canonical root below slot %d not found: %w", slot, err) } - // Given the block has been finalized, the db should not have more than one block in a given slot. - // We should error out when this happens. - if len(roots) != 1 { - return errUnknownBlock - } - aRoot = roots[0] // There's no need to generate the state if the state already exists in the DB. // We can skip saving the state. if !s.beaconDB.HasState(ctx, aRoot) { @@ -125,17 +119,18 @@ func (s *State) MigrateToCold(ctx context.Context, fRoot [32]byte) error { return err } if ok { - s.SaveFinalizedState(fSlot, fRoot, fInfo.state) + s.SaveFinalizedState(fRoot, fInfo.state) } + // The migration can complete without the finalized state being cached. Keep + // finalizedInfo coherent and advance its independent migration cursor. + s.migratedSlot = fSlot return nil } // migrateToColdHdiff saves the state-diffs for slots that are in the state diff tree after finalization func (s *State) migrateToColdHdiff(ctx context.Context, fRoot [32]byte) error { - s.finalizedInfo.lock.RLock() - oldFSlot := s.finalizedInfo.slot - s.finalizedInfo.lock.RUnlock() + oldFSlot := s.migratedSlot fSlot, err := s.beaconDB.SlotByBlockRoot(ctx, fRoot) if err != nil { return errors.Wrap(err, "could not get slot by block root") @@ -171,16 +166,10 @@ func (s *State) migrateToColdHdiff(ctx context.Context, fRoot [32]byte) error { } else { // we check for slot+1 because we don't want to treat this slot as a missed block when it's not in cache. // this specifically happens when the state is evicted from the caches in long non finalization. - _, roots, err := s.beaconDB.HighestRootsBelowSlot(ctx, slot+1) + aRoot, err = s.canonicalRootBelowSlot(ctx, slot+1) if err != nil { - return err + return fmt.Errorf("canonical root below slot %d not found: %w", slot, err) } - // Given the block has been finalized, the db should not have more than one block in a given slot. - // We should error out when this happens. - if len(roots) != 1 { - return errUnknownBlock - } - aRoot = roots[0] // Different than the legacy MigrateToCold, we need to always get the state even if // the state exists in DB as part of the hot state db, because we need to process slots // to the state diff tree slots. @@ -217,8 +206,11 @@ func (s *State) migrateToColdHdiff(ctx context.Context, fRoot [32]byte) error { return err } if ok { - s.SaveFinalizedState(fSlot, fRoot, fInfo.state) + s.SaveFinalizedState(fRoot, fInfo.state) } + // The migration can complete without the finalized state being cached. Keep + // finalizedInfo coherent and advance its independent migration cursor. + s.migratedSlot = fSlot return nil } @@ -237,3 +229,23 @@ func (s *State) migrateHotToCold(aRoot [32]byte) { } s.saveHotStateDB.lock.Unlock() } + +// canonicalRootBelowSlot returns a finalized canonical block immediately before slot. +func (s *State) canonicalRootBelowSlot(ctx context.Context, slot primitives.Slot) ([32]byte, error) { + for attempts := 0; slot > 0 && attempts < maxFinalizedRootSearch; attempts++ { + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + found, roots, err := s.beaconDB.HighestRootsBelowSlot(ctx, slot) + if err != nil { + return [32]byte{}, fmt.Errorf("highest roots below slot %d: %w", slot, err) + } + for _, root := range roots { + if s.beaconDB.IsFinalizedBlock(ctx, root) { + return root, nil + } + } + slot = found + } + return [32]byte{}, errUnknownBlock +} diff --git a/beacon-chain/state/stategen/migrate_test.go b/beacon-chain/state/stategen/migrate_test.go index 15af485434c0..f3972fc5b5ad 100644 --- a/beacon-chain/state/stategen/migrate_test.go +++ b/beacon-chain/state/stategen/migrate_test.go @@ -10,8 +10,10 @@ import ( "github.com/OffchainLabs/prysm/v7/beacon-chain/state" "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" "github.com/OffchainLabs/prysm/v7/config/features" + "github.com/OffchainLabs/prysm/v7/config/params" consensusblocks "github.com/OffchainLabs/prysm/v7/consensus-types/blocks" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" + "github.com/OffchainLabs/prysm/v7/crypto/bls" ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1" "github.com/OffchainLabs/prysm/v7/testing/assert" "github.com/OffchainLabs/prysm/v7/testing/require" @@ -32,9 +34,9 @@ func TestMigrateToCold_CanSaveFinalizedInfo(t *testing.T) { require.NoError(t, service.epochBoundaryStateCache.put(br, beaconState)) require.NoError(t, service.MigrateToCold(ctx, br)) - wanted := &finalizedInfo{state: beaconState, root: br, slot: 1} + wanted := &finalizedInfo{state: beaconState, root: br} assert.DeepEqual(t, wanted.root, service.finalizedInfo.root) - assert.Equal(t, wanted.slot, service.finalizedInfo.slot) + assert.Equal(t, primitives.Slot(1), service.migratedSlot) expectedHTR, err := wanted.state.HashTreeRoot(ctx) require.NoError(t, err) actualHTR, err := service.finalizedInfo.state.HashTreeRoot(ctx) @@ -72,7 +74,7 @@ func TestMigrateToCold_HappyPath(t *testing.T) { require.LogsContain(t, hook, "Saved state in DB") } -func TestMigrateToCold_RegeneratePath(t *testing.T) { +func TestMigrateToCold_RegeneratePath_IgnoresOrphan(t *testing.T) { hook := logTest.NewGlobal() ctx := t.Context() beaconDB := testDB.SetupDB(t) @@ -89,26 +91,40 @@ func TestMigrateToCold_RegeneratePath(t *testing.T) { assert.NoError(t, beaconDB.SaveState(ctx, beaconState, gRoot)) assert.NoError(t, beaconDB.SaveGenesisBlockRoot(ctx, gRoot)) + // Add an orphaned block at slot 1. + orphanConfig := util.DefaultBlockGenConfig() + orphanConfig.NumAttestations = 0 + orphan, err := util.GenerateFullBlock(beaconState, pks, orphanConfig, 1) + require.NoError(t, err) + orphanRoot, err := orphan.Block.HashTreeRoot() + require.NoError(t, err) + util.SaveBlock(t, ctx, service.beaconDB, orphan) + require.NoError(t, service.beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 1, Root: orphanRoot[:]})) + b1, err := util.GenerateFullBlock(beaconState, pks, util.DefaultBlockGenConfig(), 1) require.NoError(t, err) + wB1, err := consensusblocks.NewSignedBeaconBlock(b1) + require.NoError(t, err) + state1, err := executeStateTransitionStateGen(ctx, beaconState.Copy(), wB1) + require.NoError(t, err) r1, err := b1.Block.HashTreeRoot() require.NoError(t, err) util.SaveBlock(t, ctx, service.beaconDB, b1) require.NoError(t, service.beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 1, Root: r1[:]})) - b4, err := util.GenerateFullBlock(beaconState, pks, util.DefaultBlockGenConfig(), 4) + b64, err := util.GenerateFullBlock(state1, pks, util.DefaultBlockGenConfig(), 64) require.NoError(t, err) - r4, err := b4.Block.HashTreeRoot() + r64, err := b64.Block.HashTreeRoot() require.NoError(t, err) - util.SaveBlock(t, ctx, service.beaconDB, b4) - require.NoError(t, service.beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 4, Root: r4[:]})) + util.SaveBlock(t, ctx, service.beaconDB, b64) + require.NoError(t, service.beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 64, Root: r64[:]})) + require.NoError(t, service.beaconDB.SaveFinalizedCheckpoint(ctx, ðpb.Checkpoint{Epoch: 2, Root: r64[:]})) service.finalizedInfo = &finalizedInfo{ - slot: 0, - root: genesisStateRoot, + root: gRoot, state: beaconState, } - require.NoError(t, service.MigrateToCold(ctx, r4)) + require.NoError(t, service.MigrateToCold(ctx, r64)) s1, err := service.beaconDB.State(ctx, r1) require.NoError(t, err) @@ -118,6 +134,7 @@ func TestMigrateToCold_RegeneratePath(t *testing.T) { lastIndex, err := service.beaconDB.LastArchivedSlot(ctx) require.NoError(t, err) assert.Equal(t, primitives.Slot(1), lastIndex, "Did not save last archived index") + assert.Equal(t, false, service.beaconDB.HasState(ctx, orphanRoot), "Saved state for orphaned block") require.LogsContain(t, hook, "Saved state in DB") } @@ -196,9 +213,9 @@ func TestMigrateToCold_ParallelCalls(t *testing.T) { require.NoError(t, err) util.SaveBlock(t, ctx, service.beaconDB, b7) require.NoError(t, service.beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 7, Root: r7[:]})) + require.NoError(t, service.beaconDB.SaveFinalizedCheckpoint(ctx, ðpb.Checkpoint{Root: r7[:]})) service.finalizedInfo = &finalizedInfo{ - slot: 0, root: genesisStateRoot, state: genState, } @@ -272,7 +289,6 @@ func TestMigrateToColdHdiff_CanUpdateFinalizedInfo(t *testing.T) { // Set initial finalized info at genesis. service.finalizedInfo = &finalizedInfo{ - slot: 0, root: gRoot, state: beaconState, } @@ -290,7 +306,7 @@ func TestMigrateToColdHdiff_CanUpdateFinalizedInfo(t *testing.T) { require.NoError(t, service.MigrateToCold(ctx, fRoot)) // Verify finalized info is updated. - assert.Equal(t, primitives.Slot(10), service.finalizedInfo.slot) + assert.Equal(t, primitives.Slot(10), service.migratedSlot) assert.DeepEqual(t, fRoot, service.finalizedInfo.root) expectedHTR, err := finalizedState.HashTreeRoot(ctx) require.NoError(t, err) @@ -325,10 +341,10 @@ func TestMigrateToColdHdiff_SkipsSlotsNotInDiffTree(t *testing.T) { // Start from slot 1 to avoid slot 0 which is in the diff tree. service.finalizedInfo = &finalizedInfo{ - slot: 1, root: gRoot, state: beaconState, } + service.migratedSlot = 1 // Reset the log hook to ignore setup logs. hook.Reset() @@ -356,222 +372,108 @@ func TestMigrateToColdHdiff_SkipsSlotsNotInDiffTree(t *testing.T) { // when all non-boundary slots are missed. func TestMigrateToColdHdiff_MissedNonBoundarySlots(t *testing.T) { ctx := t.Context() - setStateDiffExponents() - beaconDB := testDB.SetupDB(t) - require.NoError(t, beaconDB.(*kv.Store).InitStateDiffCacheForTesting(t, 0)) - resetCfg := features.InitWithReset(&features.Flags{EnableStateDiff: true}) - defer resetCfg() - service := New(beaconDB, doublylinkedtree.New()) - - beaconState, _ := util.DeterministicGenesisState(t, 32) - genesisStateRoot, err := beaconState.HashTreeRoot(ctx) - require.NoError(t, err) - genesis := blocks.NewGenesisBlock(genesisStateRoot[:]) - util.SaveBlock(t, ctx, beaconDB, genesis) - gRoot, err := genesis.Block.HashTreeRoot() - require.NoError(t, err) - require.NoError(t, beaconDB.SaveGenesisBlockRoot(ctx, gRoot)) - // Slot 0 needs to exist as the base snapshot for level-1 state diff entries. - require.NoError(t, beaconDB.SaveState(ctx, beaconState, gRoot)) - - service.finalizedInfo = &finalizedInfo{ - slot: 0, - root: gRoot, - state: beaconState, + chain := setupHdiffMigrationTestChain(t, 32, 64, 96, 128) + service := New(chain.db, doublylinkedtree.New()) + service.finalizedInfo = &finalizedInfo{root: chain.roots[0], state: chain.states[0]} + for _, slot := range []primitives.Slot{32, 64, 96, 128} { + require.NoError(t, service.epochBoundaryStateCache.put(chain.roots[slot], chain.states[slot])) } + chain.finalize(t, 128) - state32 := beaconState.Copy() - require.NoError(t, state32.SetSlot(32)) - b32 := util.NewBeaconBlock() - b32.Block.Slot = 32 - r32, err := b32.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b32) - require.NoError(t, service.epochBoundaryStateCache.put(r32, state32)) + require.NoError(t, service.MigrateToCold(ctx, chain.roots[128])) - state64 := beaconState.Copy() - require.NoError(t, state64.SetSlot(64)) - b64 := util.NewBeaconBlock() - b64.Block.Slot = 64 - r64, err := b64.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b64) - require.NoError(t, service.epochBoundaryStateCache.put(r64, state64)) - - state96 := beaconState.Copy() - require.NoError(t, state96.SetSlot(96)) - b96 := util.NewBeaconBlock() - b96.Block.Slot = 96 - r96, err := b96.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b96) - require.NoError(t, service.epochBoundaryStateCache.put(r96, state96)) - - finalizedState := beaconState.Copy() - require.NoError(t, finalizedState.SetSlot(128)) - b128 := util.NewBeaconBlock() - b128.Block.Slot = 128 - r128, err := b128.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b128) - require.NoError(t, service.epochBoundaryStateCache.put(r128, finalizedState)) - - require.NoError(t, service.MigrateToCold(ctx, r128)) - - assert.Equal(t, true, beaconDB.HasState(ctx, r32), "Did not save slot 32 checkpoint to database") - assert.Equal(t, true, beaconDB.HasState(ctx, r64), "Did not save slot 64 checkpoint to database") - assert.Equal(t, true, beaconDB.HasState(ctx, r96), "Did not save slot 96 checkpoint to database") + for _, slot := range []primitives.Slot{32, 64, 96} { + got, err := chain.db.State(ctx, chain.roots[slot]) + require.NoError(t, err) + wantRoot, err := chain.states[slot].HashTreeRoot(ctx) + require.NoError(t, err) + gotRoot, err := got.HashTreeRoot(ctx) + require.NoError(t, err) + assert.Equal(t, wantRoot, gotRoot, "state root mismatch") + } } -// TestMigrateToColdHdiff_MissedNonBoundarySlots verifies migration -// when all non-boundary slots are missed, and the epoch boundary cache -// is also missed for slot 96. +// TestMigrateToColdHdiff_MissedNonBoundarySlots_BoundaryCacheMissed simulates a +// restart after the boundary cache has been lost. func TestMigrateToColdHdiff_MissedNonBoundarySlots_BoundaryCacheMissed(t *testing.T) { ctx := t.Context() - setStateDiffExponents() - beaconDB := testDB.SetupDB(t) - require.NoError(t, beaconDB.(*kv.Store).InitStateDiffCacheForTesting(t, 0)) - resetCfg := features.InitWithReset(&features.Flags{EnableStateDiff: true}) - defer resetCfg() - service := New(beaconDB, doublylinkedtree.New()) + chain := setupHdiffMigrationTestChain(t, 32, 64, 96, 128) + service := New(chain.db, doublylinkedtree.New()) + service.finalizedInfo = &finalizedInfo{root: chain.roots[0], state: chain.states[0]} + chain.finalize(t, 128) - beaconState, _ := util.DeterministicGenesisState(t, 32) - genesisStateRoot, err := beaconState.HashTreeRoot(ctx) - require.NoError(t, err) - genesis := blocks.NewGenesisBlock(genesisStateRoot[:]) - util.SaveBlock(t, ctx, beaconDB, genesis) - gRoot, err := genesis.Block.HashTreeRoot() - require.NoError(t, err) - require.NoError(t, beaconDB.SaveGenesisBlockRoot(ctx, gRoot)) - // Slot 0 needs to exist as the base snapshot for level-1 state diff entries. - require.NoError(t, beaconDB.SaveState(ctx, beaconState, gRoot)) + require.NoError(t, service.MigrateToCold(ctx, chain.roots[128])) - service.finalizedInfo = &finalizedInfo{ - slot: 0, - root: gRoot, - state: beaconState, + for _, slot := range []primitives.Slot{32, 64, 96} { + got, err := chain.db.State(ctx, chain.roots[slot]) + require.NoError(t, err) + wantRoot, err := chain.states[slot].HashTreeRoot(ctx) + require.NoError(t, err) + gotRoot, err := got.HashTreeRoot(ctx) + require.NoError(t, err) + assert.Equal(t, wantRoot, gotRoot, "state root mismatch") } - - state32 := beaconState.Copy() - require.NoError(t, state32.SetSlot(32)) - b32 := util.NewBeaconBlock() - b32.Block.Slot = 32 - r32, err := b32.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b32) - require.NoError(t, service.epochBoundaryStateCache.put(r32, state32)) - - state64 := beaconState.Copy() - require.NoError(t, state64.SetSlot(64)) - b64 := util.NewBeaconBlock() - b64.Block.Slot = 64 - r64, err := b64.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b64) - require.NoError(t, service.epochBoundaryStateCache.put(r64, state64)) - - state96 := beaconState.Copy() - require.NoError(t, state96.SetSlot(96)) - b96 := util.NewBeaconBlock() - b96.Block.Slot = 96 - r96, err := b96.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b96) - // Simulate epoch boundary cache miss for slot 96 while hot cache still has the state. - // this makes sure the call to StateByRoot doesn't fail here. - service.hotStateCache.put(r96, state96) - - finalizedState := beaconState.Copy() - require.NoError(t, finalizedState.SetSlot(128)) - b128 := util.NewBeaconBlock() - b128.Block.Slot = 128 - r128, err := b128.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b128) - require.NoError(t, service.epochBoundaryStateCache.put(r128, finalizedState)) - - require.NoError(t, service.MigrateToCold(ctx, r128)) - - assert.Equal(t, true, beaconDB.HasState(ctx, r32), "Did not save slot 32 checkpoint to database") - assert.Equal(t, true, beaconDB.HasState(ctx, r64), "Did not save slot 64 checkpoint to database") - assert.Equal(t, true, beaconDB.HasState(ctx, r96), "Did not save slot 96 checkpoint to database") + assert.Equal(t, primitives.Slot(128), service.migratedSlot) + assert.DeepEqual(t, chain.roots[0], service.finalizedInfo.root) + assert.Equal(t, primitives.Slot(0), service.finalizedInfo.state.Slot()) } -// TestMigrateToColdHdiff_BoundaryCacheMiss_UseTargetSlotRoot verifies that a -// cache miss at a diff-tree slot still migrates using the block root at that -// slot (or an equivalent <= slot selection), rather than strictly below it. -func TestMigrateToColdHdiff_BoundaryCacheMiss_UseTargetSlotRoot(t *testing.T) { +// TestMigrateToColdHdiff_BoundaryCacheMiss_SelectsCanonicalRoot verifies that +// the finalized block index selects the canonical root when the slot index also has an orphan. +func TestMigrateToColdHdiff_BoundaryCacheMiss_SelectsCanonicalRoot(t *testing.T) { ctx := t.Context() - setStateDiffExponents() - beaconDB := testDB.SetupDB(t) - require.NoError(t, beaconDB.(*kv.Store).InitStateDiffCacheForTesting(t, 0)) - resetCfg := features.InitWithReset(&features.Flags{EnableStateDiff: true}) - defer resetCfg() - service := New(beaconDB, doublylinkedtree.New()) + chain := setupHdiffMigrationTestChain(t, 32, 64, 96, 128) - genesisState, pks := util.DeterministicGenesisState(t, 32) - genesisStateRoot, err := genesisState.HashTreeRoot(ctx) - require.NoError(t, err) - genesis := blocks.NewGenesisBlock(genesisStateRoot[:]) - util.SaveBlock(t, ctx, beaconDB, genesis) - gRoot, err := genesis.Block.HashTreeRoot() - require.NoError(t, err) - require.NoError(t, beaconDB.SaveGenesisBlockRoot(ctx, gRoot)) - // Slot 0 base snapshot for state-diff. - require.NoError(t, beaconDB.SaveState(ctx, genesisState, gRoot)) - require.NoError(t, beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 0, Root: gRoot[:]})) + // Add an orphaned block at slot 96. + orphanConfig := util.DefaultBlockGenConfig() + orphanConfig.NumAttestations = 0 + orphanRoot, _ := chain.addBlock(t, chain.states[64], orphanConfig, 96) + service := New(chain.db, doublylinkedtree.New()) + service.finalizedInfo = &finalizedInfo{root: chain.roots[0], state: chain.states[0]} - service.finalizedInfo = &finalizedInfo{ - slot: 0, - root: gRoot, - state: genesisState, - } - - current := genesisState - var ( - r32, r64, r96, r128 [32]byte - s32, s64, s96, s128 state.BeaconState - ) - for _, slot := range []primitives.Slot{32, 64, 96, 128} { - b, err := util.GenerateFullBlock(current, pks, util.DefaultBlockGenConfig(), slot) - require.NoError(t, err) - wsb, err := consensusblocks.NewSignedBeaconBlock(b) - require.NoError(t, err) - nextState, err := executeStateTransitionStateGen(ctx, current, wsb) - require.NoError(t, err) - root, err := b.Block.HashTreeRoot() - require.NoError(t, err) - util.SaveBlock(t, ctx, beaconDB, b) - require.NoError(t, beaconDB.SaveStateSummary(ctx, ðpb.StateSummary{Slot: slot, Root: root[:]})) - - current = nextState - switch slot { - case 32: - r32 = root - s32 = nextState.Copy() - case 64: - r64 = root - s64 = nextState.Copy() - case 96: - r96 = root - s96 = nextState.Copy() - case 128: - r128 = root - s128 = nextState.Copy() - } + // Simulate cache eviction for slot 96 only: keep 32/64 and finalized 128. + for _, slot := range []primitives.Slot{32, 64, 128} { + require.NoError(t, service.epochBoundaryStateCache.put(chain.roots[slot], chain.states[slot])) } - // Simulate cache eviction for slot 96 only: keep 32/64 and finalized 128. - require.NoError(t, service.epochBoundaryStateCache.put(r32, s32)) - require.NoError(t, service.epochBoundaryStateCache.put(r64, s64)) - require.NoError(t, service.epochBoundaryStateCache.put(r128, s128)) + chain.finalize(t, 128) + assert.Equal(t, true, chain.db.IsFinalizedBlock(ctx, chain.roots[96]), "Canonical root was not finalized") + assert.Equal(t, false, chain.db.IsFinalizedBlock(ctx, orphanRoot), "Orphan root was finalized") - require.NoError(t, service.MigrateToCold(ctx, r128)) + require.NoError(t, service.MigrateToCold(ctx, chain.roots[128])) // State by the slot-96 root should remain reconstructible after migration. - got96, err := beaconDB.State(ctx, r96) + got96, err := chain.db.State(ctx, chain.roots[96]) + require.NoError(t, err) + wantRoot, err := chain.states[96].HashTreeRoot(ctx) require.NoError(t, err) - assert.DeepSSZEqual(t, s96.ToProtoUnsafe(), got96.ToProtoUnsafe(), "slot 96 state mismatch") + gotRoot, err := got96.HashTreeRoot(ctx) + require.NoError(t, err) + assert.Equal(t, wantRoot, gotRoot, "slot 96 state root mismatch") + _, err = chain.db.State(ctx, orphanRoot) + require.ErrorContains(t, "state root mismatch", err) +} + +func TestMigrateToColdHdiff_OrphanOnlyBoundaryUsesFinalizedAncestry(t *testing.T) { + ctx := t.Context() + chain := setupHdiffMigrationTestChain(t, 32, 64, 128) + orphanConfig := util.DefaultBlockGenConfig() + orphanConfig.NumAttestations = 0 + orphanRoot, _ := chain.addBlock(t, chain.states[64], orphanConfig, 96) + service := New(chain.db, doublylinkedtree.New()) + service.finalizedInfo = &finalizedInfo{root: chain.roots[0], state: chain.states[0]} + chain.finalize(t, 128) + + require.NoError(t, service.MigrateToCold(ctx, chain.roots[128])) + + // There is no canonical root at slot 96 in this fixture, so the root-keyed + // read can only exclude the orphan. The preceding test positively reads its + // canonical slot-96 root. + _, err := chain.db.State(ctx, orphanRoot) + require.ErrorContains(t, "state root mismatch", err) + assert.Equal(t, primitives.Slot(128), service.migratedSlot) + assert.DeepEqual(t, chain.roots[0], service.finalizedInfo.root) + assert.Equal(t, primitives.Slot(0), service.finalizedInfo.state.Slot()) } // TestMigrateToColdHdiff_NoOpWhenFinalizedSlotNotAdvanced verifies that @@ -601,10 +503,10 @@ func TestMigrateToColdHdiff_NoOpWhenFinalizedSlotNotAdvanced(t *testing.T) { finalizedState := beaconState.Copy() require.NoError(t, finalizedState.SetSlot(50)) service.finalizedInfo = &finalizedInfo{ - slot: 50, root: gRoot, state: finalizedState, } + service.migratedSlot = 50 // Create block at same slot 50. b := util.NewBeaconBlock() @@ -617,3 +519,76 @@ func TestMigrateToColdHdiff_NoOpWhenFinalizedSlotNotAdvanced(t *testing.T) { // Migration should be a no-op (finalized slot not advancing). require.NoError(t, service.MigrateToCold(ctx, fRoot)) } + +// hdiffMigrationTestChain is a helper struct for setting up a test chain +// with finalized blocks and states for testing state diff migration. +type hdiffMigrationTestChain struct { + db *kv.Store + keys []bls.SecretKey + roots map[primitives.Slot][32]byte + states map[primitives.Slot]state.BeaconState +} + +func setupHdiffMigrationTestChain(t *testing.T, slots ...primitives.Slot) *hdiffMigrationTestChain { + t.Helper() + ctx := t.Context() + setStateDiffExponents() + db := testDB.SetupDB(t).(*kv.Store) + require.NoError(t, db.InitStateDiffCacheForTesting(t, 0)) + t.Cleanup(features.InitWithReset(&features.Flags{EnableStateDiff: true})) + + genesisState, keys := util.DeterministicGenesisState(t, 32) + genesisStateRoot, err := genesisState.HashTreeRoot(ctx) + require.NoError(t, err) + genesis := blocks.NewGenesisBlock(genesisStateRoot[:]) + util.SaveBlock(t, ctx, db, genesis) + genesisRoot, err := genesis.Block.HashTreeRoot() + require.NoError(t, err) + require.NoError(t, db.SaveGenesisBlockRoot(ctx, genesisRoot)) + require.NoError(t, db.SaveState(ctx, genesisState, genesisRoot)) + require.NoError(t, db.SaveStateSummary(ctx, ðpb.StateSummary{Slot: 0, Root: genesisRoot[:]})) + + chain := &hdiffMigrationTestChain{ + db: db, + keys: keys, + roots: map[primitives.Slot][32]byte{0: genesisRoot}, + states: map[primitives.Slot]state.BeaconState{0: genesisState}, + } + current := genesisState + for _, slot := range slots { + root, nextState := chain.addBlock(t, current, util.DefaultBlockGenConfig(), slot) + chain.roots[slot] = root + chain.states[slot] = nextState + current = nextState + } + return chain +} + +func (chain *hdiffMigrationTestChain) finalize(t *testing.T, slot primitives.Slot) { + t.Helper() + root := chain.roots[slot] + require.NoError(t, chain.db.SaveFinalizedCheckpoint(t.Context(), ðpb.Checkpoint{ + Epoch: primitives.Epoch(slot / params.BeaconConfig().SlotsPerEpoch), + Root: root[:], + })) +} + +func (chain *hdiffMigrationTestChain) addBlock( + t *testing.T, + preState state.BeaconState, + config *util.BlockGenConfig, + slot primitives.Slot, +) ([32]byte, state.BeaconState) { + t.Helper() + block, err := util.GenerateFullBlock(preState, chain.keys, config, slot) + require.NoError(t, err) + signed, err := consensusblocks.NewSignedBeaconBlock(block) + require.NoError(t, err) + postState, err := executeStateTransitionStateGen(t.Context(), preState.Copy(), signed) + require.NoError(t, err) + root, err := block.Block.HashTreeRoot() + require.NoError(t, err) + util.SaveBlock(t, t.Context(), chain.db, block) + require.NoError(t, chain.db.SaveStateSummary(t.Context(), ðpb.StateSummary{Slot: slot, Root: root[:]})) + return root, postState.Copy() +} diff --git a/beacon-chain/state/stategen/mock/mock.go b/beacon-chain/state/stategen/mock/mock.go index 8cc13481bdd2..50af605932fe 100644 --- a/beacon-chain/state/stategen/mock/mock.go +++ b/beacon-chain/state/stategen/mock/mock.go @@ -36,7 +36,7 @@ func (_ *StateManager) Resume(_ context.Context, _ state.BeaconState) (state.Bea } // SaveFinalizedState -- -func (_ *StateManager) SaveFinalizedState(_ primitives.Slot, _ [32]byte, _ state.BeaconState) { +func (_ *StateManager) SaveFinalizedState(_ [32]byte, _ state.BeaconState) { panic("implement me") } diff --git a/beacon-chain/state/stategen/service.go b/beacon-chain/state/stategen/service.go index 8f499b62d35f..ed33675f4f24 100644 --- a/beacon-chain/state/stategen/service.go +++ b/beacon-chain/state/stategen/service.go @@ -44,7 +44,7 @@ type StateManager interface { DeleteStateFromCaches(ctx context.Context, blockRoot [32]byte) error ForceCheckpoint(ctx context.Context, root []byte) error SaveState(ctx context.Context, blockRoot [32]byte, st state.BeaconState) error - SaveFinalizedState(fSlot primitives.Slot, fRoot [32]byte, fState state.BeaconState) + SaveFinalizedState(fRoot [32]byte, fState state.BeaconState) MigrateToCold(ctx context.Context, fRoot [32]byte) error StateByRoot(ctx context.Context, blockRoot [32]byte) (state.BeaconState, error) StateByRootNoCopy(ctx context.Context, blockRoot [32]byte) (state.ReadOnlyBeaconState, error) @@ -64,6 +64,7 @@ type State struct { saveHotStateDB *saveHotStateDbConfig avb coverage.AvailableBlocker migrationLock *sync.Mutex + migratedSlot primitives.Slot // guarded by migrationLock after initialization fc forkchoice.ForkChoicer } @@ -77,10 +78,8 @@ type saveHotStateDbConfig struct { blockRootsOfSavedStates [][32]byte } -// This tracks the finalized point. It's also the point where slot and the block root of -// cold and hot sections of the DB splits. +// finalizedInfo caches a finalized block root and its matching state for replay and balance lookups. type finalizedInfo struct { - slot primitives.Slot root [32]byte state state.BeaconState lock sync.RWMutex @@ -102,7 +101,7 @@ func New(beaconDB db.NoHeadAccessDatabase, fc forkchoice.ForkChoicer, opts ...Op s := &State{ beaconDB: beaconDB, hotStateCache: newHotStateCache(), - finalizedInfo: &finalizedInfo{slot: 0, root: params.BeaconConfig().ZeroHash}, + finalizedInfo: &finalizedInfo{root: params.BeaconConfig().ZeroHash}, slotsPerArchivedPoint: params.BeaconConfig().SlotsPerArchivedPoint, epochBoundaryStateCache: newBoundaryStateCache(), saveHotStateDB: &saveHotStateDbConfig{ @@ -158,7 +157,8 @@ func (s *State) Resume(ctx context.Context, fState state.BeaconState) (state.Bea } }() - s.finalizedInfo = &finalizedInfo{slot: st.Slot(), root: fRoot, state: st.Copy()} + s.migratedSlot = st.Slot() + s.finalizedInfo = &finalizedInfo{root: fRoot, state: st.Copy()} populatePubkeyCache(ctx, st) return st, nil } @@ -192,22 +192,12 @@ func populatePubkeyCache(ctx context.Context, st state.ReadOnlyBeaconState) { }) } -// SaveFinalizedState saves the finalized slot, root and state into memory to be used by state gen service. -// This used for migration at the correct start slot and used for hot state play back to ensure -// lower bound to start is always at the last finalized state. -func (s *State) SaveFinalizedState(fSlot primitives.Slot, fRoot [32]byte, fState state.BeaconState) { +// SaveFinalizedState caches a finalized block root and its matching state. +func (s *State) SaveFinalizedState(fRoot [32]byte, fState state.BeaconState) { s.finalizedInfo.lock.Lock() defer s.finalizedInfo.lock.Unlock() s.finalizedInfo.root = fRoot s.finalizedInfo.state = fState.Copy() - s.finalizedInfo.slot = fSlot -} - -// Returns the cached and copied finalized state. -func (s *State) FinalizedState() state.BeaconState { - s.finalizedInfo.lock.RLock() - defer s.finalizedInfo.lock.RUnlock() - return s.finalizedInfo.state.Copy() } // finalizedStateIfRoot returns a copy of the cached finalized state only if diff --git a/beacon-chain/state/stategen/service_test.go b/beacon-chain/state/stategen/service_test.go index d3b2632a81cd..41a43f086f03 100644 --- a/beacon-chain/state/stategen/service_test.go +++ b/beacon-chain/state/stategen/service_test.go @@ -30,7 +30,5 @@ func TestResume(t *testing.T) { resumeState, err := service.Resume(ctx, beaconState) require.NoError(t, err) require.DeepSSZEqual(t, beaconState.ToProtoUnsafe(), resumeState.ToProtoUnsafe()) - assert.Equal(t, params.BeaconConfig().SlotsPerEpoch, service.finalizedInfo.slot, "Did not get watned slot") assert.Equal(t, service.finalizedInfo.root, root, "Did not get wanted root") - assert.NotNil(t, service.FinalizedState(), "Wanted a non nil finalized state") } diff --git a/changelog/syjn99_statediff-fork-aware-migrate.md b/changelog/syjn99_statediff-fork-aware-migrate.md new file mode 100644 index 000000000000..3cf0d5511a54 --- /dev/null +++ b/changelog/syjn99_statediff-fork-aware-migrate.md @@ -0,0 +1,4 @@ +### Fixed + +- Resolve cold-state migration boundaries using finalized canonical block roots, avoiding failures or orphan-state writes when multiple blocks share a slot. +- Track migration progress independently from the cached finalized state so cache misses do not repeat completed work or create inconsistent finalized metadata.