Skip to content

Forward-port #8294 to dev: consistent-hashing router 32-bit collision fix (#8031) - #8305

Merged
Aaronontheweb merged 3 commits into
akkadotnet:devfrom
Aaronontheweb:forward-port/8031-consistent-hash-to-dev
Jul 3, 2026
Merged

Aaronontheweb merged 3 commits into
akkadotnet:devfrom
Aaronontheweb:forward-port/8031-consistent-hash-to-dev

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

Forward-port of #8294 to dev

This is the forward-port to dev of the consistent-hashing router collision fix that was merged to v1.5 in #8294 (fixes #8031). dev carried the identical pre-fix ConsistentHash.cs, so without this it would regress the fix the next time v1.5 merges up.

Fixes #8031.

The fix

When two virtual nodes collided in the 32-bit consistent-hash ring (increasingly likely at high routee counts, e.g. when the ring is rebuilt after a node is downed), ConsistentHash.Create threw "An entry with the same key already exists". The consistent-hashing router swallowed the exception and returned NoRoutee for every subsequent message until a manual restart — and crashed the unguarded ClusterReceptionist, which builds the same ring.

src/core/Akka/Routing/ConsistentHash.cs now:

  • ConsistentHash.Create<T> — de-duplicates input nodes by ToString() (a HashSet<string> with StringComparer.Ordinal), builds the ring in canonical OrderBy(ToString, Ordinal) order, and on a 32-bit ring-key collision re-hashes the loser to a full-width, well-distributed slot (ConcatenateNodeHash(nodeHash, vnode + virtualNodesFactor)) then linear-probes to the next free slot (guaranteed termination) — instead of the old SortedDictionary.Add throw. This preserves the collided node's ring share (distribution unchanged) and, when no collision occurs, produces a byte-identical ring to prior versions (safe for rolling upgrades).
  • operator + / operator - — rebuild deterministically via Create, so node identity is ToString-based and operator - filters survivors by ToString. This makes Create(S) + x == Create(S ∪ {x}) and Create(S) - x == Create(S \ {x}) hold by construction (idempotent add; removal drops relocated/probed slots).

No public API change

No public API or wire-format change — this only defines behavior for the previously-throwing collision case. Akka.API.Tests passes unchanged with no approved-file regeneration. No BREAKING_CHANGES_V1.6.md entry is needed.

How it was applied

Cherry-picked the squashed v1.5 merge commit (cdec84e00) with -x. The only conflict was RELEASE_NOTES.md (the v1.5 1.5.70-beta3 heading does not apply to dev); resolved by adding a dev-appropriate bullet under the existing 1.6.0 unreleased section. All four code/test/benchmark files applied cleanly and are byte-identical to the v1.5-merged versions.

Validation (net10.0, Linux)

  • dotnet build src/core/Akka/Akka.csproj -c Release -warnaserror -> 0 warnings, 0 errors
  • dotnet test src/core/Akka.Tests --framework net10.0 --filter "FullyQualifiedName~ConsistentHash" -> 21 passed, 0 failed (new ConsistentHashSpec + router no-wedge test)
  • dotnet test src/core/Akka.API.Tests --framework net10.0 -> 18 passed, 0 failed, working tree clean (no public API delta)

Changes brought over

  • src/core/Akka/Routing/ConsistentHash.cs — the fix
  • src/core/Akka.Tests/Routing/ConsistentHashSpec.cs — new (collision tolerance, distribution-neutrality, cross-node determinism, byte-identical before/after proof, add/remove == Create)
  • src/core/Akka.Tests/Routing/ConsistentHashingRouterSpec.cs — added NamedRoutee + router-level no-wedge test
  • src/benchmark/Akka.Benchmarks/Utils/ConsistentHashBenchmarks.cs — added ConsistentHashCreateBenchmarks
  • RELEASE_NOTES.md — dev-appropriate entry under 1.6.0

Related: forward-port of #8294; perf follow-up #8293.

… 32-bit hash collision (akkadotnet#8294)

* Fix akkadotnet#8031: consistent-hashing router wedges cluster-wide on 32-bit hash collision

ConsistentHash.Create now linear-probes to the next free slot when two virtual
nodes collide in the 32-bit ring, instead of letting SortedDictionary.Add throw.
The throw was swallowed by ConsistentHashingRoutingLogic.Select and returned
NoRoutee for every message until a manual restart (and crashed the unguarded
ClusterReceptionist). The ring is now built in canonical node order so every
node in the cluster produces an identical ring even when a collision is resolved.

For any routee set without a collision the ring is byte-identical to prior
versions (safe for rolling upgrades) - proven by ConsistentHashSpec. operator +
is hardened the same way.

Adds ConsistentHashSpec (collision tolerance, distribution-neutrality,
cross-node determinism, and a byte-identical before/after proof), a router-level
no-wedge test, and a Create scaling benchmark. Perf follow-up: akkadotnet#8293.

* Address xhigh review: make ConsistentHash +/- consistent with Create

The akkadotnet#8031 fix made Create and operator+ linear-probe past 32-bit collisions,
but left operator- computing only natural vnode keys — so it could not remove a
vnode that had been relocated to a probed slot, leaving a phantom entry that
still routed to the removed node. operator+ also silently duplicated an
already-present node's vnodes and resolved collisions in insertion order rather
than Create's canonical order (so incremental rings could diverge from Create).

Rewrite operator+/- to rebuild deterministically via Create, so
`Create(S) + x == Create(S ∪ {x})` and `Create(S) - x == Create(S \ {x})` hold
by construction: symmetric, canonical-order collision resolution, idempotent add,
and removal that drops probed slots. Drops the now-unused SortedDictionary
CopyAndAdd/CopyAndRemove path and duplicated probe loop.

Adds regression tests: add==Create-across-collision, idempotent add, and
remove-drops-probed-slots.

* Address re-review: unify ConsistentHash node identity on ToString()

The prior review-fix used EqualityComparer<T>.Default in operator +/- but the
ring identifies nodes by ToString() (the value its keys are derived from; the
class contract requires ToString to be distinct per node). That mismatch left
three confirmed issues:

- Create did not de-duplicate, so a node supplied twice was probed into a second
  vnode set (distribution skew); the dedup guard was only on +/-.
- operator+ idempotency relied on Distinct()'s reference equality, so re-adding a
  fresh-but-equal reference-type node duplicated its vnodes unbounded.
- operator- removed by EqualityComparer<T>.Default, so a T whose Equals is broader
  than ToString could over-remove a different node.

Unify identity on ToString(): Create now de-duplicates input by ToString (and +/-
inherit it by delegating to Create); operator- matches the removed node by
ToString rather than T.Equals. Adds tests for dedup, ToString-based idempotent
add, and ToString-based removal using a reference type without an Equals override.

* Address 3rd review: full-width collision relocation + cheaper +/- rebuild

Two follow-ups from the third code-review pass on the akkadotnet#8031 fix:

- Distribution (finding #2): the key+1 linear probe placed a relocated colliding
  virtual node on a near-zero-width ring segment, so a collided node lost ~1/factor
  of its traffic - the "distribution unchanged" claim was false. Re-hash the loser
  to a well-distributed slot (full-width segment) instead, preserving the node's
  ring share, then linear-probe from there to guarantee termination. Non-colliding
  builds are unchanged (probe never fires); the sequence is a pure function of the
  node hash so every node still builds an identical ring.

- Perf (finding #3): operator +/- passed _nodes.Values (N*virtualNodesFactor
  entries) to Create, so it sorted/ToString'd N*V items per membership change.
  Distinct() the same-reference repeats down to N first; Create's ToString de-dup
  remains the correctness guarantee.

Not changed: NullReferenceException on a null ToString() (finding #1) is
pre-existing (old Create hashed node.ToString() identically), unreachable from the
router (ConsistentRoutee.ToString is never null), and outside the akkadotnet#8031 scope.

(cherry picked from commit cdec84e)
@Aaronontheweb
Aaronontheweb enabled auto-merge (squash) July 2, 2026 22:35
@Aaronontheweb
Aaronontheweb merged commit 232d3e3 into akkadotnet:dev Jul 3, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bday problem / hash collisions on Akka.Routing.ConsistentHash :: Create

1 participant