eth: add request-latency peer protection category

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.
This commit is contained in:
Csaba Kiraly 2026-04-13 21:08:59 +02:00
parent 275020f988
commit c82be6827f
2 changed files with 121 additions and 0 deletions

View file

@ -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

View file

@ -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)
}
}