Added delayed finalization#2017
Added delayed finalization#2017esuwu wants to merge 4 commits intodetermenistic-finality-featurefrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces "delayed finalization" — a 2-block visibility delay on finalized heights. A newly finalized height (PendingBlockHeight) is stored in the finalization record but only becomes externally visible when currentHeight >= PendingBlockHeight + 2. Internal finalization processing continues to use the pending height immediately. The PR also removes the redundant lastFinalizedHeight parameter from FormFinalization(), instead taking the height directly from endorsements in the pool.
Changes:
- Extends
finalizationRecordwith aPendingBlockHeightfield, addsnewestVisible()for delayed external visibility andnewestForProcessing()for immediate internal use, and updatesstore()to handle the pending/visible promotion logic - Updates
FormFinalization()inEndorsementPoolto readFinalizedBlockHeightdirectly from the endorsements (removing thelastFinalizedHeightparameter), and removes thelastFinalizedHeightfetch frommineMicro - Adds a new state test verifying the N+2 visibility behavior, and updates existing tests to pass the required
currentHeightargument tostore()
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/state/finalization.go |
Core change: adds PendingBlockHeight to finalizationRecord, implements delayed visibility via newestVisibleHeight/newestVisible, and updates store() with promotion logic |
pkg/state/state.go |
Switches LastFinalizedHeight() to use newestVisible() and updates softRollback to pass height to the new store() signature |
pkg/state/appender.go |
Updates loadLastFinalizedHeight to use newestForProcessing(), finalizeParent to pass height to store(), and expands the finalization-failure debug log |
pkg/state/keys.go |
Removes the now-unused finalizationKey struct in favor of directly using []byte{finalizationKeyPrefix} |
pkg/state/state_test.go |
Updates existing tests for the new store() signature and adds TestLastFinalizedHeight_ExposesPreFinalizationOnlyAtNPlus2 |
pkg/miner/endorsementpool/endorsement_pool.go |
Changes FormFinalization() to drop the lastFinalizedHeight parameter, reading height from the endorsements directly |
pkg/node/fsm/ng_state.go |
Removes lastFinalizedHeight fetch from mineMicro and simplifies getCurrentFinalizationVoting signature |
pkg/types/types.go |
Updates EndorsementPool interface to reflect the new FormFinalization() signature |
Comments suppressed due to low confidence (1)
pkg/miner/endorsementpool/endorsement_pool.go:233
- The
FormFinalizationfunction has been significantly changed: instead of acceptinglastFinalizedHeightas a parameter, it now readsFinalizedBlockHeightfrom the first endorsement in the pool (p.h[0].eb.FinalizedBlockHeight). There are no unit tests forFormFinalizationinendorsementpool_test.goto validate that the returnedFinalizationVoting.FinalizedBlockHeightmatches the expected endorsement's height. Given that other pool behaviors are well-tested in this file, a test covering theFormFinalizationbehavior (including the correct height extraction) would be appropriate.
func (p *EndorsementPool) FormFinalization() (proto.FinalizationVoting, error) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.h) == 0 {
return proto.FinalizationVoting{}, errors.New("failed to form finalization: pool is empty")
}
signatures := make([]bls.Signature, 0, len(p.h))
endorsersIndexes := make([]int32, 0, len(p.h))
var aggregatedSignature bls.Signature
for _, it := range p.h {
signatures = append(signatures, it.eb.Signature)
endorsersIndexes = append(endorsersIndexes, it.eb.EndorserIndex)
}
if len(signatures) != 0 {
aggregatedSignatureBytes, err := bls.AggregateSignatures(signatures)
if err != nil {
return proto.FinalizationVoting{}, err
}
var errCnvrt error
aggregatedSignature, errCnvrt = bls.NewSignatureFromBytes(aggregatedSignatureBytes)
if errCnvrt != nil {
return proto.FinalizationVoting{}, errCnvrt
}
}
return proto.FinalizationVoting{
AggregatedEndorsementSignature: aggregatedSignature,
FinalizedBlockHeight: proto.Height(p.h[0].eb.FinalizedBlockHeight),
EndorserIndexes: endorsersIndexes,
ConflictEndorsements: p.conflicts,
}, nil
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| err = importer.ApplyFromFile( | ||
| t.Context(), | ||
| importer.ImportParams{Schema: sets.AddressSchemeCharacter, BlockchainPath: blocksPath, LightNodeMode: false}, | ||
| manager, importHeight, importHeight, |
There was a problem hiding this comment.
The second ApplyFromFile call imports importHeight (15) blocks starting at startHeight=importHeight (15), advancing the chain from height 15 to approximately height 30. However, only a single additional block (reaching height 16) is necessary for the condition currentHeight >= pendingHeight+2 = 14+2 = 16 to be satisfied and the pending finalization to become visible. The comment "After one more block (N+2)" is misleading since 15 blocks are imported rather than one. Consider using startHeight=importHeight and nBlocks=1 instead to precisely test the N+2 threshold.
| manager, importHeight, importHeight, | |
| manager, importHeight, 1, |
| if h, finErr := s.stor.finalizations.newest(); finErr == nil { | ||
| finalizationHeight = h | ||
| finalizationExists = true | ||
| // TODO should we return for these errors too? | ||
| } else if !errors.Is(finErr, ErrNoFinalization) && !errors.Is(finErr, ErrNoFinalizationHistory) { | ||
| return wrapErr(stateerr.RollbackError, finErr) | ||
| } | ||
| if rollbackErr := s.rollbackToImpl(blockID); rollbackErr != nil { | ||
| return rollbackErr | ||
| } | ||
| if finalizationExists { | ||
| if storeErr := s.stor.finalizations.store(finalizationHeight, blockID); storeErr != nil { | ||
| if storeErr := s.stor.finalizations.store(finalizationHeight, height, blockID); storeErr != nil { |
There was a problem hiding this comment.
In softRollback, newest() (now an alias for newestForProcessing()) returns the maximum of FinalizedBlockHeight and PendingBlockHeight. When that value is the PendingBlockHeight, the subsequent call to store(finalizationHeight, height, blockID) can lose the original FinalizedBlockHeight.
Concrete scenario: if the stored record is {FinalizedBlockHeight: 5, PendingBlockHeight: 10} and we roll back to height 8:
newest()returns10(pending)store(10, 8, blockID)is called; since8 < 12 (= 10+2),FinalizedBlockHeightis not promoted and stays0- Only
PendingBlockHeight=10is persisted; the originalFinalizedBlockHeight=5is lost - After rollback,
LastFinalizedHeight()at height 8 would returnCalculateLastFinalizedHeight(8) = 1instead of the correct5
The fix is to have softRollback read the full finalizationRecord (via newestRecord()) before the rollback and write it back directly via writeRecord() after the rollback, rather than going through the store() path which performs logic that can drop FinalizedBlockHeight.
No description provided.