Skip to content

Commit cf28efd

Browse files
committed
Merge remote-tracking branch 'akkadotnet/dev' into cf/8484
# Conflicts: # BREAKING_CHANGES_V1.6.md
2 parents 7bbd43d + 60a01d0 commit cf28efd

3 files changed

Lines changed: 96 additions & 48 deletions

File tree

BREAKING_CHANGES_V1.6.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Akka.NET v1.6 Breaking Changes
1+
# Akka.NET v1.6 Breaking Changes
22

33
This document tracks **breaking changes** introduced into the `dev` branch during the
44
Akka.NET **v1.6** development cycle, ahead of a stable `v1.6.0` release.
@@ -40,6 +40,7 @@ of `Behavior`, `Wire`, `API` (combine with `+`).
4040

4141
| Status | PR / Branch | Component | Type | Change | Migration |
4242
|--------|-------------|-----------|------|--------|-----------|
43+
| Planned | [#8324](https://github.com/akkadotnet/akka.net/pull/8324) | `Akka.Routing` | Behavior | `ConsistentHash<T>` no longer retains the `SortedDictionary` passed to its public constructor — the ring is snapshotted into internal sorted arrays. Mutating that dictionary after construction no longer affects the instance (previously the aliasing was inconsistent: `IsEmpty` and `operator +`/`-` read it live, while `NodeFor` froze it after the first lookup). A `null` dictionary now throws `ArgumentNullException` from the constructor instead of surfacing later as a `NullReferenceException`. No public API removed; the ring built by `ConsistentHash.Create` is byte-identical. (#8293) | None for normal use — `ConsistentHash.Create` already builds the dictionary fully before constructing, so routers/receptionists are unaffected. If you call the `ConsistentHash(SortedDictionary, int)` constructor directly, populate the dictionary before passing it and don't rely on post-construction mutation being visible. |
4344
| Planned | `fix/gossip-removal-tombstones` | `Akka.Cluster` | Wire + Behavior | Cluster gossip now carries a removal tombstone for every member the leader removes (new `repeated Tombstone tombstones = 7` on the `Gossip` proto message, previously the one vacant field number). A member the leader removed can no longer be put back into the gossip by a peer that has not caught up, which previously blocked convergence permanently and needed a full cluster restart to clear. Tombstones expire after the new `akka.cluster.prune-gossip-tombstones-after` setting (default `24h`), pruned by the leader on a converged tick and reclaimed for good: gossip reception keeps the tombstones of whichever gossip wins the causal comparison, so a peer that has not pruned yet cannot hand a pruned tombstone back. Tombstones are only unioned across the two gossips when neither descends from the other -- equal clocks, or concurrent clocks, where each side may hold a removal the other has not heard about. The field is additive and proto3 ignores unknown fields in both directions, so rolling upgrades are safe; an older node parses the gossip fine but drops the tombstones when it re-emits it, and if that stripped gossip is causally newer than an upgraded node's, the upgraded node adopts it and loses those tombstones too -- so the protection only holds between upgraded nodes and the fix is not fully in effect until every node is upgraded. | No action required. Raise `akka.cluster.prune-gossip-tombstones-after` if the cluster must survive partitions longer than 24 hours, or lower it if a cluster with heavy join/leave churn needs the gossip message kept small -- each tombstone adds a full address plus a timestamp to every gossip message until it expires. |
4445
| Planned | `feature/default-bounded-shard-rebalancing` | `Akka.Cluster.Sharding` | Behavior | The default `rebalance-absolute-limit` is now `20`, selecting the bounded shard allocation strategy instead of the legacy threshold-based strategy. | To retain the legacy strategy temporarily, explicitly set `akka.cluster.sharding.least-shard-allocation-strategy.rebalance-absolute-limit = 0`. Review `rebalance-threshold` and `max-simultaneous-rebalance`, which do not apply while the bounded strategy is active. |
4546
| Planned | `fix/artery-inbound-quarantine-check` | `Akka.Remote` (Artery) | Behavior | Quarantine is now enforced on the INBOUND path too. Previously Artery only gated outbound sends -- an envelope arriving FROM a uid this system has quarantined was still delivered, and the quarantined peer was only notified once, proactively, at the moment `Quarantine()` was called. A new `InboundQuarantineCheckStage`, woven into the inbound pipeline right after handshake, now drops every inbound envelope (ordinary or control, including system messages) whose origin uid is quarantined, and reactively re-sends a `Quarantined` control notice to the origin for each drop (except for a heartbeat or the peer's own `Quarantined` notice, to avoid a reply storm). The existing one-shot proactive notice in `Quarantine()` is unchanged. Additionally, an ordinary/large outbound stream that terminates while its association is quarantined no longer wedges permanently: the materialize-once gate is released (timer-driven auto-reconnect stays suppressed), so a quarantine-piercing `ActorSelection` send -- or any send after a new incarnation's handshake lifts the quarantine -- re-materializes the stream on demand and can reach a restarted peer at the same address. | No action required -- this is a bug fix restoring the documented "no further communication" guarantee of quarantine and the documented new-incarnation piercing behavior; code that (incorrectly) depended on a quarantined peer's replies still arriving is unsupported. |

src/core/Akka.Tests/Routing/ConsistentHashSpec.cs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
// </copyright>
66
//-----------------------------------------------------------------------
77

8+
#nullable enable
9+
810
using System;
911
using System.Collections.Generic;
1012
using System.Linq;
@@ -50,14 +52,24 @@ private sealed class NamedNode
5052
#region Helpers
5153

5254
/// <summary>
53-
/// Reads the private ring dictionary out of a <see cref="ConsistentHash{T}"/> so tests can
54-
/// assert on the exact key-&gt;node mapping (there is no public accessor).
55+
/// Reads the private ring state out of a <see cref="ConsistentHash{T}"/> so tests can assert
56+
/// on the exact key-&gt;node mapping (there is no public accessor). Since #8293 the ring is
57+
/// stored as parallel sorted arrays rather than a retained SortedDictionary; rebuild the
58+
/// dictionary shape here so the assertions (and the legacy-ring comparison) stay unchanged.
5559
/// </summary>
5660
private static SortedDictionary<int, T> Ring<T>(ConsistentHash<T> hash)
5761
{
58-
var field = typeof(ConsistentHash<T>).GetField("_nodes", BindingFlags.NonPublic | BindingFlags.Instance);
59-
field.Should().NotBeNull("the ConsistentHash<T>._nodes field is required by these tests");
60-
return (SortedDictionary<int, T>)field.GetValue(hash);
62+
var keysField = typeof(ConsistentHash<T>).GetField("_nodeHashRing", BindingFlags.NonPublic | BindingFlags.Instance);
63+
var valuesField = typeof(ConsistentHash<T>).GetField("_nodeRing", BindingFlags.NonPublic | BindingFlags.Instance);
64+
keysField.Should().NotBeNull("the ConsistentHash<T>._nodeHashRing field is required by these tests");
65+
valuesField.Should().NotBeNull("the ConsistentHash<T>._nodeRing field is required by these tests");
66+
67+
var keys = (int[])keysField!.GetValue(hash)!;
68+
var values = (T[])valuesField!.GetValue(hash)!;
69+
var ring = new SortedDictionary<int, T>();
70+
for (var i = 0; i < keys.Length; i++)
71+
ring.Add(keys[i], values[i]); // Add, not the indexer: a duplicate ring slot must fail loudly
72+
return ring;
6173
}
6274

6375
/// <summary>
@@ -71,7 +83,7 @@ private static SortedDictionary<int, T> LegacyRing<T>(IEnumerable<T> nodes, int
7183
var dict = new SortedDictionary<int, T>();
7284
foreach (var node in nodes)
7385
{
74-
var nodeHash = ConsistentHash.HashFor(node.ToString());
86+
var nodeHash = ConsistentHash.HashFor(node!.ToString()!);
7587
for (var v = 1; v <= factor; v++)
7688
dict.Add(ConsistentHash.ConcatenateNodeHash(nodeHash, v), node);
7789
}
@@ -162,11 +174,11 @@ public void Operator_plus_must_not_throw_when_the_added_node_collides()
162174
{
163175
var hash = ConsistentHash.Create(new[] { CollisionA }, CollisionFactor);
164176

165-
ConsistentHash<string> combined = null;
177+
ConsistentHash<string>? combined = null;
166178
Action act = () => combined = hash + CollisionB;
167179
act.Should().NotThrow("adding a colliding node must probe rather than throw (#8031)");
168180

169-
var ring = Ring(combined);
181+
var ring = Ring(combined!);
170182
ring.Count.Should().Be(2 * CollisionFactor);
171183
ring.Values.Count(v => v == CollisionB).Should().Be(CollisionFactor);
172184
}
@@ -308,5 +320,35 @@ public void Create_must_produce_the_legacy_ring_whenever_the_legacy_algorithm_su
308320
// legacyCollisions is informational: at these scales the high-end configs usually exercise
309321
// the collision branch too, but the guarantee that matters is the equality asserted above.
310322
}
323+
324+
[Fact]
325+
public void Constructor_must_snapshot_the_dictionary_and_not_retain_it()
326+
{
327+
// #8293: the ring is materialized into parallel arrays at construction and the source
328+
// SortedDictionary is deliberately NOT retained. Mutating the dictionary after construction
329+
// must therefore have no effect on the built ring. Before #8293 the ring aliased the
330+
// dictionary (IsEmpty and the operators read it live), so this would have failed.
331+
var dict = new SortedDictionary<int, string> { { 10, "a" }, { 20, "b" }, { 30, "c" } };
332+
var hash = new ConsistentHash<string>(dict, 1);
333+
334+
var before = Ring(hash);
335+
before.Count.Should().Be(3);
336+
337+
dict.Clear(); // if the ring still aliased the dictionary this would empty the ring
338+
339+
hash.IsEmpty.Should().BeFalse("clearing the source dictionary must not empty a snapshotted ring");
340+
hash.NodeFor("any-key").Should().Match<string>(n => n == "a" || n == "b" || n == "c",
341+
"the ring must still route after the source dictionary is cleared");
342+
AssertSameRing(before, Ring(hash));
343+
}
344+
345+
[Fact]
346+
public void Constructor_must_throw_ArgumentNullException_for_a_null_dictionary()
347+
{
348+
// #8293: the dictionary is read once in the constructor, so a null is rejected up front
349+
// with a named-parameter ArgumentNullException rather than a bare NullReferenceException.
350+
Action act = () => _ = new ConsistentHash<string>(null!, 1);
351+
act.Should().Throw<ArgumentNullException>().Which.ParamName.Should().Be("nodes");
352+
}
311353
}
312354
}

0 commit comments

Comments
 (0)