src/main/generic/network/PeerScorer.js
class PeerScorer {
/**
* @constructor
* @param {NetworkConfig} networkConfig
* @param {PeerAddressBook} addresses
* @param {ConnectionPool} connections
*/
constructor(networkConfig, addresses, connections) {
/**
* @type {NetworkConfig}
* @private
*/
this._networkConfig = networkConfig;
/**
* @type {PeerAddressBook}
* @private
*/
this._addresses = addresses;
/**
* @type {ConnectionPool}
* @private
*/
this._connections = connections;
/**
* @type {Array.<PeerConnection>}
* @private
*/
this._connectionScores = null;
}
/**
* @returns {?PeerAddress}
*/
pickAddress() {
let it, numAddresses;
// Important: this switches over a *mask*.
switch (this._networkConfig.protocolMask) {
case Protocol.WSS:
it = this._addresses.wssIterator();
numAddresses = this._addresses.knownWssAddressesCount;
break;
case Protocol.WS:
it = this._addresses.wsIterator();
numAddresses = this._addresses.knownWsAddressesCount;
break;
case Protocol.WS | Protocol.WSS:
it = IteratorUtils.alternate(this._addresses.wsIterator(), this._addresses.wssIterator());
numAddresses = this._addresses.knownWsAddressesCount + this._addresses.knownWssAddressesCount;
break;
case Protocol.RTC:
it = this._addresses.rtcIterator();
numAddresses = this._addresses.knownRtcAddressesCount;
break;
case Protocol.RTC | Protocol.WS:
it = IteratorUtils.alternate(this._addresses.rtcIterator(), this._addresses.wsIterator());
numAddresses = this._addresses.knownRtcAddressesCount + this._addresses.knownWsAddressesCount;
break;
case Protocol.RTC | Protocol.WSS:
it = IteratorUtils.alternate(this._addresses.rtcIterator(), this._addresses.wssIterator());
numAddresses = this._addresses.knownRtcAddressesCount + this._addresses.knownWssAddressesCount;
break;
default:
it = this._addresses.iterator();
numAddresses = this._addresses.knownAddressesCount;
}
const findCandidates = (addressStatesIterator, numAddresses, numCandidates, allowBadPeers = false) => {
// Pick a random start index if we have a lot of addresses.
let startIndex = 0, endIndex = numAddresses;
if (numAddresses > numCandidates) {
startIndex = Math.floor(Math.random() * numAddresses);
endIndex = (startIndex + numCandidates) % numAddresses;
}
const overflow = startIndex > endIndex;
// Compute address scores until we have found at 1000 candidates with score >= 0.
const candidates = [];
let index = -1;
for (const addressState of addressStatesIterator) {
index++;
if (!overflow && index < startIndex) continue;
if (!overflow && index >= endIndex) break;
if (overflow && (index >= endIndex && index < startIndex)) continue;
const score = this._scoreAddress(addressState, allowBadPeers);
if (score >= 0) {
candidates.push({score, addressState});
if (candidates.length >= numCandidates) {
break;
}
}
}
return candidates;
};
let candidates = findCandidates(it, numAddresses, 1000);
if (candidates.length === 0 && this.needsGoodPeers()) {
switch (this._networkConfig.protocolMask) {
case Protocol.WSS:
it = this._addresses.wssIterator();
break;
case Protocol.WS:
it = this._addresses.wsIterator();
break;
case Protocol.WS | Protocol.WSS:
it = IteratorUtils.alternate(this._addresses.wsIterator(), this._addresses.wssIterator());
break;
case Protocol.RTC:
it = this._addresses.rtcIterator();
break;
case Protocol.RTC | Protocol.WS:
it = IteratorUtils.alternate(this._addresses.rtcIterator(), this._addresses.wsIterator());
break;
case Protocol.RTC | Protocol.WSS:
it = IteratorUtils.alternate(this._addresses.rtcIterator(), this._addresses.wssIterator());
break;
default:
it = this._addresses.iterator();
}
candidates = findCandidates(it, numAddresses, 1000, true);
}
if (candidates.length === 0) {
return null;
}
// Return a random candidate with a high score.
/** @type {Array.<{score: number, addressState: PeerAddressState}>} */
const scores = candidates.sort((a, b) => b.score - a.score);
const goodCandidates = scores.slice(0, PeerScorer.PICK_SELECTION_SIZE);
const winner = ArrayUtils.randomElement(goodCandidates);
return winner.addressState.peerAddress;
}
/**
* @param {PeerAddressState} peerAddressState
* @param {boolean} [allowBadPeers]
* @returns {number}
* @private
*/
_scoreAddress(peerAddressState, allowBadPeers = false) {
const peerAddress = peerAddressState.peerAddress;
// Filter addresses that we cannot connect to (needed to filter out dumb peers).
if (!this._networkConfig.canConnect(peerAddress.protocol)) {
return -1;
}
// Filter addresses not matching our accepted services.
if (!Services.providesServices(peerAddress.services, this._networkConfig.services.accepted)) {
return -1;
}
// Filter addresses that are too old.
if (peerAddress.exceedsAge()) {
return -1;
}
// A channel to that peer address is CONNECTING, CONNECTED, NEGOTIATING OR ESTABLISHED
if (this._connections.getConnectionByPeerAddress(peerAddress)) {
return -1;
}
// If we need more good peers, only allow good peers unless allowBadPeers is true.
if (this.needsGoodPeers() && !this.isGoodPeer(peerAddress) && !allowBadPeers) {
return -1;
}
// Give all peers the same base score. Penalize peers with failed connection attempts.
const score = 1;
switch (peerAddressState.state) {
case PeerAddressState.BANNED:
return -1;
case PeerAddressState.NEW:
case PeerAddressState.TRIED:
return score;
case PeerAddressState.FAILED:
// Don't pick failed addresses when they have failed the maximum number of times.
return (1 - ((peerAddressState.failedAttempts + 1) / peerAddressState.maxFailedAttempts)) * score;
default:
return -1;
}
}
/**
* @returns {boolean}
*/
isGoodPeerSet() {
return !this.needsGoodPeers() && !this.needsMorePeers();
}
/**
* @returns {boolean}
*/
needsGoodPeers() {
return this._connections.peerCountFullWsOutbound < PeerScorer.PEER_COUNT_MIN_FULL_WS_OUTBOUND;
}
/**
* @returns {boolean}
*/
needsMorePeers() {
return this._connections.peerCountOutbound < PeerScorer.PEER_COUNT_MIN_OUTBOUND;
}
/**
* @param {PeerAddress} peerAddress
* @returns {boolean}
*/
isGoodPeer(peerAddress) {
return Services.isFullNode(peerAddress.services) && (peerAddress.protocol === Protocol.WS || peerAddress.protocol === Protocol.WSS);
}
/**
* @returns {void}
*/
scoreConnections() {
const candidates = [];
for (const peerConnection of this._connections.valueIterator()) {
if (peerConnection.state === PeerConnectionState.ESTABLISHED) {
// Grant new connections a grace period from recycling.
if (peerConnection.ageEstablished > PeerScorer._getMinAge(peerConnection.peerAddress)) {
peerConnection.score = this._scoreConnection(peerConnection);
candidates.push(peerConnection);
}
peerConnection.statistics.reset();
}
}
// sort by score
this._connectionScores = candidates.sort((a, b) => b.score - a.score);
}
/**
* @param {number} count
* @param {number} type
* @param {string} reason
* @returns {void}
*/
recycleConnections(count, type, reason) {
if (!this._connectionScores) {
return;
}
while (count > 0 && this._connectionScores.length > 0) {
const peerConnection = this._connectionScores.pop();
if (peerConnection.state === PeerConnectionState.ESTABLISHED) {
peerConnection.peerChannel.close(type, `${reason}`);
count--;
}
}
}
/**
* @param {PeerConnection} peerConnection
* @returns {number}
* @private
*/
_scoreConnection(peerConnection) {
// Connection age
const scoreAge = this._scoreConnectionAge(peerConnection);
// Connection type (inbound/outbound)
const scoreOutbound = peerConnection.networkConnection.inbound ? 0 : 1;
// Node type (full/light/nano)
const peerAddress = peerConnection.peerAddress;
const scoreType = Services.isFullNode(peerAddress.services)
? 1
: Services.isLightNode(peerAddress.services) ? 0.5 : 0;
// Protocol: Prefer WebSocket over WebRTC over Dumb.
let scoreProtocol;
switch (peerAddress.protocol) {
case Protocol.WS:
case Protocol.WSS:
scoreProtocol = 0.6;
break;
case Protocol.RTC:
scoreProtocol = 0.3;
break;
case Protocol.DUMB:
default:
scoreProtocol = 0;
}
// Boost WebSocket score when low on WebSocket connections.
if (peerAddress.protocol === Protocol.WS || peerAddress.protocol === Protocol.WSS) {
const distribution = (this._connections.peerCountWs + this._connections.peerCountWss) / this._connections.peerCount;
if (distribution < PeerScorer.BEST_PROTOCOL_WS_DISTRIBUTION || this._connections.peerCountFullWsOutbound <= PeerScorer.PEER_COUNT_MIN_FULL_WS_OUTBOUND) {
scoreProtocol = 1;
}
}
// Connection speed, based on ping-pong latency median
const medianLatency = peerConnection.statistics.latencyMedian;
let scoreSpeed = 0;
if (medianLatency > 0 && medianLatency < NetworkAgent.PING_TIMEOUT) {
scoreSpeed = 1 - medianLatency / NetworkAgent.PING_TIMEOUT;
}
return 0.15 * scoreAge + 0.25 * scoreOutbound + 0.2 * scoreType + 0.2 * scoreProtocol + 0.2 * scoreSpeed;
}
/**
* @param {PeerConnection} peerConnection
* @returns {number}
* @private
*/
_scoreConnectionAge(peerConnection) {
const score = (age, bestAge, maxAge) => Math.max(Math.min(1 - (age - bestAge) / maxAge, 1), 0);
const age = peerConnection.ageEstablished;
const services = peerConnection.peerAddress.services;
if (Services.isFullNode(services)) {
return age / (2 * PeerScorer.BEST_AGE_FULL) + 0.5;
} else if (Services.isLightNode(services)) {
return score(age, PeerScorer.BEST_AGE_LIGHT, PeerScorer.MAX_AGE_LIGHT);
} else {
return score(age, PeerScorer.BEST_AGE_NANO, PeerScorer.MAX_AGE_NANO);
}
}
/**
* @param {PeerAddress} peerAddress
* @returns {number}
* @private
*/
static _getMinAge(peerAddress) {
if (Services.isFullNode(peerAddress.services)) {
return PeerScorer.MIN_AGE_FULL;
} else if (Services.isLightNode(peerAddress.services)) {
return PeerScorer.MIN_AGE_LIGHT;
} else {
return PeerScorer.MIN_AGE_NANO;
}
}
/** @type {Number} */
get lowestConnectionScore() {
if (!this._connectionScores) {
return null;
}
// Remove all closed connections from the end of connectionScores.
while (this._connectionScores.length > 0
&& this._connectionScores[this._connectionScores.length - 1].state !== PeerConnectionState.ESTABLISHED) {
this._connectionScores.pop();
}
return this._connectionScores.length > 0
? this._connectionScores[this._connectionScores.length - 1].score
: null;
}
/** @type {Array.<PeerConnection>} */
get connectionScores() {
return this._connectionScores;
}
}
/**
* @type {number}
* @constant
*/
PeerScorer.PEER_COUNT_MIN_FULL_WS_OUTBOUND = PlatformUtils.isNodeJs() ? 12 : 3;
/**
* @type {number}
* @constant
*/
PeerScorer.PEER_COUNT_MIN_OUTBOUND = PlatformUtils.isNodeJs() ? 12 : 6;
/**
* @type {number}
* @constant
*/
PeerScorer.PICK_SELECTION_SIZE = 100;
PeerScorer.MIN_AGE_FULL = 5 * 60 * 1000; // 5 minutes
PeerScorer.BEST_AGE_FULL = 24 * 60 * 60 * 1000; // 24 hours
PeerScorer.MIN_AGE_LIGHT = 2 * 60 * 1000; // 2 minutes
PeerScorer.BEST_AGE_LIGHT = 15 * 60 * 1000; // 15 minutes
PeerScorer.MAX_AGE_LIGHT = 6 * 60 * 60 * 1000; // 6 hours
PeerScorer.MIN_AGE_NANO = 60 * 1000; // 1 minute
PeerScorer.BEST_AGE_NANO = 5 * 60 * 1000; // 5 minutes
PeerScorer.MAX_AGE_NANO = 30 * 60 * 1000; // 30 minutes
PeerScorer.BEST_PROTOCOL_WS_DISTRIBUTION = 0.15; // 15%
Class.register(PeerScorer);