diff --git a/doc/release-notes.md b/doc/release-notes.md --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -4,4 +4,9 @@ -This is a maintenance release with no user-visible change. +This release includes the following features and fixes: + - `getavalanchepeerinfo` returns a new field `availability_score` that + indicates how responsive a peer's nodes are (collectively) to polls from + this node. Higher scores indicate a peer has at least one node that that + responds to polls often. Lower scores indicate a peer has nodes that do not + respond to polls reliably. diff --git a/src/avalanche/peermanager.h b/src/avalanche/peermanager.h --- a/src/avalanche/peermanager.h +++ b/src/avalanche/peermanager.h @@ -390,7 +390,6 @@ forEachNode(peer, [&](const avalanche::Node &node) { peerScore += getNodeAvailabilityScore(node.nodeid); }); - peerScore /= peer.node_count; // Calculate exponential moving average of averaged node scores peer.availabilityScore = diff --git a/src/avalanche/test/peermanager_tests.cpp b/src/avalanche/test/peermanager_tests.cpp --- a/src/avalanche/test/peermanager_tests.cpp +++ b/src/avalanche/test/peermanager_tests.cpp @@ -2150,15 +2150,16 @@ previousScore = currentScore; } - // We expect (1 - e^-i) after i * tau. The tolerance is expressed - // as a percentage, and we add a (large) 0.1% margin to account for - // floating point errors. - BOOST_CHECK_CLOSE(previousScore, -1 * std::expm1(-1. * i), + // We expect (1 - e^-i) * numNodesPerPeer after i * tau. The + // tolerance is expressed as a percentage, and we add a (large) + // 0.1% margin to account for floating point errors. + BOOST_CHECK_CLOSE(previousScore, + -1 * std::expm1(-1. * i) * numNodesPerPeer, 100.1 / tau); } // After 10 tau we should be very close to 100% (about 99.995%) - BOOST_CHECK_CLOSE(previousScore, 1., 0.01); + BOOST_CHECK_CLOSE(previousScore, numNodesPerPeer, 0.01); // Make the proof invalid BOOST_CHECK(pm.rejectProof( @@ -2188,7 +2189,7 @@ } } previousScore = getAvailabilityScore(); - BOOST_CHECK_CLOSE(previousScore, 1., 0.01); + BOOST_CHECK_CLOSE(previousScore, numNodesPerPeer, 0.01); for (size_t i = 1; i <= 3; i++) { for (uint32_t j = 0; j < tau; j += step) { @@ -2207,12 +2208,13 @@ // start the decay at exactly 100%, but the 0.1% margin is at least // an order of magnitude larger than the expected error so it // doesn't matter. - BOOST_CHECK_CLOSE(previousScore, 1. + std::expm1(-1. * i), + BOOST_CHECK_CLOSE(previousScore, + (1. + std::expm1(-1. * i)) * numNodesPerPeer, 100.1 / tau); } // After 3 more tau we should be under 5% - BOOST_CHECK_LT(previousScore, .05); + BOOST_CHECK_LT(previousScore, .05 * numNodesPerPeer); for (size_t i = 1; i <= 100; i++) { // Nodes respond to polls < 50% of the time (negative score) diff --git a/src/rpc/avalanche.cpp b/src/rpc/avalanche.cpp --- a/src/rpc/avalanche.cpp +++ b/src/rpc/avalanche.cpp @@ -867,6 +867,8 @@ {{ {RPCResult::Type::NUM, "avalanche_peerid", "The avalanche internal peer identifier"}, + {RPCResult::Type::NUM, "availability_score", + "The agreggated availability score of this peer's nodes"}, {RPCResult::Type::STR_HEX, "proofid", "The avalanche proof id used by this peer"}, {RPCResult::Type::STR_HEX, "proof", @@ -901,6 +903,7 @@ UniValue obj(UniValue::VOBJ); obj.pushKV("avalanche_peerid", uint64_t(peer.peerid)); + obj.pushKV("availability_score", peer.availabilityScore); obj.pushKV("proofid", peer.getProofId().ToString()); obj.pushKV("proof", peer.proof->ToHex()); diff --git a/test/functional/abc_rpc_getavalanchepeerinfo.py b/test/functional/abc_rpc_getavalanchepeerinfo.py --- a/test/functional/abc_rpc_getavalanchepeerinfo.py +++ b/test/functional/abc_rpc_getavalanchepeerinfo.py @@ -6,15 +6,58 @@ from random import choice from test_framework.avatools import ( + AvaP2PInterface, avalanche_proof_from_hex, create_coinbase_stakes, + gen_proof, get_ava_p2p_interface_no_handshake, ) from test_framework.key import ECKey +from test_framework.messages import ( + NODE_AVALANCHE, + NODE_NETWORK, + AvalancheVote, + AvalancheVoteError, +) +from test_framework.p2p import p2p_lock from test_framework.test_framework import BitcoinTestFramework -from test_framework.util import assert_equal, assert_raises_rpc_error, uint256_hex +from test_framework.util import ( + assert_equal, + assert_greater_than, + assert_raises_rpc_error, + uint256_hex, +) from test_framework.wallet_util import bytes_to_wif +# The interval between avalanche statistics computation +AVALANCHE_STATISTICS_INTERVAL = 10 * 60 + + +class MutedAvaP2PInterface(AvaP2PInterface): + def __init__(self, test_framework=None, node=None): + super().__init__(test_framework, node) + self.is_responding = False + self.privkey = None + self.addr = None + self.poll_received = 0 + + def on_avapoll(self, message): + self.poll_received += 1 + + +class AllYesAvaP2PInterface(MutedAvaP2PInterface): + def __init__(self, test_framework=None, node=None): + super().__init__(test_framework, node) + self.is_responding = True + + def on_avapoll(self, message): + self.send_avaresponse( + message.poll.round, [ + AvalancheVote( + AvalancheVoteError.ACCEPTED, inv.hash) for inv in message.poll.invs], + self.master_privkey if self.delegation is None else self.delegated_privkey) + super().on_avapoll(message) + class GetAvalanchePeerInfoTest(BitcoinTestFramework): def set_test_params(self): @@ -24,7 +67,7 @@ '-avaproofstakeutxoconfirmations=1', '-avacooldown=0']] - def run_test(self): + def test_proofs_and_nodecounts(self): node = self.nodes[0] peercount = 5 nodecount = 10 @@ -72,6 +115,7 @@ avalanche_proof_from_hex( proofs[i]).proofid) assert_equal(peer["avalanche_peerid"], i) + assert_equal(peer["availability_score"], 0.0) assert_equal(peer["proofid"], proofid_hex) assert_equal(peer["proof"], proofs[i]) assert_equal(peer["nodecount"], nodecount) @@ -90,6 +134,107 @@ assert_equal(len(avapeerinfo), 1) assert_equal(avapeerinfo[0]["proof"], target_proof) + def test_peer_availability_scores(self): + self.restart_node(0, extra_args=self.extra_args[0] + [ + '-avaminquorumstake=0', + '-avaminavaproofsnodecount=0', + ]) + node = self.nodes[0] + + # Setup node interfaces, some responsive and some not + avanodes = [ + # First peer has all responsive nodes + AllYesAvaP2PInterface(), + AllYesAvaP2PInterface(), + AllYesAvaP2PInterface(), + # Next peer has only one responsive node + MutedAvaP2PInterface(), + MutedAvaP2PInterface(), + AllYesAvaP2PInterface(), + # Last peer has no responsive nodes + MutedAvaP2PInterface(), + MutedAvaP2PInterface(), + MutedAvaP2PInterface(), + ] + + # Create some proofs and associate the nodes with them + avaproofids = [] + p2p_idx = 0 + num_proof = 3 + num_avanode = 3 + for p in range(num_proof): + master_privkey, proof = gen_proof(self, node) + avaproofids.append(uint256_hex(proof.proofid)) + for n in range(num_avanode): + avanode = avanodes[p * num_proof + n] + avanode.master_privkey = master_privkey + avanode.proof = proof + node.add_outbound_p2p_connection( + avanode, + p2p_idx=p2p_idx, + connection_type="avalanche", + services=NODE_NETWORK | NODE_AVALANCHE) + p2p_idx += 1 + + assert_equal(len(avanodes), num_proof * num_avanode) + + def all_nodes_connected(): + avapeers = node.getavalanchepeerinfo() + if len(avapeers) != num_proof: + return False + + for avapeer in avapeers: + if avapeer['nodecount'] != num_avanode: + return False + + return True + + self.wait_until(all_nodes_connected) + + # Force the availability score to diverge between the responding and the + # muted nodes. + self.generate(node, 1, sync_fun=self.no_op) + + def poll_all_for_block(): + with p2p_lock: + return all([avanode.poll_received > ( + 10 if avanode.is_responding else 0) for avanode in avanodes]) + self.wait_until(poll_all_for_block) + + # Move the scheduler forward so that so that our peers get availability + # scores computed. + node.mockscheduler(AVALANCHE_STATISTICS_INTERVAL) + + def check_availability_scores(): + peerinfos = node.getavalanchepeerinfo() + + # Get availability scores for each peer + scores = {} + for peerinfo in peerinfos: + p = avaproofids.index(peerinfo['proofid']) + scores[p] = peerinfo['availability_score'] + + # Wait until scores have been computed + if scores[p] == 0.0: + return False + + # Even though the first peer has more responsive nodes than the second + # peer, they will both have "good" scores because overall both peers are + # responsive to polls. But the first peer's score won't necessarily be + # higher than the second. + assert_greater_than(scores[0], 5) + assert_greater_than(scores[1], 5) + + # Last peer should have negative score since it is unresponsive + assert_greater_than(0.0, scores[2]) + return True + + self.wait_until(check_availability_scores) + + def run_test(self): + self.test_proofs_and_nodecounts() + self.test_peer_availability_scores() + if __name__ == '__main__': GetAvalanchePeerInfoTest().main()