Skip to content

Peer-exchange serves stale ENRs: PeerStore copy is never liveness-pruned #3933

Description

@igor-sirotin

Summary

Peer-exchange responds with ENRs read from the PeerStore ENRBook, which is fed add-only from discv5 and is only ever pruned on capacity, never on staleness/liveness. As a result a node keeps advertising ENRs of disconnected/dead discv5 peers via PX more or less indefinitely (as long as the store is under capacity). PX clients (e.g. go-waku light clients) then repeatedly try to dial unreachable peers.

Permalinks below pinned to master @ 86e424c (no develop branch exists).

The chain

  1. PX serves from the PeerStore, and its only liveness gate is CannotConnect. getEnrsFromStore iterates peerStore[ENRBook], skips only CannotConnect, and poolFilter then keeps only origin == Discv5:

    • proc getEnrsFromStore(
      wpx: WakuPeerExchange, numPeers: uint64
      ): seq[enr.Record] {.gcsafe.} =
      # Reservoir sampling (Algorithm R)
      var i = 0
      let k = min(MaxPeersCacheSize, numPeers.int)
      let enrStoreLen = wpx.peerManager.switch.peerStore[ENRBook].len
      var enrs = newSeqOfCap[enr.Record](min(k, enrStoreLen))
      wpx.peerManager.switch.peerStore.forEnrPeers(
      peerId, peerConnectedness, peerOrigin, peerEnrRecord
      ):
      if peerConnectedness == CannotConnect:
      debug "Could not retrieve ENR because cannot connect to peer",
      remotePeerId = peerId
      continue
      poolFilter(wpx.cluster, peerOrigin, peerEnrRecord).isOkOr:
      debug "Could not get ENR because no peer matched pool", error = error
      continue
      if i < k:
      enrs.add(peerEnrRecord)
      else:
      # Add some randomness
      let j = rand(i)
      if j < k:
      enrs[j] = peerEnrRecord
      inc(i)
      return enrs
    • proc poolFilter*(
      cluster: Option[uint16], origin: PeerOrigin, enr: enr.Record
      ): Result[void, string] =
      if origin != Discv5:
      trace "peer not from discv5", origin = $origin
      return err("peer not from discv5: " & $origin)
      if cluster.isSome() and enr.isClusterMismatched(cluster.get()):
      trace "peer has mismatching cluster"
      return err("peer has mismatching cluster")
      return ok()
  2. The ENRBook is populated add-only by the discv5 search loop (addPeer(peer, PeerOrigin.Discv5)), with no reverse path that removes an entry when discv5 later evicts it:

    • proc searchLoop(wd: WakuDiscoveryV5) {.async.} =
      ## Continuously add newly discovered nodes
      let peerManager = wd.peerManager.valueOr:
      return
      info "Starting discovery v5 search"
      while wd.listening:
      trace "running discv5 discovery loop"
      let discoveredRecords = await wd.findRandomPeers()
      var discoveredPeers: seq[RemotePeerInfo]
      var wrongRecordsReasons: seq[tuple[record: string, errorDescription: string]]
      ## this is to store the reasons why certain records could not be converted to RemotePeerInfo
      for record in discoveredRecords:
      let peerInfo = record.toRemotePeerInfo().valueOr:
      ## in case of error, we keep track of it for debugging purposes
      wrongRecordsReasons.add(($record, $error))
      waku_discv5_errors.inc(labelValues = [$error])
      continue
      discoveredPeers.add(peerInfo)
      trace "discv5 discovered peers",
      num_discovered_peers = discoveredPeers.len,
      peers = toSeq(discoveredPeers.mapIt(shortLog(it.peerId)))
      trace "discv5 discarded wrong records",
      wrong_records =
      wrongRecordsReasons.mapIt("(" & it.record & "," & it.errorDescription & ")")
      for peer in discoveredPeers:
      # Peers added are filtered by the peer manager
      peerManager.addPeer(peer, PeerOrigin.Discv5)
      # 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.
  3. The only removal path is capacity-based, not staleness-based. prunePeerStore returns early unless numPeers > capacity, runs every 10 min, and when it does fire it evicts by failed-conn-count / shard balancing — never by "this ENR is stale":

    • proc prunePeerStore*(pm: PeerManager) =
      let peerStore = pm.switch.peerStore
      let numPeers = peerStore[AddressBook].book.len
      let capacity = peerStore.getCapacity()
      if numPeers <= capacity:
      return
      trace "Peer store capacity exceeded", numPeers = numPeers, capacity = capacity
      let pruningCount = numPeers - capacity
      var peersToPrune: HashSet[PeerId]
      # prune failed connections
      for peerId, count in peerStore[NumberFailedConnBook].book.pairs:
      if count < pm.maxFailedAttempts:
      continue
      if peersToPrune.len >= pruningCount:
      break
      peersToPrune.incl(peerId)
      var notConnected = peerStore.getDisconnectedPeers().mapIt(it.peerId)
      # Always pick random non-connected peers
      shuffle(notConnected)
      var shardlessPeers: seq[PeerId]
      var peersByShard = initTable[uint16, seq[PeerId]]()
      for peer in notConnected:
      if not peerStore[ENRBook].contains(peer):
      shardlessPeers.add(peer)
      continue
      let record = peerStore[ENRBook][peer]
      let rec = record.toTyped().valueOr:
      shardlessPeers.add(peer)
      continue
      let rs = rec.relaySharding().valueOr:
      shardlessPeers.add(peer)
      continue
      for shard in rs.shardIds:
      peersByShard.mgetOrPut(shard, @[]).add(peer)
      # prune not connected peers without shard
      for peer in shardlessPeers:
      if peersToPrune.len >= pruningCount:
      break
      peersToPrune.incl(peer)
      # calculate the avg peers per shard
      let total = sum(toSeq(peersByShard.values).mapIt(it.len))
      let avg = min(1, total div max(1, peersByShard.len))
      # prune peers from shard with higher than avg count
      for shard, peers in peersByShard.pairs:
      let count = max(peers.len - avg, 0)
      for peer in peers[0 .. count]:
      if peersToPrune.len >= pruningCount:
      break
      peersToPrune.incl(peer)
      for peer in peersToPrune:
      peerStore.delete(peer)
      let afterNumPeers = peerStore[AddressBook].book.len
      trace "Finished pruning peer store",
      beforeNumPeers = numPeers,
      afterNumPeers = afterNumPeers,
      capacity = capacity,
      pruned = peersToPrune.len
    • interval:
      PrunePeerStoreInterval = chronos.minutes(10)

Why the existing CannotConnect filter doesn't catch it

A discv5-discovered or recently-disconnected peer sits at NotConnected; it only becomes CannotConnect if this node actively dials it and fails — which doesn't happen for peers it never tries to dial. discv5's own routing-table revalidation does drop dead nodes quickly (~seconds), but that eviction is not propagated to the PeerStore that PX reads. #3652 (enrCache removal) reduced this but did not close it.

Impact

Amplified in CI / ephemeral-node fleets (random keys + constant teardown on a shared bootnode). Concretely it breaks status-go functional light-client tests, where light clients fetch dead full-node ENRs via PX and time out on filter/lightpush: status-im/status-go#7513.

Suggested directions

  • Serve PX from (or reconcile against) the live, revalidated discv5 routing table instead of the add-only PeerStore copy; or
  • Add last-seen TTL / staleness eviction to ENRBook (or propagate discv5 evictions into the PeerStore); or
  • Also exclude NotConnected in getEnrsFromStore — but carefully, since discv5 peers are legitimately NotConnected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Priority

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions