diff --git a/eth/dropper.go b/eth/dropper.go index 3855439ca5..7b04656b09 100644 --- a/eth/dropper.go +++ b/eth/dropper.go @@ -75,6 +75,21 @@ type protectionCategory struct { var protectionCategories = []protectionCategory{ {"recent-finalized", func(s txtracker.PeerStats) float64 { return s.RecentFinalized }, inclusionProtectionFrac}, {"recent-included", func(s txtracker.PeerStats) float64 { return s.RecentIncluded }, inclusionProtectionFrac}, + {"request-latency", func(s txtracker.PeerStats) float64 { + // Low-latency peers should rank higher. Peers with too few samples + // score 0 so the existing `score <= 0` filter excludes them — this + // prevents a single lucky-fast reply from winning protection. Peers + // whose EMA reaches the timeout also score 0 by this path because + // the reciprocal of a very large duration is tiny but positive; the + // per-pool top-N will still push faster peers ahead of them. + if s.RequestSamples < txtracker.MinLatencySamples { + return 0 + } + if s.RequestLatencyEMA <= 0 { + return 0 + } + return 1.0 / float64(s.RequestLatencyEMA) + }, inclusionProtectionFrac}, } // dropper monitors the state of the peer pool and introduces churn by diff --git a/eth/dropper_test.go b/eth/dropper_test.go index fd2ed9b611..cd414925ce 100644 --- a/eth/dropper_test.go +++ b/eth/dropper_test.go @@ -19,6 +19,7 @@ package eth import ( "fmt" "testing" + "time" "github.com/ethereum/go-ethereum/eth/txtracker" "github.com/ethereum/go-ethereum/p2p" @@ -232,3 +233,108 @@ func TestProtectedByPoolPerPoolIndependence(t *testing.T) { 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) + } +}