Skip to content

Commit f830d52

Browse files
authored
Merge branch 'dev' into docs/8439-auto-downing-investigation
2 parents fbed6d8 + e6e014d commit f830d52

23 files changed

Lines changed: 1289 additions & 270 deletions

File tree

BREAKING_CHANGES_V1.6.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ of `Behavior`, `Wire`, `API` (combine with `+`).
4444
| 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. |
4545
| 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. |
4646
| 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. |
47+
| Planned | `fix/artery-shutdown-flush` | `Akka.Remote` (Artery) | Behavior | Graceful transport shutdown now flushes. `ArteryRemoting.Shutdown()` completes each association's outbound channels, waits up to the new `akka.remote.artery.advanced.flush-wait-on-shutdown` (default `2 s`, mirroring classic remoting's `akka.remote.flush-wait-on-shutdown`) for the outbound streams to finish writing what they had already accepted, and only then drains the remainder to `Dropped`. Previously it completed and drained in the same breath, so a message accepted microseconds before shutdown -- an ack, a graceful notice, a handshake reply -- was published as `Dropped` instead of reaching the socket. Two consequences: graceful shutdown may now take up to `flush-wait-on-shutdown` longer per transport (only when a backlog cannot reach its peer; an association with nothing left to write completes at once), and messages that previously surfaced as `Dropped` may now be delivered. Abrupt termination (materializer death without a graceful `Shutdown()`) is unchanged. | No action required. Set `akka.remote.artery.advanced.flush-wait-on-shutdown = 0` to restore the previous drain-immediately behavior, or lower it to cap how long shutdown may wait. Monitoring that counts shutdown-time `Dropped` events will see fewer of them. |
4748
| 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. |
4849
| Planned | `fix/artery-daemonmsgcreate-control-stream` | `Akka.Remote` (Artery) | Behavior | Remote deployment's `DaemonMsgCreate` now travels over Artery's CONTROL stream (as a plain envelope, no delivery/ack sequencing) instead of the ordinary stream, ordering it ahead of the `Watch` that remote deployment sends immediately afterwards. Previously the two rode independent, unordered TCP connections and `Watch` systematically arrived first, so the receiver replied `DeathWatchNotification(existenceConfirmed: false)` for a not-yet-created actor and the deployer reaped the freshly-deployed routee before its `Supervise` registration landed, emptying cluster router pools. Additionally (Pekko parity), inbound ordinary messages addressed to a remote-deployed recipient that has not been created yet are no longer dead-lettered immediately: the resolve is retried on a bounded schedule (20 attempts x 50ms, buffered per recipient in FIFO order) so first messages that arrive ahead of the in-flight `DaemonMsgCreate` are delivered once the actor exists; paths that never resolve are banned and dead-letter as before. | No action required -- this is a bug fix restoring correct create-before-watch ordering; code that (incorrectly) depended on the old race is unsupported. |
4950
| Planned | `feature/artery-test-mode` | `Akka.Remote` | Behavior | System UID generation (`AddressUidExtension` / `AddressUid`) now uses a cryptographic RNG instead of the `Environment.TickCount`-seeded `ThreadLocalRandom`. Previously, multiple processes started within the same millisecond tick (e.g. every node of a multi-node test spawning at once) could draw IDENTICAL system UIDs, silently corrupting Artery's uid-keyed identity (handshakes, quarantine, association reverse index). UIDs remain nonzero and in the legacy `[1, int.MaxValue]` range at the default (`use-64bit-system-uids = off`). | Nothing required -- UIDs were always documented as random; only code depending on the (buggy) time-seeded determinism could observe a difference. |

src/contrib/cluster/Akka.Cluster.Sharding.Tests.MultiNode/ClusterShardingSpec.cs

Lines changed: 91 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,15 +1007,29 @@ await RunOnAsync(async () =>
10071007
await ExpectMsgAsync(new ShardStopped("1"), TimeSpan.FromSeconds(10), "ShardStopped not received");
10081008

10091009
//Get the path to where the shard now resides
1010+
// HandOffStopper answers ShardStopped as soon as the last entity terminates,
1011+
// which is strictly before the Shard actor stops and before the ShardRegion
1012+
// observes Terminated and drops the shard from its routing table. A Get sent
1013+
// in that window is forwarded to the dying Shard and dead-lettered - sharding
1014+
// is at-most-once, so the request has to be re-sent, not merely re-awaited.
1015+
// Each attempt gets a fresh probe (a late reply from a previous attempt must
1016+
// not be mistaken for this attempt's) and a 1s bound: that covers one region
1017+
// retry-interval (1s) plus the shard restart, so the 5s budget admits four
1018+
// sends instead of the single one an unbounded inner expect allowed - the
1019+
// inner expect inherited akka.test.single-expect-default (5 s), the whole
1020+
// budget, so the loop never retried.
1021+
IActorRef entity13 = null;
10101022
await AwaitAssertAsync(async () =>
10111023
{
1012-
_persistentEntitiesRegion.Value.Tell(new Get(13));
1013-
await ExpectMsgAsync(0);
1024+
var probe0 = CreateTestProbe();
1025+
_persistentEntitiesRegion.Value.Tell(new Get(13), probe0.Ref);
1026+
await probe0.ExpectMsgAsync(0, TimeSpan.FromSeconds(1));
1027+
entity13 = probe0.LastSender;
10141028
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(500));
10151029

10161030
//Check that counter 1 is now alive again, even though we have
10171031
// not sent a message to it via the ShardRegion
1018-
var counter1 = Sys.ActorSelection(LastSender.Path.Parent / "1");
1032+
var counter1 = Sys.ActorSelection(entity13.Path.Parent / "1");
10191033
await WithinAsync(TimeSpan.FromSeconds(5), async () =>
10201034
{
10211035
await AwaitAssertAsync(async () =>
@@ -1058,78 +1072,98 @@ await RunOnAsync(async () =>
10581072

10591073
private async Task PersistentClusterSharding_should_permanently_stop_entities_which_passivate()
10601074
{
1061-
await WithinAsync(TimeSpan.FromSeconds(15), async () =>
1075+
// No outer Within, for the reason documented on
1076+
// PersistentClusterSharding_should_recover_entities_upon_restart: barriers derive
1077+
// their timeout from RemainingOr(barrier-timeout), so a Within here hands the
1078+
// rendezvous whatever is left of the phase budget instead of the configured 70s
1079+
// testconductor barrier-timeout. The 15s the removed Within granted this phase is
1080+
// re-attached below to the two waits it was actually protecting - the first Get
1081+
// against each region, which has to allocate the shard, recover the
1082+
// remember-entities store and persist through the shared journal - so no wait ends
1083+
// up with a smaller budget than it had, and the barriers get the full 70s.
1084+
RunOn(() =>
10621085
{
1063-
RunOn(() =>
1064-
{
1065-
_ = _persistentRegion.Value;
1066-
}, Config.Third, Config.Fourth, Config.Fifth);
1067-
await EnterBarrierAsync("cluster-started-12");
1086+
_ = _persistentRegion.Value;
1087+
}, Config.Third, Config.Fourth, Config.Fifth);
1088+
await EnterBarrierAsync("cluster-started-12");
10681089

1069-
await RunOnAsync(async () =>
1070-
{
1071-
//create and increment counter 1
1072-
_persistentRegion.Value.Tell(new EntityEnvelope(1, Increment.Instance));
1073-
_persistentRegion.Value.Tell(new Get(1));
1074-
await ExpectMsgAsync(1);
1090+
await RunOnAsync(async () =>
1091+
{
1092+
//create and increment counter 1
1093+
_persistentRegion.Value.Tell(new EntityEnvelope(1, Increment.Instance));
1094+
_persistentRegion.Value.Tell(new Get(1));
1095+
await ExpectMsgAsync(1, TimeSpan.FromSeconds(15));
10751096

1076-
var counter1 = LastSender;
1077-
var shard = Sys.ActorSelection(counter1.Path.Parent);
1078-
var region = Sys.ActorSelection(counter1.Path.Parent.Parent);
1097+
var counter1 = LastSender;
1098+
var shard = Sys.ActorSelection(counter1.Path.Parent);
1099+
var region = Sys.ActorSelection(counter1.Path.Parent.Parent);
10791100

1080-
//create and increment counter 13
1081-
_persistentRegion.Value.Tell(new EntityEnvelope(13, Increment.Instance));
1082-
_persistentRegion.Value.Tell(new Get(13));
1083-
await ExpectMsgAsync(1);
1101+
//create and increment counter 13
1102+
_persistentRegion.Value.Tell(new EntityEnvelope(13, Increment.Instance));
1103+
_persistentRegion.Value.Tell(new Get(13));
1104+
await ExpectMsgAsync(1, TimeSpan.FromSeconds(15));
10841105

1085-
var counter13 = LastSender;
1106+
var counter13 = LastSender;
10861107

1087-
counter13.Path.Parent.Should().Be(counter1.Path.Parent);
1108+
counter13.Path.Parent.Should().Be(counter1.Path.Parent);
10881109

1089-
//Send the shard the passivate message from the counter
1090-
await WatchAsync(counter1);
1091-
shard.Tell(new Passivate(Stop.Instance), counter1);
1110+
//Send the shard the passivate message from the counter
1111+
await WatchAsync(counter1);
1112+
shard.Tell(new Passivate(Stop.Instance), counter1);
10921113

1093-
// watch for the Terminated message
1094-
await ExpectTerminatedAsync(counter1, TimeSpan.FromSeconds(5));
1114+
// watch for the Terminated message
1115+
await ExpectTerminatedAsync(counter1, TimeSpan.FromSeconds(5));
10951116

1117+
await AwaitAssertAsync(async () =>
1118+
{
1119+
// check counter 1 is dead
10961120
var probe1 = CreateTestProbe();
1097-
await AwaitAssertAsync(async () =>
1098-
{
1099-
// check counter 1 is dead
1100-
counter1.Tell(new Identify(1), probe1.Ref);
1101-
await probe1.ExpectMsgAsync(new ActorIdentity(1, null), TimeSpan.FromSeconds(1), "Entity 1 was still around");
1102-
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(500));
1121+
counter1.Tell(new Identify(1), probe1.Ref);
1122+
await probe1.ExpectMsgAsync(new ActorIdentity(1, null), TimeSpan.FromSeconds(1), "Entity 1 was still around");
1123+
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(500));
11031124

1104-
// stop shard cleanly
1105-
region.Tell(new HandOff("1"));
1106-
await ExpectMsgAsync(new ShardStopped("1"), TimeSpan.FromSeconds(10), "ShardStopped not received");
1125+
// stop shard cleanly
1126+
region.Tell(new HandOff("1"));
1127+
await ExpectMsgAsync(new ShardStopped("1"), TimeSpan.FromSeconds(10), "ShardStopped not received");
11071128

1108-
}, Config.Third);
1109-
await EnterBarrierAsync("shard-shutdonw-12");
1129+
}, Config.Third);
1130+
await EnterBarrierAsync("shard-shutdonw-12");
11101131

1111-
await RunOnAsync(async () =>
1132+
await RunOnAsync(async () =>
1133+
{
1134+
// force shard backup
1135+
// ShardStopped - which released the barrier above - is answered by the
1136+
// HandOffStopper the moment the last entity terminates, before the Shard
1137+
// actor stops and before its ShardRegion drops it from the routing table.
1138+
// Shard "1" may be hosted by this node, so this Get can be forwarded to the
1139+
// dying Shard and dead-lettered. Sharding is at-most-once: re-send until the
1140+
// shard has been re-allocated. 1s per attempt covers one region
1141+
// retry-interval (1s) plus the restart, so the 5s budget admits four sends.
1142+
IActorRef entity25 = null;
1143+
await AwaitAssertAsync(async () =>
11121144
{
1113-
// force shard backup
1114-
_persistentRegion.Value.Tell(new Get(25));
1115-
await ExpectMsgAsync(0);
1145+
var probe0 = CreateTestProbe();
1146+
_persistentRegion.Value.Tell(new Get(25), probe0.Ref);
1147+
await probe0.ExpectMsgAsync(0, TimeSpan.FromSeconds(1));
1148+
entity25 = probe0.LastSender;
1149+
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(500));
11161150

1117-
var shard = LastSender.Path.Parent;
1151+
var shard = entity25.Path.Parent;
11181152

1119-
// check counter 1 is still dead
1120-
Sys.ActorSelection(shard / "1").Tell(new Identify(3));
1121-
await ExpectMsgAsync(new ActorIdentity(3, null));
1153+
// check counter 1 is still dead
1154+
Sys.ActorSelection(shard / "1").Tell(new Identify(3));
1155+
await ExpectMsgAsync(new ActorIdentity(3, null), TimeSpan.FromSeconds(5));
11221156

1123-
// check counter 13 is alive again
1157+
// check counter 13 is alive again
1158+
await AwaitAssertAsync(async () =>
1159+
{
11241160
var probe3 = CreateTestProbe();
1125-
await AwaitAssertAsync(async () =>
1126-
{
1127-
Sys.ActorSelection(shard / "13").Tell(new Identify(4), probe3.Ref);
1128-
await probe3.ExpectMsgAsync<ActorIdentity>(i => i.MessageId.Equals(4) && i.Subject != null);
1129-
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(500));
1130-
}, Config.Fourth);
1131-
await EnterBarrierAsync("after-13");
1132-
});
1161+
Sys.ActorSelection(shard / "13").Tell(new Identify(4), probe3.Ref);
1162+
await probe3.ExpectMsgAsync<ActorIdentity>(
1163+
i => i.MessageId.Equals(4) && i.Subject != null, TimeSpan.FromSeconds(1));
1164+
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(500));
1165+
}, Config.Fourth);
1166+
await EnterBarrierAsync("after-13");
11331167
}
11341168

11351169
private async Task PersistentClusterSharding_should_restart_entities_which_stop_without_passivation()

0 commit comments

Comments
 (0)