Skip to content

Commit e7994d6

Browse files
tcoratgerclaude
andauthored
refactor(peer-manager): co-locate per-peer scoring and tighten docs (leanEthereum#1113)
Put the full request-lifecycle transition on the peer and leave the manager as a pure lookup-and-delegate, then trim the documentation. - Move score and in-flight updates onto the peer as record_success and record_failure, symmetric with on_request_start. The manager's success/failure handlers now just look up the peer and delegate, so the scoring formula no longer lives split across two types. - Delete on_request_complete; its clamp folds into the two recording methods. - Replace the candidate-filter loop in peer selection with a comprehension, keeping the min-slot short-circuit and the weight floor of one. - Collapse the double dict lookup in peer registration into a single get. - Correct the network-finalized-slot documentation: it is an estimated sync target from unverified peer claims, a liveness heuristic, never verified finality or a trust anchor. The deterministic tie-break rationale stays in the inline comment at the selection. - Note that stored peer status is unvalidated and used only for routing. Tests for the removed method become record_success and record_failure unit tests asserting the score clamps at both bounds. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b0360e2 commit e7994d6

2 files changed

Lines changed: 86 additions & 79 deletions

File tree

Lines changed: 56 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
"""
2-
Peer manager for sync operations.
3-
4-
Tracks peer chain status and selects peers for block requests.
5-
"""
1+
"""Tracks peer chain status and selects peers for block download requests."""
62

73
from __future__ import annotations
84

@@ -18,167 +14,156 @@
1814
from lean_spec.spec.forks import Slot
1915

2016
INITIAL_PEER_SCORE: Final = 100
21-
"""Starting score for newly added peers."""
17+
"""Starting score, mid-range so a new peer competes without dominating."""
2218

2319
MIN_PEER_SCORE: Final = 0
24-
"""Minimum peer score (floor)."""
20+
"""Score floor."""
2521

2622
MAX_PEER_SCORE: Final = 200
27-
"""Maximum peer score (ceiling)."""
23+
"""Score ceiling."""
2824

2925
SCORE_SUCCESS_BONUS: Final = 10
30-
"""Score increase for a successful request."""
26+
"""Reward per successful request."""
3127

3228
SCORE_FAILURE_PENALTY: Final = 20
33-
"""Score decrease for a failed request."""
29+
"""
30+
Penalty per failed request.
31+
32+
Double the success reward, so a failing peer loses weight faster than it earns it.
33+
"""
3434

3535

3636
@dataclass(slots=True)
3737
class SyncPeer:
38-
"""
39-
Peer information for sync operations.
40-
41-
Wraps PeerInfo with sync-specific state: chain status and request tracking.
42-
"""
38+
"""Sync-specific state for one peer: chain status and request tracking."""
4339

4440
info: PeerInfo
45-
"""Base peer information from the networking layer."""
41+
"""Underlying record from the networking layer."""
4642

4743
status: Status | None = None
48-
"""Chain status from the last Status message exchange."""
44+
"""Chain status from the last status exchange, or None if never exchanged."""
4945

5046
requests_in_flight: int = 0
51-
"""Number of active requests to this peer."""
47+
"""Count of requests sent but not yet completed."""
5248

5349
score: int = INITIAL_PEER_SCORE
54-
"""Peer reputation score. Higher means more reliable."""
50+
"""Reputation score; higher means more reliable."""
5551

5652
@property
5753
def peer_id(self) -> PeerId:
58-
"""Get the peer's ID."""
54+
"""The peer's identifier."""
5955
return self.info.peer_id
6056

6157
def is_connected(self) -> bool:
62-
"""Check if peer is currently connected."""
58+
"""Whether the peer is currently connected."""
6359
return self.info.is_connected()
6460

6561
def is_available(self) -> bool:
66-
"""Check if peer can accept new requests."""
62+
"""Whether the peer is connected and below its in-flight request limit."""
6763
return self.is_connected() and self.requests_in_flight < MAX_CONCURRENT_REQUESTS
6864

6965
def has_slot(self, slot: Slot) -> bool:
70-
"""Check if peer likely has data for given slot."""
66+
"""Whether the peer's last-reported head reaches the given slot."""
7167
return self.status is not None and self.status.head.slot >= slot
7268

7369
def on_request_start(self) -> None:
74-
"""Mark that a request has been sent to this peer."""
70+
"""Record that a request was sent."""
7571
self.requests_in_flight += 1
7672

77-
def on_request_complete(self) -> None:
78-
"""Mark that a request has completed."""
73+
def record_success(self) -> None:
74+
"""Release the in-flight slot and reward the peer for a completed request."""
75+
self.requests_in_flight = max(0, self.requests_in_flight - 1)
76+
self.score = min(self.score + SCORE_SUCCESS_BONUS, MAX_PEER_SCORE)
77+
78+
def record_failure(self) -> None:
79+
"""Release the in-flight slot and penalize the peer for a failed request."""
7980
self.requests_in_flight = max(0, self.requests_in_flight - 1)
81+
self.score = max(self.score - SCORE_FAILURE_PENALTY, MIN_PEER_SCORE)
8082

8183

8284
@dataclass(slots=True)
8385
class PeerManager:
84-
"""
85-
Manages peers for sync operations.
86-
87-
Tracks peer chain status and provides peer selection for block requests.
88-
"""
86+
"""Tracks sync peers and selects among them for block requests."""
8987

9088
peers: dict[PeerId, SyncPeer] = field(default_factory=dict)
91-
"""Mapping of peer ID to SyncPeer. Public so callers can iterate or look up directly."""
89+
"""Tracked peers, keyed by identifier."""
9290

9391
def __len__(self) -> int:
94-
"""Return the number of tracked peers."""
92+
"""Number of tracked peers."""
9593
return len(self.peers)
9694

9795
def __contains__(self, peer_id: PeerId) -> bool:
98-
"""Check if a peer is being tracked."""
96+
"""Whether the peer is tracked."""
9997
return peer_id in self.peers
10098

10199
def add_peer(self, peer_info: PeerInfo) -> SyncPeer:
102-
"""Register a new peer or update existing."""
103-
if peer_info.peer_id in self.peers:
104-
self.peers[peer_info.peer_id].info = peer_info
105-
return self.peers[peer_info.peer_id]
100+
"""Register a new peer, or refresh the networking record of an existing one."""
101+
existing_peer = self.peers.get(peer_info.peer_id)
102+
if existing_peer is not None:
103+
existing_peer.info = peer_info
104+
return existing_peer
106105

107106
sync_peer = SyncPeer(info=peer_info)
108107
self.peers[peer_info.peer_id] = sync_peer
109108
return sync_peer
110109

111110
def remove_peer(self, peer_id: PeerId) -> SyncPeer | None:
112-
"""Remove a peer from tracking."""
111+
"""Stop tracking a peer, returning its state if it was present."""
113112
return self.peers.pop(peer_id, None)
114113

115114
def update_status(self, peer_id: PeerId, status: Status) -> None:
116-
"""Update a peer's chain status."""
115+
"""Store a peer's latest chain status, unvalidated and used only for routing."""
117116
peer = self.peers.get(peer_id)
118117
if peer is not None:
119118
peer.status = status
120119

121120
def select_peer_for_request(self, min_slot: Slot | None = None) -> SyncPeer | None:
122121
"""
123-
Select an available peer for a request using weighted random selection.
124-
125-
Peers with higher scores are more likely to be selected. This avoids
126-
concentrating all load on one peer and naturally prefers reliable peers.
122+
Pick an available peer at random, weighted by score to favor reliable peers.
127123
128124
Args:
129-
min_slot: Optional minimum slot the peer must have.
125+
min_slot: If set, only peers whose head reaches this slot are considered.
130126
131127
Returns:
132-
An available SyncPeer, or None if no suitable peer exists.
128+
A selected peer, or None if none are available.
133129
"""
134-
candidates: list[SyncPeer] = []
135-
for peer in self.peers.values():
136-
if not peer.is_available():
137-
continue
138-
if min_slot is not None and not peer.has_slot(min_slot):
139-
continue
140-
candidates.append(peer)
141-
130+
candidates = [
131+
peer
132+
for peer in self.peers.values()
133+
if peer.is_available() and (min_slot is None or peer.has_slot(min_slot))
134+
]
142135
if not candidates:
143136
return None
144137

145-
# Weight by score. A score of 0 still gets weight 1 to avoid exclusion.
138+
# Floor every weight at 1 so a zero-score peer still has a chance.
139+
# Without this floor a single failure could exclude a peer forever.
146140
weights = [max(peer.score, 1) for peer in candidates]
147141
return random.choices(candidates, weights=weights, k=1)[0]
148142

149143
def get_network_finalized_slot(self) -> Slot | None:
150-
"""
151-
Determine network consensus finalized slot.
152-
153-
Returns the most-reported finalized slot among connected peers.
154-
Ties resolve to the higher slot, never to peer insertion order.
155-
This decision is consensus-adjacent, so it stays deterministic.
156-
"""
144+
"""Estimated sync target: the finalized slot most peers claim, not verified finality."""
157145
reported_finalized_slots = Counter(
158146
peer.status.finalized.slot
159147
for peer in self.peers.values()
160148
if peer.status is not None and peer.is_connected()
161149
)
162150
if not reported_finalized_slots:
163151
return None
164-
# Rank candidates by report count first, then by the slot itself.
165-
# The higher slot wins an equal-count tie, so the result never depends
166-
# on which peer connected first.
152+
# Rank by report count first, then by the slot value.
153+
# The higher slot wins an equal-count tie, keeping the result insertion-order independent.
167154
return max(
168155
reported_finalized_slots,
169156
key=lambda finalized_slot: (reported_finalized_slots[finalized_slot], finalized_slot),
170157
)
171158

172159
def on_request_success(self, peer_id: PeerId) -> None:
173-
"""Record a successful request to a peer."""
160+
"""Close out a request as successful and raise the peer's score."""
174161
peer = self.peers.get(peer_id)
175162
if peer is not None:
176-
peer.on_request_complete()
177-
peer.score = min(peer.score + SCORE_SUCCESS_BONUS, MAX_PEER_SCORE)
163+
peer.record_success()
178164

179165
def on_request_failure(self, peer_id: PeerId) -> None:
180-
"""Record a failed request to a peer."""
166+
"""Close out a request as failed and lower the peer's score."""
181167
peer = self.peers.get(peer_id)
182168
if peer is not None:
183-
peer.on_request_complete()
184-
peer.score = max(peer.score - SCORE_FAILURE_PENALTY, MIN_PEER_SCORE)
169+
peer.record_failure()

tests/node/sync/test_peer_manager.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from lean_spec.node.sync.config import MAX_CONCURRENT_REQUESTS
1010
from lean_spec.node.sync.peer_manager import (
1111
INITIAL_PEER_SCORE,
12+
MAX_PEER_SCORE,
1213
MIN_PEER_SCORE,
1314
SCORE_FAILURE_PENALTY,
1415
SCORE_SUCCESS_BONUS,
@@ -85,18 +86,39 @@ def test_on_request_start(self, connected_peer_info: PeerInfo) -> None:
8586
sync_peer.on_request_start()
8687
assert sync_peer == SyncPeer(info=connected_peer_info, requests_in_flight=1)
8788

88-
def test_on_request_complete(self, connected_peer_info: PeerInfo) -> None:
89-
"""on_request_complete decrements requests_in_flight."""
89+
def test_record_success(self, connected_peer_info: PeerInfo) -> None:
90+
"""record_success decrements in-flight and raises the score."""
9091
sync_peer = SyncPeer(info=connected_peer_info)
9192
sync_peer.requests_in_flight = 2
92-
sync_peer.on_request_complete()
93-
assert sync_peer == SyncPeer(info=connected_peer_info, requests_in_flight=1)
93+
sync_peer.record_success()
94+
assert sync_peer == SyncPeer(
95+
info=connected_peer_info,
96+
requests_in_flight=1,
97+
score=INITIAL_PEER_SCORE + SCORE_SUCCESS_BONUS,
98+
)
99+
100+
def test_record_success_caps_at_maximum(self, connected_peer_info: PeerInfo) -> None:
101+
"""record_success does not raise the score above the ceiling."""
102+
sync_peer = SyncPeer(info=connected_peer_info, score=MAX_PEER_SCORE)
103+
sync_peer.record_success()
104+
assert sync_peer == SyncPeer(info=connected_peer_info, score=MAX_PEER_SCORE)
94105

95-
def test_on_request_complete_does_not_go_negative(self, connected_peer_info: PeerInfo) -> None:
96-
"""on_request_complete does not let in_flight go negative."""
106+
def test_record_failure(self, connected_peer_info: PeerInfo) -> None:
107+
"""record_failure decrements in-flight and lowers the score."""
97108
sync_peer = SyncPeer(info=connected_peer_info)
98-
sync_peer.on_request_complete()
99-
assert sync_peer == SyncPeer(info=connected_peer_info)
109+
sync_peer.requests_in_flight = 2
110+
sync_peer.record_failure()
111+
assert sync_peer == SyncPeer(
112+
info=connected_peer_info,
113+
requests_in_flight=1,
114+
score=INITIAL_PEER_SCORE - SCORE_FAILURE_PENALTY,
115+
)
116+
117+
def test_record_failure_does_not_go_negative(self, connected_peer_info: PeerInfo) -> None:
118+
"""record_failure does not let in-flight go negative or score below the floor."""
119+
sync_peer = SyncPeer(info=connected_peer_info, score=MIN_PEER_SCORE)
120+
sync_peer.record_failure()
121+
assert sync_peer == SyncPeer(info=connected_peer_info, score=MIN_PEER_SCORE)
100122

101123

102124
class TestPeerManagerBasicOperations:

0 commit comments

Comments
 (0)