Skip to content

Commit f0b3251

Browse files
committed
chore(kad): liveness-gated bucket eviction
1 parent 8a7b54e commit f0b3251

4 files changed

Lines changed: 115 additions & 15 deletions

File tree

libp2p/protocols/kademlia/find.nim

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ proc lookOnce*(
416416
for (peerId, res) in completedRPCBatch:
417417
let reply = res.valueOr:
418418
continue
419+
# A successful reply proves the peer useful; retain it through eviction.
420+
rtable.markUseful(peerId)
419421
let newPeerInfos = state.updateShortlist(reply)
420422
kad.switch.updatePeers(
421423
kad.config.addressPolicy, rtable, newPeerInfos, kad.config.limits.maxPeersPerIp,

libp2p/protocols/kademlia/routing_table.nim

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ proc new*(
1919
hasher: Opt[XorDHasher] = NoneHasher,
2020
maxBuckets: int = DefaultMaxBuckets,
2121
selfIdPreHashed = false,
22+
usefulnessGracePeriod: Duration = DefaultUsefulnessGracePeriod,
2223
): T =
2324
doAssert maxBuckets > 0 and maxBuckets <= MaxBucketsLimit,
2425
"maxBuckets must be in 1 .. " & $MaxBucketsLimit
@@ -27,6 +28,7 @@ proc new*(
2728
hasher: hasher,
2829
maxBuckets: maxBuckets,
2930
selfIdPreHashed: selfIdPreHashed,
31+
usefulnessGracePeriod: usefulnessGracePeriod,
3032
)
3133

3234
proc `$`*(rt: RoutingTable): string =
@@ -72,18 +74,42 @@ proc oldestPeer*(bucket: Bucket): (NodeEntry, int) =
7274
oldestIdx = i
7375
(oldest, oldestIdx)
7476

75-
proc replaceOldest(bucket: var Bucket, newNodeId: Key, replication: int): bool =
76-
if bucket.peers.len < replication:
77+
func isReplaceable*(entry: NodeEntry, gracePeriod: Duration, now: Moment): bool =
78+
## A peer is replaceable once it has spent longer than `gracePeriod` in the
79+
## table without proving useful. A peer that answered a query within the grace
80+
## period — or that was only just added — is retained.
81+
now - entry.lastUsefulAt.get(entry.addedAt) > gracePeriod
82+
83+
proc replaceableCandidate(bucket: Bucket, gracePeriod: Duration): Opt[int] =
84+
## Least-recently-seen peer that is past the usefulness grace period, i.e. the
85+
## best candidate to evict. `Opt.none` when every peer is still useful/fresh.
86+
let now = Moment.now()
87+
var candidateIdx = -1
88+
var oldestSeen: Moment
89+
for i, p in bucket.peers:
90+
if not p.isReplaceable(gracePeriod, now):
91+
continue
92+
if candidateIdx == -1 or p.lastSeen < oldestSeen:
93+
candidateIdx = i
94+
oldestSeen = p.lastSeen
95+
if candidateIdx == -1:
96+
return Opt.none(int)
97+
Opt.some(candidateIdx)
98+
99+
proc replaceReplaceable(
100+
bucket: var Bucket, newNodeId: Key, config: RoutingTableConfig
101+
): bool =
102+
if bucket.peers.len < config.replication:
77103
trace "Skipping replace: bucket is not full", newNodeId = newNodeId
78104
return false
79105

80-
let (oldest, oldestIdx) = bucket.oldestPeer()
81-
82-
if oldest.nodeId == newNodeId:
83-
trace "Failed to replace: same nodeId", newNodeId = newNodeId
106+
let idx = bucket.replaceableCandidate(config.usefulnessGracePeriod).valueOr:
107+
trace "Skipping replace: no peer past usefulness grace period",
108+
newNodeId = newNodeId
84109
return false
85110

86-
bucket.peers[oldestIdx] = NodeEntry(nodeId: newNodeId, lastSeen: Moment.now())
111+
let now = Moment.now()
112+
bucket.peers[idx] = NodeEntry(nodeId: newNodeId, lastSeen: now, addedAt: now)
87113
true
88114

89115
proc updateRoutingTableMetrics*(rtable: RoutingTable) =
@@ -113,12 +139,14 @@ proc insert*(rtable: RoutingTable, nodeId: Key): bool =
113139
if keyx.isSome:
114140
bucket.peers[keyx.unsafeValue].lastSeen = Moment.now()
115141
elif bucket.peers.len < rtable.config.replication:
116-
bucket.peers.add(NodeEntry(nodeId: nodeId, lastSeen: Moment.now()))
142+
let now = Moment.now()
143+
bucket.peers.add(NodeEntry(nodeId: nodeId, lastSeen: now, addedAt: now))
117144
kad_routing_table_insertions.inc()
118145
else:
119-
# eviction policy: replace oldest key
120-
if not bucket.replaceOldest(nodeId, rtable.config.replication):
121-
debug "Cannot insert, failed to replace oldest key in bucket",
146+
# A full bucket with no replaceable peer rejects the newcomer rather than
147+
# evicting a still-useful one.
148+
if not bucket.replaceReplaceable(nodeId, rtable.config):
149+
debug "Cannot insert, no replaceable peer in bucket",
122150
bucket = idx, nodeId = nodeId
123151
return false
124152
kad_routing_table_replacements.inc()
@@ -130,6 +158,22 @@ proc insert*(rtable: RoutingTable, nodeId: Key): bool =
130158
proc insert*(rtable: RoutingTable, peerId: PeerId): bool =
131159
insert(rtable, peerId.toKey())
132160

161+
proc markUseful*(rtable: RoutingTable, nodeId: Key) =
162+
## Records that `nodeId` answered a query: refreshes its usefulness and
163+
## last-seen timestamps so it is retained through bucket eviction. No-op when
164+
## the peer is not in the table.
165+
let idx = rtable.bucketIndex(nodeId)
166+
if idx >= rtable.buckets.len:
167+
return
168+
let pos = peerIndexInBucket(rtable.buckets[idx], nodeId).valueOr:
169+
return
170+
let now = Moment.now()
171+
rtable.buckets[idx].peers[pos].lastUsefulAt = Opt.some(now)
172+
rtable.buckets[idx].peers[pos].lastSeen = now
173+
174+
proc markUseful*(rtable: RoutingTable, peerId: PeerId) =
175+
rtable.markUseful(peerId.toKey())
176+
133177
proc findClosest*(rtable: RoutingTable, targetId: Key, count: int): seq[Key] =
134178
## Returns up to `count` nodes in the table with the smallest XOR distance to `targetId`.
135179
var allNodes: seq[Key] = @[]

libp2p/protocols/kademlia/types.nim

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const
1818
DefaultBucketRefreshTime* = 10.minutes
1919
DefaultBucketStaleTime* = 30.minutes
2020
# peer not seen for this duration marks bucket stale
21+
DefaultUsefulnessGracePeriod* = 1.hours
22+
## A newly inserted peer is protected from eviction until it has been in the
23+
## table this long without proving useful (answering a query), mirroring the
24+
## usefulness-grace concept from go-libp2p's k-bucket.
2125
DefaultRetries* = 5
2226
DefaultReplication* = 20 ## aka `k` in the spec
2327
DefaultAlpha* = 10 # concurrency parameter
@@ -194,6 +198,11 @@ type
194198
NodeEntry* = object
195199
nodeId*: Key
196200
lastSeen*: Moment
201+
addedAt*: Moment
202+
lastUsefulAt*: Opt[Moment]
203+
## Set when the peer answers a query. `Opt.none` means it has not yet
204+
## proven useful; such a peer becomes replaceable once past the grace
205+
## period since it was added.
197206

198207
Bucket* = object
199208
peers*: seq[NodeEntry]
@@ -203,6 +212,7 @@ type
203212
hasher*: Opt[XorDHasher]
204213
maxBuckets*: int
205214
selfIdPreHashed*: bool
215+
usefulnessGracePeriod*: Duration
206216

207217
RoutingTable* = ref object
208218
selfId*: Key

tests/libp2p/kademlia/test_routing_table.nim

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ suite "KadDHT Routing Table":
4545
let bucket = rt.buckets[TargetBucket]
4646
check bucket.peers.len <= config.replication
4747

48-
test "evicts oldest key at max capacity":
48+
proc agePastGrace(rt: RoutingTable, bucketIdx: int) =
49+
## Push every peer past the usefulness grace period so eviction can act.
50+
var bucket = rt.buckets[bucketIdx]
51+
for i in 0 ..< bucket.peers.len:
52+
bucket.peers[i].addedAt = Moment.now() - 2.hours
53+
rt.buckets[bucketIdx] = bucket
54+
55+
test "evicts oldest replaceable key at max capacity":
4956
let selfId = testKey(0)
5057
let config = RoutingTableConfig.new(hasher = Opt.some(noOpHasher))
5158
var rt = RoutingTable.new(selfId, config)
@@ -55,15 +62,52 @@ suite "KadDHT Routing Table":
5562

5663
check rt.buckets[TargetBucket].peers.len == config.replication
5764

65+
# Fresh peers are protected by the grace period; age them so they are
66+
# eligible for eviction.
67+
rt.agePastGrace(TargetBucket)
68+
5869
# new entry should evict oldest entry
5970
let (oldest, _) = rt.buckets[TargetBucket].oldestPeer()
6071

6172
check rt.insert(randomKeyInBucket(selfId, TargetBucket, rng()))
6273

63-
let (oldestAfterInsert, _) = rt.buckets[TargetBucket].oldestPeer()
74+
check:
75+
rt.buckets[TargetBucket].peers.len == config.replication
76+
not rt.buckets[TargetBucket].allKeys().contains(oldest.nodeId)
77+
78+
test "retains peers still within the usefulness grace period":
79+
let selfId = testKey(0)
80+
let config = RoutingTableConfig.new(hasher = Opt.some(noOpHasher))
81+
var rt = RoutingTable.new(selfId, config)
82+
for _ in 0 ..< config.replication:
83+
discard rt.insert(randomKeyInBucket(selfId, TargetBucket, rng()))
84+
85+
let before = rt.buckets[TargetBucket].allKeys()
86+
let newcomer = randomKeyInBucket(selfId, TargetBucket, rng())
87+
88+
# Bucket is full of fresh, unproven peers: the newcomer is rejected rather
89+
# than blindly evicting one of them.
90+
check:
91+
not rt.insert(newcomer)
92+
rt.buckets[TargetBucket].allKeys() == before
93+
not rt.buckets[TargetBucket].allKeys().contains(newcomer)
6494

65-
# oldest was evicted
66-
check oldest.nodeId != oldestAfterInsert.nodeId
95+
test "markUseful protects a peer from eviction":
96+
let selfId = testKey(0)
97+
let config = RoutingTableConfig.new(hasher = Opt.some(noOpHasher))
98+
var rt = RoutingTable.new(selfId, config)
99+
for _ in 0 ..< config.replication:
100+
discard rt.insert(randomKeyInBucket(selfId, TargetBucket, rng()))
101+
102+
rt.agePastGrace(TargetBucket)
103+
104+
# The oldest-seen peer would be evicted first, but marking it useful keeps
105+
# it and forces a different peer out.
106+
let (oldest, _) = rt.buckets[TargetBucket].oldestPeer()
107+
rt.markUseful(oldest.nodeId)
108+
109+
check rt.insert(randomKeyInBucket(selfId, TargetBucket, rng()))
110+
check rt.buckets[TargetBucket].allKeys().contains(oldest.nodeId)
67111

68112
test "re-insert existing key updates lastSeen":
69113
let selfId = testKey(0)

0 commit comments

Comments
 (0)