fix(discovery): age stale discv5 ENRs out of peer-exchange via freshness TTL

PX serves ENRs from the add-only PeerStore ENRBook, whose only liveness
gate was CannotConnect. Disconnected/never-dialed discv5 peers stayed
NotConnected and were advertised indefinitely (#3933).

Instead of deleting peer-store state against the bounded discv5 routing
table (which can false-prune a live peer evicted from a full k-bucket),
track an ENR last-seen Moment and gate the PX read path on it:

- EnrLastSeenBook + EnrFreshnessTTL; touchEnrSeen stamps freshness.
- Refresh-only liveness: stamped on ENR (re)write, on connect, and from
  the revalidated routing table each discv5 round. Membership never
  deletes state, so a live-but-bucket-evicted peer just ages out of PX
  rather than being purged.
- getEnrsFromStore skips non-Connected ENRs older than the TTL; a
  Connected peer is always considered fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-06-22 13:42:11 +02:00
parent 2fe7e1c373
commit 38a98ac7c1
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
4 changed files with 55 additions and 3 deletions

View File

@ -12,7 +12,8 @@ import
libp2p/multiaddress,
eth/keys as eth_keys,
eth/p2p/discoveryv5/node,
eth/p2p/discoveryv5/protocol
eth/p2p/discoveryv5/protocol,
eth/p2p/discoveryv5/routing_table
import
logos_delivery/waku/
[net/auto_port, node/peer_manager/peer_manager, waku_core, waku_enr]
@ -291,6 +292,19 @@ proc searchLoop(wd: WakuDiscoveryV5) {.async.} =
# Peers added are filtered by the peer manager
peerManager.addPeer(peer, PeerOrigin.Discv5)
# Refresh ENR liveness from discv5's revalidated routing table so stale
# ENRs age out of peer-exchange. This only extends freshness; it never
# deletes peer-store state, so a live peer absent from a full k-bucket is
# not wrongly purged (it just stops being advertised until rediscovered).
var liveIds = initHashSet[PeerId]()
for peer in discoveredPeers:
liveIds.incl(peer.peerId)
for node in wd.protocol.routingTable.randomNodes(int.high):
let info = node.record.toRemotePeerInfo().valueOr:
continue
liveIds.incl(info.peerId)
peerManager.refreshEnrLiveness(liveIds)
# Discovery `queryRandom` can have a synchronous fast path for example
# when no peers are in the routing table. Don't run it in continuous loop.
#

View File

@ -796,6 +796,9 @@ proc onPeerEvent(pm: PeerManager, peerId: PeerId, event: PeerEvent) {.async.} =
of PeerEventKind.Joined:
direction = if event.initiator: Outbound else: Inbound
connectedness = Connected
# A live connection is the strongest liveness signal; keep the ENR fresh so
# the freshness window after a later disconnect starts from now.
peerStore.touchEnrSeen(peerId)
## Check max allowed in-relay peers
let inRelayPeers = pm.connectedPeers(WakuRelayCodec)[0]
@ -1013,6 +1016,16 @@ proc manageRelayPeers*(pm: PeerManager) {.async.} =
trace "Connecting to Peers", peerIds = $uniquePeers[i ..< stop]
await pm.connectToNodes(uniquePeers[i ..< stop])
proc refreshEnrLiveness*(pm: PeerManager, liveIds: HashSet[PeerId]) =
## Extend the ENR freshness window for peers discv5 still considers reachable.
## Refresh-only on purpose: routing-table membership never deletes peer-store
## state, so a live peer evicted from a full k-bucket simply ages out of
## peer-exchange rather than being wrongly purged.
let peerStore = pm.switch.peerStore
for peerId in liveIds:
if peerStore[ENRBook].contains(peerId):
peerStore.touchEnrSeen(peerId)
proc prunePeerStore*(pm: PeerManager) =
let peerStore = pm.switch.peerStore
let numPeers = peerStore[AddressBook].book.len

View File

@ -42,9 +42,19 @@ type
# Keeps track of the ENR (Ethereum Node Record) of a peer
ENRBook* = ref object of PeerBook[enr.Record]
# Keeps track of when a peer's ENR was last seen alive (discv5 revalidation
# or active connection). Drives staleness gating of peer-exchange responses.
EnrLastSeenBook* = ref object of PeerBook[Moment]
# Keeps track of peer shards
ShardBook* = ref object of PeerBook[seq[uint16]]
const EnrFreshnessTTL* = chronos.minutes(5)
## A Discv5-origin ENR is served by peer-exchange only if seen-live within
## this window, unless the peer is currently Connected. discv5 revalidates
## its routing table within seconds and the discv5 search loop runs every
## few seconds, so this is comfortably above the refresh cadence.
proc getPeer*(peerStore: PeerStore, peerId: PeerId): RemotePeerInfo =
let addresses =
if peerStore[LastSeenBook][peerId].isSome():
@ -82,6 +92,11 @@ proc delete*(peerStore: PeerStore, peerId: PeerId) =
# Delete all the information of a given peer.
peerStore.del(peerId)
proc touchEnrSeen*(peerStore: PeerStore, peerId: PeerId) =
## Mark a peer's ENR as seen-live now. Called from liveness signals (discv5
## revalidation, active connection) so stale ENRs eventually age out of PX.
peerStore[EnrLastSeenBook][peerId] = Moment.now()
proc peers*(peerStore: PeerStore): seq[RemotePeerInfo] =
let allKeys = concat(
toSeq(peerStore[LastSeenOutboundBook].book.keys()),
@ -147,6 +162,8 @@ proc addPeer*(peerStore: PeerStore, peer: RemotePeerInfo, origin = UnknownOrigin
peerStore[NumberFailedConnBook].book.hasKeyOrPut(peer.peerId, peer.numberFailedConn)
if peer.enr.isSome():
peerStore[ENRBook][peer.peerId] = peer.enr.get()
# A freshly (re)discovered ENR starts its freshness window now.
peerStore.touchEnrSeen(peer.peerId)
proc setShardInfo*(peerStore: PeerStore, peerId: PeerID, shards: seq[uint16]) =
peerStore[ShardBook][peerId] = shards
@ -232,14 +249,16 @@ proc getPeersByCapability*(
template forEnrPeers*(
peerStore: PeerStore,
peerId, peerConnectedness, peerOrigin, peerEnrRecord, body: untyped,
peerId, peerConnectedness, peerOrigin, peerEnrRecord, peerEnrSeen, body: untyped,
) =
let enrBook = peerStore[ENRBook]
let connBook = peerStore[ConnectionBook]
let sourceBook = peerStore[SourceBook]
let seenBook = peerStore[EnrLastSeenBook]
for pid, enrRecord in tables.pairs(enrBook.book):
let peerId {.inject.} = pid
let peerConnectedness {.inject.} = connBook.book.getOrDefault(pid, NotConnected)
let peerOrigin {.inject.} = sourceBook.book.getOrDefault(pid, UnknownOrigin)
let peerEnrRecord {.inject.} = enrRecord
let peerEnrSeen {.inject.} = seenBook.book.getOrDefault(pid, Moment.low)
body

View File

@ -102,13 +102,19 @@ proc getEnrsFromStore(
let k = min(MaxPeersCacheSize, numPeers.int)
let enrStoreLen = wpx.peerManager.switch.peerStore[ENRBook].len
var enrs = newSeqOfCap[enr.Record](min(k, enrStoreLen))
let now = Moment.now()
wpx.peerManager.switch.peerStore.forEnrPeers(
peerId, peerConnectedness, peerOrigin, peerEnrRecord
peerId, peerConnectedness, peerOrigin, peerEnrRecord, peerEnrSeen
):
if peerConnectedness == CannotConnect:
debug "Could not retrieve ENR because cannot connect to peer",
remotePeerId = peerId
continue
# A connected peer is alive by definition; otherwise require a recent
# liveness refresh so we stop advertising ENRs discv5 has already evicted.
if peerConnectedness != Connected and (now - peerEnrSeen) > EnrFreshnessTTL:
debug "Could not retrieve ENR because it is stale", remotePeerId = peerId
continue
poolFilter(wpx.cluster, peerOrigin, peerEnrRecord).isOkOr:
debug "Could not get ENR because no peer matched pool", error = error
continue