diff --git a/examples/kademlia/routing_table_diagnostics.py b/examples/kademlia/routing_table_diagnostics.py new file mode 100644 index 000000000..77d75b697 --- /dev/null +++ b/examples/kademlia/routing_table_diagnostics.py @@ -0,0 +1,122 @@ +""" +Routing Table Diagnostics — live KadDHT health inspection. + +Shows how to use RoutingTableDiagnostics to get a full picture of your +node's routing table health: bucket fill rates, keyspace coverage gaps, +peer freshness, and a composite health score. + +Run two terminal windows: + + # Window 1 — bootstrap node + python examples/kademlia/routing_table_diagnostics.py --port 8888 --mode server + + # Window 2 — client node (connects to bootstrap and prints a report) + python examples/kademlia/routing_table_diagnostics.py \ + --port 9999 --mode server --bootstrap /ip4/127.0.0.1/tcp/8888 + +The client node will print a full diagnostic report after a short warm-up. +""" + +from __future__ import annotations + +import argparse +import logging + +import trio + +from libp2p import new_node +from libp2p.crypto.secp256k1 import create_new_key_pair +from libp2p.kad_dht import KadDHT +from libp2p.kad_dht.diagnostics import RoutingTableDiagnostics +from libp2p.kad_dht.kad_dht import DHTMode +from libp2p.peer.peerinfo import info_from_p2p_addr +from libp2p.tools.anyio_service import background_trio_service + +logging.basicConfig(level=logging.WARNING) +logger = logging.getLogger(__name__) + + +async def run( + port: int, + mode: str, + bootstrap_addr: str | None, +) -> None: + key_pair = create_new_key_pair() + listen_addr = f"/ip4/0.0.0.0/tcp/{port}" + + async with background_trio_service( + await new_node(key_pair=key_pair, listen_addrs=[listen_addr]) + ) as host: + dht_mode = DHTMode.SERVER if mode == "server" else DHTMode.CLIENT + dht = KadDHT(host, dht_mode, enable_random_walk=True) + + async with background_trio_service(dht): + print(f"\nNode started: {host.get_id().to_base58()}") + print(f"Listening on: {listen_addr}\n") + + if bootstrap_addr: + peer_info = info_from_p2p_addr(bootstrap_addr) + await host.connect(peer_info) + print(f"Connected to bootstrap peer: {peer_info.peer_id.to_base58()}") + print("Warming up routing table (5 s)…") + await trio.sleep(5) + + # ── Run diagnostics ────────────────────────────────────────────── + diag = dht.get_diagnostics() + report = diag.analyse() + + print("\n" + "=" * 60) + print(report.summary()) + print("=" * 60) + + # ── Bucket-level breakdown ─────────────────────────────────────── + print("\nBucket breakdown:") + print(f" {'#':>3} {'Peers':>6} {'Fill':>6} Health") + print(f" {'-'*3} {'-'*6} {'-'*6} {'-'*8}") + for stat in report.bucket_stats: + print( + f" {stat.index:>3} {stat.peer_count:>6} " + f"{stat.fill_rate * 100:>5.1f}% {stat.health}" + ) + + # ── Coverage gaps ──────────────────────────────────────────────── + if report.coverage_gaps: + print(f"\nCoverage gaps ({len(report.coverage_gaps)} buckets below threshold):") + for gap in report.coverage_gaps[:5]: + print( + f" bucket #{gap.bucket_index}: " + f"{gap.min_range_hex[:12]}… ({gap.peer_count} peers)" + ) + else: + print("\nNo coverage gaps detected.") + + # ── Programmatic access ────────────────────────────────────────── + score = report.health_score + print(f"\nFinal health score: {score:.1f}/100 ({report.verdict})") + if score < 40: + print(" Suggestion: run more random-walk rounds or add bootstrap peers.") + + # Keep bootstrap node alive + if not bootstrap_addr: + print("\nBootstrap node running. Press Ctrl-C to stop.") + await trio.sleep_forever() + + +def main() -> None: + parser = argparse.ArgumentParser(description="KadDHT routing table diagnostics") + parser.add_argument("--port", type=int, default=9000, help="TCP port to listen on") + parser.add_argument( + "--mode", choices=["server", "client"], default="server", + help="DHT mode (default: server)" + ) + parser.add_argument( + "--bootstrap", default=None, + metavar="MULTIADDR", + help="Bootstrap peer multiaddr, e.g. /ip4/127.0.0.1/tcp/8888/p2p/", + ) + args = parser.parse_args() + trio.run(run, args.port, args.mode, args.bootstrap) + + +if __name__ == "__main__": + main() diff --git a/libp2p/kad_dht/__init__.py b/libp2p/kad_dht/__init__.py index 690d37bae..2424c3748 100644 --- a/libp2p/kad_dht/__init__.py +++ b/libp2p/kad_dht/__init__.py @@ -5,6 +5,13 @@ based on the Kademlia protocol. """ +from .diagnostics import ( + BucketStat, + CoverageGap, + FreshnessDistribution, + RoutingTableDiagnostics, + RoutingTableReport, +) from .kad_dht import ( KadDHT, ) @@ -27,4 +34,9 @@ "PeerRouting", "ValueStore", "create_key_from_binary", + "RoutingTableDiagnostics", + "RoutingTableReport", + "BucketStat", + "CoverageGap", + "FreshnessDistribution", ] diff --git a/libp2p/kad_dht/diagnostics.py b/libp2p/kad_dht/diagnostics.py new file mode 100644 index 000000000..1c378a512 --- /dev/null +++ b/libp2p/kad_dht/diagnostics.py @@ -0,0 +1,351 @@ +""" +Routing table diagnostics for the Kademlia DHT. + +When a KadDHT node misbehaves — slow lookups, unreachable keys, bootstrap +failures — operators have historically had to instrument the code by hand. +This module gives them a first-class diagnostic surface without any changes +to their application code. + +Key questions answered: + • Which k-buckets are under-populated or empty? + • Where are the keyspace coverage gaps? + • How fresh are my known peers? + • What is the overall routing-table health as a single score? +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .common import BUCKET_SIZE, STALE_PEER_THRESHOLD +from .routing_table import RoutingTable, key_to_int, peer_id_to_key + +if TYPE_CHECKING: + pass + + +# ── Data classes ────────────────────────────────────────────────────────────── + + +@dataclass +class BucketStat: + """Statistics for a single k-bucket.""" + + index: int + peer_count: int + capacity: int + fill_rate: float # 0.0 – 1.0 + stale_peer_count: int + min_range_hex: str + max_range_hex: str + + @property + def is_full(self) -> bool: + return self.peer_count >= self.capacity + + @property + def is_empty(self) -> bool: + return self.peer_count == 0 + + @property + def health(self) -> str: + if self.fill_rate >= 0.8: + return "healthy" + if self.fill_rate >= 0.4: + return "degraded" + return "starved" + + +@dataclass +class CoverageGap: + """A contiguous keyspace range that has insufficient peer coverage.""" + + min_range_hex: str + max_range_hex: str + peer_count: int + bucket_index: int + + +@dataclass +class FreshnessDistribution: + """How old the known peers are, bucketed by age band.""" + + fresh: int = 0 # seen in the last hour + aging: int = 0 # seen 1–12 hours ago + stale: int = 0 # seen 12–24 hours ago + very_stale: int = 0 # not seen for > 24 hours + + @property + def total(self) -> int: + return self.fresh + self.aging + self.stale + self.very_stale + + @property + def fresh_ratio(self) -> float: + if self.total == 0: + return 0.0 + return self.fresh / self.total + + +@dataclass +class RoutingTableReport: + """Complete snapshot of routing-table health.""" + + timestamp: float + local_peer_id_hex: str + + total_peers: int + total_buckets: int + populated_buckets: int + + bucket_stats: list[BucketStat] + coverage_gaps: list[CoverageGap] + freshness: FreshnessDistribution + + # 0–100 composite score; higher is better + health_score: float + + # Human-readable verdict + verdict: str + + # Raw keyspace coverage fraction (populated ranges / total ranges) + keyspace_coverage: float + + extra: dict = field(default_factory=dict) + + def summary(self) -> str: + lines = [ + f"=== KadDHT Routing Table Report ===", + f"Timestamp : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.timestamp))}", + f"Local peer : {self.local_peer_id_hex[:16]}…", + f"Health score : {self.health_score:.1f}/100 ({self.verdict})", + f"", + f"Peers : {self.total_peers}", + f"Buckets : {self.populated_buckets}/{self.total_buckets} populated", + f"Keyspace cover : {self.keyspace_coverage * 100:.1f}%", + f"Coverage gaps : {len(self.coverage_gaps)}", + f"", + f"Peer freshness", + f" Fresh (<1 h) : {self.freshness.fresh}", + f" Aging (1–12 h): {self.freshness.aging}", + f" Stale (12–24h): {self.freshness.stale}", + f" Very stale : {self.freshness.very_stale}", + ] + if self.coverage_gaps: + lines.append(f"") + lines.append(f"Top coverage gaps (first 3):") + for gap in self.coverage_gaps[:3]: + lines.append( + f" bucket #{gap.bucket_index}: {gap.min_range_hex[:8]}…" + f"–{gap.max_range_hex[:8]}… ({gap.peer_count} peers)" + ) + return "\n".join(lines) + + +# ── Diagnostics engine ──────────────────────────────────────────────────────── + + +class RoutingTableDiagnostics: + """ + Inspect and score a live KadDHT routing table. + + Usage:: + + from libp2p.kad_dht.diagnostics import RoutingTableDiagnostics + + diag = RoutingTableDiagnostics(dht.routing_table) + report = diag.analyse() + print(report.summary()) + + The analyser is read-only and does not modify the routing table. + """ + + #: Threshold below which a bucket is flagged as a coverage gap (peer count) + GAP_THRESHOLD: int = BUCKET_SIZE // 4 + + def __init__( + self, + routing_table: RoutingTable, + stale_threshold_seconds: int = STALE_PEER_THRESHOLD, + ) -> None: + self._rt = routing_table + self._stale_threshold = stale_threshold_seconds + + # ── Public API ────────────────────────────────────────────────────────── + + def analyse(self) -> RoutingTableReport: + """Run a full diagnostic pass and return a :class:`RoutingTableReport`.""" + now = time.time() + bucket_stats = self._collect_bucket_stats(now) + freshness = self._collect_freshness(now) + gaps = self._find_coverage_gaps(bucket_stats) + keyspace_coverage = self._calc_keyspace_coverage(bucket_stats) + health_score = self._calc_health_score( + bucket_stats, freshness, keyspace_coverage + ) + verdict = self._verdict(health_score) + + populated = sum(1 for b in bucket_stats if not b.is_empty) + + local_hex = self._rt.local_id.to_bytes().hex() + + return RoutingTableReport( + timestamp=now, + local_peer_id_hex=local_hex, + total_peers=self._rt.size(), + total_buckets=len(bucket_stats), + populated_buckets=populated, + bucket_stats=bucket_stats, + coverage_gaps=gaps, + freshness=freshness, + health_score=health_score, + verdict=verdict, + keyspace_coverage=keyspace_coverage, + ) + + def get_bucket_stats(self) -> list[BucketStat]: + """Return per-bucket statistics without building a full report.""" + return self._collect_bucket_stats(time.time()) + + def get_freshness_distribution(self) -> FreshnessDistribution: + """Return the peer-age distribution without building a full report.""" + return self._collect_freshness(time.time()) + + def get_coverage_gaps(self) -> list[CoverageGap]: + """Return buckets with peer counts below :attr:`GAP_THRESHOLD`.""" + return self._find_coverage_gaps(self._collect_bucket_stats(time.time())) + + def get_health_score(self) -> float: + """Return the composite health score (0–100) without a full report.""" + now = time.time() + stats = self._collect_bucket_stats(now) + freshness = self._collect_freshness(now) + coverage = self._calc_keyspace_coverage(stats) + return self._calc_health_score(stats, freshness, coverage) + + # ── Private helpers ───────────────────────────────────────────────────── + + def _collect_bucket_stats(self, now: float) -> list[BucketStat]: + stats: list[BucketStat] = [] + for idx, bucket in enumerate(self._rt.buckets): + peer_count = bucket.size() + stale = len(bucket.get_stale_peers(self._stale_threshold)) + fill_rate = peer_count / bucket.bucket_size if bucket.bucket_size else 0.0 + stats.append( + BucketStat( + index=idx, + peer_count=peer_count, + capacity=bucket.bucket_size, + fill_rate=fill_rate, + stale_peer_count=stale, + min_range_hex=format(bucket.min_range, "064x"), + max_range_hex=format(bucket.max_range, "064x"), + ) + ) + return stats + + def _collect_freshness(self, now: float) -> FreshnessDistribution: + dist = FreshnessDistribution() + ONE_HOUR = 3600 + TWELVE_HOURS = 12 * ONE_HOUR + TWENTY_FOUR_HOURS = 24 * ONE_HOUR + + for bucket in self._rt.buckets: + for _peer_id, (_info, last_seen) in bucket.peers.items(): + age = now - last_seen + if age < ONE_HOUR: + dist.fresh += 1 + elif age < TWELVE_HOURS: + dist.aging += 1 + elif age < TWENTY_FOUR_HOURS: + dist.stale += 1 + else: + dist.very_stale += 1 + return dist + + def _find_coverage_gaps(self, stats: list[BucketStat]) -> list[CoverageGap]: + gaps: list[CoverageGap] = [] + for stat in stats: + if stat.peer_count < self.GAP_THRESHOLD: + gaps.append( + CoverageGap( + min_range_hex=stat.min_range_hex, + max_range_hex=stat.max_range_hex, + peer_count=stat.peer_count, + bucket_index=stat.index, + ) + ) + # Sort: emptiest buckets first (most urgent) + gaps.sort(key=lambda g: g.peer_count) + return gaps + + def _calc_keyspace_coverage(self, stats: list[BucketStat]) -> float: + """ + Fraction of the full 256-bit keyspace that is *covered*. + + A bucket is considered covered when it has at least one peer. + We weight each bucket by the fraction of the keyspace it represents. + """ + FULL_SPACE = 2**256 + covered = 0 + for stat in stats: + if stat.peer_count > 0: + # Width of this bucket's range in key-space units + lo = int(stat.min_range_hex, 16) + hi = int(stat.max_range_hex, 16) + covered += hi - lo + return covered / FULL_SPACE + + def _calc_health_score( + self, + stats: list[BucketStat], + freshness: FreshnessDistribution, + keyspace_coverage: float, + ) -> float: + """ + Composite 0–100 score built from three sub-scores: + + * **Fill score** (40 pts) – average bucket fill rate across all buckets. + Buckets closest to the local node matter most in Kademlia, so we + weight by proximity (index 0 = closest = highest weight). + * **Freshness score** (35 pts) – ratio of fresh (< 1 h) peers to total. + * **Coverage score** (25 pts) – keyspace coverage fraction. + """ + if not stats: + return 0.0 + + # ── fill score ────────────────────────────────────────────────────── + # Kademlia property: buckets near the local node are the most important + # for routing. We give them exponentially higher weight. + total_weight = 0.0 + weighted_fill = 0.0 + n = len(stats) + for stat in stats: + # bucket 0 is closest to local node (after split operations) + # weight decays as we move to far-away buckets + weight = 2 ** max(0, n - 1 - stat.index) + total_weight += weight + weighted_fill += weight * stat.fill_rate + + fill_score = (weighted_fill / total_weight) * 40.0 if total_weight else 0.0 + + # ── freshness score ───────────────────────────────────────────────── + freshness_score = freshness.fresh_ratio * 35.0 + + # ── coverage score ────────────────────────────────────────────────── + coverage_score = keyspace_coverage * 25.0 + + return round(fill_score + freshness_score + coverage_score, 2) + + @staticmethod + def _verdict(score: float) -> str: + if score >= 80: + return "excellent" + if score >= 60: + return "good" + if score >= 40: + return "degraded" + if score >= 20: + return "poor" + return "critical" diff --git a/libp2p/kad_dht/kad_dht.py b/libp2p/kad_dht/kad_dht.py index 01aa23afc..eee85e70b 100644 --- a/libp2p/kad_dht/kad_dht.py +++ b/libp2p/kad_dht/kad_dht.py @@ -5,12 +5,18 @@ implementation based on the Kademlia algorithm and protocol. """ +from __future__ import annotations + from collections.abc import Awaitable, Callable from enum import ( Enum, ) import logging import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .diagnostics import RoutingTableDiagnostics from multiaddr import ( Multiaddr, @@ -1198,3 +1204,27 @@ def is_random_walk_enabled(self) -> bool: """ return self.enable_random_walk + + def get_diagnostics(self) -> "RoutingTableDiagnostics": + """ + Return a diagnostics analyser for the routing table. + + Use this to inspect bucket fill rates, keyspace coverage gaps, + peer freshness, and the composite health score — without touching + any application code. + + Example:: + + report = dht.get_diagnostics().analyse() + print(report.summary()) + print(f"Health score: {report.health_score}/100") + + Returns + ------- + RoutingTableDiagnostics + A read-only analyser bound to this node's routing table. + + """ + from .diagnostics import RoutingTableDiagnostics + + return RoutingTableDiagnostics(self.routing_table) diff --git a/libp2p/kad_dht/routing_table.py b/libp2p/kad_dht/routing_table.py index ce1bfd03d..a279e2042 100644 --- a/libp2p/kad_dht/routing_table.py +++ b/libp2p/kad_dht/routing_table.py @@ -2,16 +2,22 @@ Kademlia DHT routing table implementation. """ +from __future__ import annotations + from collections import ( OrderedDict, ) import hashlib import logging import time +from typing import TYPE_CHECKING import multihash import trio +if TYPE_CHECKING: + from .diagnostics import RoutingTableDiagnostics + from libp2p.abc import ( IHost, ) @@ -681,6 +687,20 @@ def cleanup_routing_table(self) -> None: self.buckets = [KBucket(self.host, BUCKET_SIZE)] logger.info("Routing table cleaned up, all data removed.") + def get_diagnostics(self) -> "RoutingTableDiagnostics": + """ + Return a :class:`~libp2p.kad_dht.diagnostics.RoutingTableDiagnostics` + analyser bound to this routing table. + + Example:: + + report = dht.routing_table.get_diagnostics().analyse() + print(report.summary()) + """ + from .diagnostics import RoutingTableDiagnostics + + return RoutingTableDiagnostics(self) + def _should_split_bucket(self, bucket: KBucket) -> bool: """ Check if a bucket should be split according to Kademlia rules. diff --git a/tests/core/kad_dht/test_routing_table_diagnostics.py b/tests/core/kad_dht/test_routing_table_diagnostics.py new file mode 100644 index 000000000..b1de8a02e --- /dev/null +++ b/tests/core/kad_dht/test_routing_table_diagnostics.py @@ -0,0 +1,259 @@ +""" +Tests for RoutingTableDiagnostics. + +We build synthetic routing tables (without a live network) so the tests run +fast and deterministically, exercising every dimension of the diagnostics. +""" + +from __future__ import annotations + +import time +from collections import OrderedDict +from unittest.mock import MagicMock + +import pytest + +from libp2p.kad_dht.diagnostics import ( + BucketStat, + CoverageGap, + FreshnessDistribution, + RoutingTableDiagnostics, + RoutingTableReport, +) +from libp2p.kad_dht.routing_table import KBucket, RoutingTable, peer_id_to_key +from libp2p.peer.id import ID +from libp2p.peer.peerinfo import PeerInfo + + +# ── Helpers ──────────────────────────────────────────────────────────────── + + +def _make_peer_id(seed: int) -> ID: + """Create a deterministic peer ID from an integer seed.""" + raw = seed.to_bytes(32, "big") + return ID(b"\x00\x25\x08\x02\x12\x20" + raw[:32]) + + +def _make_peer_info(seed: int) -> PeerInfo: + return PeerInfo(_make_peer_id(seed), []) + + +def _host_stub() -> MagicMock: + host = MagicMock() + peerstore = MagicMock() + peerstore.addrs.return_value = [] + host.get_peerstore.return_value = peerstore + return host + + +def _make_routing_table(n_peers: int = 0, last_seen_offset: float = 0.0) -> RoutingTable: + """ + Build a RoutingTable backed by a mock host and populate it with + *n_peers* synthetic peers whose last_seen timestamp is (now - last_seen_offset). + """ + local_id = _make_peer_id(0) + host = _host_stub() + rt = RoutingTable(local_id, host) + + now = time.time() + for i in range(1, n_peers + 1): + peer_info = _make_peer_info(i) + bucket = rt.find_bucket(peer_info.peer_id) + bucket.peers[peer_info.peer_id] = (peer_info, now - last_seen_offset) + + return rt + + +# ── BucketStat ──────────────────────────────────────────────────────────── + + +class TestBucketStat: + def test_is_full_when_at_capacity(self): + stat = BucketStat( + index=0, + peer_count=20, + capacity=20, + fill_rate=1.0, + stale_peer_count=0, + min_range_hex="0" * 64, + max_range_hex="f" * 64, + ) + assert stat.is_full + + def test_is_empty(self): + stat = BucketStat( + index=0, + peer_count=0, + capacity=20, + fill_rate=0.0, + stale_peer_count=0, + min_range_hex="0" * 64, + max_range_hex="f" * 64, + ) + assert stat.is_empty + + def test_health_healthy(self): + stat = BucketStat(0, 18, 20, 0.9, 0, "0" * 64, "f" * 64) + assert stat.health == "healthy" + + def test_health_degraded(self): + stat = BucketStat(0, 10, 20, 0.5, 0, "0" * 64, "f" * 64) + assert stat.health == "degraded" + + def test_health_starved(self): + stat = BucketStat(0, 2, 20, 0.1, 0, "0" * 64, "f" * 64) + assert stat.health == "starved" + + +# ── FreshnessDistribution ───────────────────────────────────────────────── + + +class TestFreshnessDistribution: + def test_fresh_ratio_with_all_fresh(self): + fd = FreshnessDistribution(fresh=10, aging=0, stale=0, very_stale=0) + assert fd.fresh_ratio == pytest.approx(1.0) + + def test_fresh_ratio_mixed(self): + fd = FreshnessDistribution(fresh=5, aging=5, stale=0, very_stale=0) + assert fd.fresh_ratio == pytest.approx(0.5) + + def test_fresh_ratio_empty(self): + fd = FreshnessDistribution() + assert fd.fresh_ratio == pytest.approx(0.0) + + def test_total(self): + fd = FreshnessDistribution(fresh=1, aging=2, stale=3, very_stale=4) + assert fd.total == 10 + + +# ── RoutingTableDiagnostics ─────────────────────────────────────────────── + + +class TestRoutingTableDiagnosticsEmpty: + """Tests against an empty routing table (single bucket, zero peers).""" + + def setup_method(self): + self.rt = _make_routing_table(n_peers=0) + self.diag = RoutingTableDiagnostics(self.rt) + + def test_analyse_returns_report(self): + report = self.diag.analyse() + assert isinstance(report, RoutingTableReport) + + def test_total_peers_is_zero(self): + report = self.diag.analyse() + assert report.total_peers == 0 + + def test_health_score_is_zero_for_empty_table(self): + score = self.diag.get_health_score() + assert score == pytest.approx(0.0) + + def test_verdict_is_critical_for_zero_score(self): + report = self.diag.analyse() + assert report.verdict == "critical" + + def test_single_bucket_all_gap(self): + gaps = self.diag.get_coverage_gaps() + assert len(gaps) == 1 + assert gaps[0].peer_count == 0 + + def test_keyspace_coverage_is_zero(self): + report = self.diag.analyse() + assert report.keyspace_coverage == pytest.approx(0.0) + + +class TestRoutingTableDiagnosticsPopulated: + """Tests against a routing table with several fresh peers.""" + + def setup_method(self): + # 10 peers all seen < 1 hour ago + self.rt = _make_routing_table(n_peers=10, last_seen_offset=100) + self.diag = RoutingTableDiagnostics(self.rt) + + def test_total_peers(self): + assert self.diag.analyse().total_peers == 10 + + def test_freshness_all_fresh(self): + fd = self.diag.get_freshness_distribution() + assert fd.fresh == 10 + assert fd.aging == 0 + assert fd.stale == 0 + assert fd.very_stale == 0 + + def test_health_score_positive(self): + score = self.diag.get_health_score() + assert score > 0 + + def test_bucket_stats_returns_list(self): + stats = self.diag.get_bucket_stats() + assert isinstance(stats, list) + assert len(stats) >= 1 + + def test_keyspace_coverage_positive(self): + report = self.diag.analyse() + assert report.keyspace_coverage > 0.0 + + +class TestRoutingTableDiagnosticsStalePeers: + """Tests against a routing table with only very stale peers.""" + + def setup_method(self): + # peers last seen 30 hours ago + self.rt = _make_routing_table(n_peers=5, last_seen_offset=30 * 3600) + self.diag = RoutingTableDiagnostics(self.rt) + + def test_freshness_all_very_stale(self): + fd = self.diag.get_freshness_distribution() + assert fd.very_stale == 5 + assert fd.fresh == 0 + + def test_health_score_penalised_for_stale_peers(self): + # A table with only very stale peers should score lower than fresh peers + fresh_rt = _make_routing_table(n_peers=5, last_seen_offset=60) + fresh_score = RoutingTableDiagnostics(fresh_rt).get_health_score() + stale_score = self.diag.get_health_score() + assert stale_score < fresh_score + + +class TestHealthScoreMonotonicity: + """More peers should yield a higher (or equal) health score, all else equal.""" + + def test_more_fresh_peers_score_higher_or_equal(self): + scores = [] + for n in [0, 5, 10, 15, 20]: + rt = _make_routing_table(n_peers=n, last_seen_offset=60) + scores.append(RoutingTableDiagnostics(rt).get_health_score()) + + for i in range(len(scores) - 1): + assert scores[i] <= scores[i + 1], ( + f"Score dropped from {scores[i]} to {scores[i+1]} " + f"when going from {[0,5,10,15,20][i]} to {[0,5,10,15,20][i+1]} peers" + ) + + +class TestSummary: + def test_summary_is_non_empty_string(self): + rt = _make_routing_table(n_peers=3, last_seen_offset=200) + report = RoutingTableDiagnostics(rt).analyse() + summary = report.summary() + assert isinstance(summary, str) + assert len(summary) > 50 + + def test_summary_contains_score(self): + rt = _make_routing_table(n_peers=3, last_seen_offset=200) + report = RoutingTableDiagnostics(rt).analyse() + assert "Health score" in report.summary() + + +class TestGetDiagnosticsShortcut: + """RoutingTable.get_diagnostics() convenience method.""" + + def test_returns_diagnostics_instance(self): + rt = _make_routing_table(n_peers=0) + diag = rt.get_diagnostics() + assert isinstance(diag, RoutingTableDiagnostics) + + def test_analyse_via_shortcut(self): + rt = _make_routing_table(n_peers=4, last_seen_offset=60) + report = rt.get_diagnostics().analyse() + assert report.total_peers == 4