|
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.""" |
6 | 2 |
|
7 | 3 | from __future__ import annotations |
8 | 4 |
|
|
18 | 14 | from lean_spec.spec.forks import Slot |
19 | 15 |
|
20 | 16 | INITIAL_PEER_SCORE: Final = 100 |
21 | | -"""Starting score for newly added peers.""" |
| 17 | +"""Starting score, mid-range so a new peer competes without dominating.""" |
22 | 18 |
|
23 | 19 | MIN_PEER_SCORE: Final = 0 |
24 | | -"""Minimum peer score (floor).""" |
| 20 | +"""Score floor.""" |
25 | 21 |
|
26 | 22 | MAX_PEER_SCORE: Final = 200 |
27 | | -"""Maximum peer score (ceiling).""" |
| 23 | +"""Score ceiling.""" |
28 | 24 |
|
29 | 25 | SCORE_SUCCESS_BONUS: Final = 10 |
30 | | -"""Score increase for a successful request.""" |
| 26 | +"""Reward per successful request.""" |
31 | 27 |
|
32 | 28 | 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 | +""" |
34 | 34 |
|
35 | 35 |
|
36 | 36 | @dataclass(slots=True) |
37 | 37 | 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.""" |
43 | 39 |
|
44 | 40 | info: PeerInfo |
45 | | - """Base peer information from the networking layer.""" |
| 41 | + """Underlying record from the networking layer.""" |
46 | 42 |
|
47 | 43 | 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.""" |
49 | 45 |
|
50 | 46 | requests_in_flight: int = 0 |
51 | | - """Number of active requests to this peer.""" |
| 47 | + """Count of requests sent but not yet completed.""" |
52 | 48 |
|
53 | 49 | score: int = INITIAL_PEER_SCORE |
54 | | - """Peer reputation score. Higher means more reliable.""" |
| 50 | + """Reputation score; higher means more reliable.""" |
55 | 51 |
|
56 | 52 | @property |
57 | 53 | def peer_id(self) -> PeerId: |
58 | | - """Get the peer's ID.""" |
| 54 | + """The peer's identifier.""" |
59 | 55 | return self.info.peer_id |
60 | 56 |
|
61 | 57 | def is_connected(self) -> bool: |
62 | | - """Check if peer is currently connected.""" |
| 58 | + """Whether the peer is currently connected.""" |
63 | 59 | return self.info.is_connected() |
64 | 60 |
|
65 | 61 | 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.""" |
67 | 63 | return self.is_connected() and self.requests_in_flight < MAX_CONCURRENT_REQUESTS |
68 | 64 |
|
69 | 65 | 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.""" |
71 | 67 | return self.status is not None and self.status.head.slot >= slot |
72 | 68 |
|
73 | 69 | def on_request_start(self) -> None: |
74 | | - """Mark that a request has been sent to this peer.""" |
| 70 | + """Record that a request was sent.""" |
75 | 71 | self.requests_in_flight += 1 |
76 | 72 |
|
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.""" |
79 | 80 | self.requests_in_flight = max(0, self.requests_in_flight - 1) |
| 81 | + self.score = max(self.score - SCORE_FAILURE_PENALTY, MIN_PEER_SCORE) |
80 | 82 |
|
81 | 83 |
|
82 | 84 | @dataclass(slots=True) |
83 | 85 | 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.""" |
89 | 87 |
|
90 | 88 | 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.""" |
92 | 90 |
|
93 | 91 | def __len__(self) -> int: |
94 | | - """Return the number of tracked peers.""" |
| 92 | + """Number of tracked peers.""" |
95 | 93 | return len(self.peers) |
96 | 94 |
|
97 | 95 | def __contains__(self, peer_id: PeerId) -> bool: |
98 | | - """Check if a peer is being tracked.""" |
| 96 | + """Whether the peer is tracked.""" |
99 | 97 | return peer_id in self.peers |
100 | 98 |
|
101 | 99 | 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 |
106 | 105 |
|
107 | 106 | sync_peer = SyncPeer(info=peer_info) |
108 | 107 | self.peers[peer_info.peer_id] = sync_peer |
109 | 108 | return sync_peer |
110 | 109 |
|
111 | 110 | 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.""" |
113 | 112 | return self.peers.pop(peer_id, None) |
114 | 113 |
|
115 | 114 | 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.""" |
117 | 116 | peer = self.peers.get(peer_id) |
118 | 117 | if peer is not None: |
119 | 118 | peer.status = status |
120 | 119 |
|
121 | 120 | def select_peer_for_request(self, min_slot: Slot | None = None) -> SyncPeer | None: |
122 | 121 | """ |
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. |
127 | 123 |
|
128 | 124 | 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. |
130 | 126 |
|
131 | 127 | Returns: |
132 | | - An available SyncPeer, or None if no suitable peer exists. |
| 128 | + A selected peer, or None if none are available. |
133 | 129 | """ |
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 | + ] |
142 | 135 | if not candidates: |
143 | 136 | return None |
144 | 137 |
|
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. |
146 | 140 | weights = [max(peer.score, 1) for peer in candidates] |
147 | 141 | return random.choices(candidates, weights=weights, k=1)[0] |
148 | 142 |
|
149 | 143 | 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.""" |
157 | 145 | reported_finalized_slots = Counter( |
158 | 146 | peer.status.finalized.slot |
159 | 147 | for peer in self.peers.values() |
160 | 148 | if peer.status is not None and peer.is_connected() |
161 | 149 | ) |
162 | 150 | if not reported_finalized_slots: |
163 | 151 | 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. |
167 | 154 | return max( |
168 | 155 | reported_finalized_slots, |
169 | 156 | key=lambda finalized_slot: (reported_finalized_slots[finalized_slot], finalized_slot), |
170 | 157 | ) |
171 | 158 |
|
172 | 159 | 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.""" |
174 | 161 | peer = self.peers.get(peer_id) |
175 | 162 | 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() |
178 | 164 |
|
179 | 165 | 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.""" |
181 | 167 | peer = self.peers.get(peer_id) |
182 | 168 | 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() |
0 commit comments