mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-05-24 08:49:29 +00:00
Adds a third protection category to the dropper, scoring peers by per-peer tx-request response latency. Fast peers are harder to drop; peers that chronically time out (their EMA drifts toward the 5s timeout sample) score low and are normal drop candidates. PeerInclusionStats gains RequestLatencyEMA (time.Duration) and RequestSamples (int64). The stats adapter in backend.go copies them from txtracker.PeerStats. The scoring function returns 1/EMA once the peer has >= MinLatencySamples (10) recorded samples — an under-sampled peer scores 0 and is filtered by the existing "score <= 0" rule, preventing a single lucky-fast reply from displacing established peers. Adds three unit tests via protectedPeersByPool for the basic top-N selection, the bootstrap guard, and per-pool independence.
340 lines
12 KiB
Go
340 lines
12 KiB
Go
// Copyright 2026 The go-ethereum Authors
|
|
// This file is part of the go-ethereum library.
|
|
//
|
|
// The go-ethereum library is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Lesser General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// The go-ethereum library is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Lesser General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Lesser General Public License
|
|
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package eth
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ethereum/go-ethereum/eth/txtracker"
|
|
"github.com/ethereum/go-ethereum/p2p"
|
|
"github.com/ethereum/go-ethereum/p2p/enode"
|
|
)
|
|
|
|
func makePeers(n int) []*p2p.Peer {
|
|
peers := make([]*p2p.Peer, n)
|
|
for i := range peers {
|
|
id := enode.ID{byte(i)}
|
|
peers[i] = p2p.NewPeer(id, fmt.Sprintf("peer%d", i), nil)
|
|
}
|
|
return peers
|
|
}
|
|
|
|
func TestProtectedPeersNoStats(t *testing.T) {
|
|
cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30}
|
|
cm.peerStatsFunc = func() map[string]txtracker.PeerStats { return nil }
|
|
|
|
peers := makePeers(10)
|
|
protected := cm.protectedPeers(peers)
|
|
if len(protected) != 0 {
|
|
t.Fatalf("expected no protected peers with nil stats, got %d", len(protected))
|
|
}
|
|
}
|
|
|
|
func TestProtectedPeersEmptyStats(t *testing.T) {
|
|
cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30}
|
|
cm.peerStatsFunc = func() map[string]txtracker.PeerStats {
|
|
return map[string]txtracker.PeerStats{}
|
|
}
|
|
|
|
peers := makePeers(10)
|
|
protected := cm.protectedPeers(peers)
|
|
if len(protected) != 0 {
|
|
t.Fatalf("expected no protected peers with empty stats, got %d", len(protected))
|
|
}
|
|
}
|
|
|
|
func TestProtectedPeersTopPeer(t *testing.T) {
|
|
// 20 peers, 10% of 20 = 2 protected per category.
|
|
cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30}
|
|
|
|
peers := makePeers(20)
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
stats[peers[0].ID().String()] = txtracker.PeerStats{RecentFinalized: 100}
|
|
stats[peers[1].ID().String()] = txtracker.PeerStats{RecentIncluded: 5.0}
|
|
|
|
cm.peerStatsFunc = func() map[string]txtracker.PeerStats { return stats }
|
|
|
|
protected := cm.protectedPeers(peers)
|
|
if len(protected) != 2 {
|
|
t.Fatalf("expected 2 protected peers, got %d", len(protected))
|
|
}
|
|
if !protected[peers[0]] {
|
|
t.Fatal("peer 0 should be protected (top RecentFinalized)")
|
|
}
|
|
if !protected[peers[1]] {
|
|
t.Fatal("peer 1 should be protected (top RecentIncluded)")
|
|
}
|
|
}
|
|
|
|
func TestProtectedPeersZeroScore(t *testing.T) {
|
|
cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30}
|
|
|
|
peers := makePeers(10)
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
for _, p := range peers {
|
|
stats[p.ID().String()] = txtracker.PeerStats{}
|
|
}
|
|
cm.peerStatsFunc = func() map[string]txtracker.PeerStats { return stats }
|
|
|
|
protected := cm.protectedPeers(peers)
|
|
if len(protected) != 0 {
|
|
t.Fatalf("expected no protection with zero scores, got %d", len(protected))
|
|
}
|
|
}
|
|
|
|
func TestProtectedPeersOverlap(t *testing.T) {
|
|
// One peer is top in both categories — counted once.
|
|
cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30}
|
|
|
|
peers := makePeers(20)
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
stats[peers[0].ID().String()] = txtracker.PeerStats{RecentFinalized: 100, RecentIncluded: 5.0}
|
|
|
|
cm.peerStatsFunc = func() map[string]txtracker.PeerStats { return stats }
|
|
|
|
protected := cm.protectedPeers(peers)
|
|
if len(protected) != 1 {
|
|
t.Fatalf("expected 1 protected peer (overlap), got %d", len(protected))
|
|
}
|
|
}
|
|
|
|
func TestProtectedPeersNilFunc(t *testing.T) {
|
|
cm := &dropper{maxDialPeers: 20, maxInboundPeers: 30}
|
|
// peerStatsFunc is nil (default).
|
|
|
|
peers := makePeers(10)
|
|
protected := cm.protectedPeers(peers)
|
|
if protected != nil {
|
|
t.Fatalf("expected nil with nil stats func, got %v", protected)
|
|
}
|
|
}
|
|
|
|
// TestProtectedByPoolPerPoolTopN verifies that the top-N selection runs
|
|
// independently in each of the inbound and dialed pools, not globally.
|
|
// With 10 peers per pool and inclusionProtectionFrac=0.1, exactly 1 peer
|
|
// is protected per pool per category — so 2 total (one per pool), both
|
|
// for the RecentFinalized category since we don't set RecentIncluded.
|
|
func TestProtectedByPoolPerPoolTopN(t *testing.T) {
|
|
inbound := makePeers(10)
|
|
dialed := makePeers(10)
|
|
// Distinguish dialed peer IDs from inbound so stats maps don't collide.
|
|
for i := range dialed {
|
|
id := enode.ID{byte(100 + i)}
|
|
dialed[i] = p2p.NewPeer(id, fmt.Sprintf("dialed%d", i), nil)
|
|
}
|
|
// Strictly increasing scores: highest wins in each pool.
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
for i, p := range inbound {
|
|
stats[p.ID().String()] = txtracker.PeerStats{RecentFinalized: float64(1 + i)}
|
|
}
|
|
for i, p := range dialed {
|
|
stats[p.ID().String()] = txtracker.PeerStats{RecentFinalized: float64(1 + i)}
|
|
}
|
|
|
|
protected := protectedPeersByPool(inbound, dialed, stats)
|
|
|
|
// Expect top 1 of inbound (inbound[9]) and top 1 of dialed (dialed[9]).
|
|
if len(protected) != 2 {
|
|
t.Fatalf("expected 2 protected peers (1 per pool), got %d", len(protected))
|
|
}
|
|
if !protected[inbound[9]] {
|
|
t.Error("expected top inbound peer to be protected")
|
|
}
|
|
if !protected[dialed[9]] {
|
|
t.Error("expected top dialed peer to be protected")
|
|
}
|
|
}
|
|
|
|
// TestProtectedByPoolCrossCategoryOverlap verifies that the union across
|
|
// protection categories is correctly deduplicated: a peer that wins in
|
|
// multiple categories appears once, and category winners are all
|
|
// protected. Uses a pool large enough that frac*len yields n=2 per
|
|
// category, so cross-category overlap is observable.
|
|
func TestProtectedByPoolCrossCategoryOverlap(t *testing.T) {
|
|
// 20 dialed peers so 0.1 * 20 = 2 protected per category.
|
|
dialed := makePeers(20)
|
|
// P0: high RecentFinalized only. P1: high RecentIncluded only. P2: high both.
|
|
// With n=2 per category:
|
|
// RecentFinalized winners: P2 (tie-broken-ok), P0
|
|
// RecentIncluded winners: P2, P1
|
|
// Union: {P0, P1, P2}.
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
stats[dialed[0].ID().String()] = txtracker.PeerStats{RecentFinalized: 100, RecentIncluded: 0}
|
|
stats[dialed[1].ID().String()] = txtracker.PeerStats{RecentFinalized: 0, RecentIncluded: 5.0}
|
|
stats[dialed[2].ID().String()] = txtracker.PeerStats{RecentFinalized: 200, RecentIncluded: 10.0}
|
|
|
|
protected := protectedPeersByPool(nil, dialed, stats)
|
|
|
|
if len(protected) != 3 {
|
|
t.Fatalf("expected 3 protected peers (union of category winners), got %d", len(protected))
|
|
}
|
|
for _, idx := range []int{0, 1, 2} {
|
|
if !protected[dialed[idx]] {
|
|
t.Errorf("peer %d should be protected", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProtectedByPoolPerPoolIndependence locks in that selection runs
|
|
// per-pool, not globally. Every inbound peer scores higher than every
|
|
// dialed peer, so a global top-N would pick only inbound peers. Per-pool
|
|
// top-N must still protect the top dialed peers.
|
|
func TestProtectedByPoolPerPoolIndependence(t *testing.T) {
|
|
// 20 inbound, 20 dialed — frac=0.1 → 2 protected per pool per category.
|
|
// Global top-4 of RecentFinalized would be inbound[16..19] — zero dialed.
|
|
inbound := makePeers(20)
|
|
dialed := make([]*p2p.Peer, 20)
|
|
for i := range dialed {
|
|
id := enode.ID{byte(100 + i)}
|
|
dialed[i] = p2p.NewPeer(id, fmt.Sprintf("dialed%d", i), nil)
|
|
}
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
// Every inbound peer outscores every dialed peer.
|
|
for i, p := range inbound {
|
|
stats[p.ID().String()] = txtracker.PeerStats{RecentFinalized: float64(1000 + i)}
|
|
}
|
|
for i, p := range dialed {
|
|
stats[p.ID().String()] = txtracker.PeerStats{RecentFinalized: float64(1 + i)}
|
|
}
|
|
|
|
protected := protectedPeersByPool(inbound, dialed, stats)
|
|
|
|
// Per-pool top-2 of RecentFinalized:
|
|
// inbound: inbound[18], inbound[19]
|
|
// dialed: dialed[18], dialed[19]
|
|
// Global top-N would contain zero dialed peers, so asserting the top
|
|
// dialed peers are protected enforces per-pool independence.
|
|
if !protected[dialed[19]] {
|
|
t.Fatal("top dialed peer must be protected regardless of globally-higher inbound peers")
|
|
}
|
|
if !protected[dialed[18]] {
|
|
t.Fatal("second-top dialed peer must be protected regardless of globally-higher inbound peers")
|
|
}
|
|
if !protected[inbound[19]] || !protected[inbound[18]] {
|
|
t.Fatal("top inbound peers must also be protected")
|
|
}
|
|
if len(protected) != 4 {
|
|
t.Fatalf("expected 4 protected peers (top-2 of each pool), got %d", len(protected))
|
|
}
|
|
}
|
|
|
|
// TestProtectedByPoolRequestLatencyBasic verifies the latency protection
|
|
// category: with no competing inclusion stats, the lowest-latency peers
|
|
// (among those with enough samples) win top-N protection.
|
|
func TestProtectedByPoolRequestLatencyBasic(t *testing.T) {
|
|
dialed := makePeers(20) // frac=0.1 → n=2 per category
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
// Three peers have enough samples; the two fastest should win.
|
|
stats[dialed[0].ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 50 * time.Millisecond,
|
|
RequestSamples: 50,
|
|
}
|
|
stats[dialed[1].ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 100 * time.Millisecond,
|
|
RequestSamples: 50,
|
|
}
|
|
stats[dialed[2].ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 2 * time.Second,
|
|
RequestSamples: 50,
|
|
}
|
|
|
|
protected := protectedPeersByPool(nil, dialed, stats)
|
|
|
|
if !protected[dialed[0]] {
|
|
t.Error("fastest peer should be protected")
|
|
}
|
|
if !protected[dialed[1]] {
|
|
t.Error("second-fastest peer should be protected")
|
|
}
|
|
if protected[dialed[2]] {
|
|
t.Error("slowest peer should not be in top-2")
|
|
}
|
|
if len(protected) != 2 {
|
|
t.Fatalf("expected top-2 latency protection, got %d", len(protected))
|
|
}
|
|
}
|
|
|
|
// TestProtectedByPoolRequestLatencyBootstrapGuard verifies that peers with
|
|
// fewer than MinLatencySamples do not earn latency-based protection, even
|
|
// if their few samples indicate very low latency.
|
|
func TestProtectedByPoolRequestLatencyBootstrapGuard(t *testing.T) {
|
|
dialed := makePeers(20)
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
// A lucky-fast peer with only 1 sample — must NOT be protected.
|
|
stats[dialed[0].ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 1 * time.Millisecond,
|
|
RequestSamples: 1,
|
|
}
|
|
// A warmed-up but slower peer — should be protected on latency.
|
|
stats[dialed[1].ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 500 * time.Millisecond,
|
|
RequestSamples: txtracker.MinLatencySamples,
|
|
}
|
|
|
|
protected := protectedPeersByPool(nil, dialed, stats)
|
|
|
|
if protected[dialed[0]] {
|
|
t.Error("under-sampled peer should not be protected (bootstrap guard)")
|
|
}
|
|
if !protected[dialed[1]] {
|
|
t.Error("warmed-up peer should be protected")
|
|
}
|
|
}
|
|
|
|
// TestProtectedByPoolRequestLatencyPerPool verifies that the latency
|
|
// category selects top-N per pool independently, consistent with the
|
|
// other categories. An inbound peer with lower latency does not prevent
|
|
// a dialed peer from being protected as top of the dialed pool.
|
|
func TestProtectedByPoolRequestLatencyPerPool(t *testing.T) {
|
|
inbound := makePeers(20)
|
|
dialed := make([]*p2p.Peer, 20)
|
|
for i := range dialed {
|
|
id := enode.ID{byte(100 + i)}
|
|
dialed[i] = p2p.NewPeer(id, fmt.Sprintf("dialed%d", i), nil)
|
|
}
|
|
stats := make(map[string]txtracker.PeerStats)
|
|
// All inbound peers are very fast (50ms).
|
|
for _, p := range inbound {
|
|
stats[p.ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 50 * time.Millisecond,
|
|
RequestSamples: 50,
|
|
}
|
|
}
|
|
// Dialed peers are slower (1s) — globally they would all lose, but
|
|
// per-pool top-N should still protect two of them.
|
|
for _, p := range dialed {
|
|
stats[p.ID().String()] = txtracker.PeerStats{
|
|
RequestLatencyEMA: 1 * time.Second,
|
|
RequestSamples: 50,
|
|
}
|
|
}
|
|
|
|
protected := protectedPeersByPool(inbound, dialed, stats)
|
|
|
|
// 2 from inbound + 2 from dialed = 4.
|
|
var dialedProtected int
|
|
for _, p := range dialed {
|
|
if protected[p] {
|
|
dialedProtected++
|
|
}
|
|
}
|
|
if dialedProtected != 2 {
|
|
t.Fatalf("expected 2 dialed peers protected by per-pool top-N, got %d", dialedProtected)
|
|
}
|
|
}
|