Skip to content

Commit b368808

Browse files
authored
Merge pull request #833 from input-output-hk/ETCM-446-connection-limit-ranges
ETCM-446: Connection limit ranges
2 parents a5092d0 + 87aa286 commit b368808

File tree

11 files changed

+458
-22
lines changed

11 files changed

+458
-22
lines changed

src/it/scala/io/iohk/ethereum/sync/util/CommonFakePeer.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,12 @@ abstract class CommonFakePeer(peerName: String, fakePeerCustomConfig: FakePeerCu
141141
override val connectMaxRetries: Int = 3
142142
override val connectRetryDelay: FiniteDuration = 1 second
143143
override val disconnectPoisonPillTimeout: FiniteDuration = 3 seconds
144+
override val minOutgoingPeers = 5
144145
override val maxOutgoingPeers = 10
145146
override val maxIncomingPeers = 5
146147
override val maxPendingPeers = 5
148+
override val pruneIncomingPeers = 0
149+
override val minPruneAge = 1.minute
147150
override val networkId: Int = 1
148151

149152
override val updateNodesInitialDelay: FiniteDuration = 5.seconds

src/it/scala/io/iohk/ethereum/txExecTest/util/DumpChainApp.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,12 @@ object DumpChainApp extends App with NodeKeyBuilder with SecureRandomBuilder wit
5353
override val waitForChainCheckTimeout: FiniteDuration = Config.Network.peer.waitForChainCheckTimeout
5454
override val fastSyncHostConfiguration: PeerManagerActor.FastSyncHostConfiguration =
5555
Config.Network.peer.fastSyncHostConfiguration
56+
override val minOutgoingPeers: Int = Config.Network.peer.minOutgoingPeers
5657
override val maxOutgoingPeers: Int = Config.Network.peer.maxOutgoingPeers
5758
override val maxIncomingPeers: Int = Config.Network.peer.maxIncomingPeers
5859
override val maxPendingPeers: Int = Config.Network.peer.maxPendingPeers
60+
override val pruneIncomingPeers: Int = Config.Network.peer.pruneIncomingPeers
61+
override val minPruneAge: FiniteDuration = Config.Network.peer.minPruneAge
5962
override val networkId: Int = privateNetworkId
6063
override val updateNodesInitialDelay: FiniteDuration = 5.seconds
6164
override val updateNodesInterval: FiniteDuration = 20.seconds

src/main/resources/application.conf

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,22 @@ mantis {
139139
# Maximum MPT components in a single response message (as a blockchain host)
140140
max-mpt-components-per-message = 200
141141

142-
# Maximum number of peers this node can connect to
142+
# Minimum number of peers this node tries to connect to at all times
143+
min-outgoing-peers = 20
144+
145+
# Maximum number of peers this node can connect to at any time.
146+
# It's a bit higher than max-incoming-peers so that the node can quickly churn through incompatible peers after startup.
143147
max-outgoing-peers = 50
144148

145-
# Maximum number of peers that can connect to this node
146-
max-incoming-peers = 50
149+
# Maximum number of peers that can connect to this node.
150+
# Should be at least as much as `min-outgoing-peers` so on a network level `total(max-in) >= total(min-out)`
151+
max-incoming-peers = 30
152+
153+
# Number of incoming peers to prune if we hit the maximum, to free up slots for new connections.
154+
prune-incoming-peers = 10
155+
156+
# Minimum age of peers before they can be selected for pruning, and the minimum time to pass between pruning attempts.
157+
min-prune-age = 30.minutes
147158

148159
# Maximum number of peers that can be connecting to this node
149160
max-pending-peers = 20
@@ -163,9 +174,10 @@ mantis {
163174

164175
# Resolution of moving window of peer statistics.
165176
# Will be multiplied by `stat-slot-count` to give the overall length of peer statistics availability.
166-
stat-slot-duration = 1.minute
177+
stat-slot-duration = 10.minutes
178+
167179
# How many slots of peer statistics to keep in the moving window, e.g. 60 * 1.minute to keep stats for the last hour with 1 minute resolution.
168-
stat-slot-count = 60
180+
stat-slot-count = 72
169181
}
170182

171183
rpc {

src/main/scala/io/iohk/ethereum/network/ConnectedPeers.scala

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import java.net.InetSocketAddress
44

55
import akka.actor.ActorRef
66
import akka.util.ByteString
7+
import scala.concurrent.duration.FiniteDuration
78

89
case class ConnectedPeers(
910
private val incomingPendingPeers: Map[PeerId, Peer],
1011
private val outgoingPendingPeers: Map[PeerId, Peer],
11-
private val handshakedPeers: Map[PeerId, Peer]
12+
private val handshakedPeers: Map[PeerId, Peer],
13+
private val pruningPeers: Map[PeerId, Peer],
14+
private val lastPruneTimestamp: Long
1215
) {
1316

1417
// FIXME: Kept only for compatibility purposes, should eventually be removed
@@ -32,11 +35,16 @@ case class ConnectedPeers(
3235

3336
lazy val incomingPendingPeersCount: Int = incomingPendingPeers.size
3437
lazy val outgoingPendingPeersCount: Int = outgoingPendingPeers.size
35-
lazy val incomingHandshakedPeersCount: Int = handshakedPeers.count { case (_, p) => p.incomingConnection }
36-
lazy val outgoingPeersCount: Int = peers.count { case (_, p) => !p.incomingConnection }
38+
lazy val pendingPeersCount: Int = incomingPendingPeersCount + outgoingPendingPeersCount
3739

40+
lazy val incomingHandshakedPeersCount: Int = handshakedPeers.count { case (_, p) => p.incomingConnection }
41+
lazy val outgoingHandshakedPeersCount: Int = handshakedPeers.count { case (_, p) => !p.incomingConnection }
3842
lazy val handshakedPeersCount: Int = handshakedPeers.size
39-
lazy val pendingPeersCount: Int = incomingPendingPeersCount + outgoingPendingPeersCount
43+
44+
lazy val incomingPruningPeersCount: Int = pruningPeers.count { case (_, p) => p.incomingConnection }
45+
46+
/** Sum of handshaked and pending peers. */
47+
lazy val outgoingPeersCount: Int = peers.count { case (_, p) => !p.incomingConnection }
4048

4149
def getPeer(peerId: PeerId): Option[Peer] = peers.get(peerId)
4250

@@ -65,11 +73,50 @@ case class ConnectedPeers(
6573

6674
(
6775
peersId,
68-
ConnectedPeers(incomingPendingPeers -- peersId, outgoingPendingPeers -- peersId, handshakedPeers -- peersId)
76+
ConnectedPeers(
77+
incomingPendingPeers -- peersId,
78+
outgoingPendingPeers -- peersId,
79+
handshakedPeers -- peersId,
80+
pruningPeers -- peersId,
81+
lastPruneTimestamp = lastPruneTimestamp
82+
)
6983
)
7084
}
85+
86+
def prunePeers(
87+
minAge: FiniteDuration,
88+
numPeers: Int,
89+
priority: PeerId => Double = _ => 0.0,
90+
incoming: Boolean = true,
91+
currentTimeMillis: Long = System.currentTimeMillis
92+
): (Seq[Peer], ConnectedPeers) = {
93+
val ageThreshold = currentTimeMillis - minAge.toMillis
94+
if (lastPruneTimestamp > ageThreshold || numPeers == 0) {
95+
// Protect against hostile takeovers by limiting the frequency of pruning.
96+
(Seq.empty, this)
97+
} else {
98+
val candidates = handshakedPeers.values.filter(canPrune(incoming, ageThreshold)).toSeq
99+
100+
val toPrune = candidates.sortBy(peer => priority(peer.id)).take(numPeers)
101+
102+
val pruned = copy(
103+
pruningPeers = toPrune.foldLeft(pruningPeers) { case (acc, peer) =>
104+
acc + (peer.id -> peer)
105+
},
106+
lastPruneTimestamp = if (toPrune.nonEmpty) currentTimeMillis else lastPruneTimestamp
107+
)
108+
109+
(toPrune, pruned)
110+
}
111+
}
112+
113+
private def canPrune(incoming: Boolean, minCreateTimeMillis: Long)(peer: Peer): Boolean = {
114+
peer.incomingConnection == incoming &&
115+
peer.createTimeMillis <= minCreateTimeMillis &&
116+
!pruningPeers.contains(peer.id)
117+
}
71118
}
72119

73120
object ConnectedPeers {
74-
def empty: ConnectedPeers = ConnectedPeers(Map.empty, Map.empty, Map.empty)
121+
def empty: ConnectedPeers = ConnectedPeers(Map.empty, Map.empty, Map.empty, Map.empty, 0L)
75122
}

src/main/scala/io/iohk/ethereum/network/Peer.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ case class Peer(
1616
remoteAddress: InetSocketAddress,
1717
ref: ActorRef,
1818
incomingConnection: Boolean,
19-
nodeId: Option[ByteString] = None
19+
nodeId: Option[ByteString] = None,
20+
createTimeMillis: Long = System.currentTimeMillis
2021
) {
2122
// FIXME PeerId should be actual peerId i.e id derived form node public key
2223
def id: PeerId = PeerId.fromRef(ref)

src/main/scala/io/iohk/ethereum/network/PeerManagerActor.scala

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ class PeerManagerActor(
5454
import akka.pattern.{ask, pipe}
5555

5656
implicit class ConnectedPeersOps(connectedPeers: ConnectedPeers) {
57+
58+
/** Number of new connections the node should try to open at any given time. */
5759
def outgoingConnectionDemand: Int =
58-
peerConfiguration.maxOutgoingPeers - connectedPeers.outgoingPeersCount
60+
PeerManagerActor.outgoingConnectionDemand(connectedPeers, peerConfiguration)
5961

6062
def canConnectTo(node: Node): Boolean = {
6163
val socketAddress = node.tcpSocketAddress
@@ -100,7 +102,8 @@ class PeerManagerActor(
100102
handleCommonMessages(connectedPeers) orElse
101103
handleBlacklistMessages orElse
102104
handleConnections(connectedPeers) orElse
103-
handleNewNodesToConnectMessages(connectedPeers)
105+
handleNewNodesToConnectMessages(connectedPeers) orElse
106+
handlePruning(connectedPeers)
104107
}
105108

106109
private def handleNewNodesToConnectMessages(connectedPeers: ConnectedPeers): Receive = {
@@ -266,6 +269,12 @@ class PeerManagerActor(
266269
handshakedPeer.incomingConnection && connectedPeers.incomingHandshakedPeersCount >= peerConfiguration.maxIncomingPeers
267270
) {
268271
handshakedPeer.ref ! PeerActor.DisconnectPeer(Disconnect.Reasons.TooManyPeers)
272+
273+
// It looks like all incoming slots are taken; try to make some room.
274+
self ! SchedulePruneIncomingPeers
275+
276+
context become listening(connectedPeers)
277+
269278
} else if (handshakedPeer.nodeId.exists(connectedPeers.hasHandshakedWith)) {
270279
// FIXME: peers received after handshake should always have their nodeId defined, we could maybe later distinguish
271280
// it into PendingPeer/HandshakedPeer classes
@@ -286,13 +295,54 @@ class PeerManagerActor(
286295
): (Peer, ConnectedPeers) = {
287296
val ref = peerFactory(context, address, incomingConnection)
288297
context watch ref
289-
val pendingPeer = Peer(address, ref, incomingConnection, None)
298+
val pendingPeer = Peer(address, ref, incomingConnection, None, createTimeMillis = System.currentTimeMillis)
290299

291300
val newConnectedPeers = connectedPeers.addNewPendingPeer(pendingPeer)
292301

293302
(pendingPeer, newConnectedPeers)
294303
}
295304

305+
private def handlePruning(connectedPeers: ConnectedPeers): Receive = {
306+
case SchedulePruneIncomingPeers =>
307+
implicit val timeout: Timeout = Timeout(peerConfiguration.updateNodesInterval)
308+
309+
// Ask for the whole statistics duration, we'll use averages to make it fair.
310+
val window = peerConfiguration.statSlotCount * peerConfiguration.statSlotDuration
311+
312+
(peerStatistics ? PeerStatisticsActor.GetStatsForAll(window))
313+
.mapTo[PeerStatisticsActor.StatsForAll]
314+
.map(PruneIncomingPeers(_))
315+
.pipeTo(self)
316+
317+
case PruneIncomingPeers(PeerStatisticsActor.StatsForAll(stats)) =>
318+
val prunedConnectedPeers = pruneIncomingPeers(connectedPeers, stats)
319+
320+
context become listening(prunedConnectedPeers)
321+
}
322+
323+
/** Disconnect some incoming connections so we can free up slots. */
324+
private def pruneIncomingPeers(
325+
connectedPeers: ConnectedPeers,
326+
stats: Map[PeerId, PeerStat]
327+
): ConnectedPeers = {
328+
val pruneCount = PeerManagerActor.numberOfIncomingConnectionsToPrune(connectedPeers, peerConfiguration)
329+
val now = System.currentTimeMillis
330+
val (peersToPrune, prunedConnectedPeers) =
331+
connectedPeers.prunePeers(
332+
incoming = true,
333+
minAge = peerConfiguration.minPruneAge,
334+
numPeers = pruneCount,
335+
priority = prunePriority(stats, now),
336+
currentTimeMillis = now
337+
)
338+
339+
peersToPrune.foreach { peer =>
340+
peer.ref ! PeerActor.DisconnectPeer(Disconnect.Reasons.TooManyPeers)
341+
}
342+
343+
prunedConnectedPeers
344+
}
345+
296346
private def getPeers(peers: Set[Peer]): Future[Peers] = {
297347
implicit val timeout: Timeout = Timeout(2.seconds)
298348

@@ -394,7 +444,7 @@ object PeerManagerActor {
394444
ctx.actorOf(props, id)
395445
}
396446

397-
trait PeerConfiguration {
447+
trait PeerConfiguration extends PeerConfiguration.ConnectionLimits {
398448
val connectRetryDelay: FiniteDuration
399449
val connectMaxRetries: Int
400450
val disconnectPoisonPillTimeout: FiniteDuration
@@ -403,9 +453,6 @@ object PeerManagerActor {
403453
val waitForChainCheckTimeout: FiniteDuration
404454
val fastSyncHostConfiguration: FastSyncHostConfiguration
405455
val rlpxConfiguration: RLPxConfiguration
406-
val maxOutgoingPeers: Int
407-
val maxIncomingPeers: Int
408-
val maxPendingPeers: Int
409456
val networkId: Int
410457
val updateNodesInitialDelay: FiniteDuration
411458
val updateNodesInterval: FiniteDuration
@@ -414,6 +461,16 @@ object PeerManagerActor {
414461
val statSlotDuration: FiniteDuration
415462
val statSlotCount: Int
416463
}
464+
object PeerConfiguration {
465+
trait ConnectionLimits {
466+
val minOutgoingPeers: Int
467+
val maxOutgoingPeers: Int
468+
val maxIncomingPeers: Int
469+
val maxPendingPeers: Int
470+
val pruneIncomingPeers: Int
471+
val minPruneAge: FiniteDuration
472+
}
473+
}
417474

418475
trait FastSyncHostConfiguration {
419476
val maxBlocksHeadersPerMessage: Int
@@ -447,4 +504,50 @@ object PeerManagerActor {
447504
case class OutgoingConnectionAlreadyHandled(uri: URI) extends ConnectionError
448505

449506
case class PeerAddress(value: String) extends BlackListId
507+
508+
case object SchedulePruneIncomingPeers
509+
case class PruneIncomingPeers(stats: PeerStatisticsActor.StatsForAll)
510+
511+
/** Number of new connections the node should try to open at any given time. */
512+
def outgoingConnectionDemand(
513+
connectedPeers: ConnectedPeers,
514+
peerConfiguration: PeerConfiguration.ConnectionLimits
515+
): Int = {
516+
if (connectedPeers.outgoingHandshakedPeersCount >= peerConfiguration.minOutgoingPeers)
517+
// We have established at least the minimum number of working connections.
518+
0
519+
else
520+
// Try to connect to more, up to the maximum, including pending peers.
521+
peerConfiguration.maxOutgoingPeers - connectedPeers.outgoingPeersCount
522+
}
523+
524+
def numberOfIncomingConnectionsToPrune(
525+
connectedPeers: ConnectedPeers,
526+
peerConfiguration: PeerConfiguration.ConnectionLimits
527+
): Int = {
528+
val minIncomingPeers = peerConfiguration.maxIncomingPeers - peerConfiguration.pruneIncomingPeers
529+
math.max(
530+
0,
531+
connectedPeers.incomingHandshakedPeersCount - connectedPeers.incomingPruningPeersCount - minIncomingPeers
532+
)
533+
}
534+
535+
/** Assign a priority to peers that we can use to order connections,
536+
* with lower priorities being the ones to prune first.
537+
*/
538+
def prunePriority(stats: Map[PeerId, PeerStat], currentTimeMillis: Long)(peerId: PeerId): Double = {
539+
stats
540+
.get(peerId)
541+
.flatMap { stat =>
542+
val maybeAgeSeconds = stat.firstSeenTimeMillis
543+
.map(currentTimeMillis - _)
544+
.map(_ * 1000)
545+
.filter(_ > 0)
546+
547+
// Use the average number of responses per second over the lifetime of the connection
548+
// as an indicator of how fruitful the peer is for us.
549+
maybeAgeSeconds.map(age => stat.responsesReceived.toDouble / age)
550+
}
551+
.getOrElse(0.0)
552+
}
450553
}

src/main/scala/io/iohk/ethereum/utils/Config.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ object Config {
6363
val waitForStatusTimeout: FiniteDuration = peerConfig.getDuration("wait-for-status-timeout").toMillis.millis
6464
val waitForChainCheckTimeout: FiniteDuration =
6565
peerConfig.getDuration("wait-for-chain-check-timeout").toMillis.millis
66+
val minOutgoingPeers: Int = peerConfig.getInt("min-outgoing-peers")
6667
val maxOutgoingPeers: Int = peerConfig.getInt("max-outgoing-peers")
6768
val maxIncomingPeers: Int = peerConfig.getInt("max-incoming-peers")
6869
val maxPendingPeers: Int = peerConfig.getInt("max-pending-peers")
70+
val pruneIncomingPeers: Int = peerConfig.getInt("prune-incoming-peers")
71+
val minPruneAge: FiniteDuration = peerConfig.getDuration("min-prune-age").toMillis.millis
6972
val networkId: Int = blockchainConfig.networkId
7073

7174
val rlpxConfiguration = new RLPxConfiguration {

src/test/resources/application.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ mantis {
88
peer {
99
connect-retry-delay = 1 second
1010
disconnect-poison-pill-timeout = 1 second
11+
min-outgoing-peers = 3
1112
max-outgoing-peers = 3
1213
max-incoming-peers = 1
1314
max-pending-peers = 1
15+
prune-incoming-peers = 1
16+
min-prune-age = 0.seconds
1417
update-nodes-initial-delay = 5.seconds
1518
update-nodes-interval = 10.seconds
1619
}

src/test/scala/io/iohk/ethereum/blockchain/sync/BlockchainHostActorSpec.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,12 @@ class BlockchainHostActorSpec extends AnyFlatSpec with Matchers {
280280
override val connectMaxRetries: Int = 3
281281
override val connectRetryDelay: FiniteDuration = 1 second
282282
override val disconnectPoisonPillTimeout: FiniteDuration = 5 seconds
283+
override val minOutgoingPeers = 5
283284
override val maxOutgoingPeers = 10
284285
override val maxIncomingPeers = 5
285286
override val maxPendingPeers = 5
287+
override val pruneIncomingPeers = 0
288+
override val minPruneAge = 1.minute
286289
override val networkId: Int = 1
287290

288291
override val updateNodesInitialDelay: FiniteDuration = 5.seconds

0 commit comments

Comments
 (0)