go-ethereum/eth/dropper_test.go
Csaba Kiraly f24161de71 eth/txtracker: replace cumulative Finalized with slow RecentFinalized EMA
The total-finalized protection category ranked peers by a monotonic
cumulative count, so a peer that had been productive in the past kept
a high score forever — even if they had since gone silent — and held
a protected slot without contributing.

Replace txtracker.PeerStats.Finalized (int64 cumulative) with
RecentFinalized (float64 EMA). On each chain head, finalization
credits accumulated over the newly-finalized range are folded into a
slow EMA (alpha=0.0001, half-life ~6930 blocks ≈ 23 hours on 12s
mainnet blocks). Peers that continue contributing keep a high score;
peers that stop decay toward zero over roughly a day.

The dropper category renames to "recent-finalized" accordingly. The
type's docstring is rewritten to describe both categories as EMAs
with different time horizons (slow finalized, fast included).

Refactors checkFinalization to return a per-peer credits map rather
than mutating state directly, so both EMAs update in the same loop
over tracked peers.
2026-04-19 12:14:23 +02:00

234 lines
8.3 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"
"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))
}
}