Skip to content

Commit 5946637

Browse files
committed
initial impl of peer grief (deprioritization) for PeerStore
1 parent 15b9c94 commit 5946637

9 files changed

Lines changed: 446 additions & 11 deletions

File tree

tests/test_peer_store_extended.nim

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{.used.}
22

33
import
4-
std/[sequtils, times],
4+
std/[sequtils, times, random],
55
chronos,
66
libp2p/crypto/crypto,
77
libp2p/peerid,
@@ -353,3 +353,289 @@ suite "Extended nim-libp2p Peer Store":
353353
peerStore[DisconnectBook][p1] == 0
354354
peerStore[SourceBook][p1] == default(PeerOrigin)
355355
peerStore[DirectionBook][p1] == default(PeerDirection)
356+
peerStore[GriefBook][p1] == default(GriefData)
357+
358+
suite "Extended nim-libp2p Peer Store: grief scores":
359+
# These tests mock the clock and work better as a separate suite
360+
var peerStore: PeerStore
361+
var p1, p2, p3: PeerId
362+
363+
setup:
364+
peerStore = PeerStore.new(nil, capacity = 50)
365+
require p1.init(basePeerId & "1")
366+
require p2.init(basePeerId & "2")
367+
require p3.init(basePeerId & "3")
368+
369+
# Shorthand: one cooldown interval
370+
let interval = GriefCooldownInterval
371+
372+
test "new peer has grief score 0":
373+
check peerStore.getGriefScore(p1) == 0
374+
375+
test "griefPeer increases score":
376+
let t0 = Moment.init(1000, Minute)
377+
378+
peerStore.griefPeer(p1, 5, t0)
379+
check peerStore.getGriefScore(p1, t0) == 5
380+
381+
test "griefPeer accumulates":
382+
let t0 = Moment.init(1000, Minute)
383+
384+
peerStore.griefPeer(p1, 3, t0)
385+
peerStore.griefPeer(p1, 2, t0)
386+
check peerStore.getGriefScore(p1, t0) == 5
387+
388+
test "grief cools down by 1 point per interval":
389+
let t0 = Moment.init(1000, Minute)
390+
391+
peerStore.griefPeer(p1, 5, t0)
392+
393+
check peerStore.getGriefScore(p1, t0) == 5
394+
check peerStore.getGriefScore(p1, t0 + interval * 1) == 4
395+
check peerStore.getGriefScore(p1, t0 + interval * 2) == 3
396+
check peerStore.getGriefScore(p1, t0 + interval * 3) == 2
397+
check peerStore.getGriefScore(p1, t0 + interval * 4) == 1
398+
check peerStore.getGriefScore(p1, t0 + interval * 5) == 0
399+
400+
test "grief floors at 0":
401+
let t0 = Moment.init(1000, Minute)
402+
403+
peerStore.griefPeer(p1, 3, t0)
404+
405+
# Well past full cooldown, should be 0
406+
check peerStore.getGriefScore(p1, t0 + interval * 10) == 0
407+
408+
test "cooldown preserves remainder":
409+
let t0 = Moment.init(1000, Minute)
410+
# Half an interval past 2 full intervals
411+
let tHalf = t0 + interval * 2 + interval div 2
412+
# Complete the 3rd interval
413+
let t3 = t0 + interval * 3
414+
415+
peerStore.griefPeer(p1, 5, t0)
416+
417+
# After 2.5 intervals, score should be 3
418+
check peerStore.getGriefScore(p1, tHalf) == 3
419+
420+
# After completing the 3rd interval, score should be 2
421+
check peerStore.getGriefScore(p1, t3) == 2
422+
423+
test "grief after full cooldown restarts cooldown time":
424+
let t0 = Moment.init(1000, Minute)
425+
426+
peerStore.griefPeer(p1, 2, t0)
427+
428+
# Fully cool down
429+
check peerStore.getGriefScore(p1, t0 + interval * 5) == 0
430+
431+
# Grief again
432+
let t1 = t0 + interval * 5
433+
peerStore.griefPeer(p1, 3, t1)
434+
check peerStore.getGriefScore(p1, t1) == 3
435+
436+
# 1 interval after second grief
437+
check peerStore.getGriefScore(p1, t1 + interval) == 2
438+
439+
test "independent grief scores per peer":
440+
let t0 = Moment.init(1000, Minute)
441+
442+
peerStore.griefPeer(p1, 10, t0)
443+
peerStore.griefPeer(p2, 3, t0)
444+
445+
check peerStore.getGriefScore(p1, t0 + interval * 2) == 8
446+
check peerStore.getGriefScore(p2, t0 + interval * 2) == 1
447+
check peerStore.getGriefScore(p3, t0 + interval * 2) == 0
448+
449+
test "grief with default amount is 1":
450+
let t0 = Moment.init(1000, Minute)
451+
452+
peerStore.griefPeer(p1, now = t0)
453+
check peerStore.getGriefScore(p1, t0) == 1
454+
455+
test "griefPeer with zero or negative amount is ignored":
456+
let t0 = Moment.init(1000, Minute)
457+
458+
peerStore.griefPeer(p1, 5, t0)
459+
peerStore.griefPeer(p1, 0, t0)
460+
peerStore.griefPeer(p1, -3, t0)
461+
check peerStore.getGriefScore(p1, t0) == 5
462+
463+
test "grief added during partial cooldown does not reset cooldown time":
464+
let t0 = Moment.init(1000, Minute)
465+
let tHalf = t0 + interval * 2 + interval div 2
466+
let t3 = t0 + interval * 3
467+
let t4 = t0 + interval * 4
468+
469+
peerStore.griefPeer(p1, 5, t0)
470+
471+
# At 2.5 intervals: 2 consumed, score 3, half-interval remainder
472+
check peerStore.getGriefScore(p1, tHalf) == 3
473+
474+
# Add more grief — cooldown time should NOT reset, remainder preserved
475+
peerStore.griefPeer(p1, 4, tHalf)
476+
check peerStore.getGriefScore(p1, tHalf) == 7
477+
478+
# Remainder completes another interval
479+
check peerStore.getGriefScore(p1, t3) == 6
480+
481+
# And one more full interval
482+
check peerStore.getGriefScore(p1, t4) == 5
483+
484+
test "multiple reads without time change are idempotent":
485+
let t0 = Moment.init(1000, Minute)
486+
487+
peerStore.griefPeer(p1, 10, t0)
488+
489+
check peerStore.getGriefScore(p1, t0 + interval * 3) == 7
490+
check peerStore.getGriefScore(p1, t0 + interval * 3) == 7
491+
check peerStore.getGriefScore(p1, t0 + interval * 3) == 7
492+
493+
test "interleaved grief and cooldown across multiple peers":
494+
let t0 = Moment.init(1000, Minute)
495+
496+
# Stagger grief: p1 at t0, p2 at t0+1interval, p3 at t0+2interval
497+
peerStore.griefPeer(p1, 6, t0)
498+
peerStore.griefPeer(p2, 4, t0 + interval)
499+
peerStore.griefPeer(p3, 2, t0 + interval * 2)
500+
501+
# At t0+3*interval: p1 lost 3, p2 lost 2, p3 lost 1
502+
check peerStore.getGriefScore(p1, t0 + interval * 3) == 3
503+
check peerStore.getGriefScore(p2, t0 + interval * 3) == 2
504+
check peerStore.getGriefScore(p3, t0 + interval * 3) == 1
505+
506+
# Grief p2 again at t0+3I
507+
peerStore.griefPeer(p2, 10, t0 + interval * 3)
508+
509+
# At t0+5*interval: p1 lost 5 total, p2 lost 2 more since re-grief, p3 floored at 0
510+
check peerStore.getGriefScore(p1, t0 + interval * 5) == 1
511+
check peerStore.getGriefScore(p2, t0 + interval * 5) == 10
512+
check peerStore.getGriefScore(p3, t0 + interval * 5) == 0
513+
514+
suite "Extended nim-libp2p Peer Store: grief-based peer selection":
515+
# Tests for sortByGriefScore via selectPeers
516+
const testProto = "/test/grief/1.0.0"
517+
518+
proc makePeer(port: int): RemotePeerInfo =
519+
let key = generateSecp256k1Key()
520+
RemotePeerInfo.init(
521+
peerId = PeerId.init(key.getPublicKey().tryGet()).tryGet(),
522+
addrs = @[MultiAddress.init("/ip4/127.0.0.1/tcp/" & $port).tryGet()],
523+
protocols = @[testProto],
524+
)
525+
526+
test "all peers at grief 0 returns all peers (shuffled)":
527+
let switch = newTestSwitch()
528+
let pm = PeerManager.new(switch)
529+
let peerStore = switch.peerStore
530+
let peers = (1..5).mapIt(makePeer(it + 10000))
531+
for p in peers:
532+
peerStore.addPeer(p)
533+
534+
let selected = pm.selectPeers(testProto)
535+
check selected.len == 5
536+
537+
test "lower grief peers come before higher grief peers":
538+
let switch = newTestSwitch()
539+
let pm = PeerManager.new(switch)
540+
let peerStore = switch.peerStore
541+
let pA = makePeer(20001)
542+
let pB = makePeer(20002)
543+
let pC = makePeer(20003)
544+
peerStore.addPeer(pA)
545+
peerStore.addPeer(pB)
546+
peerStore.addPeer(pC)
547+
548+
# pA: grief 0 (bucket 0), pB: grief 5 (bucket 1), pC: grief 15 (bucket 3)
549+
peerStore.griefPeer(pB.peerId, 5)
550+
peerStore.griefPeer(pC.peerId, 15)
551+
552+
# Run multiple times to account for shuffle within buckets
553+
for i in 0 ..< 20:
554+
let selected = pm.selectPeers(testProto)
555+
check selected.len == 3
556+
# pA (bucket 0) must always be first
557+
check selected[0].peerId == pA.peerId
558+
# pB (bucket 1) must always come before pC (bucket 3)
559+
check selected[1].peerId == pB.peerId
560+
check selected[2].peerId == pC.peerId
561+
562+
test "peers within same bucket are interchangeable":
563+
let switch = newTestSwitch()
564+
let pm = PeerManager.new(switch)
565+
let peerStore = switch.peerStore
566+
let pA = makePeer(30001)
567+
let pB = makePeer(30002)
568+
peerStore.addPeer(pA)
569+
peerStore.addPeer(pB)
570+
571+
# Both within bucket 0 (scores 1 and 4, both div 5 == 0)
572+
peerStore.griefPeer(pA.peerId, 1)
573+
peerStore.griefPeer(pB.peerId, 4)
574+
575+
var sawAFirst = false
576+
var sawBFirst = false
577+
for i in 0 ..< 50:
578+
let selected = pm.selectPeers(testProto)
579+
check selected.len == 2
580+
if selected[0].peerId == pA.peerId:
581+
sawAFirst = true
582+
else:
583+
sawBFirst = true
584+
585+
# Both orderings should appear since they're in the same bucket
586+
check sawAFirst
587+
check sawBFirst
588+
589+
test "peers in different buckets never swap order":
590+
let switch = newTestSwitch()
591+
let pm = PeerManager.new(switch)
592+
let peerStore = switch.peerStore
593+
let pLow = makePeer(40001)
594+
let pHigh = makePeer(40002)
595+
peerStore.addPeer(pLow)
596+
peerStore.addPeer(pHigh)
597+
598+
# pLow in bucket 0 (score 1), pHigh in bucket 1 (score 5)
599+
peerStore.griefPeer(pLow.peerId, 1)
600+
peerStore.griefPeer(pHigh.peerId, 5)
601+
602+
for i in 0 ..< 30:
603+
let selected = pm.selectPeers(testProto)
604+
check selected.len == 2
605+
check selected[0].peerId == pLow.peerId
606+
check selected[1].peerId == pHigh.peerId
607+
608+
test "zero-grief peers always come before grieved peers":
609+
let switch = newTestSwitch()
610+
let pm = PeerManager.new(switch)
611+
let peerStore = switch.peerStore
612+
let pClean1 = makePeer(50001)
613+
let pClean2 = makePeer(50002)
614+
let pGrieved = makePeer(50003)
615+
peerStore.addPeer(pClean1)
616+
peerStore.addPeer(pClean2)
617+
peerStore.addPeer(pGrieved)
618+
619+
peerStore.griefPeer(pGrieved.peerId, 6)
620+
621+
for i in 0 ..< 20:
622+
let selected = pm.selectPeers(testProto)
623+
check selected.len == 3
624+
# Grieved peer (bucket 1) must be last; clean peers (bucket 0) first
625+
check selected[2].peerId == pGrieved.peerId
626+
627+
test "peers beyond MaxGriefBucket are excluded from selection":
628+
let switch = newTestSwitch()
629+
let pm = PeerManager.new(switch)
630+
let peerStore = switch.peerStore
631+
let pGood = makePeer(60001)
632+
let pBad = makePeer(60002)
633+
peerStore.addPeer(pGood)
634+
peerStore.addPeer(pBad)
635+
636+
# pBad in bucket 4 (score 20, 20 div 5 = 4 > MaxGriefBucket)
637+
peerStore.griefPeer(pBad.peerId, 20)
638+
639+
let selected = pm.selectPeers(testProto)
640+
check selected.len == 1
641+
check selected[0].peerId == pGood.peerId

tests/waku_peer_exchange/test_protocol.nim

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ suite "Waku Peer Exchange":
203203
# Start and mount peer exchange
204204
await allFutures([node1.start(), node2.start()])
205205
await allFutures([node1.mountPeerExchange(), node2.mountPeerExchange()])
206+
await allFutures([node1.mountPeerExchangeClient(), node2.mountPeerExchangeClient()])
206207

207208
# Create connection
208209
let connOpt = await node2.peerManager.dialPeer(

waku/node/peer_manager/peer_manager.nim

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -215,27 +215,64 @@ proc loadFromStorage(pm: PeerManager) {.gcsafe.} =
215215

216216
trace "recovered peers from storage", amount = amount
217217

218+
proc griefPeer*(pm: PeerManager, peerId: PeerId, amount: int = 1) =
219+
if not pm.isNil:
220+
pm.switch.peerStore.griefPeer(peerId, amount)
221+
222+
proc sortByGriefScore(pm: PeerManager, peers: var seq[RemotePeerInfo]) =
223+
## Sorts peers by grief score ascending, with random shuffling within each
224+
## score tier. Peers with lower grief are preferred.
225+
## NOTE: shuffling defaultPeerStoreCapacity (750 currently) on demand is
226+
## negligible, but if that increases, might be worth exploring different
227+
## data structures.
228+
let peerStore = pm.switch.peerStore
229+
230+
# Resolve grief scores for all peers
231+
var anyGrief = false
232+
var scored: seq[(int, RemotePeerInfo)]
233+
for p in peers:
234+
let score = peerStore.getGriefScore(p.peerId)
235+
if score > 0:
236+
anyGrief = true
237+
scored.add((score, p))
238+
239+
# Fast path: if all peers are at 0, just shuffle
240+
if not anyGrief:
241+
shuffle(peers)
242+
return
243+
244+
# Shuffle first so that within-bucket order is random
245+
shuffle(scored)
246+
247+
# Stable sort by bucket preserves the random order within each bucket
248+
scored.sort(
249+
proc(a, b: (int, RemotePeerInfo)): int =
250+
cmp(a[0] div GriefBucketSize, b[0] div GriefBucketSize),
251+
order = SortOrder.Ascending)
252+
253+
# Drop peers beyond the max grief bucket
254+
peers = scored.filterIt(it[0] div GriefBucketSize <= MaxGriefBucket).mapIt(it[1])
255+
218256
proc selectPeers*(
219257
pm: PeerManager, proto: string, shard: Option[PubsubTopic] = none(PubsubTopic)
220258
): seq[RemotePeerInfo] =
221259
## Returns all peers that support the given protocol (and optionally shard),
222-
## shuffled randomly. Callers can further filter or pick from this list.
223-
var peers = pm.switch.peerStore.getPeersByProtocol(proto)
260+
## sorted by grief score ascending (shuffled within each score tier).
261+
result = pm.switch.peerStore.getPeersByProtocol(proto)
224262
trace "Selecting peers from peerstore",
225-
protocol = proto, num_peers = peers.len, address = cast[uint](pm.switch.peerStore)
263+
protocol = proto, num_peers = result.len, address = cast[uint](pm.switch.peerStore)
226264

227265
if shard.isSome():
228266
let shardInfo = RelayShard.parse(shard.get()).valueOr:
229267
trace "Failed to parse shard from pubsub topic", topic = shard.get()
230268
return @[]
231269

232-
peers.keepItIf(
270+
result.keepItIf(
233271
(it.enr.isSome() and it.enr.get().containsShard(shard.get())) or
234272
(it.shards.len > 0 and it.shards.contains(shardInfo.shardId))
235273
)
236274

237-
shuffle(peers)
238-
return peers
275+
pm.sortByGriefScore(result)
239276

240277
proc selectPeer*(
241278
pm: PeerManager, proto: string, shard: Option[PubsubTopic] = none(PubsubTopic)
@@ -640,7 +677,7 @@ proc connectToRelayPeers*(pm: PeerManager) {.async.} =
640677

641678
var outsideBackoffPeers = notConnectedPeers.filterIt(pm.canBeConnected(it.peerId))
642679

643-
shuffle(outsideBackoffPeers)
680+
pm.sortByGriefScore(outsideBackoffPeers)
644681

645682
var index = 0
646683
var numPendingConnReqs =

0 commit comments

Comments
 (0)