Summary
org.apache.pekko.routing.ConsistentHash stores ring points in an immutable.SortedMap[Int, T]. If two different virtual nodes produce the same 32-bit ring hash, the later (hash -> node) entry overwrites the earlier one. This silently drops virtual nodes and can make routing depend on construction order.
Observed on main at 26c5086c7c.
Code evidence
ConsistentHash.apply builds the ring with immutable.SortedMap.empty[Int, T] ++ (...): actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scala lines 125-133.
:+ adds virtual nodes with nodes ++ (...): actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scala lines 49-54.
:- removes virtual nodes with nodes -- (...): actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scala lines 69-74.
SortedMap cannot hold duplicate Int keys, so a ring-point collision is last-write-wins rather than preserving both virtual nodes.
Reproducer
From the repo root, this uses the current Pekko MurmurHash and ConsistentHash implementations and two node names with known virtual-node collisions:
import org.apache.pekko.routing.ConsistentHash
val a = "node-4230"
val b = "node-14323"
def arrays(ch: ConsistentHash[String]) = {
val hf = ch.getClass.getDeclaredField("nodeHashRing")
hf.setAccessible(true)
val nf = ch.getClass.getDeclaredField("nodeRing")
nf.setAccessible(true)
(hf.get(ch).asInstanceOf[Array[Int]], nf.get(ch).asInstanceOf[Array[String]])
}
def counts(ch: ConsistentHash[String]) =
arrays(ch)._2.groupBy(identity).view.mapValues(_.length).toMap
val ab = ConsistentHash(Seq(a, b), 10)
val ba = ConsistentHash(Seq(b, a), 10)
println(s"ab size=${arrays(ab)._1.length}, counts=${counts(ab)}")
println(s"ba size=${arrays(ba)._1.length}, counts=${counts(ba)}")
println(s"after removing a from ab: size=${arrays(ab :- a)._1.length}, counts=${counts(ab :- a)}")
println(s"key-3: ab=${ab.nodeFor(\"key-3\")}, ba=${ba.nodeFor(\"key-3\")}")
I ran this with:
scala-cli --server=false -S 2.13.16 \
actor/src/main/scala/org/apache/pekko/routing/MurmurHash.scala \
actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scala \
-e '<snippet above>'
Output:
ab size=18, counts=Map(node-4230 -> 8, node-14323 -> 10)
ba size=18, counts=Map(node-4230 -> 10, node-14323 -> 8)
after removing a from ab: size=8, counts=Map(node-14323 -> 8)
key-3: ab=node-14323, ba=node-4230
Expected ring points for 2 nodes with virtualNodesFactor = 10 is 20. The current implementation has 18 because these nodes collide at two virtual-node hashes.
Impact
- Virtual nodes can be silently lost, skewing distribution.
- If construction order differs for the same logical node set, collided virtual nodes can be assigned to different owners, producing different routing results.
- Incremental removal can remove a collided hash point currently owned by another node, as shown by
ab :- a leaving node-14323 with 8 virtual nodes instead of 10.
Call sites include:
- classic
ConsistentHashingRoutingLogic: actor/src/main/scala/org/apache/pekko/routing/ConsistentHashing.scala line 219
- typed
RoutingLogics.ConsistentHashingLogic: actor-typed/src/main/scala/org/apache/pekko/actor/typed/internal/routing/RoutingLogic.scala lines 107 and 113-114
- cluster client
ClusterReceptionist: cluster-tools/src/main/scala/org/apache/pekko/cluster/client/ClusterClient.scala lines 981 and 1061-1074
Probability
Approximate probability of at least one 32-bit ring-point collision is:
P ~= 1 - exp(-n * (n - 1) / (2 * 2^32)), where n = nodes * virtualNodesFactor.
Examples:
| Ring points |
Collision probability |
| 1,000 |
0.0116% |
| 5,000 |
0.2906% |
| 20,000 |
4.55% |
| 50,000 |
25.25% |
| 100,000 |
68.78% |
Possible fix direction
A compatibility-preserving fix should probably keep the no-collision ring unchanged, and only resolve collisions deterministically. Options include:
- deterministic linear probing to the next free
Int ring position;
- storing per-hash buckets instead of a single owner;
- using a Ketama-style continuum implementation, if it preserves existing compatibility expectations or is introduced behind an explicit compatibility boundary.
Tests should cover:
- known colliding nodes still produce
nodes * virtualNodesFactor ring points;
- constructing the same logical node set in different orders is deterministic;
- removing one node does not remove collided virtual nodes belonging to another node.
Summary
org.apache.pekko.routing.ConsistentHashstores ring points in animmutable.SortedMap[Int, T]. If two different virtual nodes produce the same 32-bit ring hash, the later(hash -> node)entry overwrites the earlier one. This silently drops virtual nodes and can make routing depend on construction order.Observed on
mainat26c5086c7c.Code evidence
ConsistentHash.applybuilds the ring withimmutable.SortedMap.empty[Int, T] ++ (...):actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scalalines 125-133.:+adds virtual nodes withnodes ++ (...):actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scalalines 49-54.:-removes virtual nodes withnodes -- (...):actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scalalines 69-74.SortedMapcannot hold duplicateIntkeys, so a ring-point collision is last-write-wins rather than preserving both virtual nodes.Reproducer
From the repo root, this uses the current Pekko
MurmurHashandConsistentHashimplementations and two node names with known virtual-node collisions:I ran this with:
scala-cli --server=false -S 2.13.16 \ actor/src/main/scala/org/apache/pekko/routing/MurmurHash.scala \ actor/src/main/scala/org/apache/pekko/routing/ConsistentHash.scala \ -e '<snippet above>'Output:
Expected ring points for 2 nodes with
virtualNodesFactor = 10is 20. The current implementation has 18 because these nodes collide at two virtual-node hashes.Impact
ab :- aleavingnode-14323with 8 virtual nodes instead of 10.Call sites include:
ConsistentHashingRoutingLogic:actor/src/main/scala/org/apache/pekko/routing/ConsistentHashing.scalaline 219RoutingLogics.ConsistentHashingLogic:actor-typed/src/main/scala/org/apache/pekko/actor/typed/internal/routing/RoutingLogic.scalalines 107 and 113-114ClusterReceptionist:cluster-tools/src/main/scala/org/apache/pekko/cluster/client/ClusterClient.scalalines 981 and 1061-1074Probability
Approximate probability of at least one 32-bit ring-point collision is:
P ~= 1 - exp(-n * (n - 1) / (2 * 2^32)), wheren = nodes * virtualNodesFactor.Examples:
Possible fix direction
A compatibility-preserving fix should probably keep the no-collision ring unchanged, and only resolve collisions deterministically. Options include:
Intring position;Tests should cover:
nodes * virtualNodesFactorring points;