|
1 | 1 | {.used.} |
2 | 2 |
|
3 | 3 | import |
4 | | - std/[sequtils, times], |
| 4 | + std/[sequtils, times, random], |
5 | 5 | chronos, |
6 | 6 | libp2p/crypto/crypto, |
7 | 7 | libp2p/peerid, |
@@ -353,3 +353,289 @@ suite "Extended nim-libp2p Peer Store": |
353 | 353 | peerStore[DisconnectBook][p1] == 0 |
354 | 354 | peerStore[SourceBook][p1] == default(PeerOrigin) |
355 | 355 | 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 |
0 commit comments