core: repair stale processing head on restart

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-21 15:25:04 -07:00
co-authored by Hanzo Dev
parent 078e7b5e2d
commit 7178099092
2 changed files with 160 additions and 38 deletions
+82 -38
View File
@@ -849,6 +849,14 @@ func (bc *BlockChain) loadLastState(lastAcceptedHash common.Hash) error {
return bc.loadGenesisState()
}
// The consensus-owned accepted pointer is the durable recovery anchor. Load
// it before the optimistic processing head so a crash after rejecting a
// processing block can be repaired even if the old head body was deleted.
bc.lastAccepted = bc.GetBlockByHash(lastAcceptedHash)
if bc.lastAccepted == nil {
return fmt.Errorf("could not load last accepted block %s", lastAcceptedHash.Hex())
}
// Restore the last known head block
head := rawdb.ReadHeadBlockHash(bc.db)
if head == (common.Hash{}) {
@@ -857,7 +865,21 @@ func (bc *BlockChain) loadLastState(lastAcceptedHash common.Hash) error {
// Make sure the entire head block is available
headBlock := bc.GetBlockByHash(head)
if headBlock == nil {
return fmt.Errorf("could not load head block %s", head.Hex())
// Older releases could delete a rejected canonical processing block
// without first rewinding the head markers. Recover only from the exact
// block authenticated by the consensus last-accepted pointer; never guess
// another ancestor or silently fall back to genesis.
log.Warn("[HEAD-POINTER-REPAIR] processing head body is missing; recovering from last accepted block",
"missingHead", head,
"accepted", bc.lastAccepted.Hash(),
"acceptedHeight", bc.lastAccepted.NumberU64(),
)
if err := bc.deleteCanonicalHashesAbove(bc.lastAccepted.NumberU64()); err != nil {
return fmt.Errorf("could not remove canonical assignments above repaired head %s:%d: %w",
bc.lastAccepted.Hash(), bc.lastAccepted.NumberU64(), err)
}
bc.writeHeadBlock(bc.lastAccepted)
headBlock = bc.lastAccepted
}
// Everything seems to be fine, set as the head block
bc.currentBlock.Store(headBlock.Header())
@@ -874,13 +896,7 @@ func (bc *BlockChain) loadLastState(lastAcceptedHash common.Hash) error {
log.Info("Loaded most recent local header", "number", currentHeader.Number, "hash", currentHeader.Hash(), "age", common.PrettyAge(time.Unix(int64(currentHeader.Time), 0)))
log.Info("Loaded most recent local full block", "number", headBlock.Number(), "hash", headBlock.Hash(), "age", common.PrettyAge(time.Unix(int64(headBlock.Time()), 0)))
// Otherwise, set the last accepted block and perform a re-org.
bc.lastAccepted = bc.GetBlockByHash(lastAcceptedHash)
if bc.lastAccepted == nil {
return fmt.Errorf("could not load last accepted block")
}
// This ensures that the head block is updated to the last accepted block on startup
// This ensures that the head block is updated to the last accepted block on startup.
if err := bc.setPreference(bc.lastAccepted); err != nil {
return fmt.Errorf("failed to set preference to last accepted block while loading last state: %w", err)
}
@@ -975,6 +991,34 @@ func (bc *BlockChain) writeHeadBlock(block *types.Block) {
bc.currentBlock.Store(block.Header())
}
// deleteCanonicalHashesAbove removes stale canonical number assignments above
// a retained head. It is shared by normal reorgs and startup head-pointer repair
// so both paths leave the same durable canonical index shape.
func (bc *BlockChain) deleteCanonicalHashesAbove(head uint64) error {
// Chunk writes to avoid BadgerDB ErrTxnTooBig for large rewinds.
const indexBatchSize = 1000
batch := bc.db.NewBatch()
batchCount := 0
for height := head + 1; ; height++ {
if rawdb.ReadCanonicalHash(bc.db, height) == (common.Hash{}) {
break
}
rawdb.DeleteCanonicalHash(batch, height)
batchCount++
if batchCount >= indexBatchSize {
if err := batch.Write(); err != nil {
return err
}
batch = bc.db.NewBatch()
batchCount = 0
}
}
if batchCount > 0 {
return batch.Write()
}
return nil
}
// ValidateCanonicalChain confirms a canonical chain is well-formed.
func (bc *BlockChain) ValidateCanonicalChain() error {
// Ensure all accepted blocks are fully processed
@@ -1590,6 +1634,32 @@ func (bc *BlockChain) Reject(block *types.Block) error {
bc.chainmu.Lock()
defer bc.chainmu.Unlock()
// A processing block may be the current canonical head even though consensus
// later rejects it. Deleting that block without first moving the durable head
// markers leaves ReadHeadBlockHash pointing at a body that no longer exists;
// the next restart then fails in loadLastState with "could not load head
// block". If the rejected block is canonical at its height, rewind the full
// processing head to the retained parent before deleting any data. This also
// removes canonical assignments for descendants of the rejected block.
if bc.GetCanonicalHash(block.NumberU64()) == block.Hash() {
if block.NumberU64() == 0 {
return errors.New("cannot reject the canonical genesis block")
}
if block.NumberU64() <= bc.lastAccepted.NumberU64() {
return fmt.Errorf("cannot reject canonical block %s:%d at or below last accepted height %d",
block.Hash(), block.NumberU64(), bc.lastAccepted.NumberU64())
}
parent := bc.GetBlock(block.ParentHash(), block.NumberU64()-1)
if parent == nil {
return fmt.Errorf("cannot reject canonical block %s:%d: parent %s is unavailable",
block.Hash(), block.NumberU64(), block.ParentHash())
}
if err := bc.setPreference(parent); err != nil {
return fmt.Errorf("cannot rewind canonical head before rejecting %s:%d: %w",
block.Hash(), block.NumberU64(), err)
}
}
// Reject Trie
if err := bc.stateManager.RejectTrie(block); err != nil {
return fmt.Errorf("unable to reject trie: %w", err)
@@ -2061,36 +2131,10 @@ func (bc *BlockChain) reorg(oldHead *types.Header, newHead *types.Block) error {
bc.writeHeadBlock(newChain[i])
}
// Delete any canonical number assignments above the new head
// Use chunked batches to avoid BadgerDB ErrTxnTooBig when many blocks are present
// (e.g., after RLP import of large chains).
const indexBatchSize = 1000
indexesBatch := bc.db.NewBatch()
batchCount := 0
// Use the height of [newHead] to determine which canonical hashes to remove
// in case the new chain is shorter than the old chain, in which case
// there may be hashes set on the canonical chain that were invalidated
// but not yet overwritten by the re-org.
for i := newHead.NumberU64() + 1; ; i++ {
hash := rawdb.ReadCanonicalHash(bc.db, i)
if hash == (common.Hash{}) {
break
}
rawdb.DeleteCanonicalHash(indexesBatch, i)
batchCount++
if batchCount >= indexBatchSize {
if err := indexesBatch.Write(); err != nil {
log.Crit("Failed to delete useless indexes", "err", err)
}
indexesBatch = bc.db.NewBatch()
batchCount = 0
}
}
if batchCount > 0 {
if err := indexesBatch.Write(); err != nil {
log.Crit("Failed to delete useless indexes", "err", err)
}
// Use the height of [newHead] to remove assignments invalidated when
// rewinding to a shorter chain.
if err := bc.deleteCanonicalHashesAbove(newHead.NumberU64()); err != nil {
return fmt.Errorf("failed to delete canonical assignments above %d: %w", newHead.NumberU64(), err)
}
// Send out events for logs from the old canon chain, and 'reborn'
+78
View File
@@ -480,3 +480,81 @@ func TestHeadPin_RestartAtNonBoundary(t *testing.T) {
require.NoError(t, err)
require.NoError(t, insertAccept(t, restarted, extra[0]), "cannot build H+1 after restart at non-boundary head")
}
// TestRejectCanonicalProcessingHead_RewindsDurableHead proves a rejected H+1
// processing block cannot leave the on-disk head pointer aimed at its deleted
// body. This is the devnet restart failure: H was accepted, H+1 became the
// canonical processing head, consensus rejected H+1, and loadLastState later
// failed with "could not load head block".
func TestRejectCanonicalProcessingHead_RewindsDurableHead(t *testing.T) {
gspec := &Genesis{Config: params.TestChainConfig}
_, chain, _, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), 2, 10, nil)
require.NoError(t, err)
db := rawdb.NewMemoryDatabase()
cfg := headPinConfig()
bc, err := createBlockChain(db, cfg, gspec, common.Hash{})
require.NoError(t, err)
require.NoError(t, insertAccept(t, bc, chain[0]))
accepted := chain[0]
processing := chain[1]
require.NoError(t, bc.InsertBlock(processing))
require.Equal(t, processing.Hash(), bc.CurrentHeader().Hash(), "processing block must be the pre-reject head")
require.Equal(t, processing.Hash(), rawdb.ReadHeadBlockHash(db), "durable pre-reject head mismatch")
require.NoError(t, bc.Reject(processing))
require.Equal(t, accepted.Hash(), bc.CurrentHeader().Hash(), "reject must rewind the in-memory head")
require.Equal(t, accepted.Hash(), rawdb.ReadHeadBlockHash(db), "reject must rewind the durable head before deleting the block")
require.Nil(t, bc.GetBlockByHash(processing.Hash()), "rejected processing body must be deleted")
bc.Stop()
restarted, err := createBlockChain(db, cfg, gspec, accepted.Hash())
require.NoError(t, err, "restart followed a stale head pointer to the rejected block")
defer restarted.Stop()
require.Equal(t, accepted.Hash(), restarted.CurrentHeader().Hash())
}
// TestLoadLastState_RepairsMissingProcessingHead reproduces a database already
// damaged by an older release: the processing head pointer and canonical index
// name H+1, its block body has been deleted, and the independent consensus
// pointer still names accepted H. Startup must recover exactly to H.
func TestLoadLastState_RepairsMissingProcessingHead(t *testing.T) {
gspec := &Genesis{Config: params.TestChainConfig}
_, chain, _, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), 2, 10, nil)
require.NoError(t, err)
db := rawdb.NewMemoryDatabase()
cfg := headPinConfig()
bc, err := createBlockChain(db, cfg, gspec, common.Hash{})
require.NoError(t, err)
require.NoError(t, insertAccept(t, bc, chain[0]))
accepted := chain[0]
processing := chain[1]
require.NoError(t, bc.InsertBlock(processing))
require.Equal(t, processing.Hash(), rawdb.ReadHeadBlockHash(db))
bc.Stop()
// Model the legacy Reject ordering: delete the canonical processing block
// while leaving both durable head markers and its canonical assignment.
batch := db.NewBatch()
rawdb.DeleteBlock(batch, processing.Hash(), processing.NumberU64())
require.NoError(t, batch.Write())
require.Equal(t, processing.Hash(), rawdb.ReadHeadBlockHash(db))
require.Equal(t, processing.Hash(), rawdb.ReadCanonicalHash(db, processing.NumberU64()))
require.Nil(t, rawdb.ReadBlock(db, processing.Hash(), processing.NumberU64()))
restarted, err := createBlockChain(db, cfg, gspec, accepted.Hash())
require.NoError(t, err)
defer restarted.Stop()
require.Equal(t, accepted.Hash(), restarted.CurrentHeader().Hash())
require.Equal(t, accepted.Hash(), rawdb.ReadHeadBlockHash(db))
require.Equal(t, accepted.Hash(), rawdb.ReadHeadHeaderHash(db))
require.Equal(t, common.Hash{}, rawdb.ReadCanonicalHash(db, processing.NumberU64()))
// The repaired database is not merely readable: it can extend and accept the
// next block normally after startup.
require.NoError(t, insertAccept(t, restarted, processing))
require.Equal(t, processing.Hash(), restarted.LastConsensusAcceptedBlock().Hash())
}