mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-05-25 09:19:28 +00:00
Introduces a new eth/peerstats package as the single home for per-peer quality metrics consumed by the dropper. txtracker shrinks to a pure tx-lifecycle role: it maps tx hash to deliverer, subscribes to chain heads, computes per-block per-peer inclusion and finalization deltas, and emits them to a StatsConsumer. peerstats owns the aggregates: inclusion EMA, finalized counter, latency EMA, sample counter, and the MinLatencySamples bootstrap constant the dropper uses to filter under-sampled peers. It's a plain struct with a mutex — no goroutine of its own, no lifecycle management. The fetcher's onRequestLatency callback now flows to peerStats.NotifyRequestLatency, the handler's unregisterPeer cleans up via peerStats.NotifyPeerDrop, and the dropper reads its snapshot via peerStats.GetAllPeerStats. txtracker.handleChainHead computes deltas under its own lock, then releases the lock before calling the consumer, which avoids any cross-package lock ordering. Tests are split along the same line: tracker tests use a mock consumer to assert what signals are emitted, peerstats tests cover EMA math and aggregation semantics directly.
172 lines
6.7 KiB
Go
172 lines
6.7 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 peerstats maintains per-peer quality metrics used by the peer
|
|
// dropper to protect high-value peers from random disconnection.
|
|
//
|
|
// The package is a passive accumulator: it exposes entry points for its
|
|
// signal producers (txtracker for inclusion/finalization, the tx fetcher
|
|
// for latency, the handler for peer-drop cleanup) and a read-only
|
|
// snapshot for its consumer (the dropper). It has no goroutine of its
|
|
// own — all mutation is serialized by a single mutex.
|
|
//
|
|
// Signal sources:
|
|
// - NotifyBlock(inclusions, finalized) — per-block deltas from txtracker
|
|
// (computed under txtracker's own lock, then passed in after release)
|
|
// - NotifyRequestLatency(peer, latency) — per-request samples from the
|
|
// fetcher; timeouts are reported with the timeout value so slow peers
|
|
// contribute to the EMA
|
|
// - NotifyPeerDrop(peer) — called from the handler on disconnect
|
|
package peerstats
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// EMA smoothing factor for per-block inclusion rate.
|
|
emaAlpha = 0.05
|
|
// EMA smoothing factor for per-block finalization rate. Very slow on
|
|
// purpose: finalization is permanent, and the score should reflect
|
|
// sustained contribution over long windows, not recent bursts.
|
|
// Half-life ≈ 6930 chain heads (~23 hours on 12s blocks).
|
|
finalizedEMAAlpha = 0.0001
|
|
// EMA smoothing factor for per-request latency average. Slow on purpose:
|
|
// short bursts shouldn't shift the score, sustained behavior should.
|
|
// Half-life ≈ ln(0.5)/ln(0.99) ≈ 69 samples.
|
|
latencyEMAAlpha = 0.01
|
|
// MinLatencySamples is the number of latency samples a peer must accumulate
|
|
// before its RequestLatencyEMA is considered meaningful for protection.
|
|
// Prevents a single lucky-fast reply from displacing established peers.
|
|
MinLatencySamples = 10
|
|
)
|
|
|
|
// PeerStats is the exported per-peer snapshot returned by GetAllPeerStats.
|
|
type PeerStats struct {
|
|
RecentFinalized float64 // EMA of per-block finalization credits (slow)
|
|
RecentIncluded float64 // EMA of per-block inclusions (fast)
|
|
RequestLatencyEMA time.Duration // Slow EMA of tx-request response latency (timeouts count as the timeout value)
|
|
RequestSamples int64 // Number of latency samples seen (for bootstrap guard)
|
|
}
|
|
|
|
// peerStats is the internal mutable state per peer.
|
|
type peerStats struct {
|
|
recentFinalized float64
|
|
recentIncluded float64
|
|
requestLatencyEMA time.Duration
|
|
requestSamples int64
|
|
}
|
|
|
|
// Stats is the per-peer quality aggregator.
|
|
type Stats struct {
|
|
mu sync.Mutex
|
|
peers map[string]*peerStats
|
|
}
|
|
|
|
// New creates an empty Stats.
|
|
func New() *Stats {
|
|
return &Stats{peers: make(map[string]*peerStats)}
|
|
}
|
|
|
|
// NotifyBlock ingests a per-block update. `inclusions` is the count of the head
|
|
// block's transactions attributed to each peer; peers with a positive
|
|
// count get a stats entry created if one doesn't exist (this is how
|
|
// peerstats learns about newly-active peers). Peers not in the map but
|
|
// already tracked have their EMA decay with a zero sample.
|
|
//
|
|
// `finalized` is per-peer credits accumulated since the last NotifyBlock;
|
|
// credits are only applied to peers already tracked — we don't resurrect
|
|
// dropped peers from historical finalization data.
|
|
//
|
|
// NotifyBlock must NOT be called while the caller holds any other lock that
|
|
// could be acquired by peerstats callers in reverse order. Current callers
|
|
// (txtracker.handleChainHead) release their lock before invoking NotifyBlock.
|
|
func (s *Stats) NotifyBlock(inclusions, finalized map[string]int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Ensure a stats entry exists for any peer that just had an inclusion.
|
|
// This is the primary path by which peerstats learns about a peer's
|
|
// inclusion activity.
|
|
for peer, count := range inclusions {
|
|
if count > 0 && s.peers[peer] == nil {
|
|
s.peers[peer] = &peerStats{}
|
|
}
|
|
}
|
|
// Update inclusion and finalization EMAs for every tracked peer. A
|
|
// peer not present in the respective delta map gets a 0 contribution
|
|
// — pure decay. Finalization credits for peers no longer tracked are
|
|
// ignored (don't resurrect dropped peers from historical data).
|
|
for peer, ps := range s.peers {
|
|
ps.recentIncluded = (1-emaAlpha)*ps.recentIncluded + emaAlpha*float64(inclusions[peer])
|
|
ps.recentFinalized = (1-finalizedEMAAlpha)*ps.recentFinalized + finalizedEMAAlpha*float64(finalized[peer])
|
|
}
|
|
}
|
|
|
|
// NotifyRequestLatency records a tx-request response latency sample for
|
|
// the given peer. Timeouts should be reported as the timeout value.
|
|
// Creates a peer entry if one doesn't exist (a peer may have latency
|
|
// samples before any inclusion signal).
|
|
func (s *Stats) NotifyRequestLatency(peer string, latency time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ps := s.peers[peer]
|
|
if ps == nil {
|
|
ps = &peerStats{}
|
|
s.peers[peer] = ps
|
|
}
|
|
if ps.requestSamples == 0 {
|
|
// Bootstrap the EMA with the first sample so it doesn't drift up
|
|
// from zero over many samples before reaching realistic values.
|
|
ps.requestLatencyEMA = latency
|
|
} else {
|
|
ps.requestLatencyEMA = time.Duration(
|
|
float64(ps.requestLatencyEMA)*(1-latencyEMAAlpha) +
|
|
float64(latency)*latencyEMAAlpha,
|
|
)
|
|
}
|
|
ps.requestSamples++
|
|
}
|
|
|
|
// NotifyPeerDrop removes a peer's stats on disconnect. A rare stale
|
|
// latency sample racing with the drop may recreate the peer entry with
|
|
// one sample; that entry can never earn protection (MinLatencySamples
|
|
// guard) and is harmless.
|
|
func (s *Stats) NotifyPeerDrop(peer string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.peers, peer)
|
|
}
|
|
|
|
// GetAllPeerStats returns a snapshot of per-peer stats. Called by the
|
|
// dropper every few minutes; allocation cost is negligible at that rate.
|
|
func (s *Stats) GetAllPeerStats() map[string]PeerStats {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
result := make(map[string]PeerStats, len(s.peers))
|
|
for id, ps := range s.peers {
|
|
result[id] = PeerStats{
|
|
RecentFinalized: ps.recentFinalized,
|
|
RecentIncluded: ps.recentIncluded,
|
|
RequestLatencyEMA: ps.requestLatencyEMA,
|
|
RequestSamples: ps.requestSamples,
|
|
}
|
|
}
|
|
return result
|
|
}
|