diff --git a/.gitignore b/.gitignore
index 563597d99ab..79fe119cae5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -242,3 +242,6 @@ launchSettings.json
# Claude worktree management
.claude-wt/worktrees
/.playwright-mcp
+
+# Test certificate for Akka.Remote.Hosting.Tests (the Akka.Remote.Tests ones are force-added)
+!src/contrib/hosting/Akka.Remote.Hosting.Tests/Resources/akka-validcert.pfx
diff --git a/.incrementalist/testsOnlyNetFx.json b/.incrementalist/testsOnlyNetFx.json
index ba3b108f517..f6bdb447956 100644
--- a/.incrementalist/testsOnlyNetFx.json
+++ b/.incrementalist/testsOnlyNetFx.json
@@ -10,6 +10,7 @@
"**/.Tests.MultiNode.csproj",
"**/Akka.MultiNode.TestAdapter.Tests.csproj",
"**/Akka.MultiNode.TestAdapter.Xunit2.Tests.csproj",
+ "src/contrib/hosting/**",
"src/examples/**"
],
"target": [
diff --git a/Akka.slnx b/Akka.slnx
index a0f4ceae337..ae9beae4626 100644
--- a/Akka.slnx
+++ b/Akka.slnx
@@ -69,6 +69,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -286,6 +300,11 @@
+
+
+
+
+
diff --git a/BREAKING_CHANGES_V1.6.md b/BREAKING_CHANGES_V1.6.md
index 840e8e75902..596166c5a43 100644
--- a/BREAKING_CHANGES_V1.6.md
+++ b/BREAKING_CHANGES_V1.6.md
@@ -40,6 +40,7 @@ of `Behavior`, `Wire`, `API` (combine with `+`).
| Status | PR / Branch | Component | Type | Change | Migration |
|--------|-------------|-----------|------|--------|-----------|
+| Planned | `feature/hosting-import-dev` (forward-port of [#8591](https://github.com/akkadotnet/akka.net/pull/8591)) | `Akka.Hosting`, `Akka.Remote.Hosting`, `Akka.Cluster.Hosting`, `Akka.Persistence.Hosting`, `Akka.Hosting.TestKit`, `Akka.Hosting.TestKit.Xunit2` | API | The Akka.Hosting packages now ship from this repository at the Akka.NET version. On 1.6 they target `net10.0` only (previously `netstandard2.0;net6.0`), matching the rest of Akka.NET 1.6. `Microsoft.Extensions.*` floor moves to 10.0.0 for `Akka`, `Akka.DependencyInjection` and the Hosting packages. | Reference `Akka.Hosting.*` at the same version as `Akka.*`. Applications must target `net10.0` or later, as for every other Akka.NET 1.6 package. |
| Planned | `fix/testkit-xunit-async-dispose-chain` | `Akka.TestKit.Xunit` | API + Behavior | `Akka.TestKit.Xunit.TestKit` now also implements `Xunit.IAsyncLifetime`, adding `virtual ValueTask InitializeAsync()` and `virtual ValueTask DisposeAsync()`. This closes #8191: xUnit v3 tears a test class instance down via `IAsyncDisposable.DisposeAsync()` in preference to `IDisposable.Dispose()` whenever a type implements both, so a derived spec that declared its own `IAsyncLifetime.DisposeAsync()` (to run its own async teardown) short-circuited `TestKit.Dispose(bool)` entirely and silently leaked its `ActorSystem` -- DotNetty threads, a bound socket, and a `PeriodicTimer`-driven scheduler included. `TestKit.DisposeAsync()` now runs the synchronous dispose chain (`AfterAll()` etc.) and then shuts the system down with the non-blocking `ShutdownAsync()` instead of the blocking `Shutdown()`. | A derived class that already declares its own `InitializeAsync`/`DisposeAsync` (whether via `IAsyncLifetime` directly or by re-declaring the method) now hides a virtual member of the same name and gets `CS0114`, which is an error under this repo's own `-warnaserror` build. Add `override` to each and chain to the base (`await base.InitializeAsync();` / `await base.DisposeAsync();`), and drop the redundant `IAsyncLifetime` interface declaration and any no-op `DisposeAsync` that only returned a completed `ValueTask`. |
| Planned | `fix/8508-clusterclient-reconnect-timer` | `Akka.Cluster.Tools` | API + Behavior | `ClusterClient` now implements `Akka.Actor.IWithTimers` and manages its reconnect, heartbeat and refresh-contacts timers through the keyed timer scheduler. The reconnect deadline is armed once per entry into the establishing phase (`PreStart` and re-establish) instead of on every message handled while establishing, which is the fix for #8508: the old code abandoned one one-shot timer per message and cancelled only the last, so a leftover firing after the client returned to establishing stopped a healthy client mid-reconnect, long before its own deadline was due. API surface: the class gains the `IWithTimers` interface and a public `Timers` property; the `PostStop` override is gone because the timer scheduler cancels timers on stop and restart. `reconnect-timeout = off` (the default) remains a true no-op and arms nothing. Secondary behavior change: because the timers are keyed, a `RefreshContactsTick` still queued from the previous cadence is discarded when the interval changes on a state transition, rather than being delivered against the new cadence. | None. `ClusterClient` is `sealed`, so the added interface and property cannot break a subclass, and the removed `PostStop` override was `protected` on that sealed class and therefore unreachable from outside. Deployments that had enabled `akka.cluster.client.reconnect-timeout` should simply stop seeing clients stopped early during receptionist handover. |
| Planned | [#8324](https://github.com/akkadotnet/akka.net/pull/8324) | `Akka.Routing` | Behavior | `ConsistentHash` 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. |
diff --git a/Directory.Build.props b/Directory.Build.props
index f0902d6787d..531eca14080 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -45,8 +45,9 @@
2.0.3
6.0.1
[6.0.*,)
- [6.0.*,)
+ [10.0.0,)
10.0.10
+ [1.15.3,)
0.3.3
31.24.2
3.0.0
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index ee1b88b4619..c41596fe745 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,6 +2,8 @@
**Placeholder for nightly build**
+* Akka.Hosting: Akka.Hosting now ships from this repository. The `Akka.Hosting`, `Akka.Remote.Hosting`, `Akka.Cluster.Hosting`, `Akka.Persistence.Hosting`, `Akka.Hosting.TestKit` and `Akka.Hosting.TestKit.Xunit2` packages live under `src/contrib/hosting` and share the Akka.NET version and release cadence (forward-port of [#8591](https://github.com/akkadotnet/akka.net/pull/8591)). No package IDs, namespaces or public APIs changed; bump `Akka.Hosting.*` to the same version as `Akka.*`. Hosting-only hotfix versions no longer exist. Documentation moved to [getakka.net/articles/hosting](https://getakka.net/articles/hosting/index.html); the [akkadotnet/Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting) repository will be archived and keeps the history for versions up to 1.5.71. **Breaking on 1.6**: the Hosting packages target `net10.0` only, matching the rest of Akka.NET 1.6.
+* Build: Microsoft.Extensions minimum version moves to 10.0 for `Akka`, `Akka.DependencyInjection` and all `Akka.Hosting.*` packages (previously 6.0 for the core packages and 9.0 for Akka.Hosting). Akka.Hosting's `OpenTelemetry` dependency moves to 1.15.3 or later, which clears [GHSA-g94r-2vxg-569j](https://github.com/advisories/GHSA-g94r-2vxg-569j).
* Core: Add `ILoggingAdapter` context enrichment, explicit scopes, and bracketed context output in StandardOutLogger and Xunit logger
* Akka.Streams: Add cancellation-aware `Source.Queue` offers so backpressured pending offers can be canceled without later emitting the canceled element.
* Akka.Streams: Fixed `Source.From(IAsyncEnumerable)` cleanup so cancellation waits for any in-flight `MoveNextAsync()` before disposing the async enumerator and its cancellation token source.
diff --git a/docs/articles/deployment/akkacluster-troubleshooting.md b/docs/articles/deployment/akkacluster-troubleshooting.md
index a4a3f892edf..a0ec1c410ea 100644
--- a/docs/articles/deployment/akkacluster-troubleshooting.md
+++ b/docs/articles/deployment/akkacluster-troubleshooting.md
@@ -95,7 +95,7 @@ Generally speaking, unreachable nodes are usually caused by environment problems
#### Use Akka.Hosting
-When you use [Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting), this ensures that your `ActorSystem` is managed with the best lifecycle management practices for Akka.NET. Part of this includes making sure that when an Akka.NET process is shutdown it cleanly leaves the cluster first before terminating. One common reason for reachability problems is that during deployments users simply abort the Akka.NET process without letting the `ActorSystem` gracefully terminate, which leaves behind an unreachable node. Akka.Hosting eliminates this problem.
+When you use [Akka.Hosting](xref:akka-hosting), this ensures that your `ActorSystem` is managed with the best lifecycle management practices for Akka.NET. Part of this includes making sure that when an Akka.NET process is shutdown it cleanly leaves the cluster first before terminating. One common reason for reachability problems is that during deployments users simply abort the Akka.NET process without letting the `ActorSystem` gracefully terminate, which leaves behind an unreachable node. Akka.Hosting eliminates this problem.
#### Increase Failure Detector Thresholds
@@ -120,7 +120,7 @@ Please create an issue in our GitHub at [https://github.com/akkadotnet/akka.net]
This typically means that one of the optional serializers built on top of Akka.Cluster is not registered on this node, but this node is still receiving messages from other nodes who are using it. [`DistributedPubSub`](xref:distributed-publish-subscribe) is the most likely culprit when this occurs.
-To fix this issue, either use [Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting) or manually register the serializers in your HOCON when you start your `ActorSystem`:
+To fix this issue, either use [Akka.Hosting](xref:akka-hosting) or manually register the serializers in your HOCON when you start your `ActorSystem`:
```csharp
Config myHocon = ConfigurationFactory.ParseString("{hocon}");
diff --git a/docs/articles/discovery/index.md b/docs/articles/discovery/index.md
index 946110b6be2..9c4d772e724 100644
--- a/docs/articles/discovery/index.md
+++ b/docs/articles/discovery/index.md
@@ -41,7 +41,7 @@ Cluster.Bootstrap uses these to dynamically populate the seed nodes list and saf
## Configuration with Akka.Hosting (Recommended)
-> **Recommended:** We strongly encourage users to configure Akka.Discovery using [Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting) for a modern, type-safe, and composable experience.
+> **Recommended:** We strongly encourage users to configure Akka.Discovery using [Akka.Hosting](xref:akka-hosting) for a modern, type-safe, and composable experience.
Example using Akka.Discovery.Azure with Akka.Hosting, including remoting, clustering, Akka.Management, and Cluster Bootstrap:
diff --git a/docs/articles/hosting/configuration-builder.md b/docs/articles/hosting/configuration-builder.md
new file mode 100644
index 00000000000..27ae35cf2e2
--- /dev/null
+++ b/docs/articles/hosting/configuration-builder.md
@@ -0,0 +1,87 @@
+---
+uid: hosting-configuration-builder
+title: The AkkaConfigurationBuilder API
+---
+
+# The AkkaConfigurationBuilder API
+
+We want to make Akka.NET something that can be instantiated more typically per the patterns often used with the Microsoft.Extensions.Hosting APIs that are common throughout .NET.
+
+The `AddAkka` extension method on `IServiceCollection` is the entry point into Akka.Hosting - it hands you an `AkkaConfigurationBuilder` that you use to configure and start your `ActorSystem`:
+
+```csharp
+using Akka.Hosting;
+using Akka.Actor;
+using Akka.Actor.Dsl;
+using Akka.Cluster.Hosting;
+using Akka.Remote.Hosting;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .WithRemoting("localhost", 8110)
+ .WithClustering(new ClusterOptions(){ Roles = new[]{ "myRole" },
+ SeedNodes = new[]{ Address.Parse("akka.tcp://MyActorSystem@localhost:8110")}})
+ .WithActors((system, registry) =>
+ {
+ var echo = system.ActorOf(act =>
+ {
+ act.ReceiveAny((o, context) =>
+ {
+ context.Sender.Tell($"{context.Self} rcv {o}");
+ });
+ }, "echo");
+ registry.TryRegister(echo); // register for DI
+ });
+});
+
+var app = builder.Build();
+
+app.MapGet("/", async (context) =>
+{
+ var echo = context.RequestServices.GetRequiredService().Get();
+ var body = await echo.Ask(context.TraceIdentifier, context.RequestAborted).ConfigureAwait(false);
+ await context.Response.WriteAsync(body);
+});
+
+app.Run();
+```
+
+No HOCON. Automatically runs all Akka.NET application lifecycle best practices behind the scene. Automatically binds the `ActorSystem` and the `ActorRegistry`, another new 1.5 feature, to the `IServiceCollection` so they can be safely consumed via both actors and non-Akka.NET parts of users' .NET applications.
+
+This should be open to extension in other child plugins, such as `Akka.Persistence.SqlServer`:
+
+```csharp
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .WithRemoting("localhost", 8110)
+ .WithClustering(new ClusterOptions()
+ {
+ Roles = new[] { "myRole" },
+ SeedNodes = new[] { Address.Parse("akka.tcp://MyActorSystem@localhost:8110") }
+ })
+ .WithSqlServerPersistence(builder.Configuration.GetConnectionString("sqlServerLocal"))
+ .WithShardRegion("userActions", s => UserActionsEntity.Props(s),
+ new UserMessageExtractor(),
+ new ShardOptions(){ StateStoreMode = StateStoreMode.DData, Role = "myRole"})
+ .WithActors((system, registry) =>
+ {
+ var userActionsShard = registry.Get();
+ var indexer = system.ActorOf(Props.Create(() => new Indexer(userActionsShard)), "index");
+ registry.TryRegister(indexer); // register for DI
+ });
+})
+```
+
+## Other AkkaConfigurationBuilder Methods
+
+Beyond `WithActors`, the `AkkaConfigurationBuilder` exposes several other methods for advanced configuration scenarios:
+
+* `AddHocon` - merges a HOCON `Config` object, or a `Microsoft.Extensions.Configuration` `IConfiguration` section, into the `ActorSystem` configuration. See [Microsoft.Extensions.Configuration Integration](xref:hosting-configuration) for details.
+* `AddSetup` - adds an Akka.NET `Setup` object, such as `BootstrapSetup` or `ServiceProviderSetup`, to the `ActorSystem` startup pipeline for programmatic configuration.
+* `WithActorRefProvider` - overrides the `ActorRefProvider` used by the `ActorSystem` (for example, forcing `ActorRefProvider.Local` in a test host that would otherwise default to `ActorRefProvider.Cluster`).
+
+For dependency injection with the `ActorRegistry` and `IRequiredActor`, see [Dependency Injection Outside and Inside Akka.NET](xref:hosting-dependency-injection).
diff --git a/docs/articles/hosting/configuration.md b/docs/articles/hosting/configuration.md
new file mode 100644
index 00000000000..80a50720250
--- /dev/null
+++ b/docs/articles/hosting/configuration.md
@@ -0,0 +1,199 @@
+---
+uid: hosting-configuration
+title: Microsoft.Extensions.Configuration Integration
+---
+
+# Microsoft.Extensions.Configuration Integration
+
+## IConfiguration to HOCON Adapter
+
+The `AddHocon` extension method can convert `Microsoft.Extensions.Configuration` `IConfiguration` into HOCON `Config` instance and adds it to the ActorSystem being configured.
+
+* Unlike `IConfiguration`, all HOCON key names are **case sensitive**.
+* **Unless enclosed inside double quotes**, all "." (period) in the `IConfiguration` key will be treated as a HOCON object key separator
+* `IConfiguration` **does not support object composition**, if you declare the same key multiple times inside multiple configuration providers (JSON/environment variables/etc), **only the last one declared will take effect**.
+* For environment variable configuration provider:
+ * "__" (double underline) will be converted to "." (period).
+ * "_" (single underline) will be converted to "-" (dash).
+ * If all keys are composed of integer parseable keys, the whole object is treated as an array
+
+**Example:**
+
+`appsettings.json`:
+
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "akka": {
+ "cluster": {
+ "roles": [ "front-end", "back-end" ],
+ "min-nr-of-members": 3,
+ "log-info": true
+ }
+ }
+}
+```
+
+Environment variables:
+
+```powershell
+AKKA__ACTOR__TELEMETRY__ENABLE=true
+AKKA__CLUSTER__SEED_NODES__0=akka.tcp//mySystem@localhost:4055
+AKKA__CLUSTER__SEED_NODES__1=akka.tcp//mySystem@localhost:4056
+AKKA__CLUSTER__SEED_NODE_TIMEOUT=00:00:05
+```
+
+Note the integer parseable key inside the seed-nodes configuration, seed-nodes will be parsed as an array. These environment variables will be parsed as HOCON settings:
+
+```hocon
+akka {
+ actor {
+ telemetry.enabled: on
+ }
+ cluster {
+ seed-nodes: [
+ "akka.tcp//mySystem@localhost:4055",
+ "akka.tcp//mySystem@localhost:4056"
+ ]
+ seed-node-timeout: 5s
+ }
+}
+```
+
+Example code:
+
+```csharp
+/*
+Both appsettings.json and environment variables are combined
+into HOCON configuration:
+
+akka {
+ actor.telemetry.enabled: on
+ cluster {
+ roles: [ "front-end", "back-end" ]
+ seed-nodes: [
+ "akka.tcp//mySystem@localhost:4055",
+ "akka.tcp//mySystem@localhost:4056"
+ ]
+ min-nr-of-members: 3
+ seed-node-timeout: 5s
+ log-info: true
+ }
+}
+*/
+var host = new HostBuilder()
+ .ConfigureHostConfiguration(builder =>
+ {
+ // Setup IConfiguration to load from appsettings.json and
+ // environment variables
+ builder
+ .AddJsonFile("appsettings.json")
+ .AddEnvironmentVariables();
+ })
+ .ConfigureServices((context, services) =>
+ {
+ services.AddAkka("mySystem", (builder, provider) =>
+ {
+ // convert IConfiguration to HOCON
+ var akkaConfig = context.Configuration.GetSection("akka");
+ builder.AddHocon(akkaConfig, HoconAddMode.Prepend);
+ });
+ });
+```
+
+### Special Characters and Case Sensitivity
+
+This advanced usage of the `IConfiguration` adapter is solely used for edge cases where HOCON key capitalization needs to be preserved, such as declaring serialization binding. Note that when you're using this feature, none of the keys are normalized, you will have to write all of your keys in a HOCON compatible way.
+
+`appsettings.json`:
+
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "akka": {
+ "\"Key.With.Dots\"": "Key Value",
+ "cluster": {
+ "roles": [ "front-end", "back-end" ],
+ "min-nr-of-members": 3,
+ "log-info": true
+ }
+ }
+}
+```
+
+Note that "Key.With.Dots" needs to be inside escaped double quotes, this is a HOCON requirement that preserves the "." (period) inside HOCON property names.
+
+Environment variables:
+
+```powershell
+PS C:/> [Environment]::SetEnvironmentVariable('akka__actor__telemetry__enabled', 'true')
+PS C:/> [Environment]::SetEnvironmentVariable('akka__actor__serialization_bindings__"System.Object"', 'hyperion')
+PS C:/> [Environment]::SetEnvironmentVariable('akka__cluster__seed_nodes__0', 'akka.tcp//mySystem@localhost:4055')
+PS C:/> [Environment]::SetEnvironmentVariable('akka__cluster__seed_nodes__1', 'akka.tcp//mySystem@localhost:4056')
+PS C:/> [Environment]::SetEnvironmentVariable('akka__cluster__seed_node_timeout', '00:00:05')
+```
+
+Note that:
+
+1. All of the environment variable names are in lower case, except "System.Object" where it needs to preserve name capitalization.
+2. To set serialization binding via environment variable, you have to use "." (period) instead of "__" (double underscore), this might be problematic for some shell scripts and there is no way of getting around this.
+
+Example code:
+
+```csharp
+/*
+Both appsettings.json and environment variables are combined
+into HOCON configuration:
+
+akka {
+ "Key.With.Dots": Key Value
+ actor {
+ telemetry.enabled: on
+ serialization-bindings {
+ "System.Object" = hyperion
+ }
+ }
+ cluster {
+ roles: [ "front-end", "back-end" ]
+ seed-nodes: [
+ "akka.tcp//mySystem@localhost:4055",
+ "akka.tcp//mySystem@localhost:4056"
+ ]
+ min-nr-of-members: 3
+ seed-node-timeout: 5s
+ log-info: true
+ }
+}
+*/
+var host = new HostBuilder()
+ .ConfigureHostConfiguration(builder =>
+ {
+ // Setup IConfiguration to load from appsettings.json and
+ // environment variables
+ builder
+ .AddJsonFile("appsettings.json")
+ .AddEnvironmentVariables();
+ })
+ .ConfigureServices((context, services) =>
+ {
+ services.AddAkka("mySystem", (builder, provider) =>
+ {
+ // convert IConfiguration to HOCON
+ var akkaConfig = context.Configuration.GetSection("akka");
+ // Note the last method argument is set to false
+ builder.AddHocon(akkaConfig, HoconAddMode.Prepend, false);
+ });
+ });
+```
diff --git a/docs/articles/hosting/dependency-injection.md b/docs/articles/hosting/dependency-injection.md
new file mode 100644
index 00000000000..07139bc93d2
--- /dev/null
+++ b/docs/articles/hosting/dependency-injection.md
@@ -0,0 +1,134 @@
+---
+uid: hosting-dependency-injection
+title: Dependency Injection Outside and Inside Akka.NET
+---
+
+# Dependency Injection Outside and Inside Akka.NET
+
+One of the other design goals of Akka.Hosting is to make the dependency injection experience with Akka.NET as seamless as any other .NET technology. We accomplish this through two new APIs:
+
+* The `ActorRegistry`, a DI container that is designed to be populated with `Type`s for keys and `IActorRef`s for values, just like the `IServiceCollection` does for ASP.NET services.
+* The `IRequiredActor` - you can place this type the constructor of any dependency injected resource and it will automatically resolve a reference to the actor stored inside the `ActorRegistry` with `TKey`. This is how we inject actors into ASP.NET, SignalR, gRPC, and other Akka.NET actors!
+
+> [!NOTE]
+> The `ActorRegistry` and the `ActorSystem` are automatically registered with the `IServiceCollection` / `IServiceProvider` associated with your application.
+
+## Registering Actors with the ActorRegistry
+
+As part of Akka.Hosting, we need to provide a means of making it easy to pass around top-level `IActorRef`s via dependency injection both within the `ActorSystem` and outside of it.
+
+The `ActorRegistry` will fulfill this role through a set of generic, typed methods that make storage and retrieval of long-lived `IActorRef`s easy and coherent:
+
+* Fetch ActorRegistry from ActorSystem manually
+
+```csharp
+var registry = ActorRegistry.For(myActorSystem);
+```
+
+* Provided by the actor builder
+
+```csharp
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .WithActors((system, actorRegistry) =>
+ {
+ var actor = system.ActorOf(Props.Create(() => new MyActor));
+ actorRegistry.TryRegister(actor); // register actor for DI
+ });
+});
+```
+
+* Obtaining the `IActorRef` manually
+
+```csharp
+var registry = ActorRegistry.For(myActorSystem);
+registry.Get(); // use in DI
+```
+
+## Injecting Actors with `IRequiredActor`
+
+Suppose we have a class that depends on having a reference to a top-level actor, a router, a `ShardRegion`, or perhaps a `ClusterSingleton` (common types of actors that often interface with non-Akka.NET parts of a .NET application):
+
+```csharp
+public sealed class MyConsumer
+{
+ private readonly IActorRef _actor;
+
+ public MyConsumer(IRequiredActor actor)
+ {
+ _actor = actor.ActorRef;
+ }
+
+ public async Task Say(string word)
+ {
+ return await _actor.Ask(word, TimeSpan.FromSeconds(3));
+ }
+}
+```
+
+The `IRequiredActor` will cause the Microsoft.Extensions.DependencyInjection mechanism to resolve `MyActorType` from the `ActorRegistry` and inject it into the `IRequired` instance passed into `MyConsumer`.
+
+The `IRequiredActor` exposes a single property:
+
+```csharp
+public interface IRequiredActor
+{
+ ///
+ /// The underlying actor resolved via using the given key.
+ ///
+ IActorRef ActorRef { get; }
+}
+```
+
+By default, you can automatically resolve any actors registered with the `ActorRegistry` without having to declare anything special on your `IServiceCollection`:
+
+```csharp
+using var host = new HostBuilder()
+ .ConfigureServices(services =>
+ {
+ services.AddAkka("MySys", (builder, provider) =>
+ {
+ builder.WithActors((system, registry) =>
+ {
+ var actor = system.ActorOf(Props.Create(() => new MyActorType()), "myactor");
+ registry.Register(actor);
+ });
+ });
+ services.AddScoped();
+ })
+ .Build();
+ await host.StartAsync();
+```
+
+Adding your actor and your type key into the `ActorRegistry` is sufficient - no additional DI registration is required to access the `IRequiredActor` for that type.
+
+## Resolving `IRequiredActor` Within Akka.NET
+
+Akka.NET does not use dependency injection to start actors by default primarily because actor lifetime is unbounded by default - this means reasoning about the scope of injected dependencies isn't trivial. ASP.NET, by contrast, is trivial: all HTTP requests are request-scoped and all web socket connections are connection-scoped - these are objects have *bounded* and typically short lifetimes.
+
+Therefore, users have to explicitly signal when they want to use Microsoft.Extensions.DependencyInjection via [the `IDependencyResolver` interface in Akka.DependencyInjection](xref:dependency-injection) - which is easy to do in most of the Akka.Hosting APIs for starting actors:
+
+```csharp
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddScoped();
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .WithRemoting(hostname: "localhost", port: 8110)
+ .WithClustering(new ClusterOptions{SeedNodes = new []{ "akka.tcp://MyActorSystem@localhost:8110", }})
+ .WithShardRegion(
+ typeName: "myRegion",
+ entityPropsFactory: (_, _, resolver) =>
+ {
+ // uses DI to inject `IReplyGenerator` into EchoActor
+ return s => resolver.Props(s);
+ },
+ extractEntityId: ExtractEntityId,
+ extractShardId: ExtractShardId,
+ shardOptions: new ShardOptions());
+});
+```
+
+The `dependencyResolver.Props()` call will leverage the `ActorSystem`'s built-in `IDependencyResolver` to instantiate the `MySingletonDiActor` and inject it with all of the necessary dependencies, including `IRequiredActor`.
diff --git a/docs/articles/hosting/health-checks.md b/docs/articles/hosting/health-checks.md
new file mode 100644
index 00000000000..9030986d98d
--- /dev/null
+++ b/docs/articles/hosting/health-checks.md
@@ -0,0 +1,173 @@
+---
+uid: hosting-health-checks
+title: Microsoft.Extensions.Diagnostics.HealthChecks Integration
+---
+
+# Microsoft.Extensions.Diagnostics.HealthChecks Integration
+
+We've recently deprecated [Akka.HealthChecks](https://github.com/petabridge/akkadotnet-healthcheck) in favor of a simpler, more configurable solution that is built directly into Akka.Hosting: `IAkkaHealthCheck` and `WithAkkaHealthCheck`:
+
+```csharp
+ builder
+ .WithActorSystemLivenessCheck() // have to opt-in to the built-in health check
+ .WithHealthCheck("FooActor alive", async (system, registry, cancellationToken) =>
+{
+ /*
+ * N.B. CancellationToken is set by the call to MSFT.EXT.DIAGNOSTICS.HEALTHCHECK,
+ * so that value could be "infinite" by default.
+ *
+ * Therefore, it might be a really, really good idea to guard this with a non-infinite
+ * timeout via a LinkedCancellationToken here.
+ */
+ try
+ {
+ var fooActor = await registry.GetAsync(cancellationToken);
+
+ try
+ {
+ var r = await fooActor.Ask(new Identify("foo"), cancellationToken: cancellationToken);
+ if (r.Subject.IsNobody())
+ return HealthCheckResult.Unhealthy("FooActor was alive but is now dead");
+ }
+ catch (Exception e)
+ {
+ return HealthCheckResult.Degraded("FooActor found but non-responsive", e);
+ }
+ }
+ catch (Exception e2)
+ {
+ return HealthCheckResult.Unhealthy("FooActor not found in registry", e2);
+ }
+
+ return HealthCheckResult.Healthy("fooActor found and responsive");
+});
+```
+
+These health checks and any other you register using one of the `WithHealthCheck` overloads on the `AkkaConfigurationBuilder` will automatically be registered with the [`Microsoft.Extensions.Diagnostics.HealthCheckService`](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks) and will be called just like any other ASP.NET Core, Entity Framework, etc health check.
+
+## Dependency Injected Health Checks
+
+As of version 1.5.51, Akka.Hosting supports dependency injection for health checks. You can create custom health check classes that implement `IAkkaHealthCheck` and have dependencies injected from the DI container:
+
+```csharp
+// Define a custom health check with DI support
+public class MyHealthCheckWithDependencies : IAkkaHealthCheck
+{
+ private readonly ILogger _logger;
+ private readonly IMyService _myService;
+
+ public MyHealthCheckWithDependencies(
+ ILogger logger,
+ IMyService myService)
+ {
+ _logger = logger;
+ _myService = myService;
+ }
+
+ public async Task CheckHealthAsync(
+ AkkaHealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Running health check with DI");
+ var isHealthy = await _myService.CheckServiceHealthAsync(cancellationToken);
+
+ return isHealthy
+ ? HealthCheckResult.Healthy("Service is healthy")
+ : HealthCheckResult.Unhealthy("Service is not healthy");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Health check failed");
+ return HealthCheckResult.Unhealthy($"Health check failed: {ex.Message}");
+ }
+ }
+}
+
+// Register the health check using the generic WithHealthCheck() method
+builder
+ .WithActorSystemLivenessCheck()
+ .WithHealthCheck(
+ name: "MyServiceHealth",
+ failureStatus: HealthStatus.Degraded,
+ tags: new[] { "ready", "service" },
+ timeout: TimeSpan.FromSeconds(5));
+```
+
+The health check type will be resolved from the DI container when the health check is executed, allowing you to leverage constructor injection for any dependencies your health check needs. The health check instance itself doesn't need to be registered in DI - Akka.Hosting will automatically resolve it using `ActivatorUtilities.GetServiceOrCreateInstance()`.
+
+## Built-in Health Checks
+
+> [!NOTE]
+> All Akka.NET health checks will be tagged with the `akka` tag, so they [can easily be filtered via the health check endpoints](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-9.0#filter-health-checks).
+
+Akka.Hosting and its other packages ship with some built-in health checks:
+
+* `WithActorSystemLivenessCheck()` - a liveness probe that will fail if the `ActorSystem` is terminated. Generally, Akka.Hosting will try to shut down your process anyway if the `ActorSystem` dies.
+* `WithAkkaClusterReadinessCheck` - if you are an Akka.Cluster user, this health check will return `HealthStatus.Unhealthy` until you successfully join a cluster - that way you can stop load-balancers and other devices from routing traffic to this node until it has access to the cluster. This readiness check is also tagged with the `ready` tag for filtering purposes.
+* **Akka.Persistence Health Checks** - verify that persistence plugins (journals and snapshot stores) are properly initialized and accessible. These health checks use the built-in Akka.Persistence health check APIs to validate plugin connectivity and functionality. Health checks are tagged with `akka`, `persistence`, and either `journal` or `snapshot-store` for filtering purposes.
+
+### Configuring Persistence Health Checks
+
+You can add health checks for your persistence plugins using the `.WithHealthCheck()` method when configuring journals and snapshot stores:
+
+```csharp
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ // Journal with health check
+ .WithJournal(
+ new SqlServerJournalOptions
+ {
+ ConnectionString = "...",
+ IsDefaultPlugin = true
+ },
+ journal => journal
+ .AddWriteEventAdapter("adapter", new[] { typeof(MyEvent) })
+ .WithHealthCheck(
+ unHealthyStatus: HealthStatus.Degraded,
+ name: "sql-journal"))
+
+ // Snapshot store with health check
+ .WithSnapshot(
+ new SqlServerSnapshotOptions
+ {
+ ConnectionString = "...",
+ IsDefaultPlugin = true
+ },
+ snapshot => snapshot
+ .WithHealthCheck(
+ unHealthyStatus: HealthStatus.Degraded,
+ name: "sql-snapshot"));
+});
+```
+
+You can also configure both journal and snapshot health checks together:
+
+```csharp
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .WithJournalAndSnapshot(
+ new SqlServerJournalOptions
+ {
+ ConnectionString = "...",
+ IsDefaultPlugin = true
+ },
+ new SqlServerSnapshotOptions
+ {
+ ConnectionString = "...",
+ IsDefaultPlugin = true
+ },
+ journal => journal.WithHealthCheck(),
+ snapshot => snapshot.WithHealthCheck());
+});
+```
+
+The health checks will automatically:
+
+* Verify the persistence plugin is configured correctly
+* Test connectivity to the underlying storage (database, cloud storage, etc.)
+* Report `Healthy` when the plugin is operational
+* Report `Degraded` or `Unhealthy` (configurable) when issues are detected
diff --git a/docs/articles/hosting/index.md b/docs/articles/hosting/index.md
new file mode 100644
index 00000000000..553f55c017d
--- /dev/null
+++ b/docs/articles/hosting/index.md
@@ -0,0 +1,116 @@
+---
+uid: akka-hosting
+title: Akka.Hosting
+---
+
+# Akka.Hosting
+
+Akka.Hosting provides HOCON-less configuration, application lifecycle management, `ActorSystem` startup, and actor instantiation for [Akka.NET](https://getakka.net/).
+
+See the ["Introduction to Akka.Hosting - HOCON-less, "Pit of Success" Akka.NET Runtime and Configuration" video](https://www.youtube.com/watch?v=Mnb9W9ClnB0) for a walkthrough of the library and how it can save you a tremendous amount of time and trouble.
+
+> [!NOTE]
+> As of this release, the Akka.Hosting packages ship from the main [akka.net](https://github.com/akkadotnet/akka.net) repository and are versioned identically to the rest of Akka.NET. Previously they lived in the separate `akkadotnet/Akka.Hosting` repository.
+
+## What Akka.Hosting Ships
+
+* `Akka.Hosting` - the core package, needed for everything. Provides `AkkaConfigurationBuilder`, the `ActorRegistry`, and `IRequiredActor`.
+* `Akka.Remote.Hosting` - enables Akka.Remote configuration.
+* `Akka.Cluster.Hosting` - used for Akka.Cluster, Akka.Cluster.Sharding, and Akka.Cluster.Tools.
+* `Akka.Persistence.Hosting` - used for adding persistence functionality, including local database-less testing.
+* `Akka.Hosting.TestKit` - a `Microsoft.Extensions.Hosting`-based TestKit for writing tests against `AkkaConfigurationBuilder`-configured `ActorSystem`s.
+* `Akka.Hosting.TestKit.Xunit2` - xUnit 2 bindings for `Akka.Hosting.TestKit`.
+
+## Getting Started
+
+Install the `Akka.Hosting` package, and any of the extension packages you need, from NuGet:
+
+```console
+PS> Install-Package Akka.Hosting
+```
+
+Then configure your `ActorSystem` using the `AddAkka` extension method on `IServiceCollection`:
+
+```csharp
+using Akka.Hosting;
+using Akka.Actor;
+using Akka.Actor.Dsl;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .WithActors((system, registry) =>
+ {
+ var echo = system.ActorOf(act =>
+ {
+ act.ReceiveAny((o, context) =>
+ {
+ context.Sender.Tell($"{context.Self} rcv {o}");
+ });
+ }, "echo");
+ registry.TryRegister(echo); // register for DI
+ });
+});
+
+var app = builder.Build();
+
+app.MapGet("/", async (context) =>
+{
+ var echo = context.RequestServices.GetRequiredService().Get();
+ var body = await echo.Ask(context.TraceIdentifier, context.RequestAborted).ConfigureAwait(false);
+ await context.Response.WriteAsync(body);
+});
+
+app.Run();
+```
+
+No HOCON is required. Akka.Hosting automatically runs all Akka.NET application lifecycle best practices behind the scenes, and binds the `ActorSystem` and the `ActorRegistry` to the `IServiceCollection` so they can be safely consumed both by actors and by non-Akka.NET parts of your .NET application.
+
+To learn more, see:
+
+* [The `AkkaConfigurationBuilder` API](xref:hosting-configuration-builder)
+* [Dependency Injection Outside and Inside Akka.NET](xref:hosting-dependency-injection)
+* [Microsoft.Extensions.Configuration Integration](xref:hosting-configuration)
+* [Microsoft.Extensions.Logging Integration](xref:hosting-logging)
+* [OpenTelemetry Trace Correlation](xref:hosting-opentelemetry)
+* [Microsoft.Extensions.Diagnostics.HealthChecks Integration](xref:hosting-health-checks)
+
+## Supported Packages
+
+### Akka.NET Core Packages
+
+* `Akka.Hosting` - the core `Akka.Hosting` package, needed for everything
+* `Akka.Remote.Hosting` - enables Akka.Remote configuration
+* `Akka.Cluster.Hosting` - used for Akka.Cluster, Akka.Cluster.Sharding, and Akka.Cluster.Tools
+* `Akka.Persistence.Hosting` - used for adding persistence functionality to perform local database-less testing
+
+### Akka Persistence Plugins
+
+* [`Akka.Persistence.SqlServer.Hosting`](https://github.com/akkadotnet/Akka.Persistence.SqlServer/tree/dev/src/Akka.Persistence.SqlServer.Hosting) - used for Akka.Persistence.SqlServer support. Documentation can be read [here](https://github.com/akkadotnet/Akka.Persistence.SqlServer/blob/dev/src/Akka.Persistence.SqlServer.Hosting/README.md)
+* [`Akka.Persistence.PostgreSql.Hosting`](https://github.com/akkadotnet/Akka.Persistence.PostgreSql/tree/dev/src/Akka.Persistence.PostgreSql.Hosting) - used for Akka.Persistence.PostgreSql support. Documentation can be read [here](https://github.com/akkadotnet/Akka.Persistence.PostgreSql/blob/dev/src/Akka.Persistence.PostgreSql.Hosting/README.md)
+* [`Akka.Persistence.Azure.Hosting`](https://github.com/petabridge/Akka.Persistence.Azure) - used for Akka.Persistence.Azure support. Documentation can be read [here](https://github.com/petabridge/Akka.Persistence.Azure/blob/master/README.md)
+
+### Akka.Management Plugins
+
+Useful tools for managing Akka.NET clusters running inside containerized or cloud based environment. `Akka.Hosting` is embedded in each of its packages. See the [Akka.Management GitHub repository](https://github.com/akkadotnet/Akka.Management) for the full list.
+
+#### Akka.Management Core Package
+
+* [`Akka.Management`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/management/Akka.Management) - core module of the management utilities which provides a central HTTP endpoint for Akka management extensions. Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/tree/dev/src/management/Akka.Management#akka-management)
+* `Akka.Management.Cluster.Bootstrap` - used to bootstrap a cluster formation inside dynamic deployment environments. Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/tree/dev/src/management/Akka.Management#akkamanagementclusterbootstrap)
+
+ > [!NOTE]
+ > As of version 1.0.0, cluster bootstrap came bundled inside the core `Akka.Management` NuGet package and are part of the default HTTP endpoint for `Akka.Management`. All `Akka.Management.Cluster.Bootstrap` NuGet package versions below 1.0.0 should now be considered deprecated.
+
+#### Akka.Discovery Plugins
+
+* [`Akka.Discovery.AwsApi`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/discovery/aws/Akka.Discovery.AwsApi) - provides dynamic node discovery service for AWS EC2 environment. Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/aws/Akka.Discovery.AwsApi/README.md)
+* [`Akka.Discovery.Azure`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/discovery/azure/Akka.Discovery.Azure) - provides a dynamic node discovery service for Azure PaaS ecosystem. Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/azure/Akka.Discovery.Azure/README.md)
+* [`Akka.Discovery.KubernetesApi`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/discovery/kubernetes/Akka.Discovery.KubernetesApi) - provides a dynamic node discovery service for Kubernetes clusters. Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/kubernetes/Akka.Discovery.KubernetesApi/README.md)
+
+#### Akka.Coordination Plugins
+
+* [`Akka.Coordination.KubernetesApi`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/kubernetes/Akka.Coordination.KubernetesApi) - provides a lease-based distributed lock mechanism backed by [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) for [Akka.NET Split Brain Resolver](xref:split-brain-resolver), [Akka.Cluster.Sharding](xref:cluster-sharding), and [Akka.Cluster.Singleton](xref:cluster-singleton). Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/coordination/kubernetes/Akka.Coordination.KubernetesApi/README.md)
+* [`Akka.Coordination.Azure`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/azure/Akka.Coordination.Azure) - provides a lease-based distributed lock mechanism backed by [Microsoft Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) for [Akka.NET Split Brain Resolver](xref:split-brain-resolver), [Akka.Cluster.Sharding](xref:cluster-sharding), and [Akka.Cluster.Singleton](xref:cluster-singleton). Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/coordination/azure/Akka.Coordination.Azure/README.md)
diff --git a/docs/articles/hosting/logging.md b/docs/articles/hosting/logging.md
new file mode 100644
index 00000000000..898f2e189e8
--- /dev/null
+++ b/docs/articles/hosting/logging.md
@@ -0,0 +1,164 @@
+---
+uid: hosting-logging
+title: Microsoft.Extensions.Logging Integration
+---
+
+# Microsoft.Extensions.Logging Integration
+
+## Logger Configuration Support
+
+You can use `AkkaConfigurationBuilder` extension method called `ConfigureLoggers(Action)` to configure how Akka.NET logger behave.
+
+Example:
+
+```csharp
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .ConfigureLoggers(setup =>
+ {
+ // Example: This sets the minimum log level
+ setup.LogLevel = LogLevel.DebugLevel;
+
+ // Example: Clear all loggers
+ setup.ClearLoggers();
+
+ // Example: Add the default logger
+ // NOTE: You can also use setup.AddLogger();
+ setup.AddDefaultLogger();
+
+ // Example: Add the ILoggerFactory logger
+ // NOTE:
+ // - You can also use setup.AddLogger();
+ // - To use a specific ILoggerFactory instance, you can use setup.AddLoggerFactory(myILoggerFactory);
+ setup.AddLoggerFactory();
+
+ // Example: Adding a serilog logger
+ setup.AddLogger();
+ })
+ .WithActors((system, registry) =>
+ {
+ var echo = system.ActorOf(act =>
+ {
+ act.ReceiveAny((o, context) =>
+ {
+ Logging.GetLogger(context.System, "echo").Info($"Actor received {o}");
+ context.Sender.Tell($"{context.Self} rcv {o}");
+ });
+ }, "echo");
+ registry.TryRegister(echo); // register for DI
+ });
+});
+```
+
+A complete code sample can be viewed [here](https://github.com/akkadotnet/akka.net/tree/dev/src/examples/Hosting/Akka.Hosting.LoggingDemo).
+
+Exposed properties are:
+
+* `LogLevel`: Configure the Akka.NET minimum log level filter, defaults to `InfoLevel`
+* `LogConfigOnStart`: When set to true, Akka.NET will log the complete HOCON settings it is using at start up, this can then be used for debugging purposes.
+
+Currently supported logger methods:
+
+* `ClearLoggers()`: Clear all registered logger types.
+* `AddLogger()`: Add a logger type by providing its class type.
+* `AddDefaultLogger()`: Add the default Akka.NET console logger.
+* `AddLoggerFactory()`: Add the new `ILoggerFactory` logger.
+
+## Microsoft.Extensions.Logging.ILoggerFactory Logging Support
+
+You can now use `ILoggerFactory` from Microsoft.Extensions.Logging as one of the sinks for Akka.NET logger. This logger will use the `ILoggerFactory` service set up inside the dependency injection `ServiceProvider` as its sink.
+
+## Serilog Message Formatting Support
+
+If you're interested in using [Akka.Logger.Serilog](https://github.com/akkadotnet/Akka.Logger.Serilog), you can set Akka.NET's default logger and log message formatter to allow for Serilog's semantic logging to be enabled by default:
+
+```csharp
+builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+{
+ configurationBuilder
+ .ConfigureLoggers(setup =>
+ {
+ // Example: This sets the minimum log level
+ setup.LogLevel = LogLevel.DebugLevel;
+
+ // Example: Clear all loggers
+ setup.ClearLoggers();
+
+ // Add Serilog
+ setup.AddLogger();
+
+ // use the default SerilogFormatter everywhere
+ setup.WithDefaultLogMessageFormatter();
+ })
+ .WithActors((system, registry) =>
+ {
+ var echo = system.ActorOf(act =>
+ {
+ act.ReceiveAny((o, context) =>
+ {
+ Logging.GetLogger(context.System, "echo").Info($"Actor received {o}");
+ context.Sender.Tell($"{context.Self} rcv {o}");
+ });
+ }, "echo");
+ registry.TryRegister(echo); // register for DI
+ });
+});
+```
+
+This will eliminate the need to have to do `Context.GetLogger()` everywhere you want to use it.
+
+## Microsoft.Extensions.Logging Log Event Filtering
+
+There will be two log event filters acting on the final log input, the Akka.NET `akka.loglevel` setting and the `Microsoft.Extensions.Logging` settings, make sure that both are set correctly or some log messages will be missing.
+
+To set up the `Microsoft.Extensions.Logging` log filtering, you will need to edit the `appsettings.json` file. Note that we also set the `Akka` namespace to be filtered at debug level in the example below.
+
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "Akka": "Debug"
+ }
+ }
+}
+```
+
+## Filtering Logs in Akka.NET
+
+In Akka.NET 1.5.21, we introduced [log filtering for log messages based on the LogSource or the content of a log message](xref:logging#filtering-log-messages). Depending on your coding style, you can use this feature in Akka.Hosting in several ways.
+
+1. Using the `LoggerConfigBuilder.WithLogFilter()` method.
+
+ The `LoggerConfigBuilder.WithLogFilter()` method lets you set up the `LogFilterBuilder`
+
+ ```csharp
+ builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+ {
+ configurationBuilder
+ .ConfigureLoggers(loggerConfigBuilder =>
+ {
+ loggerConfigBuilder.WithLogFilter(filterBuilder =>
+ {
+ filterBuilder.ExcludeMessageContaining("Test");
+ });
+ });
+ });
+ ```
+
+2. Setting the `loggerConfigBuilder.LogFilterBuilder` property directly.
+
+ ```csharp
+ builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
+ {
+ configurationBuilder
+ .ConfigureLoggers(loggerConfigBuilder =>
+ {
+ loggerConfigBuilder.LogFilterBuilder = new LogFilterBuilder();
+ loggerConfigBuilder.LogFilterBuilder.ExcludeMessageContaining("Test");
+ });
+ });
+ ```
diff --git a/docs/articles/hosting/opentelemetry.md b/docs/articles/hosting/opentelemetry.md
new file mode 100644
index 00000000000..06f557fd8bc
--- /dev/null
+++ b/docs/articles/hosting/opentelemetry.md
@@ -0,0 +1,41 @@
+---
+uid: hosting-opentelemetry
+title: OpenTelemetry Trace Correlation
+---
+
+# OpenTelemetry Trace Correlation
+
+Akka.NET processes log events asynchronously, which means `Activity.Current` does not flow across actor mailbox boundaries. To preserve trace correlation, Akka.Hosting captures the `ActivityContext` at log creation time and includes it in the log state. The `AkkaTraceContextProcessor` then applies that context to OpenTelemetry `LogRecord`s so exporters can correlate logs with traces.
+
+Minimal setup:
+
+```csharp
+using Akka.Hosting;
+using Akka.Hosting.Logging;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Resources;
+
+builder.Logging.AddOpenTelemetry(options =>
+{
+ options.SetResourceBuilder(ResourceBuilder.CreateDefault()
+ .AddService("my-service"));
+
+ // Register before exporters
+ options.AddAkkaTraceCorrelation();
+
+ // Add OTLP exporter if you have not configured it elsewhere.
+ // Your mileage may vary; use the OpenTelemetry configuration that fits your app.
+ options.AddOtlpExporter();
+});
+
+builder.Services.AddAkka("MySystem", configBuilder =>
+{
+ configBuilder.ConfigureLoggers(setup =>
+ {
+ setup.ClearLoggers();
+ setup.AddLoggerFactory();
+ });
+});
+```
+
+See the demo projects under [`src/examples/Hosting`](https://github.com/akkadotnet/akka.net/tree/dev/src/examples/Hosting) in the Akka.NET repository for a working Aspire setup.
diff --git a/docs/articles/hosting/toc.yml b/docs/articles/hosting/toc.yml
new file mode 100644
index 00000000000..57a25ad9311
--- /dev/null
+++ b/docs/articles/hosting/toc.yml
@@ -0,0 +1,14 @@
+- name: Overview
+ href: index.md
+- name: The AkkaConfigurationBuilder API
+ href: configuration-builder.md
+- name: Dependency Injection Outside and Inside Akka.NET
+ href: dependency-injection.md
+- name: Microsoft.Extensions.Configuration Integration
+ href: configuration.md
+- name: Microsoft.Extensions.Logging Integration
+ href: logging.md
+- name: OpenTelemetry Trace Correlation
+ href: opentelemetry.md
+- name: Microsoft.Extensions.Diagnostics.HealthChecks Integration
+ href: health-checks.md
diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml
index e38eaea8856..8bf0f1a2f38 100644
--- a/docs/articles/toc.yml
+++ b/docs/articles/toc.yml
@@ -6,6 +6,8 @@
href: concepts/toc.yml
- name: Actors
href: actors/toc.yml
+- name: Hosting
+ href: hosting/toc.yml
- name: Persistence
href: persistence/toc.yml
- name: Streams
diff --git a/docs/articles/utilities/logging.md b/docs/articles/utilities/logging.md
index 08aed01f80c..55187af5bb3 100644
--- a/docs/articles/utilities/logging.md
+++ b/docs/articles/utilities/logging.md
@@ -158,7 +158,7 @@ These loggers are also available as separate nuget packages
* **Akka.Logger.Serilog** which logs using [serilog](http://serilog.net/). See [Detailed instructions on using Serilog](xref:serilog).
* **Akka.Logger.NLog** which logs using [NLog](http://nlog-project.org/)
-* **Microsoft.Extensions.Logging** - which is [built into Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting#microsoftextensionslogging-integration).
+* **Microsoft.Extensions.Logging** - which is [built into Akka.Hosting](xref:hosting-logging).
Note that you need to modify the config as explained below.
@@ -184,7 +184,7 @@ akka {
}
```
-Or using [Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting), you can configure loggers programmatically using strongly typed references to the underlying logging classes:
+Or using [Akka.Hosting](xref:hosting-logging), you can configure loggers programmatically using strongly typed references to the underlying logging classes:
```csharp
builder.Services.AddAkka("MyActorSystem", configurationBuilder =>
diff --git a/docs/cSpell.json b/docs/cSpell.json
index 4766770c600..99cd2166332 100644
--- a/docs/cSpell.json
+++ b/docs/cSpell.json
@@ -60,6 +60,7 @@
"overridable",
"parallelizable",
"partitioner",
+ "parseable",
"Passivation",
"Petabridge",
"pluggable",
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj
new file mode 100644
index 00000000000..2a7f6815738
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+
+ $(NetTestVersion)
+ Exe
+ false
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterClientDiscoverySpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterClientDiscoverySpecs.cs
new file mode 100644
index 00000000000..43192e44a25
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterClientDiscoverySpecs.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Actor.Setup;
+using Akka.Cluster.Tools.Client;
+using Akka.Configuration;
+using Akka.Discovery;
+using Akka.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterClientDiscoverySpecs
+{
+ [Fact(DisplayName = "Empty ClusterClientDiscoveryOptions should generate default values")]
+ public void EmptyClusterClientDiscoverySpec()
+ {
+ var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test");
+ var options = new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = "whatever"
+ };
+ builder.ApplyClusterClientDiscovery(options);
+
+ var defaultConfig = ClusterClientReceptionist.DefaultConfig().GetConfig("akka.cluster.client");
+ var systemConfig = builder.Configuration.Value;
+ var config = ConfigurationFactory.ParseString(options.ToString())
+ .WithFallback(systemConfig.GetConfig("akka.cluster.client"));
+
+ Assert.True(config.GetBoolean("use-initial-contacts-discovery"));
+ Assert.Equal(ConfigServiceDiscoveryOptions.DefaultPath, config.GetString("discovery.method"));
+ Assert.Equal("whatever", config.GetString("discovery.service-name"));
+
+ defaultConfig.AssertSameString(config, "discovery.port-name");
+ defaultConfig.AssertSameInt(config, "discovery.number-of-contacts");
+ defaultConfig.AssertSameTimeSpan(config, "discovery.interval");
+ defaultConfig.AssertSameTimeSpan(config, "discovery.resolve-timeout");
+
+ Assert.Equal("config", systemConfig.GetString("akka.discovery.method"));
+ }
+
+ [Fact(DisplayName = "ClusterClientDiscoverySettings should be set correctly")]
+ public void ClusterClientDiscoverySettingsSpec()
+ {
+ var options = new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions
+ {
+ ConfigPath = "custom",
+ IsDefaultPlugin = false,
+ Services = new List
+ {
+ new ()
+ {
+ Name = "testService",
+ Endpoints = new[] { "ep1", "ep2" }
+ }
+ }
+ },
+ ServiceName = "testService",
+ PortName = "testPort",
+ Timeout = 1.Seconds(),
+ RetryInterval = 2.Seconds(),
+ NumberOfContacts = 10
+ };
+
+ var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test");
+ builder.ApplyClusterClientDiscovery(options);
+
+ var systemConfig = builder.Configuration.Value;
+ var config = ConfigurationFactory.ParseString(options.ToString())
+ .WithFallback(systemConfig.GetConfig("akka.cluster.client"));
+ var settings = ClusterClientSettings.Create(config);
+
+ Assert.Equal("custom", config.GetString("discovery.method"));
+ Assert.True(config.GetBoolean("use-initial-contacts-discovery"));
+ Assert.Empty(settings.InitialContacts);
+ Assert.Equal("testService", settings.DiscoverySettings.ServiceName);
+ Assert.Equal("testPort", settings.DiscoverySettings.PortName);
+ Assert.Equal(1.Seconds(), settings.DiscoverySettings.ResolveTimeout);
+ Assert.Equal(2.Seconds(), settings.DiscoverySettings.Interval);
+ Assert.Equal(10, settings.DiscoverySettings.NumberOfContacts);
+
+ Assert.Equal("", systemConfig.GetString("akka.discovery.method"));
+
+ var discoveryConfig = systemConfig.GetConfig("akka.discovery.custom");
+ Assert.NotNull(discoveryConfig);
+ Assert.Equal("akka.discovery.custom.services", discoveryConfig.GetString("services-path"));
+ Assert.NotNull(discoveryConfig.GetConfig("services"));
+ }
+
+ [Fact(DisplayName = "ClusterClientDiscoverySettings with invalid values should throw")]
+ public void ClusterClientDiscoveryInvalidSettingsSpec()
+ {
+ var ex = Assert.Throws(() => new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = null!
+ }.ToString());
+ Assert.StartsWith("Service name must be provided", ex.Message);
+
+
+ ex = Assert.Throws(() => new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = string.Empty
+ }.ToString());
+ Assert.StartsWith("Service name must be provided", ex.Message);
+
+ ex = Assert.Throws(() => new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = "whatever",
+ Timeout = Timeout.InfiniteTimeSpan
+ }.ToString());
+ Assert.StartsWith("Timeout must be greater than zero", ex.Message);
+
+ ex = Assert.Throws(() => new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = "whatever",
+ NumberOfContacts = 0
+ }.ToString());
+ Assert.StartsWith("Number of contacts must be greater than zero", ex.Message);
+
+ ex = Assert.Throws(() => new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = "whatever",
+ ClientActorName = string.Empty
+ }.ToString());
+ Assert.StartsWith("Cluster client actor name must not be empty or whitespace", ex.Message);
+
+ ex = Assert.Throws(() => new ClusterClientDiscoveryOptions
+ {
+ DiscoveryOptions = new ConfigServiceDiscoveryOptions(),
+ ServiceName = "whatever",
+ ClientActorName = " "
+ }.ToString());
+ Assert.StartsWith("Cluster client actor name must not be empty or whitespace", ex.Message);
+ }
+
+ private class ConfigServiceDiscoveryOptions: IDiscoveryOptions
+ {
+ internal const string DefaultPath = "config";
+ internal const string DefaultConfigPath = "akka.discovery." + DefaultPath;
+ public static string FullPath(string path) => $"akka.discovery.{path}";
+
+ public string ConfigPath { get; set; } = DefaultPath;
+
+ public Type Class { get; } = typeof(ConfigServiceDiscovery);
+
+ public List Services { get; set; } = new ();
+ public bool IsDefaultPlugin { get; set; } = true;
+
+ public void Apply(AkkaConfigurationBuilder builder, Setup? inputSetup = null)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"{FullPath(ConfigPath)} {{");
+ sb.AppendLine($"class = {Class.AssemblyQualifiedName!.ToHocon()}");
+ sb.AppendLine($"services-path = {FullPath(ConfigPath)}.services");
+
+ sb.AppendLine("services {");
+ foreach (var service in Services)
+ {
+ service.Apply(sb);
+ }
+ sb.AppendLine("}");
+
+ sb.AppendLine("}");
+
+ if(IsDefaultPlugin)
+ sb.AppendLine($"akka.discovery.method = {ConfigPath}");
+
+ builder.AddHocon(sb.ToString(), HoconAddMode.Prepend);
+
+ var fallback = DiscoveryProvider.DefaultConfiguration()
+ .GetConfig(DefaultConfigPath)
+ .MoveTo(FullPath(ConfigPath));
+ builder.AddHocon(fallback, HoconAddMode.Append);
+ }
+ }
+
+ private class Service
+ {
+ public string Name { get; set; } = string.Empty;
+ public string[] Endpoints { get; set; } = Array.Empty();
+
+ internal StringBuilder Apply(StringBuilder builder)
+ {
+ builder.AppendLine($"{Name} {{");
+ builder.AppendLine($"endpoints = [ { string.Join(",", Endpoints.Select(s => s.ToHocon()))} ]");
+ builder.AppendLine("}");
+
+ return builder;
+ }
+ }
+
+ public class ConfigServiceDiscovery : ServiceDiscovery
+ {
+ private const string DefaultPath = "config";
+ private const string DefaultConfigPath = "akka.discovery." + DefaultPath;
+
+ public ConfigServiceDiscovery(ExtendedActorSystem system, Config config)
+ {
+ }
+
+ public override Task Lookup(Lookup lookup, TimeSpan resolveTimeout)
+ {
+ return Task.FromResult(new Resolved(lookup.ServiceName, null));
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs
new file mode 100644
index 00000000000..2abf8a08630
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using Akka.Actor;
+using Akka.Cluster.Tools.Client;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterClientSpecs
+{
+ [Fact(DisplayName = "ClusterClientReceptionistSettings should be set correctly")]
+ public void ClusterClientReceptionistSettingsSpec()
+ {
+ var config = AkkaClusterHostingExtensions.CreateReceptionistConfig("customName", "customRole")
+ .GetConfig("akka.cluster.client.receptionist");
+ var settings = ClusterReceptionistSettings.Create(config);
+
+ Assert.Equal("customName", config.GetString("name"));
+ Assert.Equal("customRole", settings.Role);
+ }
+
+ [Fact(DisplayName = "ClusterClientSettings should be set correctly")]
+ public void ClusterClientSettingsSpec()
+ {
+ var contacts = new List
+ {
+ ActorPath.Parse("akka.tcp://one@localhost:1111/system/receptionist"),
+ ActorPath.Parse("akka.tcp://two@localhost:1111/system/receptionist"),
+ ActorPath.Parse("akka.tcp://three@localhost:1111/system/receptionist"),
+ };
+
+ var settings = AkkaClusterHostingExtensions.CreateClusterClientSettings(
+ ClusterClientReceptionist.DefaultConfig(),
+ contacts);
+
+ contacts.CollectionEquals(settings.InitialContacts);
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs
new file mode 100644
index 00000000000..fd4db96d190
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs
@@ -0,0 +1,174 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2009-2022 Lightbend Inc.
+// Copyright (C) 2013-2022 .NET Foundation
+//
+// -----------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Akka.Actor;
+using Akka.Cluster.Hosting.SBR;
+using Akka.Cluster.SBR;
+using Akka.Configuration;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterOptionsSpec
+{
+ [Fact(DisplayName = "Empty ClusterOptions should contain default HOCON values")]
+ public void EmptyClusterOptionsTest()
+ {
+ var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "")
+ .AddHocon(ConfigurationFactory.FromResource("Akka.Cluster.Configuration.Cluster.conf", typeof(Cluster).Assembly), HoconAddMode.Append)
+ .WithActorRefProvider(ProviderSelection.Cluster.Instance)
+ .BuildClusterHocon(new ClusterOptions());
+
+ Assert.True(builder.Configuration.HasValue);
+
+ var settings = new ClusterSettings(builder.Configuration.Value, "");
+
+ Assert.Empty(settings.Roles);
+ Assert.Equal(0, settings.AppVersion.CompareTo(Util.AppVersion.Create("assembly-version")));
+ Assert.Empty(settings.MinNrOfMembersOfRole);
+ Assert.Empty(settings.SeedNodes);
+ Assert.Equal(1, settings.MinNrOfMembers);
+ Assert.True(settings.LogInfo);
+ Assert.False(settings.LogInfoVerbose);
+ Assert.Equal(typeof(SplitBrainResolverProvider), settings.DowningProviderType);
+ Assert.Equal(1.Seconds(), settings.HeartbeatInterval);
+ Assert.Equal(1.Seconds(), settings.HeartbeatExpectedResponseAfter);
+ }
+
+ [Fact(DisplayName = "ClusterOptions should generate proper HOCON values")]
+ public void ClusterOptionsTest()
+ {
+ var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "")
+ .AddHocon(ConfigurationFactory.FromResource("Akka.Cluster.Configuration.Cluster.conf", typeof(Cluster).Assembly), HoconAddMode.Append)
+ .BuildClusterHocon(new ClusterOptions
+ {
+ Roles = new []{ "front-end", "back-end"},
+ MinimumNumberOfMembersPerRole = new Dictionary
+ {
+ ["back-end"] = 5
+ },
+ AppVersion = "1.0.0",
+ MinimumNumberOfMembers = 99,
+ SeedNodes = new [] { "akka.tcp://system@somewhere.com:9999" },
+ LogInfo = false,
+ LogInfoVerbose = true,
+ SplitBrainResolver = new KeepMajorityOption
+ {
+ Role = "back-end"
+ },
+ FailureDetector = new PhiAccrualFailureDetectorOptions
+ {
+ HeartbeatInterval = 1.1.Seconds(),
+ AcceptableHeartbeatPause = 1.1.Seconds(),
+ Threshold = 1.1,
+ MaxSampleSize = 1,
+ MinStandardDeviation = 1.1.Seconds(),
+ UnreachableNodesReaperInterval = 1.1.Seconds(),
+ ExpectedResponseAfter = 1.1.Seconds()
+ }
+ });
+
+ Assert.True(builder.Configuration.HasValue);
+ var settings = new ClusterSettings(builder.Configuration.Value, "");
+
+ new[] { "front-end", "back-end" }.CollectionEquals(settings.Roles);
+
+ Assert.Single(settings.MinNrOfMembersOfRole);
+ Assert.True(settings.MinNrOfMembersOfRole.ContainsKey("back-end"));
+ Assert.Equal(5, settings.MinNrOfMembersOfRole["back-end"]);
+
+ Assert.Equal(0, settings.AppVersion.CompareTo(Util.AppVersion.Create("1.0.0")));
+ Assert.Equal(new[] { Address.Parse("akka.tcp://system@somewhere.com:9999") }, settings.SeedNodes);
+ Assert.Equal(99, settings.MinNrOfMembers);
+ Assert.True(settings.LogInfo); // This is not intuitive, but LogInfo is defined as LogInfoVerbose || LogInfo in ClusterSettings
+ Assert.True(settings.LogInfoVerbose);
+ Assert.Equal(typeof(SplitBrainResolverProvider), settings.DowningProviderType);
+
+ var sbrConfig = builder.Configuration.Value.GetConfig("akka.cluster.split-brain-resolver");
+ Assert.Equal(SplitBrainResolverSettings.KeepMajorityName, sbrConfig.GetString("active-strategy"));
+ Assert.Equal("back-end", sbrConfig.GetString($"{SplitBrainResolverSettings.KeepMajorityName}.role"));
+
+ var detectorConfig = builder.Configuration.Value.GetConfig("akka.cluster.failure-detector");
+ Assert.Equal(1.1.Seconds(), detectorConfig.GetTimeSpan("heartbeat-interval"));
+ Assert.Equal(1.1.Seconds(), detectorConfig.GetTimeSpan("acceptable-heartbeat-pause"));
+ Assert.Equal(1.1, detectorConfig.GetDouble("threshold"));
+ Assert.Equal(1, detectorConfig.GetInt("max-sample-size"));
+ Assert.Equal(1.1.Seconds(), detectorConfig.GetTimeSpan("min-std-deviation"));
+ Assert.Equal(1.1.Seconds(), detectorConfig.GetTimeSpan("unreachable-nodes-reaper-interval"));
+ Assert.Equal(1.1.Seconds(), detectorConfig.GetTimeSpan("expected-response-after"));
+ }
+
+ [Fact(DisplayName = "ClusterOptions should be bindable using Microsoft.Extensions.Configuration")]
+ public void ClusterOptionsConfigurationTest()
+ {
+ const string json = @"
+{
+ ""Logging"": {
+ ""LogLevel"": {
+ ""Default"": ""Information"",
+ ""Microsoft.AspNetCore"": ""Warning""
+ }
+ },
+ ""ConnectionStrings"": {
+ ""sqlServerLocal"": ""Server=localhost,1533;Database=Akka;User Id=sa;Password=l0lTh1sIsOpenSource;"",
+ },
+ ""Akka"": {
+ ""ClusterOptions"": {
+ ""Roles"": [ ""front-end"", ""back-end"" ],
+ ""MinimumNumberOfMembersPerRole"" : {
+ ""back-end"" : 5
+ },
+ ""AppVersion"": ""1.0.0"",
+ ""MinimumNumberOfMembers"": 99,
+ ""SeedNodes"": [ ""akka.tcp://system@somewhere.com:9999"" ],
+ ""LogInfo"": false,
+ ""LogInfoVerbose"": true
+ },
+ ""KeepMajorityOption"": {
+ ""Role"" : ""back-end""
+ }
+ }
+}";
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
+ var jsonConfig = new ConfigurationBuilder().AddJsonStream(stream).Build();
+
+ var clusterOptions = jsonConfig.GetSection("Akka:ClusterOptions").Get()!;
+ clusterOptions.SplitBrainResolver = jsonConfig.GetSection("Akka:KeepMajorityOption").Get();
+
+ var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "")
+ .AddHocon(ConfigurationFactory.FromResource("Akka.Cluster.Configuration.Cluster.conf", typeof(Cluster).Assembly), HoconAddMode.Append)
+ .BuildClusterHocon(clusterOptions);
+
+ Assert.True(builder.Configuration.HasValue);
+ var settings = new ClusterSettings(builder.Configuration.Value, "");
+
+ new[] { "front-end", "back-end" }.CollectionEquals(settings.Roles);
+
+ Assert.Single(settings.MinNrOfMembersOfRole);
+ Assert.True(settings.MinNrOfMembersOfRole.ContainsKey("back-end"));
+ Assert.Equal(5, settings.MinNrOfMembersOfRole["back-end"]);
+
+ Assert.Equal(0, settings.AppVersion.CompareTo(Util.AppVersion.Create("1.0.0")));
+ Assert.Equal(new[] { Address.Parse("akka.tcp://system@somewhere.com:9999") }, settings.SeedNodes);
+ Assert.Equal(99, settings.MinNrOfMembers);
+ Assert.True(settings.LogInfo); // This is not intuitive, but LogInfo is defined as LogInfoVerbose || LogInfo in ClusterSettings
+ Assert.True(settings.LogInfoVerbose);
+ Assert.Equal(typeof(SplitBrainResolverProvider), settings.DowningProviderType);
+
+ var sbrConfig = builder.Configuration.Value.GetConfig("akka.cluster.split-brain-resolver");
+ Assert.Equal(SplitBrainResolverSettings.KeepMajorityName, sbrConfig.GetString("active-strategy"));
+ Assert.Equal("back-end", sbrConfig.GetString($"{SplitBrainResolverSettings.KeepMajorityName}.role"));
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterShardingDistributedDataSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterShardingDistributedDataSpecs.cs
new file mode 100644
index 00000000000..17fd09e7229
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterShardingDistributedDataSpecs.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.DistributedData;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterShardingDistributedDataSpecs: Akka.Hosting.TestKit.TestKit
+{
+ private const string ReplicatorName = "dDataReplicator";
+
+ public ClusterShardingDistributedDataSpecs(ITestOutputHelper output): base(output: output)
+ {
+ }
+
+ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
+ {
+ builder
+ .WithRemoting()
+ .WithClustering()
+ // Join the cluster during host startup (matching the other cluster specs) rather than in
+ // the test body, so cluster formation completes before the test body runs.
+ .WithActors(async (system, _) =>
+ {
+ var cluster = Cluster.Get(system);
+ await cluster.JoinAsync(cluster.SelfAddress);
+ })
+ .WithDistributedData(opt =>
+ {
+ opt.Name = ReplicatorName;
+ });
+ }
+
+ [Fact(DisplayName = "WithDistributedData should start DistributedData extension automatically")]
+ public async Task WithDistributedDataStartsAutomaticallyTest()
+ {
+ var cluster = Cluster.Get(Sys);
+ await AwaitAssertAsync(() =>
+ Assert.Equal(1, cluster.State.Members.Count(m => m.Status == MemberStatus.Up)),
+ interval: TimeSpan.FromMilliseconds(200),
+ duration: TimeSpan.FromSeconds(10));
+
+ var settings = ReplicatorSettings.Create(Sys);
+ var coordinatorName = settings.RestartReplicatorOnFailure ? $"{ReplicatorName}Supervisor" : ReplicatorName;
+
+ var actorSelection = Sys.ActorSelection(new RootActorPath(cluster.SelfAddress) / "user" / coordinatorName);
+
+ // Use a fresh TestProbe rather than TestActor: on Windows, TestActor can be a stale dead
+ // reference due to the startup race window between EnsureTestActorAliveAsync and the test body.
+ var probe = CreateTestProbe();
+ await AwaitAssertAsync(async () =>
+ {
+ actorSelection.Tell(new Identify("coordinator"), probe.Ref);
+ var identity = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(1));
+ Assert.NotNull(identity.Subject);
+ }, duration: TimeSpan.FromSeconds(10));
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs
new file mode 100644
index 00000000000..1e4066990a8
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Cluster.Hosting.Tests.Lease;
+using Akka.Cluster.Sharding;
+using Akka.Cluster.Tools.Singleton;
+using Akka.Configuration;
+using Akka.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterShardingSpecs
+{
+ public sealed class MyTopLevelActor : ReceiveActor
+ {
+ }
+
+ public sealed class MyEntityActor : ReceiveActor
+ {
+ public MyEntityActor(string entityId, IActorRef sourceRef)
+ {
+ EntityId = entityId;
+ SourceRef = sourceRef;
+
+ Receive(g => { Sender.Tell(EntityId); });
+ Receive(g => Sender.Tell(SourceRef));
+ }
+
+ public string EntityId { get; }
+
+ public IActorRef SourceRef { get; }
+
+ public sealed class GetId : IWithId
+ {
+ public GetId(string id)
+ {
+ Id = id;
+ }
+
+ public string Id { get; }
+ }
+
+ public sealed class GetSourceRef : IWithId
+ {
+ public GetSourceRef(string id)
+ {
+ Id = id;
+ }
+
+ public string Id { get; }
+ }
+ }
+
+ public interface IWithId
+ {
+ string Id { get; }
+ }
+
+ public sealed class Extractor : HashCodeMessageExtractor
+ {
+ public Extractor() : base(30)
+ {
+ }
+
+ public override string EntityId(object message)
+ {
+ if (message is IWithId withId)
+ return withId.Id;
+ return string.Empty;
+ }
+ }
+
+ public ClusterShardingSpecs(ITestOutputHelper output)
+ {
+ Output = output;
+ }
+
+ public ITestOutputHelper Output { get; }
+
+ [Fact]
+ public async Task Should_use_ActorRegistry_with_ShardRegion()
+ {
+ // arrange
+ using var host = await TestHelper.CreateHost(builder =>
+ {
+ builder.WithActors((system, registry) =>
+ {
+ var tLevel = system.ActorOf(Props.Create(() => new MyTopLevelActor()), "toplevel");
+ registry.Register(tLevel);
+ })
+ .WithShardRegion("entities", (system, registry) =>
+ {
+ var tLevel = registry.Get();
+ return s => Props.Create(() => new MyEntityActor(s, tLevel));
+ }, new Extractor(), new ShardOptions() { Role = "my-host", StateStoreMode = StateStoreMode.DData });
+ }, new ClusterOptions() { Roles = new[] { "my-host" } }, Output);
+
+ var actorSystem = host.Services.GetRequiredService();
+ var actorRegistry = ActorRegistry.For(actorSystem);
+ var shardRegion = actorRegistry.Get();
+
+ // act
+ // the region buffers until the coordinator singleton is up, which needs a formed cluster first
+ var id = await shardRegion.Ask(new MyEntityActor.GetId("foo"), TimeSpan.FromSeconds(30));
+ var sourceRef =
+ await shardRegion.Ask(new MyEntityActor.GetSourceRef("foo"), TimeSpan.FromSeconds(30));
+
+ // assert
+ Assert.Equal("foo", id);
+ Assert.Equal(actorRegistry.Get(), sourceRef);
+ }
+
+ [Fact(DisplayName = "ShardOptions with different values should generate valid ClusterShardSettings")]
+ public void ShardOptionsTest()
+ {
+ var settings1 = ToSettings(new ShardOptions
+ {
+ RememberEntities = true,
+ StateStoreMode = StateStoreMode.Persistence,
+ RememberEntitiesStore = RememberEntitiesStore.Eventsourced,
+ Role = "first",
+ PassivateIdleEntityAfter = 1.Seconds(),
+ SnapshotPluginId = "firstSnapshot",
+ JournalPluginId = "firstJournal",
+ LeaseImplementation = new TestLeaseOption(),
+ LeaseRetryInterval = 2.Seconds(),
+ ShardRegionQueryTimeout = 3.Seconds(),
+ });
+
+ Assert.True(settings1.RememberEntities);
+ Assert.Equal(StateStoreMode.Persistence, settings1.StateStoreMode);
+ Assert.Equal(RememberEntitiesStore.Eventsourced, settings1.RememberEntitiesStore);
+ Assert.Equal("first", settings1.Role);
+ Assert.Equal(1.Seconds(), settings1.PassivateIdleEntityAfter);
+ Assert.Equal("firstSnapshot", settings1.SnapshotPluginId);
+ Assert.Equal("firstJournal", settings1.JournalPluginId);
+ Assert.NotNull(settings1.LeaseSettings);
+ Assert.Equal("test-lease", settings1.LeaseSettings!.LeaseImplementation);
+ Assert.Equal(2.Seconds(), settings1.LeaseSettings.LeaseRetryInterval);
+ Assert.Equal(3.Seconds(), settings1.ShardRegionQueryTimeout);
+
+ var settings2 = ToSettings(new ShardOptions
+ {
+ RememberEntities = false,
+ StateStoreMode = StateStoreMode.DData,
+ RememberEntitiesStore = RememberEntitiesStore.DData,
+ Role = "second",
+ PassivateIdleEntityAfter = 4.Seconds(),
+ SnapshotPluginId = "secondSnapshot",
+ JournalPluginId = "secondJournal",
+ ShardRegionQueryTimeout = 5.Seconds(),
+ });
+
+ Assert.False(settings2.RememberEntities);
+ Assert.Equal(StateStoreMode.DData, settings2.StateStoreMode);
+ Assert.Equal(RememberEntitiesStore.DData, settings2.RememberEntitiesStore);
+ Assert.Equal("second", settings2.Role);
+ Assert.Equal(4.Seconds(), settings2.PassivateIdleEntityAfter);
+ Assert.Equal("secondJournal", settings2.JournalPluginId);
+ Assert.Equal("secondSnapshot", settings2.SnapshotPluginId);
+ Assert.Null(settings2.LeaseSettings);
+ Assert.Equal(5.Seconds(), settings2.ShardRegionQueryTimeout);
+ }
+
+ private static ClusterShardingSettings ToSettings(ShardOptions shardOptions)
+ {
+ var defaultConfig = ClusterSharding.DefaultConfig()
+ .WithFallback(DistributedData.DistributedData.DefaultConfig())
+ .WithFallback(ClusterSingleton.DefaultConfig());
+
+ var shardingConfig = ConfigurationFactory.ParseString(shardOptions.ToString())
+ .WithFallback(defaultConfig.GetConfig("akka.cluster.sharding"));
+ var coordinatorConfig = defaultConfig.GetConfig(
+ shardingConfig.GetString("coordinator-singleton"));
+
+ return ClusterShardingSettings.Create(shardingConfig, coordinatorConfig);
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs
new file mode 100644
index 00000000000..b9a5e87fab0
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs
@@ -0,0 +1,291 @@
+using System;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterSingletonSpecs
+{
+ public ClusterSingletonSpecs(ITestOutputHelper output)
+ {
+ Output = output;
+ }
+
+ public ITestOutputHelper Output { get; }
+
+ private class MySingletonActor : ReceiveActor
+ {
+ public static Props MyProps => Props.Create(() => new ClusterSingletonSpecs.MySingletonActor());
+
+ public MySingletonActor()
+ {
+ ReceiveAny(_ => Sender.Tell(_));
+ }
+ }
+
+ [Fact]
+ public async Task Should_launch_ClusterSingletonAndProxy()
+ {
+ // arrange
+ using var host = await TestHelper.CreateHost(
+ builder => { builder.WithSingleton("my-singleton", MySingletonActor.MyProps); },
+ new ClusterOptions(){ Roles = new[] { "my-host" }}, Output);
+
+ var registry = host.Services.GetRequiredService();
+ var singletonProxy = registry.Get();
+
+ // act
+
+ // verify round-trip to the singleton proxy and back
+ // the proxy buffers until the singleton exists, which needs the node to be Up and Oldest first
+ var respond = await singletonProxy.Ask("hit", TimeSpan.FromSeconds(30));
+
+ // assert
+ Assert.Equal("hit", respond);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "Should launch singleton manager and proxy at the appropriate path (no manager name, actor props)")]
+ public async Task ClusterSingletonAndProxyWithNoManagerNameTest()
+ {
+ using var host = await TestHelper.CreateHost(
+ builder =>
+ {
+ builder.WithSingleton(
+ singletonName: "my-singleton",
+ actorProps: MySingletonActor.MyProps);
+ },
+ new ClusterOptions
+ {
+ Roles = new[] { "my-host" }
+ }, Output);
+
+ var system = host.Services.GetRequiredService();
+ var registry = host.Services.GetRequiredService();
+ var singletonProxy = await registry.GetAsync();
+
+ var address = Cluster.Get(system).SelfAddress;
+ var expectedSingletonPath = new RootActorPath(address) / "user" / "my-singleton" / "my-singleton";
+ var singletonSelector = system.ActorSelection(expectedSingletonPath);
+
+ await AssertSingletonSelectionAsync(singletonSelector);
+
+ Assert.Equal("akka://TestSys/user/my-singleton-proxy", singletonProxy.Path.ToString());
+
+ await host.StopAsync();
+ }
+
+ private static async Task AssertSingletonSelectionAsync(ActorSelection singletonSelector)
+ {
+ var startTime = DateTime.UtcNow;
+ // The node has to join itself, be promoted to Up by the leader, become Oldest and only then
+ // start the singleton. On a busy CI agent that regularly takes more than a few seconds.
+ var timeout = TimeSpan.FromSeconds(30);
+ await Test();
+ return;
+
+ async Task Test()
+ {
+ // might take multiple tries to resolve the singleton if it hasn't been created yet
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ try
+ {
+ var identify = await singletonSelector.ResolveOne(250.Milliseconds());
+ Assert.NotEqual(ActorRefs.Nobody, identify);
+ return;
+ }
+ catch (Exception)
+ {
+ // not there yet; back off briefly and try again
+ await Task.Delay(100.Milliseconds());
+ }
+ }
+
+ throw new AskTimeoutException("Failed to resolve singleton within timeout");
+ }
+ }
+
+ [Fact(DisplayName = "Should launch singleton manager and proxy at the appropriate path (no manager name, actor factory)")]
+ public async Task ClusterSingletonAndProxyWithNoManagerNameAndFactoryTest()
+ {
+ using var host = await TestHelper.CreateHost(
+ builder =>
+ {
+ builder.WithSingleton(
+ singletonName: "my-singleton",
+ propsFactory: (_, _, _) => MySingletonActor.MyProps);
+ },
+ new ClusterOptions
+ {
+ Roles = new[] { "my-host" }
+ }, Output);
+
+ var system = host.Services.GetRequiredService();
+ var registry = host.Services.GetRequiredService();
+ var singletonProxy = await registry.GetAsync();
+
+ var address = Cluster.Get(system).SelfAddress;
+ var expectedSingletonPath = new RootActorPath(address) / "user" / "my-singleton" / "my-singleton";
+ var singletonSelector = system.ActorSelection(expectedSingletonPath);
+
+ await AssertSingletonSelectionAsync(singletonSelector);
+
+ Assert.Equal("akka://TestSys/user/my-singleton-proxy", singletonProxy.Path.ToString());
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "Should launch singleton manager and proxy at the appropriate path (with manager name, actor props)")]
+ public async Task ClusterSingletonAndProxyWithManagerNameTest()
+ {
+ using var host = await TestHelper.CreateHost(
+ builder =>
+ {
+ builder.WithSingleton(
+ singletonManagerName: "my-singleton",
+ singletonName: "singleton",
+ actorProps: MySingletonActor.MyProps);
+ },
+ new ClusterOptions
+ {
+ Roles = new[] { "my-host" }
+ }, Output);
+
+ var system = host.Services.GetRequiredService();
+ var registry = host.Services.GetRequiredService();
+ var singletonProxy = await registry.GetAsync();
+
+ var address = Cluster.Get(system).SelfAddress;
+ var expectedSingletonPath = new RootActorPath(address) / "user" / "my-singleton" / "singleton";
+ var singletonSelector = system.ActorSelection(expectedSingletonPath);
+
+ await AssertSingletonSelectionAsync(singletonSelector);
+
+ Assert.Equal("akka://TestSys/user/singleton-proxy", singletonProxy.Path.ToString());
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "Should launch singleton manager and proxy at the appropriate path (with manager name, actor factory)")]
+ public async Task ClusterSingletonAndProxyWithManagerNameAndFactoryTest()
+ {
+ using var host = await TestHelper.CreateHost(
+ builder =>
+ {
+ builder.WithSingleton(
+ singletonManagerName: "my-singleton",
+ singletonName: "singleton",
+ propsFactory: (_, _, _) => MySingletonActor.MyProps);
+ },
+ new ClusterOptions
+ {
+ Roles = new[] { "my-host" }
+ }, Output);
+
+ var system = host.Services.GetRequiredService();
+ var registry = host.Services.GetRequiredService();
+ var singletonProxy = await registry.GetAsync();
+
+ var address = Cluster.Get(system).SelfAddress;
+ var expectedSingletonPath = new RootActorPath(address) / "user" / "my-singleton" / "singleton";
+ var singletonSelector = system.ActorSelection(expectedSingletonPath);
+
+ await AssertSingletonSelectionAsync(singletonSelector);
+
+ Assert.Equal("akka://TestSys/user/singleton-proxy", singletonProxy.Path.ToString());
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "WithSingletonProxy should work with no manager name")]
+ public async Task Should_launch_ClusterSingleton_and_Proxy_separately()
+ {
+ // arrange
+
+ var singletonOptions = new ClusterSingletonOptions() { Role = "my-host" };
+ using var singletonHost = await TestHelper.CreateHost(
+ builder => { builder.WithSingleton("my-singleton", MySingletonActor.MyProps, singletonOptions, createProxyToo:false); },
+ new ClusterOptions(){ Roles = new[] { "my-host" }}, Output);
+
+ var singletonSystem = singletonHost.Services.GetRequiredService();
+ var address = Cluster.Get(singletonSystem).SelfAddress;
+
+ using var singletonProxyHost = await TestHelper.CreateHost(
+ builder => { builder.WithSingletonProxy("my-singleton", singletonOptions); },
+ new ClusterOptions(){ Roles = new[] { "proxy" }, SeedNodes = new []{ address.ToString() } }, Output);
+
+ var registry = singletonProxyHost.Services.GetRequiredService();
+ var singletonProxy = registry.Get();
+
+ // act
+
+ // verify round-trip to the singleton proxy and back
+ // the proxy buffers until the singleton exists, which needs the node to be Up and Oldest first
+ var respond = await singletonProxy.Ask("hit", TimeSpan.FromSeconds(30));
+
+ // assert
+ Assert.Equal("hit", respond);
+
+ await Task.WhenAll(singletonHost.StopAsync(), singletonProxyHost.StopAsync());
+ }
+
+ [Fact(DisplayName = "WithSingletonProxy should work with manager name")]
+ public async Task SeparateProxyWithManagerNameTest()
+ {
+ // arrange
+
+ var singletonOptions = new ClusterSingletonOptions() { Role = "my-host" };
+ using var singletonHost = await TestHelper.CreateHost(
+ builder =>
+ {
+ builder.WithSingleton(
+ singletonManagerName: "my-singleton",
+ singletonName: "singleton",
+ actorProps: MySingletonActor.MyProps,
+ options: singletonOptions,
+ createProxyToo:false);
+ },
+ new ClusterOptions
+ {
+ Roles = new[] { "my-host" }
+ }, Output);
+
+ var singletonSystem = singletonHost.Services.GetRequiredService();
+ var address = Cluster.Get(singletonSystem).SelfAddress;
+
+ using var singletonProxyHost = await TestHelper.CreateHost(
+ builder =>
+ {
+ builder.WithSingletonProxy(
+ singletonManagerName: "my-singleton",
+ singletonName: "singleton",
+ options: singletonOptions);
+ },
+ new ClusterOptions
+ {
+ Roles = new[] { "proxy" },
+ SeedNodes = new []{ address.ToString() }
+ }, Output);
+
+ var registry = singletonProxyHost.Services.GetRequiredService();
+ var singletonProxy = await registry.GetAsync();
+
+ // act
+
+ // verify round-trip to the singleton proxy and back
+ // two nodes: the proxy host has to join the singleton host over seed nodes, reach Up, and
+ // the proxy has to locate the singleton before this round-trip can complete
+ var respond = await singletonProxy.Ask("hit", 30.Seconds());
+
+ // assert
+ Assert.Equal("hit", respond);
+
+ await Task.WhenAll(singletonHost.StopAsync(), singletonProxyHost.StopAsync());
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterSingletonWithDiSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterSingletonWithDiSpecs.cs
new file mode 100644
index 00000000000..887f4c5dc25
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ClusterSingletonWithDiSpecs.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ClusterSingletonWithDiSpecs : Akka.Hosting.TestKit.TestKit
+{
+ #region Actor and DI impls
+
+
+ public interface IMyThing
+ {
+ string ThingId { get; }
+ }
+
+ public sealed class ThingImpl : IMyThing
+ {
+ public ThingImpl(string thingId)
+ {
+ ThingId = thingId;
+ }
+
+ public string ThingId { get; }
+ }
+
+ private class MySingletonDiActor : ReceiveActor
+ {
+ private readonly IMyThing _thing;
+
+ public MySingletonDiActor(IMyThing thing)
+ {
+ _thing = thing;
+ ReceiveAny(_ => Sender.Tell(_thing.ThingId));
+ }
+ }
+
+ #endregion
+
+ private readonly TaskCompletionSource _tcs = new(TimeSpan.FromSeconds(3));
+
+ public ClusterSingletonWithDiSpecs(ITestOutputHelper output) : base(output: output)
+ {
+ }
+
+ protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services)
+ {
+ services.AddSingleton(new ThingImpl("foo1"));
+ base.ConfigureServices(context, services);
+ }
+
+ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
+ {
+ builder.ConfigureHost(configurationBuilder =>
+ {
+ configurationBuilder.WithSingleton("my-singleton",
+ (_, _, dependencyResolver) => dependencyResolver.Props());
+ }, new ClusterOptions(){ Roles = new[] { "my-host" }}, _tcs, Output!);
+ }
+
+ [Fact]
+ public async Task Should_launch_ClusterSingletonAndProxy_with_DI_delegate()
+ {
+ // arrange
+ await _tcs.Task; // wait for cluster to start
+
+ var registry = Host.Services.GetRequiredService();
+ var singletonProxy = registry.Get();
+ var thing = Host.Services.GetRequiredService();
+
+ // act
+
+ // verify round-trip to the singleton proxy and back
+ // the proxy buffers until the singleton exists, which needs the node to be Up and Oldest first
+ var respond = await singletonProxy.Ask("hit", TimeSpan.FromSeconds(30));
+
+ // assert
+ Assert.Equal(thing.ThingId, respond);
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ConfigAssertionHelper.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ConfigAssertionHelper.cs
new file mode 100644
index 00000000000..979eb38e461
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ConfigAssertionHelper.cs
@@ -0,0 +1,16 @@
+using Akka.Configuration;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public static class ConfigAssertionHelper
+{
+ public static void AssertSameString(this Config first, Config second, string key)
+ => Assert.Equal(second.GetString(key), first.GetString(key));
+
+ public static void AssertSameInt(this Config first, Config second, string key)
+ => Assert.Equal(second.GetInt(key), first.GetInt(key));
+
+ public static void AssertSameTimeSpan(this Config first, Config second, string key)
+ => Assert.Equal(second.GetTimeSpan(key), first.GetTimeSpan(key));
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/DistributedPubSubSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/DistributedPubSubSpecs.cs
new file mode 100644
index 00000000000..829a8868586
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/DistributedPubSubSpecs.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Cluster.Tools.PublishSubscribe;
+using Akka.Event;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Xunit;
+using LogLevel = Microsoft.Extensions.Logging.LogLevel;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class DistributedPubSubSpecs : IAsyncLifetime
+{
+ private readonly ITestOutputHelper _helper;
+ private readonly Action _specBuilder;
+ private readonly ClusterOptions _clusterOptions;
+ private IHost? _host;
+ private ActorSystem? _system;
+ private ILoggingAdapter? _log;
+ private Cluster? _cluster;
+ private TestKit.Xunit.TestKit? _testKit;
+
+ private IActorRef? _mediator;
+
+ public DistributedPubSubSpecs(ITestOutputHelper helper)
+ {
+ _helper = helper;
+ _specBuilder = _ => { };
+ _clusterOptions = new ClusterOptions { Roles = ["my-host"] };
+ }
+
+ // Issue #55 https://github.com/akkadotnet/Akka.Hosting/issues/55
+ [Fact]
+ public async Task Should_launch_distributed_pub_sub_with_roles()
+ {
+ var testProbe = _testKit!.CreateTestProbe(_system);
+
+ // act
+ testProbe.Send(_mediator, new Subscribe("testSub", testProbe));
+ var response = await testProbe.ExpectMsgAsync();
+
+ // assert
+ Assert.Equal("my-host", _system!.Settings.Config.GetString("akka.cluster.pub-sub.role"));
+ Assert.Equal("testSub", response.Subscribe.Topic);
+ Assert.Equal(testProbe, response.Subscribe.Ref);
+ }
+
+ [Fact]
+ public Task Distributed_pub_sub_should_work()
+ {
+ const string topic = "testSub";
+
+ var subscriber = _testKit!.CreateTestProbe(_system);
+ var publisher = _testKit.CreateTestProbe(_system);
+
+ subscriber.Send(_mediator, new Subscribe(topic, subscriber));
+ subscriber.ExpectMsg();
+
+ publisher.Send(_mediator, new Publish(topic, "test message"));
+ subscriber.ExpectMsg("test message");
+
+ return Task.CompletedTask;
+ }
+
+ public async ValueTask InitializeAsync()
+ {
+ using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+ _host = new HostBuilder()
+ .ConfigureLogging(builder =>
+ {
+ builder.AddProvider(new XUnitLoggerProvider(_helper, LogLevel.Information));
+ })
+ .ConfigureServices(collection =>
+ {
+ collection
+ .AddAkka("TestSys", (configurationBuilder, _) =>
+ {
+ configurationBuilder
+ .AddHocon(TestKit.Xunit.TestKit.DefaultConfig, HoconAddMode.Append)
+ .WithRemoting("localhost", 0)
+ .WithClustering(_clusterOptions)
+ .WithActors((system, _) =>
+ {
+ _testKit = new TestKit.Xunit.TestKit(system, _helper);
+ _system = system;
+ _log = Logging.GetLogger(system, this);
+ _cluster = Cluster.Get(system);
+
+ _log.Info("Distributed pub-sub test system initialized.");
+ })
+ .WithDistributedPubSub("my-host");
+ _specBuilder(configurationBuilder);
+ });
+ }).Build();
+
+ await _host.StartAsync(cancellationTokenSource.Token);
+
+ // Lifetime should be healthy
+ var lifetime = _host.Services.GetRequiredService();
+ Assert.False(lifetime.ApplicationStopped.IsCancellationRequested);
+ Assert.False(lifetime.ApplicationStopping.IsCancellationRequested);
+
+ // Join cluster
+ var myAddress = _cluster!.SelfAddress;
+ await _cluster.JoinAsync(myAddress); // force system to wait until we're up
+
+ // Prepare test
+ var registry = _host.Services.GetRequiredService();
+ _mediator = registry.Get();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_host != null)
+ await _host.StopAsync();
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs
new file mode 100644
index 00000000000..f0c2f20b83f
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs
@@ -0,0 +1,169 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (C) 2009-2021 Lightbend Inc.
+// Copyright (C) 2013-2021 .NET Foundation
+//
+//-----------------------------------------------------------------------
+
+using System;
+using System.Collections.Concurrent;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Actor.Setup;
+using Akka.Cluster.Hosting.SBR;
+using Akka.Configuration;
+using Akka.Coordination;
+using Akka.Event;
+using Akka.Hosting;
+using Akka.Hosting.Coordination;
+using Akka.Util;
+
+namespace Akka.Cluster.Hosting.Tests.Lease
+{
+ public class TestLeaseExtExtensionProvider : ExtensionIdProvider
+ {
+ public override TestLeaseExt CreateExtension(ExtendedActorSystem system)
+ {
+ var extension = new TestLeaseExt(system);
+ return extension;
+ }
+ }
+
+ public class TestLeaseExt : IExtension
+ {
+ public static TestLeaseExt Get(ActorSystem system)
+ {
+ return system.WithExtension();
+ }
+
+ private readonly ExtendedActorSystem _system;
+ private readonly ConcurrentDictionary _testLeases = new();
+
+ public TestLeaseExt(ExtendedActorSystem system)
+ {
+ _system = system;
+ _system.Settings.InjectTopLevelFallback(LeaseProvider.DefaultConfig());
+ }
+
+ public TestLease GetTestLease(string name)
+ {
+ if (!_testLeases.TryGetValue(name, out var lease))
+ {
+ throw new InvalidOperationException($"Test lease {name} has not been set yet. Current leases {string.Join(",", _testLeases.Keys)}");
+ }
+ return lease;
+ }
+
+ public void SetTestLease(string name, TestLease lease)
+ {
+ _testLeases[name] = lease;
+ }
+ }
+
+ public sealed class TestLeaseOption : LeaseOptionBase
+ {
+ public override string ConfigPath => "test-lease";
+ public override Type Class => typeof(TestLease);
+ public override void Apply(AkkaConfigurationBuilder builder, Setup? setup = null)
+ {
+ // no-op
+ }
+ }
+
+ public class TestLease : Coordination.Lease
+ {
+ public sealed class AcquireReq : IEquatable
+ {
+ public string Owner { get; }
+
+ public AcquireReq(string owner)
+ {
+ Owner = owner;
+ }
+
+ public bool Equals(AcquireReq? other)
+ {
+ if (ReferenceEquals(other, null)) return false;
+ if (ReferenceEquals(this, other)) return true;
+
+ return Equals(Owner, other.Owner);
+ }
+
+ public override bool Equals(object? obj) => obj is AcquireReq a && Equals(a);
+
+ public override int GetHashCode() => Owner.GetHashCode();
+
+ public override string ToString() => $"AcquireReq({Owner})";
+ }
+
+ public sealed class ReleaseReq : IEquatable
+ {
+ public string Owner { get; }
+
+ public ReleaseReq(string owner)
+ {
+ Owner = owner;
+ }
+
+ public bool Equals(ReleaseReq? other)
+ {
+ if (ReferenceEquals(other, null)) return false;
+ if (ReferenceEquals(this, other)) return true;
+
+ return Equals(Owner, other.Owner);
+ }
+
+ public override bool Equals(object? obj) => obj is ReleaseReq r && Equals(r);
+
+ public override int GetHashCode() => Owner.GetHashCode();
+
+ public override string ToString() => $"ReleaseReq({Owner})";
+ }
+
+ public static Config Configuration => ConfigurationFactory.ParseString(
+ $"test-lease.lease-class = \"{typeof(TestLease).AssemblyQualifiedName}\"");
+
+ private readonly AtomicReference> _nextAcquireResult;
+ private readonly AtomicBoolean _nextCheckLeaseResult = new();
+ private readonly AtomicReference> _currentCallBack = new(_ => { });
+ private readonly ILoggingAdapter _log;
+ private TaskCompletionSource InitialPromise { get; } = new();
+
+ public TestLease(LeaseSettings settings, ExtendedActorSystem system)
+ : base(settings)
+ {
+ _log = Logging.GetLogger(system, "TestLease");
+ _log.Info("Creating lease {0}", settings);
+
+ _nextAcquireResult = new AtomicReference>(InitialPromise.Task);
+
+ TestLeaseExt.Get(system).SetTestLease(settings.LeaseName, this);
+ }
+
+ public void SetNextAcquireResult(Task next) => _nextAcquireResult.GetAndSet(next);
+
+ public void SetNextCheckLeaseResult(bool value) => _nextCheckLeaseResult.GetAndSet(value);
+
+ public Action GetCurrentCallback() => _currentCallBack.Value;
+
+
+ public override Task Acquire()
+ {
+ _log.Info("acquire, current response " + _nextAcquireResult);
+ return _nextAcquireResult.Value;
+ }
+
+ public override Task Release()
+ {
+ return Task.FromResult(true);
+ }
+
+ public override bool CheckLease() => _nextCheckLeaseResult.Value;
+
+ public override Task Acquire(Action leaseLostCallback)
+ {
+ _currentCallBack.GetAndSet(leaseLostCallback);
+ return Acquire();
+ }
+ }
+}
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Lease/TestLeaseActor.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Lease/TestLeaseActor.cs
new file mode 100644
index 00000000000..c7f3b36e106
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/Lease/TestLeaseActor.cs
@@ -0,0 +1,253 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (C) 2009-2021 Lightbend Inc.
+// Copyright (C) 2013-2021 .NET Foundation
+//
+//-----------------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Coordination;
+using Akka.Event;
+using Akka.Util;
+
+namespace Akka.Cluster.Hosting.Tests.Lease
+{
+ public class TestLeaseActor : ActorBase
+ {
+ public interface ILeaseRequest
+ {
+ }
+
+ public sealed class Acquire : ILeaseRequest, IEquatable
+ {
+ public string Owner { get; }
+
+ public Acquire(string owner)
+ {
+ Owner = owner;
+ }
+
+ public bool Equals(Acquire? other)
+ {
+ if (ReferenceEquals(other, null)) return false;
+ if (ReferenceEquals(this, other)) return true;
+
+ return Equals(Owner, other.Owner);
+ }
+
+ public override bool Equals(object? obj) => obj is Acquire a && Equals(a);
+
+ public override int GetHashCode() => Owner.GetHashCode();
+
+ public override string ToString() => $"Acquire({Owner})";
+ }
+
+ public sealed class Release : ILeaseRequest, IEquatable
+ {
+ public string Owner { get; }
+
+ public Release(string owner)
+ {
+ Owner = owner;
+ }
+
+ public bool Equals(Release? other)
+ {
+ if (ReferenceEquals(other, null)) return false;
+ if (ReferenceEquals(this, other)) return true;
+
+ return Equals(Owner, other.Owner);
+ }
+
+ public override bool Equals(object? obj) => obj is Release r && Equals(r);
+
+ public override int GetHashCode() => Owner.GetHashCode();
+
+ public override string ToString() => $"Release({Owner})";
+ }
+
+ public sealed class Create : ILeaseRequest, IEquatable
+ {
+ public string LeaseName { get; }
+ public string OwnerName { get; }
+
+ public Create(string leaseName, string ownerName)
+ {
+ LeaseName = leaseName;
+ OwnerName = ownerName;
+ }
+
+ public bool Equals(Create? other)
+ {
+ if (ReferenceEquals(other, null)) return false;
+ if (ReferenceEquals(this, other)) return true;
+
+ return Equals(LeaseName, other.LeaseName) && Equals(OwnerName, other.OwnerName);
+ }
+
+ public override bool Equals(object? obj) => obj is Create c && Equals(c);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hashCode = LeaseName.GetHashCode();
+ hashCode = (hashCode * 397) ^ OwnerName.GetHashCode();
+ return hashCode;
+ }
+ }
+
+ public override string ToString() => $"Create({LeaseName}, {OwnerName})";
+ }
+
+ public sealed class GetRequests
+ {
+ public static readonly GetRequests Instance = new GetRequests();
+ private GetRequests()
+ {
+ }
+ }
+
+ public sealed class LeaseRequests
+ {
+ public List Requests { get; }
+
+ public LeaseRequests(List requests)
+ {
+ Requests = requests;
+ }
+
+ public override string ToString() => $"LeaseRequests({string.Join(", ", Requests.Select(i => i.ToString()))})";
+ }
+
+
+ public sealed class ActionRequest // boolean of Failure
+ {
+ public ILeaseRequest Request { get; }
+ public bool Result { get; }
+
+ public ActionRequest(ILeaseRequest request, bool result)
+ {
+ Request = request;
+ Result = result;
+ }
+
+ public override string ToString() => $"ActionRequest({Request}, {Result})";
+ }
+
+ public static Props Props => Props.Create(() => new TestLeaseActor());
+
+ private ILoggingAdapter _log = Context.GetLogger();
+ private readonly List<(IActorRef, ILeaseRequest)> _requests = new List<(IActorRef, ILeaseRequest)>();
+
+ protected override bool Receive(object message)
+ {
+ switch (message)
+ {
+ case Create c:
+ _log.Info("Lease created with name {0} ownerName {1}", c.LeaseName, c.OwnerName);
+ return true;
+
+ case ILeaseRequest request:
+ _log.Info("Lease request {0} from {1}", request, Sender);
+ _requests.Insert(0, (Sender, request));
+ return true;
+
+ case GetRequests _:
+ Sender.Tell(new LeaseRequests(_requests.Select(i => i.Item2).ToList()));
+ return true;
+
+ case ActionRequest ar:
+ var r = _requests.FirstOrDefault(i => i.Item2.Equals(ar.Request));
+ if (r.Item1 != null)
+ {
+ _log.Info("Actioning request {0} to {1}", r.Item2, ar.Result);
+ r.Item1.Tell(ar.Result);
+ _requests.RemoveAll(i => i.Item2.Equals(ar.Request));
+ }
+ else
+ throw new InvalidOperationException($"unknown request to action: {ar.Request}. Requests: { string.Join(", ", _requests.Select(i => $"([{i.Item1}],[{i.Item2}])"))}");
+ return true;
+ }
+ return false;
+ }
+ }
+
+
+
+ public class TestLeaseActorClientExtExtensionProvider : ExtensionIdProvider
+ {
+ public override TestLeaseActorClientExt CreateExtension(ExtendedActorSystem system)
+ {
+ var extension = new TestLeaseActorClientExt(system);
+ return extension;
+ }
+ }
+
+ public class TestLeaseActorClientExt : IExtension
+ {
+ public static TestLeaseActorClientExt Get(ActorSystem system)
+ {
+ return system.WithExtension();
+ }
+
+ private readonly ExtendedActorSystem _system;
+ private AtomicReference leaseActor = new AtomicReference();
+
+ public TestLeaseActorClientExt(ExtendedActorSystem system)
+ {
+ _system = system;
+ }
+
+ public IActorRef GetLeaseActor()
+ {
+ var lease = leaseActor.Value;
+ if (lease == null)
+ throw new InvalidOperationException("LeaseActorRef must be set first");
+ return lease;
+ }
+
+ public void SetActorLease(IActorRef client)
+ {
+ leaseActor.GetAndSet(client);
+ }
+ }
+
+ public class TestLeaseActorClient : Coordination.Lease
+ {
+ private ILoggingAdapter _log;
+
+ private IActorRef leaseActor;
+
+ public TestLeaseActorClient(LeaseSettings settings, ExtendedActorSystem system)
+ : base(settings)
+ {
+ _log = Logging.GetLogger(system, "TestLeaseActorClient");
+
+ leaseActor = TestLeaseActorClientExt.Get(system).GetLeaseActor();
+ _log.Info("lease created {0}", settings);
+ leaseActor.Tell(new TestLeaseActor.Create(settings.LeaseName, settings.OwnerName));
+ }
+
+ public override Task Acquire()
+ {
+ return leaseActor.Ask(new TestLeaseActor.Acquire(Settings.OwnerName)).ContinueWith(r => (bool)r.Result);
+ }
+
+ public override Task Release()
+ {
+ return leaseActor.Ask(new TestLeaseActor.Release(Settings.OwnerName)).ContinueWith(r => (bool)r.Result);
+ }
+
+ public override bool CheckLease() => false;
+
+ public override Task Acquire(Action leaseLostCallback)
+ {
+ return leaseActor.Ask(new TestLeaseActor.Acquire(Settings.OwnerName)).ContinueWith(r => (bool)r.Result);
+ }
+ }
+}
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardOptionsSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardOptionsSpecs.cs
new file mode 100644
index 00000000000..a10595497ef
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardOptionsSpecs.cs
@@ -0,0 +1,726 @@
+using System;
+using System.Collections.Immutable;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Cluster.Hosting.Tests.Lease;
+using Akka.Cluster.Sharding;
+using Akka.Cluster.Tools.Singleton;
+using Akka.Configuration;
+using Akka.Coordination;
+using Akka.DistributedData;
+using Akka.Hosting;
+using Akka.Persistence.Hosting;
+using Akka.Remote.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ShardOptionsSpecs
+{
+ private sealed class MyEntityActor : ReceiveActor
+ {
+ public MyEntityActor(string entityId)
+ {
+ EntityId = entityId;
+ ReceiveAny(m => Sender.Tell(m));
+ }
+
+ public string EntityId { get; }
+ }
+
+ private sealed class Extractor : HashCodeMessageExtractor
+ {
+ public Extractor() : base(30)
+ {
+ }
+
+ public override string EntityId(object message)
+ {
+ return string.Empty;
+ }
+ }
+
+ private sealed class StopMessage
+ {
+ public static readonly StopMessage Instance = new();
+ private StopMessage() { }
+ }
+
+ [Fact(DisplayName = "Empty ShardOptions and ShardingDDataOptions without DData should contain default HOCON values")]
+ public async Task EmptyShardOptionsTest()
+ {
+ using var host = Host.CreateDefaultBuilder()
+ .ConfigureServices(services =>
+ {
+ services.AddAkka("ConfigSys", (builder, _) =>
+ {
+ builder
+ .WithRemoting()
+ .WithClustering()
+ .WithShardRegion(
+ typeName: "entities",
+ entityPropsFactory: (_, _) =>
+ {
+ return s => Props.Create(() => new MyEntityActor(s));
+ },
+ messageExtractor: new Extractor(),
+ shardOptions: new ShardOptions())
+ .WithShardingDistributedData(new ShardingDDataOptions());
+ });
+ }).Build();
+ await host.StartAsync();
+
+ Config appliedShardingConfig;
+ ClusterShardingSettings shardingSettings;
+ ReplicatorSettings replicatorSettings;
+ try
+ {
+ var sys = host.Services.GetRequiredService();
+ (appliedShardingConfig, shardingSettings) = GetClusterShardingSettings(new ShardOptions(), sys);
+ replicatorSettings = GetReplicatorSettings(shardingSettings, sys);
+ }
+ finally
+ {
+ await host.StopAsync();
+ }
+
+ var shardingConfig = ClusterSharding.DefaultConfig().GetConfig("akka.cluster.sharding");
+
+ #region ClusterShardingSettings validation
+
+ Assert.Null(shardingSettings.Role);
+ Assert.Equal(shardingConfig.GetBoolean("remember-entities"), shardingSettings.RememberEntities);
+ Assert.Equal(shardingConfig.GetString("journal-plugin-id"), shardingSettings.JournalPluginId);
+ Assert.Equal(shardingConfig.GetString("snapshot-plugin-id"), shardingSettings.SnapshotPluginId);
+ Assert.Equal(Enum.Parse(shardingConfig.GetString("state-store-mode"), true), shardingSettings.StateStoreMode);
+ Assert.Equal(Enum.Parse(shardingConfig.GetString("remember-entities-store"), true), shardingSettings.RememberEntitiesStore);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-region-query-timeout"), shardingSettings.ShardRegionQueryTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("passivate-idle-entity-after"), shardingSettings.PassivateIdleEntityAfter);
+ Assert.Equal(shardingConfig.GetBoolean("fail-on-invalid-entity-state-transition"),
+ appliedShardingConfig.GetBoolean("fail-on-invalid-entity-state-transition"));
+
+ Assert.Equal(shardingConfig.GetTimeSpan("coordinator-failure-backoff"), shardingSettings.TuningParameters.CoordinatorFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("retry-interval"), shardingSettings.TuningParameters.RetryInterval);
+ Assert.Equal(shardingConfig.GetInt("buffer-size"), shardingSettings.TuningParameters.BufferSize);
+ Assert.Equal(shardingConfig.GetTimeSpan("handoff-timeout"), shardingSettings.TuningParameters.HandOffTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-start-timeout"), shardingSettings.TuningParameters.ShardStartTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-failure-backoff"), shardingSettings.TuningParameters.ShardFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-restart-backoff"), shardingSettings.TuningParameters.EntityRestartBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("rebalance-interval"), shardingSettings.TuningParameters.RebalanceInterval);
+ Assert.Equal(shardingConfig.GetInt("snapshot-after"), shardingSettings.TuningParameters.SnapshotAfter);
+ Assert.Equal(shardingConfig.GetInt("keep-nr-of-batches"), shardingSettings.TuningParameters.KeepNrOfBatches);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-threshold"), shardingSettings.TuningParameters.LeastShardAllocationRebalanceThreshold);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.max-simultaneous-rebalance"), shardingSettings.TuningParameters.LeastShardAllocationMaxSimultaneousRebalance);
+ Assert.Equal(shardingConfig.GetTimeSpan("waiting-for-state-timeout"), shardingSettings.TuningParameters.WaitingForStateTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("updating-state-timeout"), shardingSettings.TuningParameters.UpdatingStateTimeout);
+ Assert.Equal(shardingConfig.GetString("entity-recovery-strategy"), shardingSettings.TuningParameters.EntityRecoveryStrategy);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-recovery-constant-rate-strategy.frequency"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyFrequency);
+ Assert.Equal(shardingConfig.GetInt("entity-recovery-constant-rate-strategy.number-of-entities"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyNumberOfEntities);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.write-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateWriteMajorityPlus);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.read-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateReadMajorityPlus);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-absolute-limit"), shardingSettings.TuningParameters.LeastShardAllocationAbsoluteLimit);
+ Assert.Equal(shardingConfig.GetDouble("least-shard-allocation-strategy.rebalance-relative-limit"), shardingSettings.TuningParameters.LeastShardAllocationRelativeLimit);
+
+ var singletonConfig = ClusterSingleton.DefaultConfig().GetConfig("akka.cluster.singleton");
+ Assert.Equal(singletonConfig.GetString("singleton-name"), shardingSettings.CoordinatorSingletonSettings.SingletonName);
+ Assert.Null(shardingSettings.CoordinatorSingletonSettings.Role);
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Tools/Singleton/ClusterSingletonManagerSettings.cs#L58
+ Assert.Equal(TimeSpan.Zero, shardingSettings.CoordinatorSingletonSettings.RemovalMargin);
+ Assert.Equal(singletonConfig.GetTimeSpan("hand-over-retry-interval"), shardingSettings.CoordinatorSingletonSettings.HandOverRetryInterval);
+ {
+ var expectedLease = GetLeaseUsageSettings(shardingConfig);
+ var actualLease = shardingSettings.CoordinatorSingletonSettings.LeaseSettings;
+ if (expectedLease is null)
+ {
+ Assert.Null(actualLease);
+ }
+ else
+ {
+ Assert.NotNull(actualLease);
+ Assert.Equal(expectedLease.LeaseImplementation, actualLease.LeaseImplementation);
+ Assert.Equal(expectedLease.LeaseRetryInterval, actualLease.LeaseRetryInterval);
+ }
+ }
+#pragma warning disable CS0618 // Type or member is obsolete
+ Assert.Equal(singletonConfig.GetBoolean("consider-app-version"), shardingSettings.CoordinatorSingletonSettings.ConsiderAppVersion);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ Assert.Null(shardingSettings.LeaseSettings);
+
+ #endregion
+
+ #region ReplicatorSettings validation
+ var repConfig = shardingConfig.GetConfig("distributed-data")
+ .WithFallback(DistributedData.DistributedData.DefaultConfig().GetConfig("akka.cluster.distributed-data"));
+
+ Assert.Equal(repConfig.GetString("role"), replicatorSettings.Role);
+ Assert.Equal(repConfig.GetTimeSpan("gossip-interval"), replicatorSettings.GossipInterval);
+ Assert.Equal(repConfig.GetTimeSpan("notify-subscribers-interval"), replicatorSettings.NotifySubscribersInterval);
+ Assert.Equal(repConfig.GetInt("max-delta-elements"), replicatorSettings.MaxDeltaElements);
+ Assert.Equal("akka.actor.internal-dispatcher", replicatorSettings.Dispatcher);
+ Assert.Equal(repConfig.GetTimeSpan("pruning-interval"), replicatorSettings.PruningInterval);
+ Assert.Equal(repConfig.GetTimeSpan("max-pruning-dissemination"), replicatorSettings.MaxPruningDissemination);
+ Assert.Empty(replicatorSettings.DurableKeys);
+ Assert.Equal(repConfig.GetTimeSpan("pruning-marker-time-to-live"), replicatorSettings.PruningMarkerTimeToLive);
+ Assert.NotNull(replicatorSettings.DurableStoreProps);
+ Assert.Equal(repConfig.GetInt("delta-crdt.max-delta-size"), replicatorSettings.MaxDeltaSize);
+ Assert.Equal(repConfig.GetBoolean("recreate-on-failure"), replicatorSettings.RestartReplicatorOnFailure);
+ Assert.Equal(repConfig.GetBoolean("prefer-oldest"), replicatorSettings.PreferOldest);
+ Assert.Equal(repConfig.GetBoolean("verbose-debug-logging"), replicatorSettings.VerboseDebugLogging);
+
+ #endregion
+ }
+
+ [Fact(DisplayName = "Empty ShardOptions and ShardingDDataOptions with DData should contain default HOCON values")]
+ public async Task EmptyDDataShardOptionsTest()
+ {
+ var shardOptions = new ShardOptions
+ {
+ RememberEntitiesStore = RememberEntitiesStore.DData,
+ RememberEntities = true,
+ };
+
+ using var host = Host.CreateDefaultBuilder()
+ .ConfigureServices(services =>
+ {
+ services.AddAkka("ConfigSys", (builder, _) =>
+ {
+ builder
+ .WithRemoting()
+ .WithClustering()
+ .WithShardRegion(
+ typeName: "entities",
+ entityPropsFactory: (_, _) =>
+ {
+ return s => Props.Create(() => new MyEntityActor(s));
+ },
+ messageExtractor: new Extractor(),
+ shardOptions: shardOptions)
+ .WithShardingDistributedData(new ShardingDDataOptions());
+ });
+ }).Build();
+ await host.StartAsync();
+
+ Config appliedShardingConfig;
+ ClusterShardingSettings shardingSettings;
+ ReplicatorSettings replicatorSettings;
+ try
+ {
+ var sys = host.Services.GetRequiredService();
+ (appliedShardingConfig, shardingSettings) = GetClusterShardingSettings(shardOptions, sys);
+ replicatorSettings = GetReplicatorSettings(shardingSettings, sys);
+ }
+ finally
+ {
+ await host.StopAsync();
+ }
+
+ var shardingConfig = ClusterSharding.DefaultConfig().GetConfig("akka.cluster.sharding");
+
+ #region ClusterShardingSettings validation
+
+ Assert.Null(shardingSettings.Role);
+ Assert.True(shardingSettings.RememberEntities);
+ Assert.Equal(RememberEntitiesStore.DData, shardingSettings.RememberEntitiesStore);
+ Assert.Equal(shardingConfig.GetString("journal-plugin-id"), shardingSettings.JournalPluginId);
+ Assert.Equal(shardingConfig.GetString("snapshot-plugin-id"), shardingSettings.SnapshotPluginId);
+ Assert.Equal(Enum.Parse(shardingConfig.GetString("state-store-mode"), true), shardingSettings.StateStoreMode);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-region-query-timeout"), shardingSettings.ShardRegionQueryTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("passivate-idle-entity-after"), shardingSettings.PassivateIdleEntityAfter);
+ Assert.Equal(shardingConfig.GetBoolean("fail-on-invalid-entity-state-transition"),
+ appliedShardingConfig.GetBoolean("fail-on-invalid-entity-state-transition"));
+
+ Assert.Equal(shardingConfig.GetTimeSpan("coordinator-failure-backoff"), shardingSettings.TuningParameters.CoordinatorFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("retry-interval"), shardingSettings.TuningParameters.RetryInterval);
+ Assert.Equal(shardingConfig.GetInt("buffer-size"), shardingSettings.TuningParameters.BufferSize);
+ Assert.Equal(shardingConfig.GetTimeSpan("handoff-timeout"), shardingSettings.TuningParameters.HandOffTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-start-timeout"), shardingSettings.TuningParameters.ShardStartTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-failure-backoff"), shardingSettings.TuningParameters.ShardFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-restart-backoff"), shardingSettings.TuningParameters.EntityRestartBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("rebalance-interval"), shardingSettings.TuningParameters.RebalanceInterval);
+ Assert.Equal(shardingConfig.GetInt("snapshot-after"), shardingSettings.TuningParameters.SnapshotAfter);
+ Assert.Equal(shardingConfig.GetInt("keep-nr-of-batches"), shardingSettings.TuningParameters.KeepNrOfBatches);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-threshold"), shardingSettings.TuningParameters.LeastShardAllocationRebalanceThreshold);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.max-simultaneous-rebalance"), shardingSettings.TuningParameters.LeastShardAllocationMaxSimultaneousRebalance);
+ Assert.Equal(shardingConfig.GetTimeSpan("waiting-for-state-timeout"), shardingSettings.TuningParameters.WaitingForStateTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("updating-state-timeout"), shardingSettings.TuningParameters.UpdatingStateTimeout);
+ Assert.Equal(shardingConfig.GetString("entity-recovery-strategy"), shardingSettings.TuningParameters.EntityRecoveryStrategy);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-recovery-constant-rate-strategy.frequency"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyFrequency);
+ Assert.Equal(shardingConfig.GetInt("entity-recovery-constant-rate-strategy.number-of-entities"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyNumberOfEntities);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.write-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateWriteMajorityPlus);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.read-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateReadMajorityPlus);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-absolute-limit"), shardingSettings.TuningParameters.LeastShardAllocationAbsoluteLimit);
+ Assert.Equal(shardingConfig.GetDouble("least-shard-allocation-strategy.rebalance-relative-limit"), shardingSettings.TuningParameters.LeastShardAllocationRelativeLimit);
+
+ var singletonConfig = ClusterSingleton.DefaultConfig().GetConfig("akka.cluster.singleton");
+ Assert.Equal(singletonConfig.GetString("singleton-name"), shardingSettings.CoordinatorSingletonSettings.SingletonName);
+ Assert.Null(shardingSettings.CoordinatorSingletonSettings.Role);
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Tools/Singleton/ClusterSingletonManagerSettings.cs#L58
+ Assert.Equal(TimeSpan.Zero, shardingSettings.CoordinatorSingletonSettings.RemovalMargin);
+ Assert.Equal(singletonConfig.GetTimeSpan("hand-over-retry-interval"), shardingSettings.CoordinatorSingletonSettings.HandOverRetryInterval);
+ {
+ var expectedLease = GetLeaseUsageSettings(shardingConfig);
+ var actualLease = shardingSettings.CoordinatorSingletonSettings.LeaseSettings;
+ if (expectedLease is null)
+ {
+ Assert.Null(actualLease);
+ }
+ else
+ {
+ Assert.NotNull(actualLease);
+ Assert.Equal(expectedLease.LeaseImplementation, actualLease.LeaseImplementation);
+ Assert.Equal(expectedLease.LeaseRetryInterval, actualLease.LeaseRetryInterval);
+ }
+ }
+#pragma warning disable CS0618 // Type or member is obsolete
+ Assert.Equal(singletonConfig.GetBoolean("consider-app-version"), shardingSettings.CoordinatorSingletonSettings.ConsiderAppVersion);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ Assert.Null(shardingSettings.LeaseSettings);
+
+ #endregion
+
+ #region ReplicatorSettings validation
+ var repConfig = shardingConfig.GetConfig("distributed-data")
+ .WithFallback(DistributedData.DistributedData.DefaultConfig().GetConfig("akka.cluster.distributed-data"));
+
+ Assert.Equal(repConfig.GetString("role"), replicatorSettings.Role);
+ Assert.Equal(repConfig.GetTimeSpan("gossip-interval"), replicatorSettings.GossipInterval);
+ Assert.Equal(repConfig.GetTimeSpan("notify-subscribers-interval"), replicatorSettings.NotifySubscribersInterval);
+ Assert.Equal(repConfig.GetInt("max-delta-elements"), replicatorSettings.MaxDeltaElements);
+ Assert.Equal("akka.actor.internal-dispatcher", replicatorSettings.Dispatcher);
+ Assert.Equal(repConfig.GetTimeSpan("pruning-interval"), replicatorSettings.PruningInterval);
+ Assert.Equal(repConfig.GetTimeSpan("max-pruning-dissemination"), replicatorSettings.MaxPruningDissemination);
+ Assert.Single(replicatorSettings.DurableKeys, "shard-*");
+ Assert.Equal(repConfig.GetTimeSpan("pruning-marker-time-to-live"), replicatorSettings.PruningMarkerTimeToLive);
+ Assert.NotNull(replicatorSettings.DurableStoreProps);
+ Assert.Equal(repConfig.GetInt("delta-crdt.max-delta-size"), replicatorSettings.MaxDeltaSize);
+ Assert.Equal(repConfig.GetBoolean("recreate-on-failure"), replicatorSettings.RestartReplicatorOnFailure);
+ Assert.Equal(repConfig.GetBoolean("prefer-oldest"), replicatorSettings.PreferOldest);
+ Assert.Equal(repConfig.GetBoolean("verbose-debug-logging"), replicatorSettings.VerboseDebugLogging);
+
+ #endregion
+ }
+
+ [Fact(DisplayName = "Modified ShardOptions and ShardingDDataOptions without DData should contain proper HOCON values")]
+ public async Task ModifiedShardOptionsTest()
+ {
+ var shardOptions = new ShardOptions
+ {
+ StateStoreMode = StateStoreMode.DData,
+ RememberEntitiesStore = RememberEntitiesStore.Eventsourced,
+ RememberEntities = true,
+ Role = "test",
+ JournalPluginId = "custom-journal",
+ SnapshotPluginId = "custom-snapshot-store",
+ LeaseImplementation = new TestLeaseOption(),
+ LeaseRetryInterval = TimeSpan.FromSeconds(1),
+ HandOffStopMessage = StopMessage.Instance, // can't be tested, assigned directly
+ FailOnInvalidEntityStateTransition = true,
+#pragma warning disable CS0618 // Type or member is obsolete
+ // This property should never get applied to HOCON
+ DistributedData =
+ {
+ Role = "wrong-role",
+ Name = "wrong-name"
+ },
+#pragma warning restore CS0618 // Type or member is obsolete
+ ShouldPassivateIdleEntities = false,
+ ShardRegionQueryTimeout = TimeSpan.FromSeconds(2),
+ PassivateIdleEntityAfter = TimeSpan.FromSeconds(3),
+ };
+
+ using var host = Host.CreateDefaultBuilder()
+ .ConfigureServices(services =>
+ {
+ services.AddAkka("ConfigSys", (builder, _) =>
+ {
+ builder
+ .WithRemoting()
+ .WithClustering(new ClusterOptions
+ {
+ Roles = ["test"]
+ })
+ .WithShardRegion(
+ typeName: "entities",
+ entityPropsFactory: (_, _) =>
+ {
+ return s => Props.Create(() => new MyEntityActor(s));
+ },
+ messageExtractor: new Extractor(),
+ shardOptions: shardOptions)
+ .WithInMemoryJournal(_ => { }, "custom-journal")
+ .WithInMemorySnapshotStore("custom-snapshot-store")
+ .WithShardingDistributedData(new ShardingDDataOptions
+ {
+ Name = "customReplicator",
+ Role = "test",
+ RecreateOnFailure = true,
+ PreferOldest = false,
+ VerboseDebugLogging = true,
+ Durable = new DurableOptions
+ {
+ Keys = ["custom-*"],
+ Lmdb = new LmdbOptions
+ {
+ Directory = "lmdb",
+ MapSize = 1024 * 1024
+ }
+ },
+ MajorityMinimumCapacity = 1,
+ MaxDeltaElements = 2, // This setting ("max-delta-elements") never get used in core
+ });
+ });
+ }).Build();
+ await host.StartAsync();
+
+ Config appliedShardingConfig;
+ ClusterShardingSettings shardingSettings;
+ ReplicatorSettings replicatorSettings;
+ try
+ {
+ var sys = host.Services.GetRequiredService();
+ (appliedShardingConfig, shardingSettings) = GetClusterShardingSettings(shardOptions, sys);
+ replicatorSettings = GetReplicatorSettings(shardingSettings, sys);
+ }
+ finally
+ {
+ await host.StopAsync();
+ }
+
+ var shardingConfig = ClusterSharding.DefaultConfig().GetConfig("akka.cluster.sharding");
+
+ #region ClusterShardingSettings validation
+
+ Assert.Equal("test", shardingSettings.Role);
+ Assert.True(shardingSettings.RememberEntities);
+ Assert.Equal("custom-journal", shardingSettings.JournalPluginId);
+ Assert.Equal("custom-snapshot-store", shardingSettings.SnapshotPluginId);
+ Assert.Equal(StateStoreMode.DData, shardingSettings.StateStoreMode);
+ Assert.Equal(RememberEntitiesStore.Eventsourced, shardingSettings.RememberEntitiesStore);
+ Assert.Equal(TimeSpan.FromSeconds(2), shardingSettings.ShardRegionQueryTimeout);
+ Assert.Equal(TimeSpan.Zero, shardingSettings.PassivateIdleEntityAfter);
+
+ Assert.True(appliedShardingConfig.GetBoolean("fail-on-invalid-entity-state-transition"));
+ Assert.Equal(1, appliedShardingConfig.GetInt("distributed-data.majority-min-cap"));
+
+ Assert.Equal(shardingConfig.GetTimeSpan("coordinator-failure-backoff"), shardingSettings.TuningParameters.CoordinatorFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("retry-interval"), shardingSettings.TuningParameters.RetryInterval);
+ Assert.Equal(shardingConfig.GetInt("buffer-size"), shardingSettings.TuningParameters.BufferSize);
+ Assert.Equal(shardingConfig.GetTimeSpan("handoff-timeout"), shardingSettings.TuningParameters.HandOffTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-start-timeout"), shardingSettings.TuningParameters.ShardStartTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-failure-backoff"), shardingSettings.TuningParameters.ShardFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-restart-backoff"), shardingSettings.TuningParameters.EntityRestartBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("rebalance-interval"), shardingSettings.TuningParameters.RebalanceInterval);
+ Assert.Equal(shardingConfig.GetInt("snapshot-after"), shardingSettings.TuningParameters.SnapshotAfter);
+ Assert.Equal(shardingConfig.GetInt("keep-nr-of-batches"), shardingSettings.TuningParameters.KeepNrOfBatches);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-threshold"), shardingSettings.TuningParameters.LeastShardAllocationRebalanceThreshold);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.max-simultaneous-rebalance"), shardingSettings.TuningParameters.LeastShardAllocationMaxSimultaneousRebalance);
+ Assert.Equal(shardingConfig.GetTimeSpan("waiting-for-state-timeout"), shardingSettings.TuningParameters.WaitingForStateTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("updating-state-timeout"), shardingSettings.TuningParameters.UpdatingStateTimeout);
+ Assert.Equal(shardingConfig.GetString("entity-recovery-strategy"), shardingSettings.TuningParameters.EntityRecoveryStrategy);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-recovery-constant-rate-strategy.frequency"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyFrequency);
+ Assert.Equal(shardingConfig.GetInt("entity-recovery-constant-rate-strategy.number-of-entities"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyNumberOfEntities);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.write-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateWriteMajorityPlus);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.read-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateReadMajorityPlus);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-absolute-limit"), shardingSettings.TuningParameters.LeastShardAllocationAbsoluteLimit);
+ Assert.Equal(shardingConfig.GetDouble("least-shard-allocation-strategy.rebalance-relative-limit"), shardingSettings.TuningParameters.LeastShardAllocationRelativeLimit);
+
+ var singletonConfig = ClusterSingleton.DefaultConfig().GetConfig("akka.cluster.singleton");
+ Assert.Equal(singletonConfig.GetString("singleton-name"), shardingSettings.CoordinatorSingletonSettings.SingletonName);
+ Assert.Null(shardingSettings.CoordinatorSingletonSettings.Role);
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Tools/Singleton/ClusterSingletonManagerSettings.cs#L58
+ Assert.Equal(TimeSpan.Zero, shardingSettings.CoordinatorSingletonSettings.RemovalMargin);
+ Assert.Equal(singletonConfig.GetTimeSpan("hand-over-retry-interval"), shardingSettings.CoordinatorSingletonSettings.HandOverRetryInterval);
+ {
+ var expectedLease = GetLeaseUsageSettings(shardingConfig);
+ var actualLease = shardingSettings.CoordinatorSingletonSettings.LeaseSettings;
+ if (expectedLease is null)
+ {
+ Assert.Null(actualLease);
+ }
+ else
+ {
+ Assert.NotNull(actualLease);
+ Assert.Equal(expectedLease.LeaseImplementation, actualLease.LeaseImplementation);
+ Assert.Equal(expectedLease.LeaseRetryInterval, actualLease.LeaseRetryInterval);
+ }
+ }
+#pragma warning disable CS0618 // Type or member is obsolete
+ Assert.Equal(singletonConfig.GetBoolean("consider-app-version"), shardingSettings.CoordinatorSingletonSettings.ConsiderAppVersion);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ Assert.NotNull(shardingSettings.LeaseSettings);
+ Assert.Equal("test-lease", shardingSettings.LeaseSettings!.LeaseImplementation);
+ Assert.Equal(1.Seconds(), shardingSettings.LeaseSettings.LeaseRetryInterval);
+
+ #endregion
+
+ #region ReplicatorSettings validation
+ var repConfig = shardingConfig.GetConfig("distributed-data")
+ .WithFallback(DistributedData.DistributedData.DefaultConfig().GetConfig("akka.cluster.distributed-data"));
+
+ Assert.NotEqual("wrong-name", appliedShardingConfig.GetString("distributed-data.name"));
+ Assert.Equal("customReplicator", appliedShardingConfig.GetString("distributed-data.name"));
+
+ Assert.NotEqual("wrong-role", replicatorSettings.Role);
+ Assert.Equal("test", replicatorSettings.Role);
+
+ Assert.Equal(repConfig.GetTimeSpan("gossip-interval"), replicatorSettings.GossipInterval);
+ Assert.Equal(repConfig.GetTimeSpan("notify-subscribers-interval"), replicatorSettings.NotifySubscribersInterval);
+ Assert.Equal(2, replicatorSettings.MaxDeltaElements);
+ Assert.Equal("akka.actor.internal-dispatcher", replicatorSettings.Dispatcher);
+ Assert.Equal(repConfig.GetTimeSpan("pruning-interval"), replicatorSettings.PruningInterval);
+ Assert.Equal(repConfig.GetTimeSpan("max-pruning-dissemination"), replicatorSettings.MaxPruningDissemination);
+ Assert.Empty(replicatorSettings.DurableKeys);
+ Assert.Equal(repConfig.GetTimeSpan("pruning-marker-time-to-live"), replicatorSettings.PruningMarkerTimeToLive);
+ Assert.NotNull(replicatorSettings.DurableStoreProps);
+ Assert.Equal(repConfig.GetInt("delta-crdt.max-delta-size"), replicatorSettings.MaxDeltaSize);
+ Assert.True(replicatorSettings.RestartReplicatorOnFailure);
+ Assert.False(replicatorSettings.PreferOldest);
+ Assert.True(replicatorSettings.VerboseDebugLogging);
+
+ Assert.Equal("lmdb", appliedShardingConfig.GetString("distributed-data.durable.lmdb.dir"));
+ Assert.Equal(1024 * 1024, appliedShardingConfig.GetLong("distributed-data.durable.lmdb.map-size"));
+
+ #endregion
+ }
+
+ [Fact(DisplayName = "Modified ShardOptions and ShardingDDataOptions with DData should contain proper HOCON values")]
+ public async Task ModifiedDDataShardOptionsTest()
+ {
+ var shardOptions = new ShardOptions
+ {
+ StateStoreMode = StateStoreMode.DData,
+ RememberEntitiesStore = RememberEntitiesStore.DData,
+ RememberEntities = true,
+ Role = "test",
+ JournalPluginId = "custom-journal",
+ SnapshotPluginId = "custom-snapshot-store",
+ LeaseImplementation = new TestLeaseOption(),
+ LeaseRetryInterval = TimeSpan.FromSeconds(1),
+ HandOffStopMessage = StopMessage.Instance, // can't be tested, assigned directly
+ FailOnInvalidEntityStateTransition = true,
+#pragma warning disable CS0618 // Type or member is obsolete
+ // This property should never get applied to HOCON
+ DistributedData =
+ {
+ Role = "wrong-role",
+ Name = "wrong-name"
+ },
+#pragma warning restore CS0618 // Type or member is obsolete
+ ShouldPassivateIdleEntities = false,
+ ShardRegionQueryTimeout = TimeSpan.FromSeconds(2),
+ PassivateIdleEntityAfter = TimeSpan.FromSeconds(3),
+ };
+
+ using var host = Host.CreateDefaultBuilder()
+ .ConfigureServices(services =>
+ {
+ services.AddAkka("ConfigSys", (builder, _) =>
+ {
+ builder
+ .WithRemoting()
+ .WithClustering(new ClusterOptions
+ {
+ Roles = ["test"]
+ })
+ .WithShardRegion(
+ typeName: "entities",
+ entityPropsFactory: (_, _) =>
+ {
+ return s => Props.Create(() => new MyEntityActor(s));
+ },
+ messageExtractor: new Extractor(),
+ shardOptions: shardOptions)
+ .WithInMemoryJournal(_ => { }, "custom-journal")
+ .WithInMemorySnapshotStore("custom-snapshot-store")
+ .WithShardingDistributedData(new ShardingDDataOptions
+ {
+ Name = "customReplicator",
+ Role = "test",
+ RecreateOnFailure = true,
+ PreferOldest = false,
+ VerboseDebugLogging = true,
+ Durable = new DurableOptions
+ {
+ Keys = ["custom-*"],
+ Lmdb = new LmdbOptions
+ {
+ Directory = "lmdb",
+ MapSize = 1024 * 1024
+ }
+ },
+ MajorityMinimumCapacity = 1,
+ MaxDeltaElements = 2, // This setting ("max-delta-elements") never get used in core
+ });
+ });
+ }).Build();
+ await host.StartAsync();
+
+ Config appliedShardingConfig;
+ ClusterShardingSettings shardingSettings;
+ ReplicatorSettings replicatorSettings;
+ try
+ {
+ var sys = host.Services.GetRequiredService();
+ (appliedShardingConfig, shardingSettings) = GetClusterShardingSettings(shardOptions, sys);
+ replicatorSettings = GetReplicatorSettings(shardingSettings, sys);
+ }
+ finally
+ {
+ await host.StopAsync();
+ }
+
+ var shardingConfig = ClusterSharding.DefaultConfig().GetConfig("akka.cluster.sharding");
+
+ #region ClusterShardingSettings validation
+
+ Assert.Equal("test", shardingSettings.Role);
+ Assert.True(shardingSettings.RememberEntities);
+ Assert.Equal("custom-journal", shardingSettings.JournalPluginId);
+ Assert.Equal("custom-snapshot-store", shardingSettings.SnapshotPluginId);
+ Assert.Equal(StateStoreMode.DData, shardingSettings.StateStoreMode);
+ Assert.Equal(RememberEntitiesStore.DData, shardingSettings.RememberEntitiesStore);
+ Assert.Equal(2.Seconds(), shardingSettings.ShardRegionQueryTimeout);
+ Assert.Equal(TimeSpan.Zero, shardingSettings.PassivateIdleEntityAfter);
+
+ Assert.True(appliedShardingConfig.GetBoolean("fail-on-invalid-entity-state-transition"));
+ Assert.Equal(1, appliedShardingConfig.GetInt("distributed-data.majority-min-cap"));
+
+ Assert.Equal(shardingConfig.GetTimeSpan("coordinator-failure-backoff"), shardingSettings.TuningParameters.CoordinatorFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("retry-interval"), shardingSettings.TuningParameters.RetryInterval);
+ Assert.Equal(shardingConfig.GetInt("buffer-size"), shardingSettings.TuningParameters.BufferSize);
+ Assert.Equal(shardingConfig.GetTimeSpan("handoff-timeout"), shardingSettings.TuningParameters.HandOffTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-start-timeout"), shardingSettings.TuningParameters.ShardStartTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("shard-failure-backoff"), shardingSettings.TuningParameters.ShardFailureBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-restart-backoff"), shardingSettings.TuningParameters.EntityRestartBackoff);
+ Assert.Equal(shardingConfig.GetTimeSpan("rebalance-interval"), shardingSettings.TuningParameters.RebalanceInterval);
+ Assert.Equal(shardingConfig.GetInt("snapshot-after"), shardingSettings.TuningParameters.SnapshotAfter);
+ Assert.Equal(shardingConfig.GetInt("keep-nr-of-batches"), shardingSettings.TuningParameters.KeepNrOfBatches);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-threshold"), shardingSettings.TuningParameters.LeastShardAllocationRebalanceThreshold);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.max-simultaneous-rebalance"), shardingSettings.TuningParameters.LeastShardAllocationMaxSimultaneousRebalance);
+ Assert.Equal(shardingConfig.GetTimeSpan("waiting-for-state-timeout"), shardingSettings.TuningParameters.WaitingForStateTimeout);
+ Assert.Equal(shardingConfig.GetTimeSpan("updating-state-timeout"), shardingSettings.TuningParameters.UpdatingStateTimeout);
+ Assert.Equal(shardingConfig.GetString("entity-recovery-strategy"), shardingSettings.TuningParameters.EntityRecoveryStrategy);
+ Assert.Equal(shardingConfig.GetTimeSpan("entity-recovery-constant-rate-strategy.frequency"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyFrequency);
+ Assert.Equal(shardingConfig.GetInt("entity-recovery-constant-rate-strategy.number-of-entities"), shardingSettings.TuningParameters.EntityRecoveryConstantRateStrategyNumberOfEntities);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.write-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateWriteMajorityPlus);
+ Assert.Equal(ConfigMajorityPlus(shardingConfig, "coordinator-state.read-majority-plus"), shardingSettings.TuningParameters.CoordinatorStateReadMajorityPlus);
+ Assert.Equal(shardingConfig.GetInt("least-shard-allocation-strategy.rebalance-absolute-limit"), shardingSettings.TuningParameters.LeastShardAllocationAbsoluteLimit);
+ Assert.Equal(shardingConfig.GetDouble("least-shard-allocation-strategy.rebalance-relative-limit"), shardingSettings.TuningParameters.LeastShardAllocationRelativeLimit);
+
+ var singletonConfig = ClusterSingleton.DefaultConfig().GetConfig("akka.cluster.singleton");
+ Assert.Equal(singletonConfig.GetString("singleton-name"), shardingSettings.CoordinatorSingletonSettings.SingletonName);
+ Assert.Null(shardingSettings.CoordinatorSingletonSettings.Role);
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Tools/Singleton/ClusterSingletonManagerSettings.cs#L58
+ Assert.Equal(TimeSpan.Zero, shardingSettings.CoordinatorSingletonSettings.RemovalMargin);
+ Assert.Equal(singletonConfig.GetTimeSpan("hand-over-retry-interval"), shardingSettings.CoordinatorSingletonSettings.HandOverRetryInterval);
+ {
+ var expectedLease = GetLeaseUsageSettings(shardingConfig);
+ var actualLease = shardingSettings.CoordinatorSingletonSettings.LeaseSettings;
+ if (expectedLease is null)
+ {
+ Assert.Null(actualLease);
+ }
+ else
+ {
+ Assert.NotNull(actualLease);
+ Assert.Equal(expectedLease.LeaseImplementation, actualLease.LeaseImplementation);
+ Assert.Equal(expectedLease.LeaseRetryInterval, actualLease.LeaseRetryInterval);
+ }
+ }
+#pragma warning disable CS0618 // Type or member is obsolete
+ Assert.Equal(singletonConfig.GetBoolean("consider-app-version"), shardingSettings.CoordinatorSingletonSettings.ConsiderAppVersion);
+#pragma warning restore CS0618 // Type or member is obsolete
+
+ Assert.NotNull(shardingSettings.LeaseSettings);
+ Assert.Equal("test-lease", shardingSettings.LeaseSettings!.LeaseImplementation);
+ Assert.Equal(TimeSpan.FromSeconds(1), shardingSettings.LeaseSettings.LeaseRetryInterval);
+
+ #endregion
+
+ #region ReplicatorSettings validation
+ var repConfig = shardingConfig.GetConfig("distributed-data")
+ .WithFallback(DistributedData.DistributedData.DefaultConfig().GetConfig("akka.cluster.distributed-data"));
+
+ Assert.NotEqual("wrong-name", appliedShardingConfig.GetString("distributed-data.name"));
+ Assert.Equal("customReplicator", appliedShardingConfig.GetString("distributed-data.name"));
+
+ Assert.NotEqual("wrong-role", replicatorSettings.Role);
+ Assert.Equal("test", replicatorSettings.Role);
+
+ Assert.Equal(repConfig.GetTimeSpan("gossip-interval"), replicatorSettings.GossipInterval);
+ Assert.Equal(repConfig.GetTimeSpan("notify-subscribers-interval"), replicatorSettings.NotifySubscribersInterval);
+ Assert.Equal(2, replicatorSettings.MaxDeltaElements);
+ Assert.Equal("akka.actor.internal-dispatcher", replicatorSettings.Dispatcher);
+ Assert.Equal(repConfig.GetTimeSpan("pruning-interval"), replicatorSettings.PruningInterval);
+ Assert.Equal(repConfig.GetTimeSpan("max-pruning-dissemination"), replicatorSettings.MaxPruningDissemination);
+ Assert.Single(replicatorSettings.DurableKeys, "custom-*");
+ Assert.Equal(repConfig.GetTimeSpan("pruning-marker-time-to-live"), replicatorSettings.PruningMarkerTimeToLive);
+ Assert.NotNull(replicatorSettings.DurableStoreProps);
+ Assert.Equal(repConfig.GetInt("delta-crdt.max-delta-size"), replicatorSettings.MaxDeltaSize);
+ Assert.True(replicatorSettings.RestartReplicatorOnFailure);
+ Assert.False(replicatorSettings.PreferOldest);
+ Assert.True(replicatorSettings.VerboseDebugLogging);
+
+ Assert.Equal("lmdb", appliedShardingConfig.GetString("distributed-data.durable.lmdb.dir"));
+ Assert.Equal(1024 * 1024, appliedShardingConfig.GetLong("distributed-data.durable.lmdb.map-size"));
+
+ #endregion
+ }
+
+ #region Helper methods
+
+ // This is how ShardSettings is created in Akka.Cluster.Hosting
+ // https://github.com/akkadotnet/Akka.Hosting/blob/2f63b5d14b1664003f166a3f30a913dac1428104/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs#L977-L982
+ private static (Config, ClusterShardingSettings) GetClusterShardingSettings(ShardOptions shardOptions, ActorSystem system)
+ {
+ var shardingConfig = ConfigurationFactory.ParseString(shardOptions.ToString())
+ .WithFallback(system.Settings.Config.GetConfig("akka.cluster.sharding"));
+ var coordinatorConfig = system.Settings.Config.GetConfig(
+ shardingConfig.GetString("coordinator-singleton"));
+
+ return (shardingConfig, ClusterShardingSettings.Create(shardingConfig, coordinatorConfig));
+ }
+
+ // Copied from Akka core code
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Sharding/ClusterShardingSettings.cs#L404-L407
+ private static LeaseUsageSettings? GetLeaseUsageSettings(Config config)
+ {
+ LeaseUsageSettings? lease = null;
+ var leaseConfigPath = config.GetString("use-lease");
+ if (!string.IsNullOrEmpty(leaseConfigPath))
+ lease = new LeaseUsageSettings(leaseConfigPath, config.GetTimeSpan("lease-retry-interval"));
+
+ return lease;
+ }
+
+ // Copied from Akka core code
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Sharding/ClusterShardingSettings.cs#L422-L427
+ private static int ConfigMajorityPlus(Config config, string p)
+ {
+ if (config.GetString(p)?.ToLowerInvariant() == "all")
+ return int.MaxValue;
+ return config.GetInt(p);
+ }
+
+ // Copied from Akka core code
+ // This is how sharding replicator settings is populated in core
+ // https://github.com/akkadotnet/akka.net/blob/4ae47927da9f2539742c336acfa8ae0037fabbb7/src/contrib/cluster/Akka.Cluster.Sharding/ClusterShardingGuardian.cs#L300-L310
+ private static ReplicatorSettings GetReplicatorSettings(ClusterShardingSettings shardingSettings, ActorSystem system)
+ {
+ var config = system.Settings.Config.GetConfig("akka.cluster.sharding.distributed-data")
+ .WithFallback(system.Settings.Config.GetConfig("akka.cluster.distributed-data"));
+ var configuredSettings = ReplicatorSettings.Create(config);
+ var settingsWithRoles = configuredSettings.WithRole(shardingSettings.Role);
+ if (shardingSettings is { RememberEntities: true, RememberEntitiesStore: RememberEntitiesStore.DData })
+ return settingsWithRoles; // only enable durable keys when using DData for remember-entities
+ else
+ return settingsWithRoles.WithDurableKeys(ImmutableHashSet.Empty);
+ }
+
+ #endregion
+}
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardedDaemonProcessProxySpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardedDaemonProcessProxySpecs.cs
new file mode 100644
index 00000000000..992ce7a1ec1
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardedDaemonProcessProxySpecs.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Cluster.Sharding;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ShardedDaemonProcessProxySpecs: Akka.Hosting.TestKit.TestKit
+{
+ private class EchoActor : ReceiveActor
+ {
+ public static Props EchoProps(int i) => Props.Create(() => new EchoActor());
+
+ public EchoActor()
+ {
+ ReceiveAny(msg => Sender.Tell(msg));
+ }
+ }
+
+ internal enum ShardedDaemonRouter { }
+
+ public const int NumWorkers = 10;
+ public const string Name = "daemonTest";
+ public const string Role = "workers";
+
+ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
+ {
+ builder
+ .WithRemoting(new RemoteOptions
+ {
+ Port = 0
+ })
+ .WithClustering(new ClusterOptions
+ {
+ Roles = [Role]
+ })
+ // Join cluster via WithActors (not AddStartup) so it runs before
+ // WithShardedDaemonProcess, which needs a formed cluster.
+ .WithActors((system, _) =>
+ {
+ var cluster = Cluster.Get(system);
+ cluster.Join(cluster.SelfAddress);
+ })
+ .WithShardedDaemonProcess(
+ name: Name,
+ numberOfInstances: NumWorkers,
+ entityPropsFactory: (_, _, _) => EchoActor.EchoProps,
+ options: new ClusterDaemonOptions
+ {
+ KeepAliveInterval = 500.Milliseconds(),
+ Role = Role,
+ HandoffStopMessage = PoisonPill.Instance
+ });
+ }
+
+ public ShardedDaemonProcessProxySpecs(ITestOutputHelper output) : base(nameof(ShardedDaemonProcessProxySpecs), output)
+ { }
+
+ [Fact]
+ public async Task ShardedDaemonProcessProxy_must_start_daemon_process_on_proxy()
+ {
+ // validate that we have a cluster
+ await AwaitAssertAsync(() =>
+ {
+ Assert.Equal(1, Cluster.Get(Sys).State.Members.Count(x => x.Status == MemberStatus.Up));
+ }, TimeSpan.FromSeconds(30));
+
+ //
+ var host = await Host.Services.GetRequiredService().GetAsync();
+
+ // ping some of the workers via the host
+ for(var i = 0; i < NumWorkers; i++)
+ {
+ var result = await host.Ask(i);
+ Assert.Equal(i, result);
+ }
+ //
+
+ //
+ // start the proxy on the proxy system, which runs on a different role not capable of hosting workers
+ ProxySystem? proxySystem = null;
+ try
+ {
+ proxySystem = new ProxySystem(Output, Sys);
+ await proxySystem.InitializeAsync();
+
+ // validate that we have a 2 node cluster with both members marked as up.
+ // Joining plus gossip convergence to Up regularly takes longer than the TestKit's
+ // default 3 s assertion budget on CI (akkadotnet/Akka.Hosting#663), so give it room.
+ await AwaitAssertAsync(() =>
+ {
+ Assert.Equal(2, Cluster.Get(Sys).State.Members.Count(x => x.Status == MemberStatus.Up));
+ Assert.Equal(2, Cluster.Get(proxySystem.Sys).State.Members.Count(x => x.Status == MemberStatus.Up));
+ }, TimeSpan.FromSeconds(30));
+
+ var proxyRouter = await proxySystem.Host.Services
+ .GetRequiredService().GetAsync();
+
+ // ping some of the workers via the proxy
+ for(var i = 0; i < NumWorkers; i++)
+ {
+ var result = await proxyRouter.Ask(i);
+ Assert.Equal(i, result);
+ }
+ }
+ finally
+ {
+ if (proxySystem is not null)
+ await proxySystem.DisposeAsync();
+ }
+ //
+ }
+
+}
+
+public class ProxySystem: Akka.Hosting.TestKit.TestKit
+{
+ private readonly Cluster _remoteCluster;
+
+ public ProxySystem(ITestOutputHelper? output, ActorSystem remoteSystem)
+ : base(nameof(ShardedDaemonProcessProxySpecs), output)
+ {
+ _remoteCluster = Cluster.Get(remoteSystem);
+ }
+
+ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
+ {
+ builder
+ .WithRemoting(new RemoteOptions
+ {
+ Port = 0
+ })
+ .WithClustering(new ClusterOptions
+ {
+ Roles = new[]{ "proxy" }
+ })
+ // Join cluster via WithActors (not AddStartup) so it runs before
+ // WithShardedDaemonProcessProxy, which needs a formed cluster.
+ .WithActors((system, _) =>
+ {
+ var cluster = Cluster.Get(system);
+ cluster.Join(_remoteCluster.SelfAddress);
+ })
+ .WithShardedDaemonProcessProxy(
+ name: ShardedDaemonProcessProxySpecs.Name,
+ numberOfInstances: ShardedDaemonProcessProxySpecs.NumWorkers,
+ role: ShardedDaemonProcessProxySpecs.Role);
+ }
+}
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardedDaemonProcessSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardedDaemonProcessSpecs.cs
new file mode 100644
index 00000000000..e7491cea6e3
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/ShardedDaemonProcessSpecs.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Event;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Akka.TestKit;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class ShardedDaemonProcessSpecs: Akka.Hosting.TestKit.TestKit
+{
+ private sealed class Stop
+ {
+ public static Stop Instance { get; } = new();
+ private Stop() { }
+ }
+
+ internal sealed class Started
+ {
+ public int Id { get; }
+ public IActorRef SelfRef { get; }
+
+ public Started(int id, IActorRef selfRef)
+ {
+ Id = id;
+ SelfRef = selfRef;
+ }
+ }
+
+ internal class MyDaemonActor : UntypedActor
+ {
+ private readonly int _id;
+ private readonly IActorRef _probe;
+ private readonly ILoggingAdapter _log;
+
+ public MyDaemonActor(int id, IRequiredActor probe)
+ {
+ _id = id;
+ _probe = probe.ActorRef;
+ _log = Context.GetLogger();
+ }
+
+ protected override void PreStart()
+ {
+ base.PreStart();
+ _probe.Tell(new Started(_id, Context.Self));
+ _log.Info("Actor {0} started", _id);
+ }
+
+ protected override void PostStop()
+ {
+ base.PostStop();
+ _log.Info("Actor {0} stopped", _id);
+ }
+
+ protected override void OnReceive(object message)
+ {
+ switch (message)
+ {
+ case Stop:
+ Context.Stop(Self);
+ break;
+ default:
+ Unhandled(message);
+ break;
+ }
+ }
+ }
+
+ internal enum ShardedDaemonRouter { }
+
+ private Cluster _cluster = null!;
+
+ public ShardedDaemonProcessSpecs(ITestOutputHelper output) : base(nameof(ShardedDaemonProcessSpecs), output)
+ {
+ }
+
+ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
+ {
+ builder
+ .WithRemoting(new RemoteOptions
+ {
+ Port = 0
+ })
+ .WithClustering()
+ // Join cluster via WithActors (not AddStartup) so it runs before
+ // WithShardedDaemonProcess, which depends on cluster formation.
+ .WithActors(async (system, _) =>
+ {
+ var cluster = Cluster.Get(system);
+ await cluster.JoinAsync(cluster.SelfAddress);
+ })
+ .WithShardedDaemonProcess(
+ name: "test",
+ numberOfInstances: 5,
+ entityPropsFactory: (_, _, resolver) => id => resolver.Props(typeof(MyDaemonActor), id),
+ options: new ClusterDaemonOptions
+ {
+ KeepAliveInterval = 500.Milliseconds()
+ });
+ }
+
+ protected override async Task BeforeTestStart()
+ {
+ _cluster = Cluster.Get(Sys);
+
+ await AwaitAssertAsync(() => Assert.Equal(MemberStatus.Up, _cluster.SelfMember.Status), 30.Seconds());
+ }
+
+ [Fact]
+ public async Task ShardedDaemonProcess_must_start_N_actors_with_unique_ids()
+ {
+ var started = new List();
+ foreach (var _ in Enumerable.Range(0, 5))
+ {
+ started.Add(await ExpectMsgAsync());
+ }
+
+ Assert.Equal(5, started.Count);
+ Assert.Equal([0, 1, 2, 3, 4], started.Select(s => s.Id).OrderBy(s => s));
+ await ExpectNoMsgAsync(1.Seconds());
+ }
+
+ [Fact]
+ public async Task ShardedDaemonProcess_must_restart_actors_if_they_stop()
+ {
+ var startMessages = new List();
+ foreach (var _ in Enumerable.Range(0, 5))
+ {
+ startMessages.Add(await ExpectMsgAsync());
+ }
+
+ Assert.Equal(5, startMessages.Count);
+ Assert.Equal([0, 1, 2, 3, 4], startMessages.Select(s => s.Id).OrderBy(s => s));
+
+ // Stop all entities
+ foreach (var start in startMessages)
+ {
+ start.SelfRef.Tell(Stop.Instance);
+ }
+
+ startMessages.Clear();
+ // periodic ping every 1s makes it restart
+ foreach (var _ in Enumerable.Range(0, 5))
+ {
+ startMessages.Add(await ExpectMsgAsync());
+ }
+
+ Assert.Equal(5, startMessages.Count);
+ Assert.Equal([0, 1, 2, 3, 4], startMessages.Select(s => s.Id).OrderBy(s => s));
+ }
+}
+
+public class ShardedDaemonProcessFailureSpecs : Akka.Hosting.TestKit.TestKit
+{
+ protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
+ {
+ builder
+ .WithRemoting()
+ .WithClustering()
+ // Join cluster via WithActors (not AddStartup) so it runs before
+ // WithShardedDaemonProcess, which depends on cluster formation.
+ .WithActors(async (system, _) =>
+ {
+ var cluster = Cluster.Get(system);
+ await cluster.JoinAsync(cluster.SelfAddress);
+ })
+ .WithShardedDaemonProcess(
+ name: "test",
+ numberOfInstances: 5,
+ entityPropsFactory: (_, _, resolver) => id => resolver.Props(typeof(ShardedDaemonProcessSpecs.MyDaemonActor), id),
+ options: new ClusterDaemonOptions
+ {
+ KeepAliveInterval = 500.Milliseconds(),
+ Role = "DoNotExist"
+ });
+ }
+
+ [Fact]
+ public async Task ShardedDaemonProcess_must_not_run_if_the_role_does_not_match_node_role()
+ {
+ var registry = Host.Services.GetRequiredService();
+ Assert.False(registry.TryGet(out _));
+
+ await ExpectNoMsgAsync(1.Seconds());
+ }
+}
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs
new file mode 100644
index 00000000000..73a96489123
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs
@@ -0,0 +1,189 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2009-2022 Lightbend Inc.
+// Copyright (C) 2013-2022 .NET Foundation
+//
+// -----------------------------------------------------------------------
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Cluster.Hosting.SBR;
+using Akka.Cluster.Hosting.Tests.Lease;
+using Akka.Cluster.SBR;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Xunit;
+using LogLevel = Microsoft.Extensions.Logging.LogLevel;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class SplitBrainResolverSpecs
+{
+ private readonly ITestOutputHelper _output;
+
+ public SplitBrainResolverSpecs(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ private async Task StartHost(Action specBuilder)
+ {
+ var tcs = new TaskCompletionSource();
+ using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+
+ var host = new HostBuilder()
+ .ConfigureLogging(logger =>
+ {
+ logger.ClearProviders();
+ logger.AddProvider(new XUnitLoggerProvider(_output, LogLevel.Information));
+ })
+ .ConfigureServices(collection =>
+ {
+ collection.AddAkka("TestSys", (configurationBuilder, provider) =>
+ {
+ configurationBuilder
+ .ConfigureLoggers(logger =>
+ {
+ logger.ClearLoggers();
+ logger.AddLoggerFactory();
+ })
+ .WithRemoting("localhost", 0)
+ .AddStartup((system, registry) =>
+ {
+ var cluster = Cluster.Get(system);
+ cluster.RegisterOnMemberUp(() =>
+ {
+ tcs.SetResult();
+ });
+ cluster.Join(cluster.SelfAddress);
+ });
+ specBuilder(configurationBuilder);
+ });
+ }).Build();
+
+ await host.StartAsync(cancellationTokenSource.Token);
+ await tcs.Task.WaitAsync(cancellationTokenSource.Token);
+
+ return host;
+ }
+
+ [Fact(DisplayName = "Default SBR set from Akka.Hosting should load")]
+ public async Task HostingSbrTest()
+ {
+ var host = await StartHost(builder =>
+ {
+ builder.WithClustering( new ClusterOptions{ SplitBrainResolver = SplitBrainResolverOption.Default });
+ });
+
+ var system = host.Services.GetRequiredService();
+
+ Assert.IsType(Cluster.Get(system).DowningProvider);
+
+ var settings = new SplitBrainResolverSettings(system.Settings.Config);
+ Assert.Equal(SplitBrainResolverSettings.KeepMajorityName, settings.DowningStrategy);
+ Assert.Null(settings.KeepMajorityRole);
+ }
+
+ [Fact(DisplayName = "Static quorum SBR set from Akka.Hosting should load")]
+ public async Task StaticQuorumTest()
+ {
+ var host = await StartHost(builder =>
+ {
+ builder.WithClustering( new ClusterOptions
+ {
+ SplitBrainResolver = new StaticQuorumOption
+ {
+ QuorumSize = 1,
+ Role = "myRole"
+ }
+ });
+ });
+
+ var system = host.Services.GetRequiredService();
+ Assert.IsType(Cluster.Get(system).DowningProvider);
+
+ var settings = new SplitBrainResolverSettings(system.Settings.Config);
+ Assert.Equal(SplitBrainResolverSettings.StaticQuorumName, settings.DowningStrategy);
+ Assert.Equal(1, settings.StaticQuorumSettings.Size);
+ Assert.Equal("myRole", settings.StaticQuorumSettings.Role);
+ }
+
+ [Fact(DisplayName = "Keep majority SBR set from Akka.Hosting should load")]
+ public async Task KeepMajorityTest()
+ {
+ var host = await StartHost(builder =>
+ {
+ builder.WithClustering(new ClusterOptions
+ {
+ SplitBrainResolver = new KeepMajorityOption
+ {
+ Role = "myRole"
+ }
+ });
+ });
+
+ var system = host.Services.GetRequiredService();
+ Assert.IsType(Cluster.Get(system).DowningProvider);
+
+ var settings = new SplitBrainResolverSettings(system.Settings.Config);
+ Assert.Equal(SplitBrainResolverSettings.KeepMajorityName, settings.DowningStrategy);
+ Assert.Equal("myRole", settings.KeepMajorityRole);
+ }
+
+ [Fact(DisplayName = "Keep oldest SBR set from Akka.Hosting should load")]
+ public async Task KeepOldestTest()
+ {
+ var host = await StartHost(builder =>
+ {
+ builder.WithClustering(new ClusterOptions
+ {
+ SplitBrainResolver = new KeepOldestOption
+ {
+ DownIfAlone = false,
+ Role = "myRole"
+ }
+ });
+ });
+
+ var system = host.Services.GetRequiredService();
+ Assert.IsType(Cluster.Get(system).DowningProvider);
+
+ var settings = new SplitBrainResolverSettings(system.Settings.Config);
+ Assert.Equal(SplitBrainResolverSettings.KeepOldestName, settings.DowningStrategy);
+ Assert.False(settings.KeepOldestSettings.DownIfAlone);
+ Assert.Equal("myRole", settings.KeepOldestSettings.Role);
+ }
+
+ [Fact(DisplayName = "Lease Majority SBR set from Akka.Hosting should load")]
+ public async Task LeaseMajorityTest()
+ {
+ var host = await StartHost(builder =>
+ {
+ builder.AddHocon(TestLease.Configuration, HoconAddMode.Prepend);
+ builder.WithClustering(new ClusterOptions
+ {
+ SplitBrainResolver = new LeaseMajorityOption
+ {
+ LeaseImplementation = new TestLeaseOption(),
+ LeaseName = "myService-akka-sbr",
+ Role = "myRole"
+ }
+ });
+ });
+
+ var system = host.Services.GetRequiredService();
+ Assert.IsType(Cluster.Get(system).DowningProvider);
+
+ var settings = new SplitBrainResolverSettings(system.Settings.Config);
+ Assert.Equal(SplitBrainResolverSettings.LeaseMajorityName, settings.DowningStrategy);
+ Assert.Equal("test-lease", settings.LeaseMajoritySettings.LeaseImplementation);
+ Assert.Equal("myService-akka-sbr", settings.LeaseMajoritySettings.LeaseName);
+ Assert.Equal("myRole", settings.LeaseMajoritySettings.Role);
+ }
+
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/TestHelper.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/TestHelper.cs
new file mode 100644
index 00000000000..85ead8b810b
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/TestHelper.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Event;
+using Akka.Hosting;
+using Akka.Remote.Hosting;
+using Akka.TestKit.Xunit.Internals;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public static class TestHelper
+{
+
+ public static void ConfigureHost(this AkkaConfigurationBuilder builder,
+ Action specBuilder,
+ ClusterOptions options, TaskCompletionSource tcs, ITestOutputHelper output)
+ {
+ builder
+ .WithRemoting("localhost", 0)
+ .WithClustering(options)
+ .WithActors((system, registry) =>
+ {
+ var extSystem = (ExtendedActorSystem)system;
+ var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(output)));
+ logger.Tell(new InitializeLogger(system.EventStream));
+ })
+ // Use WithActors (not AddStartup) so cluster join runs as an _actorStarter
+ // before any cluster-dependent starters like WithShardRegion registered by specBuilder.
+ .WithActors(async (system, registry) =>
+ {
+ var cluster = Cluster.Get(system);
+ cluster.RegisterOnMemberUp(tcs.SetResult);
+ if (options.SeedNodes == null || options.SeedNodes.Length == 0)
+ {
+ var myAddress = cluster.SelfAddress;
+ await cluster.JoinAsync(myAddress);
+ }
+ });
+ specBuilder(builder);
+ }
+
+ public static async Task CreateHost(Action specBuilder, ClusterOptions options, ITestOutputHelper output)
+ {
+ var tcs = new TaskCompletionSource();
+
+ var host = new HostBuilder()
+ .ConfigureServices(collection =>
+ {
+ collection.AddAkka("TestSys", (configurationBuilder, provider) =>
+ {
+ configurationBuilder.ConfigureHost(specBuilder, options, tcs, output);
+ });
+ }).Build();
+
+ // Use a generous startup timeout — must not be so tight that it triggers
+ // host.StopAsync (and CoordinatedShutdown) while startup is still in progress.
+ using var startupCts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
+ await host.StartAsync(startupCts.Token);
+
+ // Separate timeout for cluster formation (happens after host startup completes).
+ using var clusterCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ await tcs.Task.WaitAsync(clusterCts.Token);
+
+ return host;
+ }
+
+ public static TimeSpan Seconds(this double value)
+ => TimeSpan.FromSeconds(value);
+
+ public static TimeSpan Seconds(this int value)
+ => TimeSpan.FromSeconds(value);
+
+ public static TimeSpan Milliseconds(this double value)
+ => TimeSpan.FromMilliseconds(value);
+
+ public static TimeSpan Milliseconds(this int value)
+ => TimeSpan.FromMilliseconds(value);
+
+ public static void CollectionEquals(this IEnumerable list1, IEnumerable list2)
+ {
+ Assert.Equal(list1.OrderBy(a => a), list2.OrderBy(a => a));
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/XUnitLogger.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/XUnitLogger.cs
new file mode 100644
index 00000000000..e5ce8db23c1
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/XUnitLogger.cs
@@ -0,0 +1,84 @@
+using System;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class XUnitLogger: ILogger
+{
+ private const string NullFormatted = "[null]";
+
+ private readonly string _category;
+ private readonly ITestOutputHelper _helper;
+ private readonly LogLevel _logLevel;
+
+ public XUnitLogger(string category, ITestOutputHelper helper, LogLevel logLevel)
+ {
+ _category = category;
+ _helper = helper;
+ _logLevel = logLevel;
+ }
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ return;
+
+ if (!TryFormatMessage(state, exception, formatter, out var formattedMessage))
+ return;
+
+ WriteLogEntry(logLevel, eventId, formattedMessage, exception);
+ }
+
+ private void WriteLogEntry(LogLevel logLevel, EventId eventId, string? message, Exception? exception)
+ {
+ var level = logLevel switch
+ {
+ LogLevel.Critical => "CRT",
+ LogLevel.Debug => "DBG",
+ LogLevel.Error => "ERR",
+ LogLevel.Information => "INF",
+ LogLevel.Warning => "WRN",
+ LogLevel.Trace => "DBG",
+ _ => "???"
+ };
+
+ var msg = $"{DateTime.Now}:{level}:{_category}:{eventId} {message}";
+ if (exception != null)
+ msg += $"\n{exception.GetType()} {exception.Message}\n{exception.StackTrace}";
+ _helper.WriteLine(msg);
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return logLevel switch
+ {
+ LogLevel.None => false,
+ _ => logLevel >= _logLevel
+ };
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull
+ {
+ throw new NotImplementedException();
+ }
+
+ private static bool TryFormatMessage(
+ TState state,
+ Exception? exception,
+ Func formatter,
+ out string? result)
+ {
+ formatter = formatter ?? throw new ArgumentNullException(nameof(formatter));
+
+ var formattedMessage = formatter(state, exception);
+ if (formattedMessage == NullFormatted)
+ {
+ result = null;
+ return false;
+ }
+
+ result = formattedMessage;
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting.Tests/XUnitLoggerProvider.cs b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/XUnitLoggerProvider.cs
new file mode 100644
index 00000000000..7928bf54434
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting.Tests/XUnitLoggerProvider.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace Akka.Cluster.Hosting.Tests;
+
+public class XUnitLoggerProvider : ILoggerProvider
+{
+ private readonly ITestOutputHelper _helper;
+ private readonly LogLevel _logLevel;
+
+ public XUnitLoggerProvider(ITestOutputHelper helper, LogLevel logLevel)
+ {
+ _helper = helper;
+ _logLevel = logLevel;
+ }
+
+ public void Dispose()
+ {
+ // no-op
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new XUnitLogger(categoryName, _helper, _logLevel);
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj b/src/contrib/hosting/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj
new file mode 100644
index 00000000000..8499cddb393
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Akka.Cluster and Akka.Cluster.Sharding Microsoft.Extensions.Hosting support.
+ $(NetStandardLibVersion)
+ enable
+ $(AkkaPackageTags);hosting;cluster;sharding
+ true
+ https://getakka.net/articles/hosting/index.html
+
+
+
+
+
+
+
+
+
+
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/AkkaClusterHealthCheck.cs b/src/contrib/hosting/Akka.Cluster.Hosting/AkkaClusterHealthCheck.cs
new file mode 100644
index 00000000000..1c0827acecd
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting/AkkaClusterHealthCheck.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Akka.Hosting;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Akka.Cluster.Hosting;
+
+internal static class ClusterHealthCheckHelpers
+{
+ public static IReadOnlyDictionary DumpClusterState(this ClusterEvent.CurrentClusterState state)
+ {
+ return new Dictionary
+ {
+ {"cluster.members", state.Members.Count},
+ {"cluster.unreachable", state.Unreachable.Count},
+ {"cluster.leader", state.Leader.ToString()}
+ };
+ }
+}
+
+///
+/// Checks to see if we've joined a cluster and have been marked as
+/// or
+///
+public sealed class AkkaClusterReadinessCheck : IAkkaHealthCheck
+{
+ ///
+ /// Have we successfully joined the cluster?
+ ///
+ public bool WeHaveJoined { get; private set; }
+
+ public DateTime BeganJoining { get; } = DateTime.UtcNow;
+
+ public DateTime? FinishedJoining { get; private set; }
+
+ public HealthCheckResult HealthyResult(DateTime finishedJoining) => HealthCheckResult.Healthy(
+ $"Observed successful cluster join after [{finishedJoining - BeganJoining:g}] - actual join duration was probably faster, but this is how quickly the health check observed it.");
+
+ public HealthCheckResult UnhealthyResult(DateTime now, HealthStatus failureStatus) =>
+ new HealthCheckResult(failureStatus, $"Have not yet joined Akka.NET cluster [{now - BeganJoining:g}] elapsed");
+
+ public Task CheckHealthAsync(AkkaHealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ if (WeHaveJoined && FinishedJoining != null)
+ return Task.FromResult(HealthyResult(FinishedJoining.Value));
+
+ var cluster = Cluster.Get(context.ActorSystem);
+ WeHaveJoined = cluster.SelfMember.Status is MemberStatus.Up or MemberStatus.WeaklyUp;
+
+ if (WeHaveJoined)
+ {
+ FinishedJoining = DateTime.UtcNow;
+ return Task.FromResult(HealthyResult(FinishedJoining.Value));
+ }
+
+ return Task.FromResult(UnhealthyResult(DateTime.UtcNow, context.Registration.FailureStatus));
+ }
+}
\ No newline at end of file
diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs b/src/contrib/hosting/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs
new file mode 100644
index 00000000000..33ac613cb1b
--- /dev/null
+++ b/src/contrib/hosting/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs
@@ -0,0 +1,1997 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Akka.Actor;
+using Akka.Cluster.Hosting.SBR;
+using Akka.Cluster.Sharding;
+using Akka.Cluster.Tools.Client;
+using Akka.Cluster.Tools.PublishSubscribe;
+using Akka.Cluster.Tools.Singleton;
+using Akka.Configuration;
+using Akka.Coordination;
+using Akka.DependencyInjection;
+using Akka.Discovery;
+using Akka.DistributedData;
+using Akka.Hosting;
+using Akka.Hosting.Coordination;
+using Akka.Persistence.Hosting;
+using Akka.Remote.Hosting;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Akka.Cluster.Hosting
+{
+ ///
+ /// The set of options for enabling Akka.Cluster support.
+ ///
+ public sealed class ClusterOptions
+ {
+ ///
+ /// The akka.cluster.roles values.
+ ///
+ public string[]? Roles { get; set; }
+
+ ///
+ /// Optional cluster role check to consider if a specific cluster role have enough
+ /// members to be considered to be up. The default value is 1 node per role.
+ ///
+ public Dictionary? MinimumNumberOfMembersPerRole { get; set; }
+
+ ///
+ /// If populated, the akka.cluster.seed-nodes that will be used.
+ ///
+ public string[]? SeedNodes { get; set; }
+
+ ///
+ ///
+ /// Minimum required number of members before the leader changes member status
+ /// of 'Joining' members to 'Up'. Typically used together with
+ /// to defer some action, such as starting actors,
+ /// until the cluster has reached a certain size.
+ ///
+ /// Default: 1
+ ///
+ public int? MinimumNumberOfMembers { get; set; }
+
+ ///
+ ///
+ /// Application version of the deployment. Used by rolling update features
+ /// to distinguish between old and new nodes. The typical convention is to use
+ /// 3 digit version numbers `major.minor.patch`, but 1 or two digits are also
+ /// supported.
+ ///
+ ///
+ /// If no `.` is used it is interpreted as a single digit version number or as
+ /// plain alphanumeric if it couldn't be parsed as a number.
+ ///
+ ///
+ /// It may also have a qualifier at the end for 2 or 3 digit version numbers such
+ /// as "1.2-RC1".
+ /// For 1 digit with qualifier, 1-RC1, it is interpreted as plain alphanumeric.
+ ///
+ ///
+ /// It has support for https://github.com/dwijnand/sbt-dynver format with `+` or
+ /// `-` separator. The number of commits from the tag is handled as a numeric part.
+ /// For example `1.0.0+3-73475dce26` is less than `1.0.10+10-ed316bd024` (3 < 10).
+ ///
+ ///
+ /// Values can be "assembly-version" or a version string as defined above, i.e.
+ /// app-version = "1.0.0"
+ /// app-version = "1.1-beta1"
+ /// app-version = "1"
+ /// app-version = "1.1"
+ ///
+ /// Default: by default the app-version will default to the entry assembly's version,
+ /// i.e. the assembly of the executable running `Program.cs`
+ ///
+ public string? AppVersion { get; set; }
+
+ ///
+ ///
+ /// Enable/disable info level logging of cluster events
+ ///
+ /// Default: true
+ ///
+ public bool? LogInfo { get; set; }
+
+ ///
+ ///
+ /// Enable/disable verbose info-level logging of cluster events for temporary troubleshooting.
+ ///
+ /// Default: false
+ ///
+ public bool? LogInfoVerbose { get; set; }
+
+ ///
+ /// Split brain resolver configuration parameters. This can be an instance of one of these classes:
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// To use the default split brain resolver options, use which
+ /// uses the keep majority resolving strategy.
+ ///
+ public SplitBrainResolverOption? SplitBrainResolver { get; set; }
+
+ ///
+ ///
+ /// Settings for the failure detector used by the cluster subsystem to detect unreachable members.
+ ///
+ ///
+ public PhiAccrualFailureDetectorOptions? FailureDetector { get; set; }
+
+ ///
+ /// Enables a to be attached to Microsoft.Extensions.Diagnostics.HealthChecks
+ /// by default. This will check the cluster state and ensure that the cluster is ready.
+ ///
+ ///
+ /// Defaults to false.
+ ///
+ public bool ClusterReadyCheck { get; set; }
+ }
+
+ public sealed class ClusterSingletonOptions
+ {
+ ///
+ ///
+ /// The number of messages will buffer when the cluster singleton
+ /// location is unknown. Older messages will be dropped on buffer overflow. Setting this property to 0
+ /// will disable the buffer.
+ ///
+ /// Valid values: 0 - 10000
+ /// Default: 1000
+ ///
+ public int? BufferSize { get; set; } = null;
+
+ ///
+ /// If set, the singleton will only be instantiated on nodes set with the role name.
+ ///
+ public string? Role { get; set; }
+
+ ///
+ /// When handing over to a new oldest node this is sent to the singleton actor
+ /// to tell it to finish its work, close resources, and stop. The hand-over to the new oldest node
+ /// is completed when the singleton actor is terminated. Note that is a
+ /// perfectly fine if you only need to stop the actor.
+ ///
+ public object? TerminationMessage { get; set; }
+
+ ///
+ /// An class instance that extends , used to configure the lease provider used in this
+ /// cluster singleton.
+ ///
+ public LeaseOptionBase? LeaseImplementation { get; set; }
+
+ ///
+ /// The interval between retries for acquiring the lease
+ ///
+ public TimeSpan? LeaseRetryInterval { get; set; }
+
+ ///
+ /// Interval at which the proxy will try to resolve the singleton instance.
+ ///
+ public TimeSpan? SingletonIdentificationInterval { get; set; }
+
+ ///
+ /// Should the singleton proxy publish a warning if no singleton actor were found after a period of time
+ ///
+ public bool? LogSingletonIdentificationFailure { get; set; }
+
+ ///
+ /// The period the proxy will wait until it logs a missing singleton warning, defaults to 1 minute
+ ///
+ public TimeSpan? SingletonIdentificationFailurePeriod { get; set; }
+
+ internal ClusterSingletonManagerSettings ToManagerSettings(string singletonName, ActorSystem system)
+ {
+ var settings = ClusterSingletonManagerSettings.Create(system);
+
+ var retry = LeaseRetryInterval ?? system.Settings.Config.GetTimeSpan("akka.cluster.singleton.lease-retry-interval");
+ var leaseSettings = LeaseImplementation is not null
+ ? new LeaseUsageSettings(LeaseImplementation.ConfigPath, retry)
+ : null;
+
+ return new ClusterSingletonManagerSettings(
+ singletonName: singletonName,
+ role: Role ?? settings.Role,
+ removalMargin: settings.RemovalMargin,
+ handOverRetryInterval: settings.HandOverRetryInterval,
+ leaseSettings: leaseSettings ?? settings.LeaseSettings,
+ considerAppVersion: false);
+ }
+
+ internal ClusterSingletonProxySettings ToProxySettings(string singletonName, ActorSystem system)
+ {
+ var settings = ClusterSingletonProxySettings.Create(system);
+ return new ClusterSingletonProxySettings(
+ singletonName: singletonName,
+ role: Role ?? settings.Role,
+ singletonIdentificationInterval: SingletonIdentificationInterval ?? settings.SingletonIdentificationInterval,
+ bufferSize: BufferSize ?? settings.BufferSize,
+ considerAppVersion: settings.ConsiderAppVersion,
+ logSingletonIdentificationFailure: LogSingletonIdentificationFailure ?? settings.LogSingletonIdentificationFailure,
+ singletonIdentificationFailurePeriod: SingletonIdentificationFailurePeriod ?? settings.SingletonIdentificationFailurePeriod);
+ }
+
+ }
+
+ public sealed class ShardOptions
+ {
+ ///
+ ///
+ /// Defines how the coordinator stores its state. The same setting is also used by the
+ /// shards when is set to true.
+ ///
+ ///
+ /// Possible values are and
+ ///
+ ///
+ public StateStoreMode? StateStoreMode { get; set; }
+
+ ///
+ ///
+ /// When is enabled and the state store mode is
+ /// , this controls how the remembered entities
+ /// and shards are stored.
+ ///
+ ///
+ ///
+ /// Possible values are and
+ ///
+ ///
+ ///
+ public RememberEntitiesStore? RememberEntitiesStore { get; set; }
+
+ ///
+ /// When set to true, the active entity actors will automatically be restarted
+ /// upon Shard restart. i.e. if the Shard is started on a different ShardRegion
+ /// due to re-balance or crash.
+ ///
+ public bool? RememberEntities { get; set; }
+
+ ///
+ /// Specifies that entities should be instantiated on cluster nodes with a specific role.
+ /// If not specified, all nodes in the cluster are used.
+ ///
+ public string? Role { get; set; }
+
+ ///
+ ///
+ /// The journal plugin configuration identifier used by persistence mode, eg. "sql-server" or
+ /// "postgresql".
+ /// You only need to declare or ,
+ /// Identifier will be used if both are declared.
+ ///
+ /// NOTE This setting is only used when is set to
+ ///
+ ///
+ public string? JournalPluginId { get; set; }
+
+ ///
+ ///
+ /// The journal plugin options used by persistence mode, eg. SqlServerJournalOptions
+ /// or PostgreSqlJournalOptions.
+ /// You only need to declare or ,
+ /// Identifier will be used if both are declared.
+ ///
+ /// NOTE This setting is only used when is set to
+ ///
+ ///
+ public JournalOptions? JournalOptions { get; set; }
+
+ ///
+ ///
+ /// The snapshot store plugin configuration identifier used by persistence mode, eg. "sql-server" or
+ /// "postgresql".
+ /// You only need to declare or ,
+ /// Identifier will be used if both are declared.
+ ///
+ /// NOTE This setting is only used when is set to
+ ///
+ ///
+ public string? SnapshotPluginId { get; set; }
+
+ ///
+ ///
+ /// The snapshot store plugin options used by persistence mode, eg. SqlServerSnapshotOptions
+ /// or PostgreSqlSnapshotOptions.
+ /// You only need to declare or ,
+ /// Identifier will be used if both are declared.
+ ///
+ /// NOTE This setting is only used when is set to
+ ///
+ ///
+ public SnapshotOptions? SnapshotOptions { get; set; }
+
+ ///
+ /// An class instance that extends , used to configure the lease provider used in this
+ /// sharding region.
+ ///
+ public LeaseOptionBase? LeaseImplementation { get; set; }
+
+ ///
+ /// The interval between retries for acquiring the lease
+ ///
+ public TimeSpan? LeaseRetryInterval { get; set; }
+
+ ///
+ /// The message that will be sent to entities when they are to be stopped for a rebalance or
+ /// graceful shutdown of a , e.g. .
+ ///
+ public object? HandOffStopMessage { get; set; }
+
+ ///
+ /// Throw an exception if the internal state machine in the Shard actor does an invalid state transition.
+ /// Mostly for the Akka test suite, if off the invalid transition is logged as a warning instead of throwing and
+ /// crashing the shard.
+ ///
+ public bool? FailOnInvalidEntityStateTransition { get; set; }
+
+ ///
+ ///
+ /// Settings for the Distributed Data replicator.
+ /// The property is not used. The distributed-data
+ /// role will be the same as .
+ /// Note that there is one Replicator per role and it's not possible
+ /// to have different distributed-data settings for different sharding entity types.
+ ///
+ /// NOTE This setting is only used when is set to
+ ///
+ ///
+ [Obsolete("This property is not being applied to the ActorSystem anymore. " +
+ "Use `WithShardingDistributedData()` extension method or set them using manual HOCON " +
+ "configuration to set \"akka.cluster.sharding.distributed-data\" values. " +
+ "Since v1.5.27")]
+ public ShardingDDataOptions DistributedData { get; } = new();
+
+ ///
+ /// Set this to false to disable idle entity passivation. When set to false,
+ /// will always override
+ ///
+ public bool? ShouldPassivateIdleEntities { get; set; }
+
+ ///
+ /// Set this to a time duration to have sharding passivate entities when they have not
+ /// received any message in this length of time.
+ /// It is always disabled if is enabled
+ /// or is set to false.
+ ///
+ public TimeSpan? PassivateIdleEntityAfter { get; set; }
+
+ public TimeSpan? ShardRegionQueryTimeout { get; set; }
+
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+
+ if (Role is not null)
+ sb.AppendLine($"role = {Role.ToHocon()}");
+
+ if(RememberEntities is not null)
+ sb.AppendLine($"remember-entities = {RememberEntities.ToHocon()}");
+
+ if(RememberEntitiesStore is not null)
+ sb.AppendLine($"remember-entities-store = {RememberEntitiesStore.Value.ToString().ToLowerInvariant().ToHocon()}");
+
+ var journalId = JournalOptions?.PluginId ?? JournalPluginId ?? null;
+ if (journalId is not null)
+ sb.AppendLine($"journal-plugin-id = {journalId.ToHocon()}");
+
+ var snapshotId = SnapshotOptions?.PluginId ?? SnapshotPluginId ?? null;
+ if (snapshotId is not null)
+ sb.AppendLine($"snapshot-plugin-id = {snapshotId.ToHocon()}");
+
+ if (StateStoreMode is not null)
+ sb.AppendLine($"state-store-mode = {StateStoreMode.Value.ToString().ToLowerInvariant().ToHocon()}");
+
+ if (LeaseImplementation is not null)
+ sb.AppendLine($"use-lease = {LeaseImplementation.ConfigPath}");
+
+ if (LeaseRetryInterval is not null)
+ sb.AppendLine($"lease-retry-interval = {LeaseRetryInterval.ToHocon()}");
+
+ if (FailOnInvalidEntityStateTransition is not null)
+ sb.AppendLine(
+ $"fail-on-invalid-entity-state-transition = {FailOnInvalidEntityStateTransition.ToHocon()}");
+
+ if(ShouldPassivateIdleEntities is false)
+ sb.AppendLine("passivate-idle-entity-after = off");
+ else if(PassivateIdleEntityAfter is not null)
+ sb.AppendLine($"passivate-idle-entity-after = {PassivateIdleEntityAfter.ToHocon()}");
+
+ if (ShardRegionQueryTimeout is not null)
+ sb.AppendLine($"shard-region-query-timeout = {ShardRegionQueryTimeout.ToHocon()}");
+
+ return sb.ToString();
+ }
+ }
+
+ public sealed class ShardingDDataOptions : DDataOptions
+ {
+ public int? MajorityMinimumCapacity { get; set; }
+ public int? MaxDeltaElements { get; set; }
+
+ internal void Apply(AkkaConfigurationBuilder builder)
+ {
+ base.Apply(builder, "akka.cluster.sharding");
+
+ var sb = new StringBuilder();
+ if (MajorityMinimumCapacity is not null)
+ sb.AppendLine($"majority-min-cap = {MajorityMinimumCapacity}");
+ if (MaxDeltaElements is not null)
+ sb.AppendLine($"max-delta-elements = {MaxDeltaElements}");
+
+ if(sb.Length == 0)
+ return;
+
+ sb.Insert(0, "akka.cluster.sharding.distributed-data {");
+ sb.AppendLine("}");
+ builder.AddHocon(sb.ToString(), HoconAddMode.Prepend);
+ }
+ }
+
+ public class DDataOptions
+ {
+ ///
+ ///
+ /// Actor name of the Replicator actor.
+ ///
+ /// Default: "ddataReplicator"
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Replicas are running on members tagged with this role.
+ /// All members are used if null or empty.
+ ///
+ public string? Role { get; set; }
+
+ ///
+ /// When set to true, this flag will attach a backoff supervisor to the replicator;
+ /// any failing replicator to be restarted
+ ///
+ public bool? RecreateOnFailure { get; set; }
+
+ ///
+ /// When set to true, Update and Get operations are sent to oldest nodes first.
+ /// This is useful together with Cluster Singleton, which is running on oldest nodes.
+ ///
+ public bool? PreferOldest { get; set; }
+
+ ///
+ /// When set to true, provide a higher level of details in the debug logs, including gossip status.
+ /// Be careful about enabling in production systems.
+ ///
+ public bool? VerboseDebugLogging { get; set; }
+
+ public DurableOptions Durable { get; set; } = new();
+
+ internal virtual void Apply(AkkaConfigurationBuilder builder, string prefix = "akka.cluster")
+ {
+ var sb = new StringBuilder();
+
+ if (Name is not null)
+ sb.AppendLine($"name = {Name.ToHocon()}");
+ if (Role is not null)
+ sb.AppendLine($"role = {Role.ToHocon()}");
+ if (RecreateOnFailure is not null)
+ sb.AppendLine($"recreate-on-failure = {RecreateOnFailure.ToHocon()}");
+ if (PreferOldest is not null)
+ sb.AppendLine($"prefer-oldest = {PreferOldest.ToHocon()}");
+ if (VerboseDebugLogging is not null)
+ sb.AppendLine($"verbose-debug-logging = {VerboseDebugLogging.ToHocon()}");
+
+ var durableSb = new StringBuilder();
+ if (Durable.Keys is not null)
+ durableSb.AppendLine($"keys = [{string.Join(",", Durable.Keys.Select(s => s.ToHocon()))}]");
+
+ var lmdbSb = new StringBuilder();
+ var lmdb = Durable.Lmdb;
+ if (lmdb.Directory is not null)
+ lmdbSb.AppendLine($"dir = {lmdb.Directory.ToHocon()}");
+ if (lmdb.MapSize is not null)
+ lmdbSb.AppendLine($"map-size = {lmdb.MapSize}");
+ if (lmdb.WriteBehindInterval is not null)
+ lmdbSb.AppendLine($"write-behind-interval = {lmdb.WriteBehindInterval.ToHocon()}");
+
+ if (lmdbSb.Length > 0)
+ {
+ durableSb
+ .AppendLine("lmdb {")
+ .AppendLine(lmdbSb.ToString())
+ .AppendLine("}");
+ }
+
+ if (durableSb.Length > 0)
+ {
+ sb.AppendLine("durable {")
+ .AppendLine(durableSb.ToString())
+ .AppendLine("}");
+ }
+
+ if(sb.Length == 0)
+ return;
+
+ sb.Insert(0, $"{prefix}.distributed-data {{");
+ sb.AppendLine("}");
+
+ builder.AddHocon(sb.ToString(), HoconAddMode.Prepend);
+ }
+ }
+
+ public class DurableOptions
+ {
+ ///
+ /// List of keys that are durable. Prefix matching is supported by using * at the
+ /// end of a key.
+ ///
+ public string[]? Keys { get; set; }
+
+ public LmdbOptions Lmdb { get; set; } = new();
+ }
+
+ public class LmdbOptions
+ {
+ ///
+ /// Directory of LMDB file. There are two options:
+ ///
+ /// -
+ /// A relative or absolute path to a directory that ends with 'ddata'
+ /// the full name of the directory will contain name of the ActorSystem
+ /// and its remote port.
+ ///
+ /// -
+ /// Otherwise the path is used as is, as a relative or absolute path to
+ /// a directory.
+ ///
+ ///
+ /// When running in production you may want to configure this to a specific
+ /// path (alt 2), since the default directory contains the remote port of the
+ /// actor system to make the name unique. If using a dynamically assigned
+ /// port (0) it will be different each time and the previously stored data
+ /// will not be loaded.
+ ///
+ public string? Directory { get; set; }
+
+ ///
+ /// Size in bytes of the memory mapped file.
+ ///
+ public long? MapSize { get; set; }
+
+ ///
+ /// Accumulate changes before storing improves performance with the
+ /// risk of losing the last writes if the process crashes.
+ /// The interval is by default set to 0 to write each update immediately.
+ /// Enabling write behind by specifying a duration, e.g. 200ms, is especially
+ /// efficient when performing many writes to the same key, because it is only
+ /// the last value for each key that will be serialized and stored.
+ ///
+ public TimeSpan? WriteBehindInterval { get; set; }
+ }
+
+ public static class AkkaClusterHostingExtensions
+ {
+ internal static AkkaConfigurationBuilder BuildClusterHocon(
+ this AkkaConfigurationBuilder builder,
+ ClusterOptions? options)
+ {
+ if (options == null)
+ return builder.AddHocon(ClusterSharding.DefaultConfig()
+ .WithFallback(ClusterSingleton.DefaultConfig())
+ .WithFallback(DistributedPubSub.DefaultConfig())
+ .WithFallback(ClusterClientReceptionist.DefaultConfig())
+ .WithFallback(DistributedData.DistributedData.DefaultConfig()), HoconAddMode.Append);
+
+ var sb = new StringBuilder()
+ .AppendLine("akka.cluster {");
+
+ if (options.Roles is { Length: > 0 })
+ {
+ sb.AppendLine($"roles = [{string.Join(",", options.Roles)}]");
+ }
+
+ if (options.MinimumNumberOfMembersPerRole is { Count: > 0 })
+ {
+ sb.AppendLine("role {");
+ foreach (var kvp in options.MinimumNumberOfMembersPerRole)
+ {
+ sb.AppendLine($"{kvp.Key}.min-nr-of-members = {kvp.Value}");
+ }
+
+ sb.AppendLine("}");
+ }
+
+ if (options.SeedNodes is { Length: > 0 })
+ {
+ // Validate that all addresses are valid.
+ sb.Append("seed-nodes = [");
+ foreach (var addrString in options.SeedNodes)
+ {
+ Address.Parse(addrString);
+ sb.Append($"{addrString.ToHocon()}, ");
+ }
+
+ sb.AppendLine("]");
+ }
+
+ if (options.MinimumNumberOfMembers is not null)
+ sb.AppendLine($"min-nr-of-members = {options.MinimumNumberOfMembers}");
+
+ if (options.AppVersion is not null)
+ sb.AppendLine($"app-version = {options.AppVersion.ToHocon()}");
+
+ if (options.LogInfo is not null)
+ sb.AppendLine($"log-info = {options.LogInfo.ToHocon()}");
+
+ if (options.LogInfoVerbose is not null)
+ sb.AppendLine($"log-info-verbose = {options.LogInfoVerbose.ToHocon()}");
+
+ if (options.FailureDetector is not null)
+ {
+ var fsb = options.FailureDetector.ToHocon();
+ if (fsb.Length > 0)
+ {
+ sb.AppendLine("failure-detector {\n");
+ sb.Append(fsb);
+ sb.AppendLine("}");
+ }
+ }
+ sb.AppendLine("}");
+
+ // prepend the composed configuration
+ builder.AddHocon(sb.ToString(), HoconAddMode.Prepend);
+
+ options.SplitBrainResolver?.Apply(builder);
+
+ // populate all of the possible Clustering default HOCON configurations here
+ return builder.AddHocon(ClusterSharding.DefaultConfig()
+ .WithFallback(ClusterSingleton.DefaultConfig())
+ .WithFallback(DistributedPubSub.DefaultConfig())
+ .WithFallback(ClusterClientReceptionist.DefaultConfig())
+ .WithFallback(DistributedData.DistributedData.DefaultConfig()), HoconAddMode.Append);
+ }
+
+ ///
+ /// Adds Akka.Cluster support to the .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// Optional. Akka.Cluster configuration parameters.
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithClustering(
+ this AkkaConfigurationBuilder builder,
+ ClusterOptions? options = null)
+ {
+ var hoconBuilder = BuildClusterHocon(builder, options);
+
+ if (options is { ClusterReadyCheck: true })
+ {
+ builder.WithAkkaClusterReadinessCheck();
+ }
+
+ if (builder.ActorRefProvider.HasValue)
+ {
+ switch (builder.ActorRefProvider.Value)
+ {
+ case ProviderSelection.Cluster:
+ case ProviderSelection.Custom:
+ return hoconBuilder; // no-op
+ }
+ }
+
+ return hoconBuilder.WithActorRefProvider(ProviderSelection.Cluster.Instance);
+ }
+
+ ///
+ /// Adds a to the ,
+ /// which will return `Unhealthy` until we have successfully joined a cluster. Used to prevent nodes
+ /// from accepting load-balancer traffic until we have access to the cluster.
+ ///
+ /// The builder instance being configured.
+ ///
+ /// The that should be reported upon failure of the health check. If the provided value
+ /// is null, then will be reported.
+ ///
+ /// A list of tags that can be used for filtering health checks.
+ ///
+ /// If you need to customize the readiness check, you can use to
+ /// register your own with the .
+ ///
+ public static AkkaConfigurationBuilder WithAkkaClusterReadinessCheck(
+ this AkkaConfigurationBuilder builder,
+ HealthStatus? failureStatus = null,
+ IEnumerable? tags = null)
+ {
+ string[] defaultTags = ["akka", "ready", "akka.cluster"];
+
+ // add the default cluster readiness check
+ return builder.WithHealthCheck(new AkkaHealthCheckRegistration("akka.cluster.join", new AkkaClusterReadinessCheck(),
+ failureStatus ?? HealthStatus.Unhealthy, tags ?? defaultTags));
+ }
+
+ public static AkkaConfigurationBuilder WithDistributedData(
+ this AkkaConfigurationBuilder builder,
+ Action configurator)
+ {
+ var options = new DDataOptions();
+ configurator(options);
+ return builder.WithDistributedData(options);
+ }
+
+ public static AkkaConfigurationBuilder WithDistributedData(
+ this AkkaConfigurationBuilder builder,
+ DDataOptions options)
+ {
+ options.Apply(builder);
+ builder.AddHocon(DistributedData.DistributedData.DefaultConfig(), HoconAddMode.Append);
+ builder.WithExtension();
+ return builder;
+ }
+
+ ///
+ /// Configure the global sharding distributed data settings. This settings will only be used when ShardOptions.StateStoreMode.
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// Configuration method for configuring the
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardingDistributedData(
+ this AkkaConfigurationBuilder builder,
+ Action configure)
+ {
+ var options = new ShardingDDataOptions();
+ configure(options);
+ return builder.WithShardingDistributedData(options);
+ }
+
+ ///
+ /// Configure the global sharding distributed data settings
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The that will be used to configure cluster sharding
+ /// global distributed data settings
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardingDistributedData(
+ this AkkaConfigurationBuilder builder,
+ ShardingDDataOptions options)
+ {
+ options.Apply(builder);
+
+ var dDataSettings = DistributedData.DistributedData.DefaultConfig()
+ .MoveTo("akka.cluster.sharding.distributed-data");
+ builder.AddHocon(dDataSettings, HoconAddMode.Append);
+
+ return builder;
+ }
+
+ ///
+ /// Starts a actor for the given entity
+ /// and registers the ShardRegion with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// Function that, given an entity id, returns the of the entity actors that will be created by the
+ ///
+ ///
+ /// Functions to extract the entity id, shard id, and the message to send to the entity from the incoming message.
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardRegion(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ Func entityPropsFactory,
+ IMessageExtractor messageExtractor,
+ ShardOptions shardOptions)
+ {
+ return builder.WithShardRegion(typeName, (_, _, _) => entityPropsFactory,
+ messageExtractor, shardOptions);
+ }
+
+ ///
+ /// Starts a actor for the given entity
+ /// and registers the ShardRegion with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// Function that, given an entity id, returns the of the entity actors that will be created by the
+ ///
+ ///
+ /// Partial function to extract the entity id and the message to send to the entity from the incoming message,
+ /// if the partial function does not match the message will be `unhandled`,
+ /// i.e.posted as `Unhandled` messages on the event stream
+ ///
+ ///
+ /// Function to determine the shard id for an incoming message, only messages that passed the `extractEntityId` will be used
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ [Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates.")]
+ public static AkkaConfigurationBuilder WithShardRegion(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ Func entityPropsFactory,
+ ExtractEntityId extractEntityId,
+ ExtractShardId extractShardId,
+ ShardOptions shardOptions)
+ {
+ return builder.WithShardRegion(typeName, (_, _, _) => entityPropsFactory,
+ extractEntityId, extractShardId, shardOptions);
+ }
+
+ ///
+ /// Starts a actor for the given entity
+ /// and registers the ShardRegion with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// Function that, given an entity id, returns the of the entity actors that will be created by the .
+ ///
+ /// This function also accepts the and the as inputs.
+ ///
+ ///
+ /// Functions to extract the entity id, shard id, and the message to send to the entity from the incoming message.
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardRegion(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ Func> entityPropsFactory,
+ IMessageExtractor messageExtractor,
+ ShardOptions shardOptions)
+ {
+ return builder.WithShardRegion(typeName,
+ (system, registry, _) => entityPropsFactory(system, registry),
+ messageExtractor, shardOptions);
+ }
+
+ ///
+ /// Starts a actor for the given entity
+ /// and registers the ShardRegion with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// Function that, given an entity id, returns the of the entity actors that will be created by the .
+ ///
+ /// This function also accepts the and the as inputs.
+ ///
+ ///
+ /// Partial function to extract the entity id and the message to send to the entity from the incoming message,
+ /// if the partial function does not match the message will be `unhandled`,
+ /// i.e.posted as `Unhandled` messages on the event stream
+ ///
+ ///
+ /// Function to determine the shard id for an incoming message, only messages that passed the `extractEntityId` will be used
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ [Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates.")]
+ public static AkkaConfigurationBuilder WithShardRegion(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ Func> entityPropsFactory,
+ ExtractEntityId extractEntityId,
+ ExtractShardId extractShardId,
+ ShardOptions shardOptions)
+ {
+ return builder.WithShardRegion(typeName,
+ (system, registry, _) => entityPropsFactory(system, registry),
+ extractEntityId, extractShardId, shardOptions);
+ }
+
+ ///
+ /// Starts a actor for the given entity
+ /// and registers the ShardRegion with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// Function that, given an entity id, returns the of the entity actors that will be created by the .
+ ///
+ /// This function also accepts the and the as inputs.
+ ///
+ ///
+ /// Functions to extract the entity id, shard id, and the message to send to the entity from the incoming message.
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardRegion(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ Func> entityPropsFactory,
+ IMessageExtractor messageExtractor,
+ ShardOptions shardOptions)
+ {
+ builder.AddHocon(
+ ClusterSharding.DefaultConfig()
+ .WithFallback(DistributedData.DistributedData.DefaultConfig())
+ .WithFallback(ClusterSingleton.DefaultConfig()),
+ HoconAddMode.Append);
+
+ return builder.StartActors(Resolver);
+
+ async Task Resolver(ActorSystem system, IActorRegistry registry, IDependencyResolver resolver)
+ {
+ var props = entityPropsFactory(system, registry, resolver);
+ var shardingConfig = ConfigurationFactory.ParseString(shardOptions.ToString())
+ .WithFallback(system.Settings.Config.GetConfig("akka.cluster.sharding"));
+ var coordinatorConfig = system.Settings.Config.GetConfig(
+ shardingConfig.GetString("coordinator-singleton"));
+
+ var settings = ClusterShardingSettings.Create(shardingConfig, coordinatorConfig);
+ var allocationStrategy = ClusterSharding.Get(system).DefaultShardAllocationStrategy(settings);
+ var shardRegion = await ClusterSharding.Get(system).StartAsync(
+ typeName, props, settings, messageExtractor, allocationStrategy,
+ shardOptions.HandOffStopMessage ?? PoisonPill.Instance).ConfigureAwait(false);
+ registry.Register(shardRegion);
+ }
+ }
+
+ ///
+ /// Starts a actor for the given entity
+ /// and registers the ShardRegion with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// Function that, given an entity id, returns the of the entity actors that will be created by the .
+ ///
+ /// This function also accepts the and the as inputs.
+ ///
+ ///
+ /// Partial function to extract the entity id and the message to send to the entity from the incoming message,
+ /// if the partial function does not match the message will be `unhandled`,
+ /// i.e.posted as `Unhandled` messages on the event stream
+ ///
+ ///
+ /// Function to determine the shard id for an incoming message, only messages that passed the `extractEntityId` will be used
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ [Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates.")]
+ public static AkkaConfigurationBuilder WithShardRegion(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ Func> entityPropsFactory,
+ ExtractEntityId extractEntityId,
+ ExtractShardId extractShardId,
+ ShardOptions shardOptions)
+ {
+ builder.AddHocon(
+ ClusterSharding.DefaultConfig()
+ .WithFallback(DistributedData.DistributedData.DefaultConfig())
+ .WithFallback(ClusterSingletonManager.DefaultConfig()),
+ HoconAddMode.Append);
+
+ return builder.StartActors(Resolver);
+
+ async Task Resolver(ActorSystem system, IActorRegistry registry, IDependencyResolver resolver)
+ {
+ var props = entityPropsFactory(system, registry, resolver);
+ var shardingConfig = ConfigurationFactory.ParseString(shardOptions.ToString())
+ .WithFallback(system.Settings.Config.GetConfig("akka.cluster.sharding"));
+ var coordinatorConfig = system.Settings.Config.GetConfig(
+ shardingConfig.GetString("coordinator-singleton"));
+
+ var settings = ClusterShardingSettings.Create(shardingConfig, coordinatorConfig);
+ var allocationStrategy = ClusterSharding.Get(system).DefaultShardAllocationStrategy(settings);
+ var shardRegion = await ClusterSharding.Get(system).StartAsync(
+ typeName, props, settings, extractEntityId, extractShardId, allocationStrategy,
+ shardOptions.HandOffStopMessage ?? PoisonPill.Instance).ConfigureAwait(false);
+ registry.Register(shardRegion);
+ }
+ }
+
+ ///
+ /// Starts a ShardRegionProxy that points to a hosted on a different role inside the cluster
+ /// and registers the with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// The role of the Akka.Cluster member that is hosting this .
+ ///
+ ///
+ /// Partial function to extract the entity id and the message to send to the entity from the incoming message,
+ /// if the partial function does not match the message will be `unhandled`,
+ /// i.e.posted as `Unhandled` messages on the event stream
+ ///
+ ///
+ /// Function to determine the shard id for an incoming message, only messages that passed the `extractEntityId` will be used
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ [Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates.")]
+ public static AkkaConfigurationBuilder WithShardRegionProxy(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ string roleName,
+ ExtractEntityId extractEntityId,
+ ExtractShardId extractShardId)
+ {
+ return builder.WithActors(async (system, registry) =>
+ {
+ var shardRegionProxy = await ClusterSharding.Get(system)
+ .StartProxyAsync(typeName, roleName, extractEntityId, extractShardId);
+
+ registry.Register(shardRegionProxy);
+ });
+ }
+
+ ///
+ /// Starts a ShardRegionProxy that points to a hosted on a different role inside the cluster
+ /// and registers the with in the
+ /// for this .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// The role of the Akka.Cluster member that is hosting this .
+ ///
+ ///
+ /// Functions to extract the entity id, shard id, and the message to send to the entity from the incoming message.
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardRegionProxy(
+ this AkkaConfigurationBuilder builder,
+ string typeName,
+ string roleName,
+ IMessageExtractor messageExtractor)
+ {
+ return builder.WithActors(async (system, registry) =>
+ {
+ var shardRegionProxy = await ClusterSharding.Get(system)
+ .StartProxyAsync(typeName, roleName, messageExtractor);
+
+ registry.Register(shardRegionProxy);
+ });
+ }
+
+ ///
+ /// Starts a actor for the
+ /// given entity type and registers the
+ /// with in the for this
+ /// .
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// The number of actors the should instantiate during start-up
+ ///
+ ///
+ /// Function that, given an integer, returns the of the entity actors that will
+ /// be created by the .
+ ///
+ /// This function also accepts the and the as inputs.
+ ///
+ ///
+ /// The set of options for configuring
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardedDaemonProcess(
+ this AkkaConfigurationBuilder builder,
+ string name,
+ int numberOfInstances,
+ Func> entityPropsFactory,
+ ClusterDaemonOptions? options = null)
+ {
+ var config = options?.ToHocon();
+ if (config != null)
+ builder.AddHocon(config, HoconAddMode.Prepend);
+
+ builder
+ .AddHocon(ClusterSharding.DefaultConfig(), HoconAddMode.Append)
+ .AddHocon(ClusterSingleton.DefaultConfig(), HoconAddMode.Append)
+ .AddHocon(DistributedData.DistributedData.DefaultConfig(), HoconAddMode.Append);
+
+ builder.WithActors((system, registry, resolver) =>
+ {
+ var settings = ShardedDaemonProcessSettings.Create(system);
+
+ if (options is not null)
+ {
+ if (!string.IsNullOrWhiteSpace(options.Role))
+ settings = settings.WithRole(options.Role!);
+ if (options.ShardingSettings is not null)
+ settings = settings.WithShardingSettings(options.ShardingSettings);
+ if (options.KeepAliveInterval is not null)
+ settings = settings.WithKeepAliveInterval(options.KeepAliveInterval.Value);
+ }
+
+ var props = entityPropsFactory(system, registry, resolver);
+
+ var router = ShardedDaemonProcess.Get(system: system).Init(
+ name: name,
+ numberOfInstances: numberOfInstances,
+ propsFactory: props,
+ settings: settings,
+ stopMessage: options?.HandoffStopMessage);
+
+ if(router is not null)
+ registry.Register(router);
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Starts a proxy actor that
+ /// points to a hosted on a different inside
+ /// the cluster and registers the with in the
+ /// for this .
+ ///
+ /// Note that the , , and
+ /// argument MUST match the target
+ /// for the proxy to work.
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the entity type
+ ///
+ ///
+ /// The number of actors the should instantiate during start-up
+ ///
+ ///
+ /// The role of the Akka.Cluster member that is hosting this .
+ ///
+ ///
+ /// The type key to use to retrieve the for this .
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithShardedDaemonProcessProxy(
+ this AkkaConfigurationBuilder builder,
+ string name,
+ int numberOfInstances,
+ string role)
+ {
+ builder
+ .AddHocon(ClusterSharding.DefaultConfig(), HoconAddMode.Append)
+ .AddHocon(ClusterSingletonProxy.DefaultConfig(), HoconAddMode.Append)
+ .AddHocon(DistributedData.DistributedData.DefaultConfig(), HoconAddMode.Append)
+ .WithActors((system, registry) =>
+ {
+ var proxyRouter = ShardedDaemonProcess.Get(system).InitProxy(name, numberOfInstances, role);
+ registry.Register(proxyRouter);
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Starts on this node immediately upon startup.
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// Specifies which role will broadcast gossip to. If this value
+ /// is left blank then ALL roles will be targeted.
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ ///
+ /// Stores the mediator in the registry using the key.
+ ///
+ public static AkkaConfigurationBuilder WithDistributedPubSub(
+ this AkkaConfigurationBuilder builder,
+ string role)
+ {
+ var middle = builder.AddHocon(DistributedPubSub.DefaultConfig(), HoconAddMode.Append);
+ if (!string.IsNullOrEmpty(role)) // add role config
+ {
+ middle = middle.AddHocon($"akka.cluster.pub-sub.role = \"{role}\"", HoconAddMode.Prepend);
+ }
+
+ return middle.WithActors((system, registry) =>
+ {
+ // force the initialization
+ var mediator = DistributedPubSub.Get(system).Mediator;
+ registry.Register(mediator);
+ });
+ }
+
+ ///
+ ///
+ /// Creates a new to host an actor created via .
+ ///
+ ///
+ /// If is set to true then this method will also create a
+ /// that will be added to the using the key
+ /// . Otherwise, this method will register nothing with the .
+ ///
+ /// -
+ /// The complete singleton manager actor path name will be
+ /// akka://MyActorSystem/user/singletonName/singletonName
+ ///
+ /// -
+ /// The complete singleton proxy actor path name, if created, will be
+ /// akka://MyActorSystem/user/singletonName-proxy
+ ///
+ ///
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of this singleton instance. Will also be used in the for the
+ /// and optionally, the created
+ /// by this method. See summary above.
+ ///
+ ///
+ /// A function that accepts the , , and
+ /// and returns the for the actor
+ ///
+ ///
+ /// Optional. The set of options for configuring both the and
+ /// optionally, the .
+ ///
+ ///
+ /// When set to true>, creates a that automatically points to
+ /// the created by this method.
+ ///
+ ///
+ /// The key type to use for the when is set to true.
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithSingleton(
+ this AkkaConfigurationBuilder builder,
+ string singletonName,
+ Func propsFactory,
+ ClusterSingletonOptions? options = null,
+ bool createProxyToo = true)
+ => builder.WithSingleton(singletonName, singletonName, propsFactory, options, createProxyToo);
+
+ ///
+ ///
+ /// Creates a new to host an actor created via .
+ ///
+ /// If is set to true then this method will also create a
+ /// that will be added to the using the key
+ /// . Otherwise, this method will register nothing with the .
+ ///
+ /// -
+ /// The complete singleton manager actor path name will be
+ /// akka://MyActorSystem/user/singletonManagerName/singletonName
+ ///
+ /// -
+ /// The complete singleton proxy actor path name, if created, will be
+ /// akka://MyActorSystem/user/singletonName-proxy
+ ///
+ ///
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the created by this method. See summary above.
+ ///
+ ///
+ /// The name of this singleton instance and optionally, part of the
+ /// name created by this method. See summary above.
+ ///
+ ///
+ /// A function that accepts the , , and
+ /// and returns the for the actor
+ ///
+ ///
+ /// Optional. The set of options for configuring both the and
+ /// optionally, the .
+ ///
+ ///
+ /// When set to true>, creates a that automatically points to
+ /// the created by this method.
+ ///
+ ///
+ /// The key type to use for the when is set to true.
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithSingleton(
+ this AkkaConfigurationBuilder builder,
+ string singletonManagerName,
+ string singletonName,
+ Func propsFactory,
+ ClusterSingletonOptions? options = null,
+ bool createProxyToo = true)
+ {
+ // make sure that default configuration is loaded, not an exhaustive check.
+ if (!builder.Configuration.HasValue || builder.Configuration.Value.HasPath("akka.cluster.singleton"))
+ {
+ builder.AddHocon(ClusterSingleton.DefaultConfig(), HoconAddMode.Append);
+ }
+
+ return builder.WithActors((system, registry, resolver) =>
+ {
+ var actorProps = propsFactory(system, registry, resolver);
+
+ options ??= new ClusterSingletonOptions();
+ var clusterSingletonManagerSettings = options.ToManagerSettings(singletonName, system);
+
+ var singletonProps = options.TerminationMessage == null
+ ? ClusterSingletonManager.Props(actorProps, clusterSingletonManagerSettings)
+ : ClusterSingletonManager.Props(actorProps, options.TerminationMessage,
+ clusterSingletonManagerSettings);
+
+ var singletonManagerRef = system.ActorOf(singletonProps, singletonManagerName);
+
+ // create a proxy that can talk to the singleton we just created
+ // and add it to the ActorRegistry
+ if (createProxyToo)
+ {
+ var singletonProxySettings = options.ToProxySettings(singletonName, system);
+ CreateAndRegisterSingletonProxy(singletonName,
+ $"/user/{singletonManagerName}", singletonProxySettings, system, registry);
+ }
+ });
+ }
+
+ ///
+ ///
+ /// Creates a new to host an actor created via .
+ ///
+ ///
+ /// If is set to true then this method will also create a
+ /// that will be added to the using the key
+ /// . Otherwise, this method will register nothing with the .
+ ///
+ /// -
+ /// The complete singleton manager actor path name will be
+ /// akka://MyActorSystem/user/singletonManagerName/singletonName
+ ///
+ /// -
+ /// The complete singleton proxy actor path name, if created, will be
+ /// akka://MyActorSystem/user/singletonName-proxy
+ ///
+ ///
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of the created by this method. See summary above.
+ ///
+ ///
+ /// The name of this singleton instance and optionally, part of the
+ /// name created by this method. See summary above.
+ ///
+ ///
+ /// The underlying actor type. SHOULD NOT BE CREATED USING
+ ///
+ ///
+ /// Optional. The set of options for configuring both the and
+ /// optionally, the .
+ ///
+ ///
+ /// When set to true>, creates a that automatically points to
+ /// the created by this method.
+ ///
+ ///
+ /// The key type to use for the when is set to true.
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithSingleton(
+ this AkkaConfigurationBuilder builder,
+ string singletonManagerName,
+ string singletonName,
+ Props actorProps,
+ ClusterSingletonOptions? options = null,
+ bool createProxyToo = true)
+ {
+ return builder.WithSingleton(singletonManagerName, singletonName, (_, _, _) => actorProps, options,
+ createProxyToo);
+ }
+
+ ///
+ ///
+ /// Creates a new to host an actor created via .
+ ///
+ ///
+ /// If is set to true then this method will also create a
+ /// that will be added to the using the key
+ /// . Otherwise, this method will register nothing with the .
+ ///
+ /// -
+ /// The complete singleton manager actor path name will be
+ /// akka://MyActorSystem/user/singletonName/singletonName
+ ///
+ /// -
+ /// The complete singleton proxy actor path name, if created, will be
+ /// akka://MyActorSystem/user/singletonName-proxy
+ ///
+ ///
+ ///
+ ///
+ /// The builder instance being configured.
+ ///
+ ///
+ /// The name of this singleton instance. Will also be used in the for the
+ /// and optionally, the created
+ /// by this method. See summary above.
+ ///
+ ///
+ /// The underlying actor type. SHOULD NOT BE CREATED USING
+ ///
+ ///
+ /// Optional. The set of options for configuring both the and
+ /// optionally, the .
+ ///
+ ///
+ /// When set to true>, creates a that automatically points to
+ /// the created by this method.
+ ///
+ ///
+ /// The key type to use for the when is set to true.
+ ///
+ ///
+ /// The same instance originally passed in.
+ ///
+ public static AkkaConfigurationBuilder WithSingleton(
+ this AkkaConfigurationBuilder builder,
+ string singletonName,
+ Props actorProps,
+ ClusterSingletonOptions? options = null,
+ bool createProxyToo = true)
+ {
+ return builder.WithSingleton(singletonName, singletonName, (_, _, _) => actorProps, options,
+ createProxyToo);
+ }
+
+ private static void CreateAndRegisterSingletonProxy(
+ string singletonName,
+ string singletonManagerActorPath,
+ ClusterSingletonProxySettings singletonProxySettings,
+ ActorSystem system,
+ IActorRegistry registry)
+ {
+ var singletonProxyProps = ClusterSingletonProxy.Props(
+ singletonManagerPath: singletonManagerActorPath,
+ settings: singletonProxySettings);
+ var singletonProxy = system.ActorOf(singletonProxyProps, $"{singletonName}-proxy");
+
+ registry.Register(singletonProxy);
+ }
+
+ ///
+ ///
+ /// Creates a