Skip to content

Commit 461e306

Browse files
authored
fix(chainselection): peer tracking limit (#1412)
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent ebbb057 commit 461e306

File tree

3 files changed

+492
-11
lines changed

3 files changed

+492
-11
lines changed

chainselection/event.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
PeerTipUpdateEventType event.EventType = "chainselection.peer_tip_update"
2525
ChainSwitchEventType event.EventType = "chainselection.chain_switch"
2626
ChainSelectionEventType event.EventType = "chainselection.selection"
27+
PeerEvictedEventType event.EventType = "chainselection.peer_evicted"
2728
)
2829

2930
// PeerTipUpdateEvent is published when a peer's chain tip is updated via
@@ -60,3 +61,10 @@ type ChainSelectionEvent struct {
6061
PeerCount int
6162
SwitchOccurred bool
6263
}
64+
65+
// PeerEvictedEvent is published when a tracked peer is evicted from the
66+
// chain selector to make room for a new peer. Subscribers (e.g. connection
67+
// manager) can use this to close the evicted peer's connection.
68+
type PeerEvictedEvent struct {
69+
ConnectionId ouroboros.ConnectionId
70+
}

chainselection/selector.go

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ import (
3030
const (
3131
defaultEvaluationInterval = 10 * time.Second
3232
defaultStaleTipThreshold = 60 * time.Second
33+
34+
// DefaultMaxTrackedPeers is the maximum number of peers tracked by
35+
// the ChainSelector. When a new peer is added and the limit is reached,
36+
// the least-recently-updated peer is evicted. This bounds memory usage
37+
// and CPU cost of chain selection, preventing Sybil-based resource
38+
// exhaustion.
39+
DefaultMaxTrackedPeers = 200
3340
)
3441

3542
// safeBlockDiff computes the difference between two block numbers as int64,
@@ -64,19 +71,21 @@ type ChainSelectorConfig struct {
6471
EvaluationInterval time.Duration
6572
StaleTipThreshold time.Duration
6673
SecurityParam uint64
74+
MaxTrackedPeers int // 0 means use DefaultMaxTrackedPeers
6775
}
6876

6977
// ChainSelector tracks chain tips from multiple peers and selects the best
7078
// chain according to Ouroboros Praos rules.
7179
type ChainSelector struct {
72-
config ChainSelectorConfig
73-
securityParam uint64
74-
peerTips map[ouroboros.ConnectionId]*PeerChainTip
75-
bestPeerConn *ouroboros.ConnectionId
76-
localTip ochainsync.Tip
77-
mutex sync.RWMutex
78-
ctx context.Context
79-
cancel context.CancelFunc
80+
config ChainSelectorConfig
81+
securityParam uint64
82+
maxTrackedPeers int
83+
peerTips map[ouroboros.ConnectionId]*PeerChainTip
84+
bestPeerConn *ouroboros.ConnectionId
85+
localTip ochainsync.Tip
86+
mutex sync.RWMutex
87+
ctx context.Context
88+
cancel context.CancelFunc
8089
}
8190

8291
// NewChainSelector creates a new ChainSelector with the given configuration.
@@ -91,10 +100,15 @@ func NewChainSelector(cfg ChainSelectorConfig) *ChainSelector {
91100
if cfg.StaleTipThreshold == 0 {
92101
cfg.StaleTipThreshold = defaultStaleTipThreshold
93102
}
103+
maxPeers := cfg.MaxTrackedPeers
104+
if maxPeers <= 0 {
105+
maxPeers = DefaultMaxTrackedPeers
106+
}
94107
return &ChainSelector{
95-
config: cfg,
96-
securityParam: cfg.SecurityParam,
97-
peerTips: make(map[ouroboros.ConnectionId]*PeerChainTip),
108+
config: cfg,
109+
securityParam: cfg.SecurityParam,
110+
maxTrackedPeers: maxPeers,
111+
peerTips: make(map[ouroboros.ConnectionId]*PeerChainTip),
98112
}
99113
}
100114

@@ -130,6 +144,7 @@ func (cs *ChainSelector) UpdatePeerTip(
130144
) bool {
131145
shouldEvaluate := false
132146
accepted := true
147+
var evictedConn *ouroboros.ConnectionId
133148

134149
func() {
135150
cs.mutex.Lock()
@@ -160,6 +175,20 @@ func (cs *ChainSelector) UpdatePeerTip(
160175
if peerTip, exists := cs.peerTips[connId]; exists {
161176
peerTip.UpdateTip(tip, vrfOutput)
162177
} else {
178+
// Evict the least-recently-updated peer if at capacity
179+
if len(cs.peerTips) >= cs.maxTrackedPeers {
180+
evictedConn = cs.evictLeastRecentPeerLocked()
181+
if evictedConn == nil {
182+
cs.config.Logger.Warn(
183+
"cannot accept new peer: at capacity and best peer is the only tracked peer",
184+
"connection_id", connId.String(),
185+
"peer_count", len(cs.peerTips),
186+
"max_tracked_peers", cs.maxTrackedPeers,
187+
)
188+
accepted = false
189+
return
190+
}
191+
}
163192
cs.peerTips[connId] = NewPeerChainTip(connId, tip, vrfOutput)
164193
}
165194

@@ -183,6 +212,15 @@ func (cs *ChainSelector) UpdatePeerTip(
183212
}
184213
}()
185214

215+
// Publish eviction event outside the lock to prevent deadlock
216+
if evictedConn != nil && cs.config.EventBus != nil {
217+
evt := event.NewEvent(
218+
PeerEvictedEventType,
219+
PeerEvictedEvent{ConnectionId: *evictedConn},
220+
)
221+
cs.config.EventBus.Publish(PeerEvictedEventType, evt)
222+
}
223+
186224
if !accepted {
187225
return false
188226
}
@@ -194,6 +232,65 @@ func (cs *ChainSelector) UpdatePeerTip(
194232
return true
195233
}
196234

235+
// evictLeastRecentPeerLocked removes the peer with the oldest LastUpdated
236+
// timestamp from the peerTips map. It never evicts the current best peer.
237+
// When multiple peers share the same LastUpdated timestamp (common on
238+
// Windows where clock resolution is ~15ms), the peer with the lowest
239+
// block number is evicted first. If block numbers also tie, the
240+
// connection ID string is used as a final deterministic tie-breaker.
241+
// Returns a pointer to the evicted connection ID, or nil if no eviction
242+
// was possible (e.g. the only tracked peer is the best peer).
243+
// Must be called with cs.mutex held.
244+
func (cs *ChainSelector) evictLeastRecentPeerLocked() *ouroboros.ConnectionId {
245+
var oldestConn ouroboros.ConnectionId
246+
var oldestTip *PeerChainTip
247+
found := false
248+
249+
for connId, peerTip := range cs.peerTips {
250+
// Never evict the current best peer
251+
if cs.bestPeerConn != nil && *cs.bestPeerConn == connId {
252+
continue
253+
}
254+
if !found {
255+
oldestConn = connId
256+
oldestTip = peerTip
257+
found = true
258+
continue
259+
}
260+
// Primary: oldest LastUpdated wins eviction
261+
if peerTip.LastUpdated.Before(oldestTip.LastUpdated) {
262+
oldestConn = connId
263+
oldestTip = peerTip
264+
} else if peerTip.LastUpdated.Equal(oldestTip.LastUpdated) {
265+
// Tie-break on block number: evict the peer with
266+
// the lower block number (less useful chain)
267+
if peerTip.Tip.BlockNumber < oldestTip.Tip.BlockNumber {
268+
oldestConn = connId
269+
oldestTip = peerTip
270+
} else if peerTip.Tip.BlockNumber == oldestTip.Tip.BlockNumber {
271+
// Final tie-break: deterministic by connection ID
272+
if connId.String() < oldestConn.String() {
273+
oldestConn = connId
274+
oldestTip = peerTip
275+
}
276+
}
277+
}
278+
}
279+
280+
if found {
281+
cs.config.Logger.Debug(
282+
"evicting least-recent peer due to tracking limit",
283+
"connection_id", oldestConn.String(),
284+
"last_updated", oldestTip.LastUpdated,
285+
"peer_count", len(cs.peerTips),
286+
"max_tracked_peers", cs.maxTrackedPeers,
287+
)
288+
delete(cs.peerTips, oldestConn)
289+
return &oldestConn
290+
}
291+
return nil
292+
}
293+
197294
// RemovePeer removes a peer from tracking.
198295
func (cs *ChainSelector) RemovePeer(connId ouroboros.ConnectionId) {
199296
var switchEvent *event.Event

0 commit comments

Comments
 (0)