def remove(self, node_id: NodeID) -> None: """Remove a node from the routing table if it is present. If possible, the node will be replaced with the newest entry in the replacement cache. """ bucket_index, bucket, replacement_cache = self.get_index_bucket_and_replacement_cache( node_id, ) in_bucket = node_id in bucket in_replacement_cache = node_id in replacement_cache if in_bucket: bucket.remove(node_id) if replacement_cache: replacement_node_id = replacement_cache.popleft() self.logger.debug( "Replacing %s from bucket %d with %s from replacement cache", humanize_node_id(node_id), bucket_index, humanize_node_id(replacement_node_id), ) bucket.append(replacement_node_id) else: self.logger.debug( "Removing %s from bucket %d without replacement", humanize_node_id(node_id), bucket_index, ) if in_replacement_cache: self.logger.debug( "Removing %s from replacement cache of bucket %d", humanize_node_id(node_id), bucket_index, ) replacement_cache.remove(node_id) if not in_bucket and not in_replacement_cache: self.logger.debug( "Not removing %s as it is neither present in the bucket nor the replacement cache", humanize_node_id(node_id), bucket_index, ) # bucket_update_order should only contain non-empty buckets, so remove it if necessary if not bucket: try: self.bucket_update_order.remove(bucket_index) except ValueError: pass
def update(self, node_id: NodeID) -> Optional[NodeID]: """Insert a node into the routing table or move it to the top if already present. If the bucket is already full, the node id will be added to the replacement cache and the oldest node is returned as an eviction candidate. Otherwise, the return value is `None`. """ if node_id == self.center_node_id: raise ValueError("Cannot insert center node into routing table") bucket_index, bucket, replacement_cache = self.get_index_bucket_and_replacement_cache( node_id, ) is_bucket_full = len(bucket) >= self.bucket_size is_node_in_bucket = node_id in bucket if not is_node_in_bucket and not is_bucket_full: self.logger.debug("Adding %s to bucket %d", humanize_node_id(node_id), bucket_index) self.update_bucket_unchecked(node_id) eviction_candidate = None elif is_node_in_bucket: self.logger.debug("Updating %s in bucket %d", humanize_node_id(node_id), bucket_index) self.update_bucket_unchecked(node_id) eviction_candidate = None elif not is_node_in_bucket and is_bucket_full: if node_id not in replacement_cache: self.logger.debug( "Adding %s to replacement cache of bucket %d", humanize_node_id(node_id), bucket_index, ) else: self.logger.debug( "Updating %s in replacement cache of bucket %d", humanize_node_id(node_id), bucket_index, ) replacement_cache.remove(node_id) replacement_cache.appendleft(node_id) eviction_candidate = bucket[-1] else: raise Exception("unreachable") return eviction_candidate
async def do_lookup(peer: Node) -> None: self.logger.debug( "Looking up %s via node %s", humanize_node_id(target_id), humanize_node_id(peer.node_id), ) distance = compute_log_distance(peer.node_id, target_id) try: with trio.fail_after(FIND_NODES_TIMEOUT): found_nodes = await self.single_lookup( peer, distance=distance, ) except trio.TooSlowError: unresponsive_node_ids.add(peer.node_id) else: if len(found_nodes) == 0: unresponsive_node_ids.add(peer.node_id) else: received_nodes[peer.node_id].add(peer.endpoint) for node in found_nodes: received_nodes[node.node_id].add(node.endpoint)
async def _monitor_endpoints(self) -> None: """ Listen for completed handshakes and record the nodes in the routing table as well as the endpoint database. """ async with self.client.events.handshake_complete.subscribe( ) as subscription: while self.manager.is_running: session = await subscription.receive() self.logger.debug( 'recording node and endpoint: %s@%s', humanize_node_id(session.remote_node_id), session.remote_endpoint, ) self.endpoint_db.set_endpoint(session.remote_node_id, session.remote_endpoint) self.routing_table.update(session.remote_node_id)
async def _lookup_occasionally(self) -> None: async with trio.open_nursery() as nursery: async for _ in every(self.config.LOOKUP_INTERVAL): # noqa: F841 if self.routing_table.is_empty: self.logger.debug( 'Aborting scheduled lookup due to empty routing table') continue target_node_id = NodeID(secrets.randbits(256)) found_nodes = await self.network.iterative_lookup( target_node_id) self.logger.debug( 'Lookup for %s yielded %d nodes', humanize_node_id(target_node_id), len(found_nodes), ) for node in found_nodes: if node.node_id == self.client.local_node_id: continue nursery.start_soon(self.network.verify_and_add, node)
async def _periodic_report_routing_table_status(self) -> None: async for _ in every(300, 10): # noqa: F841 routing_stats = self.routing_table.get_stats() if routing_stats.full_buckets: full_buckets = '/'.join( (str(index) for index in routing_stats.full_buckets)) else: full_buckets = 'None' content_stats = self.content_manager.get_stats() self.logger.debug( ("\n" "###################[%s]#####################\n" " RoutingTable(bucket_size=%d, num_buckets=%d):\n" " - %d nodes\n" " - full buckets: %s\n" " - %d nodes in replacement cache\n" " ContentDB():\n" " - durable: %d\n" " - ephemeral-db: %d (%d / %d)\n" " - ephemeral-index: %d / %d\n" " - cache-db: %d (%d / %d)\n" " - cache-index: %d / %d\n" "####################################################"), humanize_node_id(self.client.local_node_id), routing_stats.bucket_size, routing_stats.num_buckets, routing_stats.total_nodes, full_buckets, routing_stats.num_in_replacement_cache, content_stats.durable_item_count, content_stats.ephemeral_db_count, content_stats.ephemeral_db_capacity, content_stats.ephemeral_db_total_capacity, content_stats.ephemeral_index_capacity, content_stats.ephemeral_index_total_capacity, content_stats.cache_db_count, content_stats.cache_db_capacity, content_stats.cache_db_total_capacity, content_stats.cache_index_capacity, content_stats.cache_index_total_capacity, )
async def test_application(bootnode, base_db_path): bootnodes = (Node(bootnode.client.local_node_id, bootnode.client.listen_on),) connected_nodes = [] async def monitor_bootnode(): async with bootnode.client.events.handshake_complete.subscribe() as subscription: async for session in subscription: logger.info('NODE_CONNECTED_TO_BOOTNODE: %s', humanize_node_id(session.remote_node_id)) # noqa: E501 connected_nodes.append(session.remote_node_id) config = KademliaConfig( LOOKUP_INTERVAL=20, ANNOUNCE_INTERVAL=30, ANNOUNCE_CONCURRENCY=1, storage_config=StorageConfig( ephemeral_storage_size=1024, ephemeral_index_size=500, cache_storage_size=1024, cache_index_size=100 ), ) async with AsyncExitStack() as stack: for i in range(16): # small delay between starting each client. await trio.sleep(random.random()) # content database durable_db = make_durable_db(base_db_path / f"client-{i}") app = ApplicationFactory( bootnodes=bootnodes, durable_db=durable_db, config=config, ) logger.info('CLIENT-%d: %s', i, humanize_node_id(app.client.local_node_id)) await stack.enter_async_context(background_trio_service(app)) await trio.sleep_forever()
async def run(self) -> None: # Run the subscription manager with gets fed all decoded inbound # messages and dispatches them to individual subscriptions. manager = self.manager.run_daemon_child_service(self.message_dispatcher) await manager.wait_started() listener = DatagramListener( self.listen_on, self._inbound_datagram_send_channel, self._outbound_datagram_receive_channel, events=self.events, ) async with background_trio_service(listener): await listener.wait_listening() await self.events.listening.trigger(self.listen_on) self.manager.run_daemon_task( self._handle_inbound_datagrams, self._inbound_datagram_receive_channel, ) self.manager.run_daemon_task( self._handle_outbound_messages, self._outbound_message_receive_channel, ) self.manager.run_daemon_task( self._handle_outbound_packets, self._outbound_packet_receive_channel, ) self.manager.run_daemon_task(self._periodically_ping_sessions) self.logger.info( 'Client running: %s@%s', humanize_node_id(self.local_node_id), self.listen_on, ) self._ready.set() await self.manager.wait_finished()
async def bootnode(base_db_path): config = KademliaConfig( LOOKUP_INTERVAL=20, ANNOUNCE_INTERVAL=30, ANNOUNCE_CONCURRENCY=1, storage_config=StorageConfig( ephemeral_storage_size=1024, ephemeral_index_size=500, cache_storage_size=1024, cache_index_size=100 ), can_initialize_network_skip_graph=True, ) durable_db = make_durable_db(base_db_path / f"bootnode") bootnode = ApplicationFactory( durable_db=durable_db, config=config, ) logger.info('BOOTNODE: %s', humanize_node_id(bootnode.client.local_node_id)) async with bootnode.client.events.listening.subscribe() as listening: async with background_trio_service(bootnode): await listening.receive() yield bootnode
async def iterative_lookup( self, target_id: NodeID, filter_self: bool = True, ) -> Tuple[Node, ...]: self.logger.debug("Starting looking up @ %s", humanize_node_id(target_id)) # tracks the nodes that have already been queried queried_node_ids: Set[NodeID] = set() # keeps track of the nodes that are unresponsive unresponsive_node_ids: Set[NodeID] = set() # accumulator of all of the valid responses received received_nodes: DefaultDict[ NodeID, Set[Endpoint]] = collections.defaultdict(set) async def do_lookup(peer: Node) -> None: self.logger.debug( "Looking up %s via node %s", humanize_node_id(target_id), humanize_node_id(peer.node_id), ) distance = compute_log_distance(peer.node_id, target_id) try: with trio.fail_after(FIND_NODES_TIMEOUT): found_nodes = await self.single_lookup( peer, distance=distance, ) except trio.TooSlowError: unresponsive_node_ids.add(peer.node_id) else: if len(found_nodes) == 0: unresponsive_node_ids.add(peer.node_id) else: received_nodes[peer.node_id].add(peer.endpoint) for node in found_nodes: received_nodes[node.node_id].add(node.endpoint) @to_tuple def get_endpoints(node_id: NodeID) -> Iterator[Endpoint]: try: yield self.endpoint_db.get_endpoint(node_id) except KeyError: pass yield from received_nodes[node_id] for lookup_round_number in itertools.count(): received_node_ids = tuple(received_nodes.keys()) candidates = iter_closest_nodes(target_id, self.routing_table, received_node_ids) responsive_candidates = itertools.dropwhile( lambda node: node in unresponsive_node_ids, candidates, ) closest_k_candidates = take(self.routing_table.bucket_size, responsive_candidates) closest_k_unqueried_candidates = ( candidate for candidate in closest_k_candidates if candidate not in queried_node_ids) nodes_to_query = tuple( take( LOOKUP_CONCURRENCY_FACTOR, closest_k_unqueried_candidates, )) if nodes_to_query: self.logger.debug( "Starting lookup round %d for %s", lookup_round_number + 1, humanize_node_id(target_id), ) queried_node_ids.update(nodes_to_query) async with trio.open_nursery() as nursery: for peer_id in nodes_to_query: if peer_id == self.client.local_node_id: continue for endpoint in get_endpoints(peer_id): nursery.start_soon(do_lookup, Node(peer_id, endpoint)) else: self.logger.debug( "Lookup for %s finished in %d rounds", humanize_node_id(target_id), lookup_round_number, ) break found_nodes = tuple( Node(node_id, endpoint) for node_id, endpoints in received_nodes.items() for endpoint in endpoints if (not filter_self or node_id != self.client.local_node_id)) sorted_found_nodes = tuple( sorted( found_nodes, key=lambda node: compute_distance(self.client.local_node_id, node.node_id), )) self.logger.debug( "Finished looking up %s in %d rounds: Found %d nodes after querying %d nodes", humanize_node_id(target_id), lookup_round_number, len(found_nodes), len(queried_node_ids), ) return sorted_found_nodes
async def monitor_bootnode(): async with bootnode.client.events.handshake_complete.subscribe() as subscription: async for session in subscription: logger.info('NODE_CONNECTED_TO_BOOTNODE: %s', humanize_node_id(session.remote_node_id)) # noqa: E501 connected_nodes.append(session.remote_node_id)