1- //-----------------------------------------------------------------------
1+ //-----------------------------------------------------------------------
22// <copyright file="ShardedDaemonProcessSpec.cs" company="Akka.NET Project">
33// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
44// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
55// </copyright>
66//-----------------------------------------------------------------------
77
8+ #nullable enable
9+
810using System ;
11+ using System . Collections . Generic ;
912using System . Linq ;
13+ using System . Threading ;
14+ using System . Threading . Tasks ;
1015using Akka . Actor ;
1116using Akka . Cluster . TestKit ;
1217using Akka . Cluster . Tools . Singleton ;
1318using Akka . Configuration ;
19+ using Akka . Event ;
1420using Akka . MultiNode . TestAdapter ;
1521using Akka . Remote . TestKit ;
22+ using Akka . TestKit ;
1623using FluentAssertions ;
1724
1825namespace Akka . Cluster . Sharding . Tests . MultiNode
@@ -32,14 +39,22 @@ public ShardedDaemonProcessSpecConfig()
3239 CommonConfig = DebugConfig ( false )
3340 . WithFallback ( ConfigurationFactory . ParseString ( @"
3441 akka.loglevel = INFO
35- akka.cluster.sharded-daemon-process {{
36- sharding {{
42+ # `first` collects Started events from every node, so it sits in front of the
43+ # trailing barrier for as long as cross-node shard allocation takes. Give the
44+ # barrier more room than the 30s default so a slow allocation cannot turn into
45+ # a barrier timeout on the nodes waiting for `first`.
46+ akka.testconductor.barrier-timeout = 60s
47+ # NB: these braces used to be doubled, which HOCON parsed as nested anonymous
48+ # objects and quietly dropped keep-alive-interval on the floor - the spec ran on
49+ # the 10s default, so a missed initial start waited 10s for the next ping.
50+ akka.cluster.sharded-daemon-process {
51+ sharding {
3752 # First is likely to be ignored as shard coordinator not ready
3853 retry-interval = 0.2s
39- }}
54+ }
4055 # quick ping to make test swift
4156 keep-alive-interval = 1s
42- }}
57+ }
4358 " ) )
4459 . WithFallback ( ClusterSharding . DefaultConfig ( ) )
4560 . WithFallback ( ClusterSingleton . DefaultConfig ( ) )
@@ -55,6 +70,14 @@ protected ShardedDaemonProcessMultiNode(ShardedDaemonProcessSpecConfig config) :
5570
5671 public abstract class ShardedDaemonProcessSpec : MultiNodeClusterSpec
5772 {
73+ private const int TotalProcesses = 4 ;
74+
75+ /// <summary>
76+ /// Deterministic name for the single cluster-wide collector, so the other nodes can address
77+ /// it. TestKit creates probe actors under the system guardian, hence the /system path below.
78+ /// </summary>
79+ private const string CollectorName = "process-event-collector" ;
80+
5881 private readonly ShardedDaemonProcessSpecConfig _config ;
5982
6083 protected ShardedDaemonProcessSpec ( ShardedDaemonProcessSpecConfig config , Type type )
@@ -64,59 +87,68 @@ protected ShardedDaemonProcessSpec(ShardedDaemonProcessSpecConfig config, Type t
6487 }
6588
6689 [ MultiNodeFact ]
67- public void ShardedDaemonProcess_Specs ( )
90+ public async Task ShardedDaemonProcess_Specs ( )
6891 {
69- ShardedDaemonProcess_Should_Init_Actor_Set ( ) ;
92+ await ShardedDaemonProcess_Should_Init_Actor_Set ( ) ;
7093 }
7194
72- public void ShardedDaemonProcess_Should_Init_Actor_Set ( )
95+ private async Task ShardedDaemonProcess_Should_Init_Actor_Set ( )
7396 {
74- // HACK
75- RunOn ( ( ) => FormCluster ( _config . First , _config . Second , _config . Third ) , _config . First ) ;
97+ await AwaitClusterUpAsync ( CancellationToken . None , _config . First , _config . Second , _config . Third ) ;
98+
99+ // One collector for the whole cluster, living on `first`. Every node passes this same
100+ // ref into its entity Props, so a ProcessActor reports in no matter which node ends up
101+ // hosting it. Handing each node its own local probe would only ever prove that a node
102+ // sees its own entities, which says nothing about the set as a whole.
103+ var collectorProbe = IsNode ( _config . First ) ? CreateTestProbe ( CollectorName ) : null ;
104+ await EnterBarrierAsync ( "collector-started" ) ;
76105
77- var probe = CreateTestProbe ( ) ;
78- ShardedDaemonProcess . Get ( Sys ) . Init ( "the-fearless" , 4 , id => ProcessActor . Props ( id , probe . Ref ) ) ;
79- EnterBarrier ( "sharded-daemon-process-initialized" ) ;
106+ var collector = await Sys
107+ . ActorSelection ( await NodeAsync ( _config . First ) / "system" / CollectorName )
108+ . ResolveOne ( TimeSpan . FromSeconds ( 20 ) ) ;
80109
81- RunOn ( ( ) =>
110+ ShardedDaemonProcess . Get ( Sys ) . Init ( "the-fearless" , TotalProcesses , id => ProcessActor . Props ( id , collector ) ) ;
111+ await EnterBarrierAsync ( "sharded-daemon-process-initialized" ) ;
112+
113+ if ( collectorProbe is not null )
82114 {
83- var startedIds = Enumerable . Range ( 0 , 4 ) . Select ( _ =>
84- {
85- var evt = probe . ExpectMsg < ProcessActorEvent > ( TimeSpan . FromSeconds ( 5 ) ) ;
86- evt . Event . Should ( ) . Be ( "Started" ) ;
87- return evt . Id ;
88- } ) . ToList ( ) ;
89- startedIds . Count . Should ( ) . Be ( 4 ) ;
90- } , _config . First ) ;
91- EnterBarrier ( "sharded-daemon-process-started" ) ;
115+ await AssertAllProcessesStarted ( collectorProbe ) ;
116+ }
117+
118+ await EnterBarrierAsync ( "sharded-daemon-process-started" ) ;
92119 }
93120
94- private void FormCluster ( RoleName first , params RoleName [ ] rest )
121+ private async Task AssertAllProcessesStarted ( TestProbe collectorProbe )
95122 {
96- RunOn ( ( ) =>
97- {
98- Cluster . Join ( GetAddress ( first ) ) ;
99- AwaitAssert ( ( ) =>
123+ var startedIds = new HashSet < int > ( ) ;
124+ var hostingNodes = new HashSet < string > ( ) ;
125+
126+ // A shard can be reallocated while the cluster settles, which stops an entity and starts
127+ // it again elsewhere, so the same id may report twice. Fish until every distinct id has
128+ // checked in rather than assuming exactly TotalProcesses messages arrive; this returns
129+ // as soon as the set is complete, so the bound below is a ceiling and not a delay.
130+ await collectorProbe . FishForMessageAsync < ProcessActorEvent > (
131+ isMessage : evt =>
100132 {
101- Cluster . State . Members . Select ( i => i . UniqueAddress ) . Should ( ) . Contain ( Cluster . SelfUniqueAddress ) ;
102- Cluster . State . Members . Select ( i => i . Status ) . Should ( ) . OnlyContain ( i => i == MemberStatus . Up ) ;
103- } ) ;
104- } , first ) ;
105- EnterBarrier ( first . Name + "-joined" ) ;
106-
107- foreach ( var node in rest )
108- {
109- RunOn ( ( ) =>
110- {
111- Cluster . Join ( GetAddress ( first ) ) ;
112- AwaitAssert ( ( ) =>
113- {
114- Cluster . State . Members . Select ( i => i . UniqueAddress ) . Should ( ) . Contain ( Cluster . SelfUniqueAddress ) ;
115- Cluster . State . Members . Select ( i => i . Status ) . Should ( ) . OnlyContain ( i => i == MemberStatus . Up ) ;
116- } ) ;
117- } , node ) ;
118- }
119- EnterBarrier ( "all-joined" ) ;
133+ if ( evt . Event != ProcessActorEvent . Started )
134+ return false ;
135+
136+ startedIds . Add ( evt . Id ) ;
137+ hostingNodes . Add ( evt . HostAddress ) ;
138+ return startedIds . Count == TotalProcesses ;
139+ } ,
140+ max : TimeSpan . FromSeconds ( 30 ) ,
141+ hint : $ "a Started event from each of the { TotalProcesses } sharded daemon processes" ) ;
142+
143+ startedIds . Should ( ) . BeEquivalentTo (
144+ Enumerable . Range ( 0 , TotalProcesses ) ,
145+ "every sharded daemon process must start somewhere in the cluster" ) ;
146+
147+ // Placement is up to the allocation strategy, so this is reported rather than asserted -
148+ // a single-node placement is legal, just not what this spec is here to exercise.
149+ Log . Info (
150+ "[{0}] sharded daemon processes started across [{1}] node(s): [{2}]" ,
151+ startedIds . Count , hostingNodes . Count , string . Join ( ", " , hostingNodes . OrderBy ( x => x ) ) ) ;
120152 }
121153 }
122154
@@ -145,31 +177,50 @@ public ProcessActor(int id, IActorRef probe)
145177 public IActorRef Probe { get ; }
146178 public int Id { get ; }
147179
180+ private string SelfAddress => Cluster . Get ( Context . System ) . SelfAddress . ToString ( ) ;
181+
148182 protected override void PreStart ( )
149183 {
150184 base . PreStart ( ) ;
151- Probe . Tell ( new ProcessActorEvent ( Id , " Started" ) ) ;
185+ Probe . Tell ( new ProcessActorEvent ( Id , ProcessActorEvent . Started , SelfAddress ) ) ;
152186 }
153187
154188 protected override void OnReceive ( object message )
155189 {
156190 if ( message is Stop )
157191 {
158- Probe . Tell ( new ProcessActorEvent ( Id , " Stopped" ) ) ;
192+ Probe . Tell ( new ProcessActorEvent ( Id , ProcessActorEvent . Stopped , SelfAddress ) ) ;
159193 Context . Stop ( Self ) ;
160194 }
161195 }
162196 }
163197
198+ /// <summary>
199+ /// Reported by a <see cref="ProcessActor"/> to the cluster-wide collector. This crosses the wire
200+ /// whenever the entity is hosted somewhere other than <c>first</c>, so every member is a plain
201+ /// serializable type.
202+ /// </summary>
203+ [ Serializable ]
164204 internal sealed class ProcessActorEvent
165205 {
166- public ProcessActorEvent ( int id , object @event )
206+ public const string Started = "Started" ;
207+ public const string Stopped = "Stopped" ;
208+
209+ public ProcessActorEvent ( int id , string @event , string hostAddress )
167210 {
168211 Id = id ;
169212 Event = @event ;
213+ HostAddress = hostAddress ;
170214 }
171215
172216 public int Id { get ; }
173- public object Event { get ; }
217+
218+ public string Event { get ; }
219+
220+ /// <summary>
221+ /// Address of the node that hosted the entity, so the spec can report how the processes were
222+ /// spread across the cluster.
223+ /// </summary>
224+ public string HostAddress { get ; }
174225 }
175226}
0 commit comments