Skip to content

Commit 76c6ace

Browse files
Merge pull request #40 from 0xPolygon/ppatil/honest-catching-up
feat(consensus): report catching_up when fallen behind peers
1 parent 69245ad commit 76c6ace

11 files changed

Lines changed: 406 additions & 2 deletions

File tree

config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,26 @@ type ConsensusConfig struct {
10301030

10311031
// BlockTimeTolerance is the maximum allowed difference between the proposed block time and wall-clock time.
10321032
BlockTimeTolerance time.Duration `mapstructure:"block_time_tolerance"`
1033+
1034+
// CatchupLagThreshold is how many blocks ahead of us a peer must be to count as
1035+
// reporting us behind; catching_up=true requires a majority of connected peers to
1036+
// be that far ahead. The reactor's WaitSync latch only reflects the initial
1037+
// block-sync at startup and stays false for the rest of the process, so without
1038+
// this check a node that later stops keeping up with its peers still reports
1039+
// catching_up=false. The comparison is against peer-reported heights rather than
1040+
// block-time staleness, so a network where every node has legitimately stopped at
1041+
// the same height is not misreported as catching up; peer heights are unverified
1042+
// gossip, so the lag must be corroborated by a majority of at least two connected
1043+
// peers, which keeps a single peer from driving the signal.
1044+
// 0 disables peer-height lag detection only; must be >=2 when enabled to absorb
1045+
// the normal one-height round skew between synced peers. The separate zero-peer
1046+
// rule (a node with no peers that is not the sole validator reports catching_up)
1047+
// always applies, independent of this threshold.
1048+
CatchupLagThreshold int64 `mapstructure:"catchup_lag_threshold"`
1049+
// CatchupDebounceDuration is how long the peer-lag condition must hold
1050+
// continuously before catching_up flips to true, damping flapping at the
1051+
// threshold boundary. The transition back to false is immediate.
1052+
CatchupDebounceDuration time.Duration `mapstructure:"catchup_debounce_duration"`
10331053
}
10341054

10351055
// DefaultConsensusConfig returns a default configuration for the consensus service
@@ -1050,6 +1070,8 @@ func DefaultConsensusConfig() *ConsensusConfig {
10501070
PeerQueryMaj23SleepDuration: 2000 * time.Millisecond,
10511071
DoubleSignCheckHeight: int64(0),
10521072
BlockTimeTolerance: 60 * time.Second,
1073+
CatchupLagThreshold: 5,
1074+
CatchupDebounceDuration: 10 * time.Second,
10531075
}
10541076
}
10551077

@@ -1155,6 +1177,15 @@ func (cfg *ConsensusConfig) ValidateBasic() error {
11551177
if cfg.BlockTimeTolerance <= 0 {
11561178
return errors.New("block_time_tolerance must be positive")
11571179
}
1180+
if cfg.CatchupLagThreshold < 0 {
1181+
return errors.New("catchup_lag_threshold can't be negative")
1182+
}
1183+
if cfg.CatchupLagThreshold == 1 {
1184+
return errors.New("catchup_lag_threshold must be 0 (disabled) or >=2 (margin beyond the one-height round skew)")
1185+
}
1186+
if cfg.CatchupDebounceDuration < 0 {
1187+
return errors.New("catchup_debounce_duration can't be negative")
1188+
}
11581189
return nil
11591190
}
11601191

config/config_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ func TestConsensusConfig_ValidateBasic(t *testing.T) {
179179
"BlockTimeTolerance": {func(c *config.ConsensusConfig) { c.BlockTimeTolerance = time.Second }, false},
180180
"BlockTimeTolerance zero": {func(c *config.ConsensusConfig) { c.BlockTimeTolerance = 0 }, true},
181181
"BlockTimeTolerance negative": {func(c *config.ConsensusConfig) { c.BlockTimeTolerance = -1 }, true},
182+
"CatchupLagThreshold disabled": {func(c *config.ConsensusConfig) { c.CatchupLagThreshold = 0 }, false},
183+
"CatchupLagThreshold one": {func(c *config.ConsensusConfig) { c.CatchupLagThreshold = 1 }, true},
184+
"CatchupLagThreshold two": {func(c *config.ConsensusConfig) { c.CatchupLagThreshold = 2 }, false},
185+
"CatchupLagThreshold negative": {func(c *config.ConsensusConfig) { c.CatchupLagThreshold = -1 }, true},
186+
"CatchupDebounceDuration zero": {func(c *config.ConsensusConfig) { c.CatchupDebounceDuration = 0 }, false},
187+
"CatchupDebounceDuration negative": {func(c *config.ConsensusConfig) { c.CatchupDebounceDuration = -1 }, true},
182188
}
183189
for desc, tc := range testcases {
184190
t.Run(desc, func(t *testing.T) {
@@ -195,6 +201,12 @@ func TestConsensusConfig_ValidateBasic(t *testing.T) {
195201
}
196202
}
197203

204+
func TestDefaultConsensusConfigCatchupDefaults(t *testing.T) {
205+
cfg := config.DefaultConsensusConfig()
206+
assert.Equal(t, int64(5), cfg.CatchupLagThreshold)
207+
assert.Equal(t, 10*time.Second, cfg.CatchupDebounceDuration)
208+
}
209+
198210
func TestInstrumentationConfigValidateBasic(t *testing.T) {
199211
cfg := config.TestInstrumentationConfig()
200212
assert.NoError(t, cfg.ValidateBasic())

config/toml.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,17 @@ peer_query_maj23_sleep_duration = "{{ .Consensus.PeerQueryMaj23SleepDuration }}"
519519
# Maximum allowed difference between proposed block time and wall-clock time.
520520
block_time_tolerance = "{{ .Consensus.BlockTimeTolerance }}"
521521
522+
# After initial block-sync completes, report catching_up=true when a majority of the
523+
# connected peers (at least two) are more than this many blocks ahead of us (i.e. this
524+
# node has stopped keeping up). Set to 0 to disable peer-height lag detection only;
525+
# must be >=2 when enabled to absorb normal round skew. A node with no peers that is
526+
# not the sole validator still reports catching_up regardless of this setting.
527+
catchup_lag_threshold = {{ .Consensus.CatchupLagThreshold }}
528+
529+
# How long the peer-lag condition must hold before catching_up flips to true
530+
# (flap damping). The transition back to false is immediate.
531+
catchup_debounce_duration = "{{ .Consensus.CatchupDebounceDuration }}"
532+
522533
#######################################################
523534
### Storage Configuration Options ###
524535
#######################################################

consensus/reactor.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ type Reactor struct {
4646
eventBus *types.EventBus
4747
rs *cstypes.RoundState
4848

49+
// catchUpLagThreshold and catchUpDebounce drive IsBehind, which lets /status
50+
// report catching_up=true after initial sync when this node has fallen behind
51+
// live peers. behindSince tracks how long the lag condition has held
52+
// continuously, for debouncing.
53+
catchUpLagThreshold int64
54+
catchUpDebounce time.Duration
55+
behindSince time.Time
56+
4957
Metrics *Metrics
5058
}
5159

@@ -422,6 +430,97 @@ func (conR *Reactor) WaitSync() bool {
422430
return conR.waitSync
423431
}
424432

433+
// IsBehind reports whether this node has stopped keeping up with its peers after
434+
// the initial block-sync has completed. WaitSync only reflects startup block-sync
435+
// and stays false for the rest of the process, so on its own catching_up never
436+
// reflects a node that later falls behind. IsBehind closes that gap from
437+
// peer-reported heights.
438+
//
439+
// It compares heights rather than block-time staleness on purpose: a stale local
440+
// block time can't distinguish a node that is behind its peers (some peer reports a
441+
// greater height) from a network where every node has legitimately stopped at the
442+
// same height (no peer is ahead). Only the former is "catching up"; reporting the
443+
// latter as catching up would be a false positive.
444+
func (conR *Reactor) IsBehind() bool {
445+
raw := conR.isBehindRaw()
446+
447+
conR.mtx.Lock()
448+
defer conR.mtx.Unlock()
449+
return conR.applyDebounceLocked(raw, time.Now())
450+
}
451+
452+
// isBehindRaw gathers the local height and peer heights and applies the lag
453+
// decision, without debouncing. It holds no lock while reading peers / round state.
454+
func (conR *Reactor) isBehindRaw() bool {
455+
peerHeights := collectPeerHeights(conR.Switch.Peers().List())
456+
457+
// Sole-validator status is read live from consensus state on every call, so it
458+
// reflects validator-set changes this node has committed into local round state
459+
// without any cached value to update. (A node partitioned before a set change
460+
// can't observe the other side's update, but that only makes it report behind,
461+
// which is correct.)
462+
return conR.evaluateBehind(conR.getRoundState().Height, peerHeights, conR.conS.isLocalSoleValidator())
463+
}
464+
465+
// collectPeerHeights returns the gossiped consensus height of every peer that
466+
// carries a consensus PeerState. Peers without one (key absent or wrong type)
467+
// are skipped rather than counted as height 0, so they don't dilute the
468+
// majority calculation in evaluateBehind.
469+
func collectPeerHeights(peers []p2p.Peer) []int64 {
470+
heights := make([]int64, 0, len(peers))
471+
for _, peer := range peers {
472+
ps, ok := peer.Get(types.PeerStateKey).(*PeerState)
473+
if !ok {
474+
continue
475+
}
476+
heights = append(heights, ps.GetHeight())
477+
}
478+
return heights
479+
}
480+
481+
// minCorroboratingPeers is the fewest connected peers required before peer-height
482+
// lag is trusted. Peer heights come from unverified gossip (NewRoundStepMessage), and
483+
// with a single peer that peer is a trivial "majority"; requiring at least two means
484+
// no single peer can drive catching_up on its own. Below this we can't corroborate, so
485+
// the height-lag check abstains (the zero-peer rule still covers the no-peer case).
486+
const minCorroboratingPeers = 2
487+
488+
// evaluateBehind is the pure lag decision. With no peers we can't observe progress,
489+
// so we report behind unless this node can finalize on its own (it's the sole
490+
// validator); a non-validator or a validator in a larger set genuinely needs peers.
491+
// Otherwise we report behind only when a majority of at least minCorroboratingPeers
492+
// connected peers report a height more than catchUpLagThreshold ahead of ours, so an
493+
// inflated height from a single peer (or a minority) can't drive the signal. A zero
494+
// threshold disables the height-lag check; the zero-peer rule still applies.
495+
func (conR *Reactor) evaluateBehind(myHeight int64, peerHeights []int64, isSoleValidator bool) bool {
496+
if len(peerHeights) == 0 {
497+
return !isSoleValidator
498+
}
499+
if conR.catchUpLagThreshold <= 0 || len(peerHeights) < minCorroboratingPeers {
500+
return false
501+
}
502+
ahead := 0
503+
for _, h := range peerHeights {
504+
if h-myHeight > conR.catchUpLagThreshold {
505+
ahead++
506+
}
507+
}
508+
return 2*ahead > len(peerHeights)
509+
}
510+
511+
// applyDebounceLocked requires the lag condition to hold for catchUpDebounce before
512+
// returning true; recovery to false is immediate. conR.mtx must be held.
513+
func (conR *Reactor) applyDebounceLocked(raw bool, now time.Time) bool {
514+
if !raw {
515+
conR.behindSince = time.Time{}
516+
return false
517+
}
518+
if conR.behindSince.IsZero() {
519+
conR.behindSince = now
520+
}
521+
return now.Sub(conR.behindSince) >= conR.catchUpDebounce
522+
}
523+
425524
//--------------------------------------
426525

427526
// subscribeToBroadcastEvents subscribes for new round steps and votes
@@ -1057,6 +1156,14 @@ func ReactorMetrics(metrics *Metrics) ReactorOption {
10571156
return func(conR *Reactor) { conR.Metrics = metrics }
10581157
}
10591158

1159+
// ReactorCatchupConfig configures the IsBehind heuristic that backs catching_up.
1160+
func ReactorCatchupConfig(lagThreshold int64, debounce time.Duration) ReactorOption {
1161+
return func(conR *Reactor) {
1162+
conR.catchUpLagThreshold = lagThreshold
1163+
conR.catchUpDebounce = debounce
1164+
}
1165+
}
1166+
10601167
//-----------------------------------------------------------------------------
10611168

10621169
var (

0 commit comments

Comments
 (0)