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 and adds it to the using + /// the given . + /// + /// + /// The complete singleton proxy actor path name will be akka://MyActorSystem/user/singletonName-proxy + /// + /// + /// The builder instance being configured. + /// + /// + /// The name of the singleton manager. + /// + /// + /// The name of the singleton. Will also be part of the created + /// by this method. See summary above. + /// + /// + /// Optional. The set of options for configuring the . + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithSingletonProxy( + this AkkaConfigurationBuilder builder, + string singletonManagerName, + string singletonName, + ClusterSingletonOptions? options = null) + { + // make sure that default configuration is loaded, not an exhaustive check. + if (!builder.Configuration.HasValue || builder.Configuration.Value.HasPath("akka.cluster.singleton-proxy")) + { + builder.AddHocon(ClusterSingleton.DefaultConfig(), HoconAddMode.Append); + } + + return builder.WithActors((system, registry) => + { + options ??= new ClusterSingletonOptions(); + var singletonProxySettings = options.ToProxySettings(singletonName, system); + + var singletonManagerPath = $"/user/{singletonManagerName}"; + + CreateAndRegisterSingletonProxy( + singletonName: singletonName, + singletonManagerActorPath: singletonManagerPath, + singletonProxySettings: singletonProxySettings, + system: system, + registry: registry); + }); + + } + + /// + /// + /// Creates a and adds it to the using + /// the given . + /// + /// + /// The complete singleton proxy actor path name will be akka://MyActorSystem/user/singletonName-proxy + /// + /// + /// The builder instance being configured. + /// + /// + /// The name of the singleton. Will also be part of the created by this method. + /// + /// + /// Optional. The set of options for configuring the . + /// + /// + /// Optional. By default, Akka.Hosting will assume the is hosted at + /// "/user/{singletonManagerName}" - but if for some reason the path is different you can use this property to + /// override that value. + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithSingletonProxy( + this AkkaConfigurationBuilder builder, + string singletonName, + ClusterSingletonOptions? options = null, + string? singletonManagerPath = null) + { + // make sure that default configuration is loaded, not an exhaustive check. + if (!builder.Configuration.HasValue || builder.Configuration.Value.HasPath("akka.cluster.singleton-proxy")) + { + builder.AddHocon(ClusterSingleton.DefaultConfig(), HoconAddMode.Append); + } + + return builder.WithActors((system, registry) => + { + options ??= new ClusterSingletonOptions(); + var singletonProxySettings = options.ToProxySettings(singletonName, system); + + singletonManagerPath ??= $"/user/{singletonName}"; + + CreateAndRegisterSingletonProxy( + singletonName: singletonName, + singletonManagerActorPath: singletonManagerPath, + singletonProxySettings: singletonProxySettings, + system: system, + registry: registry); + }); + } + + /// + /// Configures a for the + /// + /// + /// The builder instance being configured. + /// + /// + /// Actor name of the ClusterReceptionist actor under the system path, by default it is /system/receptionist + /// + /// + /// Checks that the receptionist only start on members tagged with this role. All members are used if empty. + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClientReceptionist( + this AkkaConfigurationBuilder builder, + string name = "receptionist", + string? role = null) + { + builder.AddHocon(CreateReceptionistConfig(name, role), HoconAddMode.Prepend); + return builder; + } + + internal static Config CreateReceptionistConfig(string name, string? role) + { + const string root = "akka.cluster.client.receptionist."; + + var sb = new StringBuilder() + .Append(root).Append("name:").AppendLine(name.ToHocon()); + + if (!string.IsNullOrEmpty(role)) + sb.Append(root).Append("role:").AppendLine(role!.ToHocon()); + + return ConfigurationFactory.ParseString(sb.ToString()); + } + + /// + /// Creates a and adds it to the using the given + /// . + /// + /// + /// The builder instance being configured. + /// + /// + /// + /// List of that will be used as a seed + /// to discover all of the receptionists in the cluster. + /// + /// + /// This should look something like "akka.tcp://systemName@networkAddress:2552/system/receptionist" + /// + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClient( + this AkkaConfigurationBuilder builder, + IList initialContacts) + { + if (initialContacts == null) + throw new ArgumentNullException(nameof(initialContacts)); + + if (initialContacts.Count < 1) + throw new ArgumentException("Must specify at least one initial contact", nameof(initialContacts)); + + return builder + .AddHocon(ClusterClientReceptionist.DefaultConfig(), HoconAddMode.Append) + .WithActors((system, registry) => + { + var clusterClient = system.ActorOf(ClusterClient.Props( + CreateClusterClientSettings(system.Settings.Config, initialContacts))); + registry.TryRegister(clusterClient); + }); + } + + /// + /// Creates a and adds it to the using the given + /// . + /// + /// + /// The builder instance being configured. + /// + /// + /// + /// List of node addresses where the are located that will be + /// used as seed to discover all of the receptionists in the cluster. + /// + /// + /// This should look something like "akka.tcp://systemName@networkAddress:2552" + /// + /// + /// + /// The name of the actor.
+ /// Defaults to "receptionist" + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClient( + this AkkaConfigurationBuilder builder, + IEnumerable
initialContactAddresses, + string receptionistActorName = "receptionist") + => builder.WithClusterClient(initialContactAddresses + .Select(address => new RootActorPath(address) / "system" / receptionistActorName) + .ToList()); + + /// + /// Creates a and adds it to the using the given + /// . + /// + /// + /// The builder instance being configured. + /// + /// + /// + /// List of actor paths that will be used as a seed to discover all of the receptionists in the cluster. + /// + /// + /// This should look something like "akka.tcp://systemName@networkAddress:2552/system/receptionist" + /// + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClient( + this AkkaConfigurationBuilder builder, + IEnumerable initialContacts) + => builder.WithClusterClient(initialContacts.Select(ActorPath.Parse).ToList()); + + internal static ClusterClientSettings CreateClusterClientSettings(Config config, + IEnumerable initialContacts) + { + var clientConfig = config.GetConfig("akka.cluster.client"); + return ClusterClientSettings.Create(clientConfig) + .WithInitialContacts(initialContacts.ToImmutableHashSet()); + } + + /// + /// + /// Creates a and adds it to the using the given + /// . + /// + /// Instead of using an initial contact list, the will leverage + /// to discover the needed initial contacts inside the declared + /// service. + /// + /// + /// The builder instance being configured. + /// + /// + /// The service name that are being discovered. + /// + /// + /// + /// The discovery sub-system that will be used to discover cluster client contacts. This has to be + /// an instance of KubernetesDiscoveryOptions, AkkaDiscoveryOptions, + /// Ec2ServiceDiscoveryOptions", EcsServiceDiscoveryOptions", or + /// "ConfigServiceDiscoveryOptions". + /// + /// Note that if you're also using Akka.Discovery for ClusterBootstrap, in order for + /// to work, you will need to set + /// discoveryOptions.IsDefaultPlugin to false + /// + /// + /// The Akka.Management port name, usually used in conjunction with Akka.Discovery.KubernetesApi + /// + /// + /// Interval at which service discovery will be polled in search for new initial contacts + /// + /// + /// Timeout for getting a reply from the service-discovery subsystem + /// + /// + /// The number of initial contacts will be trimmed down to this number of contact points to the client + /// + /// + /// The name of the cluster client actor + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClientDiscovery( + this AkkaConfigurationBuilder builder, + string serviceName, + IDiscoveryOptions discoveryOptions, + string? portName = null, + TimeSpan? retryInterval = null, + TimeSpan? timeout = null, + int? numberOfContacts = null, + string? clientActorName = null) + { + return builder.ApplyClusterClientDiscovery(new ClusterClientDiscoveryOptions + { + DiscoveryOptions = discoveryOptions, + ServiceName = serviceName, + PortName = portName, + RetryInterval = retryInterval, + Timeout = timeout, + NumberOfContacts = numberOfContacts, + ClientActorName = clientActorName + }); + } + + /// + /// + /// Creates a and adds it to the using the given + /// . + /// + /// Instead of using an initial contact list, the will leverage + /// to discover the needed initial contacts inside the declared + /// service. + /// + /// + /// The builder instance being configured. + /// + /// + /// Configuration method for configuring the + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClientDiscovery( + this AkkaConfigurationBuilder builder, + Action configure) + { + var options = new ClusterClientDiscoveryOptions(); + configure(options); + return builder.ApplyClusterClientDiscovery(options); + } + + /// + /// + /// Creates a and adds it to the using the given + /// . + /// + /// Instead of using an initial contact list, the will leverage + /// to discover the needed initial contacts inside the declared + /// service. + /// + /// + /// The builder instance being configured. + /// + /// + /// The that will be used to configure + /// + /// + /// + /// The key type to use for the . + /// + /// + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithClusterClientDiscovery( + this AkkaConfigurationBuilder builder, + ClusterClientDiscoveryOptions options) + { + return builder.ApplyClusterClientDiscovery(options); + } + + internal static AkkaConfigurationBuilder ApplyClusterClientDiscovery( + this AkkaConfigurationBuilder builder, + ClusterClientDiscoveryOptions options) + { + options.DiscoveryOptions.Apply(builder); + + builder + .AddHocon(ClusterClientReceptionist.DefaultConfig(), HoconAddMode.Append) + .AddHocon(DiscoveryProvider.DefaultConfiguration(), HoconAddMode.Append); + + builder.WithActors((system, registry) => + { + var config = ConfigurationFactory.ParseString(options.ToString()) + .WithFallback(system.Settings.Config.GetConfig("akka.cluster.client")); + + var clusterClient = system.ActorOf(ClusterClient.Props(ClusterClientSettings.Create(config)), options.ClientActorName); + registry.TryRegister(clusterClient); + }); + + return builder; + } + } +} diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/ClusterClientDiscoveryOptions.cs b/src/contrib/hosting/Akka.Cluster.Hosting/ClusterClientDiscoveryOptions.cs new file mode 100644 index 00000000000..62cb674f0db --- /dev/null +++ b/src/contrib/hosting/Akka.Cluster.Hosting/ClusterClientDiscoveryOptions.cs @@ -0,0 +1,91 @@ +using System; +using System.Text; +using Akka.Cluster.Tools.Client; +using Akka.Configuration; +using Akka.Hosting; + +namespace Akka.Cluster.Hosting; + +public sealed class ClusterClientDiscoveryOptions +{ + /// + /// + /// The discovery sub-system that will be used to discover cluster client contacts. + /// Must be discovery options class instance from Akka.Management v1.5.27 and higher. + /// + /// Note that if you're also using Akka.Discovery for ClusterBootstrap, in order for + /// to work, you will need to set + /// DiscoveryOptions.IsDefaultPlugin to false + /// + public IDiscoveryOptions DiscoveryOptions { get; set; } = null!; + + /// + /// The service name that are being discovered. This setting is not optional. + /// + public string ServiceName { get; set; } = string.Empty; + + /// + /// The Akka.Management port name, usually used in conjunction with Akka.Discovery.KubernetesApi + /// + public string? PortName { get; set; } + + /// + /// Interval at which service discovery will be polled in search for new initial contacts + /// + public TimeSpan? RetryInterval { get; set; } + + /// + /// Timeout for getting a reply from the service-discovery subsystem + /// + public TimeSpan? Timeout { get; set; } + + /// + /// The number of initial contacts will be trimmed down to this number of contact points to the client + /// + public int? NumberOfContacts { get; set; } + + /// + /// The name of the cluster client actor + /// + public string? ClientActorName { get; set; } + + public override string ToString() + { + Validate(); + + var sb = new StringBuilder(); + sb.AppendLine("use-initial-contacts-discovery = true"); + sb.AppendLine("discovery {"); + sb.AppendLine($"method = {DiscoveryOptions.ConfigPath.ToHocon()}"); + sb.AppendLine($"service-name = {ServiceName.ToHocon()}"); + if (!string.IsNullOrWhiteSpace(PortName)) + sb.AppendLine($"port-name = {PortName.ToHocon()}"); + if (NumberOfContacts is not null) + sb.AppendLine($"number-of-contacts = {NumberOfContacts.ToHocon()}"); + if (RetryInterval is not null) + sb.AppendLine($"interval = {RetryInterval.ToHocon()}"); + if (Timeout is not null) + sb.AppendLine($"resolve-timeout = {Timeout.ToHocon()}"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private void Validate() + { + if (string.IsNullOrWhiteSpace(ServiceName)) + throw new ArgumentException("Service name must be provided", nameof(ServiceName)); + + if (RetryInterval is not null && RetryInterval < TimeSpan.Zero) + throw new ArgumentException("Retry interval must be greater than zero", nameof(RetryInterval)); + + if(Timeout is not null && Timeout < TimeSpan.Zero) + throw new ArgumentException("Timeout must be greater than zero", nameof(Timeout)); + + if (NumberOfContacts < 1) + throw new ArgumentException("Number of contacts must be greater than zero", nameof(NumberOfContacts)); + + if (ClientActorName is not null && string.IsNullOrWhiteSpace(ClientActorName)) + throw new ArgumentException("Cluster client actor name must not be empty or whitespace", nameof(ClientActorName)); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/ClusterDaemonOptions.cs b/src/contrib/hosting/Akka.Cluster.Hosting/ClusterDaemonOptions.cs new file mode 100644 index 00000000000..127ca810f23 --- /dev/null +++ b/src/contrib/hosting/Akka.Cluster.Hosting/ClusterDaemonOptions.cs @@ -0,0 +1,21 @@ +using System; +using Akka.Cluster.Sharding; +using Akka.Configuration; +using Akka.Hosting; + +namespace Akka.Cluster.Hosting; + +public sealed class ClusterDaemonOptions +{ + public TimeSpan? KeepAliveInterval { get; set; } + public ClusterShardingSettings? ShardingSettings { get; set; } + public string? Role { get; set; } + public object? HandoffStopMessage { get; set; } + + internal Config ToHocon() + { + return KeepAliveInterval is not null + ? $"akka.cluster.sharded-daemon-process.keep-alive-interval = {KeepAliveInterval.ToHocon()}" + : Config.Empty; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/Properties/FriendsOf.cs b/src/contrib/hosting/Akka.Cluster.Hosting/Properties/FriendsOf.cs new file mode 100644 index 00000000000..0511f30ed0e --- /dev/null +++ b/src/contrib/hosting/Akka.Cluster.Hosting/Properties/FriendsOf.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Akka.Cluster.Hosting.Tests")] \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/README.md b/src/contrib/hosting/Akka.Cluster.Hosting/README.md new file mode 100644 index 00000000000..8538080be9f --- /dev/null +++ b/src/contrib/hosting/Akka.Cluster.Hosting/README.md @@ -0,0 +1,547 @@ +# Akka.Cluster.Hosting + +This module provides `Akka.Hosting` ease-of-use extension methods for [`Akka.Cluster`](https://getakka.net/articles/clustering/cluster-overview.html), [`Akka.Cluster.Sharding`](https://getakka.net/articles/clustering/cluster-sharding.html), and `Akka.Cluster.Tools`. + +## Content + +- [Akka.Cluster](https://getakka.net/articles/clustering/cluster-overview.html) + - [WithClustering()](#withclustering-method) + - [Configure A Cluster With Split-Brain Resolver](#configure-a-cluster-with-split-brain-resolver-sbr) + - [Using Lease-Majority Split Brain Resolver Strategy](#using-lease-majority-split-brain-resolver-strategy) +- [Akka.Cluster.Sharding](https://getakka.net/articles/clustering/cluster-sharding.html) + - [WithShardRegion()](#withshardregion-method) + - [Using Lease With Cluster Sharding](#using-lease-with-cluster-sharding) + - [WithShardRegionProxy()](#withshardregionproxy-method) +- [Distributed Publish-Subscribe](https://getakka.net/articles/clustering/distributed-publish-subscribe.html) + - [WithDistributedPubSub()](#withdistributedpubsub-method) +- [Cluster Singleton](https://getakka.net/articles/clustering/cluster-singleton.html) + - [WithSingleton()](#withsingleton-method) + - [Using Lease With Cluster Singleton](#using-lease-with-cluster-singleton) + - [WithSingletonProxy()](#withsingletonproxy-method) +- [Cluster Client](https://getakka.net/articles/clustering/cluster-client.html) + - [WithClusterClient()](#withclusterclient-method) + - [WithClusterClientReceptionist()](#withclusterclientreceptionist-method) + +# Akka.Cluster Extension Methods + +## WithClustering Method + +An extension method to add [Akka.Cluster](https://getakka.net/articles/clustering/cluster-overview.html) support to the `ActorSystem`. + +```csharp +public static AkkaConfigurationBuilder WithClustering( + this AkkaConfigurationBuilder builder, + ClusterOptions options = null); +``` + +### Parameters +* `options` __ClusterOptions__ + + Optional. Akka.Cluster configuration parameters. + +### Example +```csharp +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")}, + SplitBrainResolver = SplitBrainResolverOption.Default + }); +}); + +var app = builder.Build(); +app.Run(); +``` + +The code above will start [`Akka.Cluster`](https://getakka.net/articles/clustering/cluster-overview.html) with [`Akka.Remote`](https://getakka.net/articles/remoting/index.html) at localhost domain port 8110 and joins itself through the configured `SeedNodes` to form a single node cluster. The `ClusterOptions` class lets you configure the node roles and the seed nodes it should join at start up. + +### Configure A Cluster With Split-Brain Resolver (SBR) + +The __ClusterOptions.SplitBrainResolver__ property lets you configure a SBR. There are four different strategies that the SBR can use, to set one up you will need to pass in one of these class instances: + +| Strategy name | Option class | +|----------------|-----------------------| +| Keep Majority | `KeepMajorityOption` | +| Static-Quorum | `StaticQuorumOption` | +| Keep Oldest | `KeepOldestOption` | +| Lease Majority | `LeaseMajorityOption` | + +You can also pass in `SplitBrainResolverOption.Default` for the default SBR setting that uses the Keep Majority strategy with no role defined. + +```csharp +builder.Services.AddAkka("MyActorSystem", configurationBuilder => +{ + configurationBuilder + .WithClustering(new ClusterOptions { + SplitBrainResolver = new KeepMajorityOption{ Role = "myRole" }, + }); +}); +``` + +### Using Lease-Majority Split-Brain Resolver Strategy + +In order to use `LeaseMajorityOption` you will need to provide an instance of the option class of the `Lease` module you're going to use in the `LeaseMajorityOption.LeaseImplementation` property. + +- For [`Akka.Coordination.KubernetesApi`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/kubernetes/Akka.Coordination.KubernetesApi), this is an instance of `Akka.Coordination.KubernetesApi.KubernetesLeaseOption` class. + + ```csharp + builder.Services.AddAkka("MyActorSystem", configurationBuilder => + { + var leaseOptions = new KubernetesLeaseOption(); + + configurationBuilder + .WithClustering(new ClusterOptions { + SplitBrainResolver = new LeaseMajorityOption{ + LeaseImplementation = leaseOptions, + }, + }) + .WithKubernetesLease(leaseOptions); + }); + ``` + +- For [`Akka.Coordination.Azure`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/azure/Akka.Coordination.Azure) this is an instance of `Akka.Coordination.Azure.AzureLeaseOption` class. + + ```csharp + builder.Services.AddAkka("MyActorSystem", configurationBuilder => + { + var leaseOptions = new AzureLeaseOption { + ConnectionString = "", + ContainerName = ""; + }; + + configurationBuilder + .WithClustering(new ClusterOptions { + SplitBrainResolver = new LeaseMajorityOption{ + LeaseImplementation = leaseOptions, + }, + }) + .WithAzureLease(leaseOptions); + }); + ``` + +# Akka.Cluster.Sharding Extension Methods + +## WithShardRegion Method + +An extension method to set up [Cluster Sharding](https://getakka.net/articles/clustering/cluster-sharding.html). Starts a `ShardRegion` actor for the given entity `typeName` and registers the ShardRegion `IActorRef` with `TKey` in the `ActorRegistry` for this `ActorSystem`. + +## Overloads +```csharp +public static AkkaConfigurationBuilder WithShardRegion( + this AkkaConfigurationBuilder builder, + string typeName, + Func entityPropsFactory, + IMessageExtractor messageExtractor, + ShardOptions shardOptions); +``` + +```csharp +public static AkkaConfigurationBuilder WithShardRegion( + this AkkaConfigurationBuilder builder, + string typeName, + Func entityPropsFactory, + ExtractEntityId extractEntityId, + ExtractShardId extractShardId, + ShardOptions shardOptions); +``` + +```csharp +public static AkkaConfigurationBuilder WithShardRegion( + this AkkaConfigurationBuilder builder, + string typeName, + Func> compositePropsFactory, + IMessageExtractor messageExtractor, + ShardOptions shardOptions); +``` + +````csharp +public static AkkaConfigurationBuilder WithShardRegion( + this AkkaConfigurationBuilder builder, + string typeName, + Func> compositePropsFactory, + ExtractEntityId extractEntityId, + ExtractShardId extractShardId, + ShardOptions shardOptions); +```` +### Type Parameters +* `TKey` + + The type key to use to retrieve the `IActorRef` for this `ShardRegion` from the `ActorRegistry`. + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `typeName` __string__ + + The name of the entity type + +* `entityPropsFactory` __Func__ + + Function that, given an entity id, returns the `Actor.Props` of the entity actors that will be created by the `Sharding.ShardRegion` + +* `compositePropsFactory` __Func>__ + + A delegate function that takes an `ActorSystem` and an `ActorRegistry` as parameters and returns a `Props` factory. Used when the `Props` factory either depends on another actor or needs to access the `ActorSystem` to set the `Props` up. + +* `messageExtractor` __IMessageExtractor__ + + An `IMessageExtractor` interface implementation to extract the entity id, shard id, and the message to send to the entity from the incoming message. + +* `extractEntityId` __ExtractEntityId__ + + Partial delegate 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 + +* `extractShardId` __ExtractShardId__ + + Delegate function to determine the shard id for an incoming message, only messages that passed the `extractEntityId` will be used + +* `shardOptions` __ShardOptions__ + + The set of options for configuring `ClusterShardingSettings` + +### Example +```csharp +public class EchoActor : ReceiveActor +{ + private readonly string _entityId; + public EchoActor(string entityId) + { + _entityId = entityId; + ReceiveAny(message => { + Sender.Tell($"{Self} rcv {message}"); + }); + } +} + +public class Program +{ + private const int NumberOfShards = 5; + + private static Option<(string, object)> ExtractEntityId(object message) + => message switch { + string id => (id, id), + _ => Option<(string, object)>.None + }; + + private static string? ExtractShardId(object message) + => message switch { + string id => (id.GetHashCode() % NumberOfShards).ToString(), + _ => null + }; + + private static Props PropsFactory(string entityId) + => Props.Create(() => new EchoActor(entityId)); + + public static void Main(params string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddAkka("MyActorSystem", configurationBuilder => + { + configurationBuilder + .WithRemoting(hostname: "localhost", port: 8110) + .WithClustering(new ClusterOptions{SeedNodes = new []{ Address.Parse("akka.tcp://MyActorSystem@localhost:8110"), }}) + .WithShardRegion( + typeName: "myRegion", + entityPropsFactory: PropsFactory, + extractEntityId: ExtractEntityId, + extractShardId: ExtractShardId, + shardOptions: new ShardOptions()); + }); + + var app = builder.Build(); + + app.MapGet("/", async (context) => + { + var echo = context.RequestServices.GetRequiredService().Get(); + var body = await echo.Ask( + message: context.TraceIdentifier, + cancellationToken: context.RequestAborted) + .ConfigureAwait(false); + await context.Response.WriteAsync(body); + }); + + app.Run(); + } +} +``` + +### Using Lease With Cluster Sharding + +To use the cluster sharding lease feature, you will need to pass in the lease option into the `shardOptions` parameter: + +```csharp +var leaseOptions = new AzureLeaseOption { + ConnectionString = "", + ContainerName = ""; +}; + +configurationBuilder + .WithRemoting(hostname: "localhost", port: 8110) + .WithClustering(new ClusterOptions{SeedNodes = new []{ Address.Parse("akka.tcp://MyActorSystem@localhost:8110"), }}) + .WithShardRegion( + typeName: "myRegion", + entityPropsFactory: PropsFactory, + extractEntityId: ExtractEntityId, + extractShardId: ExtractShardId, + shardOptions: new ShardOptions{ + LeaseImplementation = leaseOptions, + }) + .WithAzureLease(leaseOptions); + +``` + +## WithShardRegionProxy Method + +An extension method to start a `ShardRegion` proxy actor that points to a `ShardRegion` hosted on a different role inside the cluster and registers the `IActorRef` with `TKey` in the `ActorRegistry` for this `ActorSystem`. + +## Overloads + +```csharp +public static AkkaConfigurationBuilder WithShardRegionProxy( + this AkkaConfigurationBuilder builder, + string typeName, + string roleName, + ExtractEntityId extractEntityId, + ExtractShardId extractShardId); +``` + +```csharp +public static AkkaConfigurationBuilder WithShardRegionProxy( + this AkkaConfigurationBuilder builder, + string typeName, + string roleName, + IMessageExtractor messageExtractor); +``` + +### Type Parameters +* `TKey` + + The type key to use to retrieve the `IActorRef` for this `ShardRegion` from the `ActorRegistry`. + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `typeName` __string__ + + The name of the entity type + +* `roleName` __string__ + + The role of the Akka.Cluster member that is hosting the target `ShardRegion`. + +* `messageExtractor` __IMessageExtractor__ + + An `IMessageExtractor` interface implementation to extract the entity id, shard id, and the message to send to the entity from the incoming message. + +* `extractEntityId` __ExtractEntityId__ + + Partial delegate 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 + +* `extractShardId` __ExtractShardId__ + + Delegate function to determine the shard id for an incoming message, only messages that passed the `extractEntityId` will be used + +# Akka.Cluster.Tools Extension Methods + +## WithDistributedPubSub Method + +An extension method to start [`Distributed Publish Subscribe`](https://getakka.net/articles/clustering/distributed-publish-subscribe.html) on this node immediately upon `ActorSystem` startup. Stores the pub-sub mediator `IActorRef` in the `ActorRegistry` using the `DistributedPubSub` key. + +```csharp +public static AkkaConfigurationBuilder WithDistributedPubSub( + this AkkaConfigurationBuilder builder, + string role); +``` + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `role` __string__ + + Specifies which role `DistributedPubSub` will broadcast gossip to. If this value is left blank then ALL roles will be targeted. + +## WithSingleton Method + +An extension method to start [Cluster Singleton](https://getakka.net/articles/clustering/cluster-singleton.html). Creates a new [Singleton Manager](https://getakka.net/articles/clustering/cluster-singleton.html#singleton-manager) to host an actor created via `actorProps`. + +If `createProxyToo` is set to _true_ then this method will also create a `ClusterSingletonProxy` that will be added to the `ActorRegistry` using the key `TKey`. Otherwise this method will register nothing with the `ActorRegistry`. + +```csharp +public static AkkaConfigurationBuilder WithSingleton( + this AkkaConfigurationBuilder builder, + string singletonName, + Props actorProps, + ClusterSingletonOptions options = null, + bool createProxyToo = true); +``` + +### Type Parameters +* `TKey` + + The key type to use for the `ActorRegistry` when `createProxyToo` is set to _true_. + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `singletonName` __string__ + +The name of this singleton instance. Will also be used in the `ActorPath` for the `ClusterSingletonManager` and optionally, the `ClusterSingletonProxy` created by this method. + +* `actorProps` __Props__ + +The underlying actor type. __SHOULD NOT BE CREATED USING `ClusterSingletonManager.Props`__ + +* `options` __ClusterSingletonOptions__ + +Optional. The set of options for configuring both the `ClusterSingletonManager` and optionally, the `ClusterSingletonProxy`. + +* `createProxyToo` __bool__ + +When set to _true_, creates a `ClusterSingletonProxy` that automatically points to the `ClusterSingletonManager` created by this method. + +### Using Lease With Cluster Singleton + +To use the cluster singleton lease feature, you will need to pass in the lease option into the `options` parameter: + +```csharp +Props propsFactory(ActorSystem system, IActorRegistry registry, IDependencyResolver resolver) + => Props.Create(() => new EchoActor()); + +var leaseOptions = new AzureLeaseOption { + ConnectionString = "", + ContainerName = ""; +}; + +configurationBuilder + .WithRemoting() + .WithClustering() + .WithSingleton( + singletonName: "singleton", + propsFactory: propsFactory, + options: new ClusterSingletonOptions { + LeaseImplementation = leaseOptions, + }) + .WithAzureLease(leaseOptions); + +``` + +## WithSingletonProxy Method + +An extension method to create a [Cluster Singleton Proxy](https://getakka.net/articles/clustering/cluster-singleton.html#singleton-proxy) and adds it to the `ActorRegistry` using the given `TKey`. + +```csharp +public static AkkaConfigurationBuilder WithSingletonProxy( + this AkkaConfigurationBuilder builder, + string singletonName, + ClusterSingletonOptions options = null, + string singletonManagerPath = null); +``` + +### Type Parameters +* `TKey` + + The key type to use for the `ActorRegistry`. + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `singletonName` __string__ + + The name of this singleton instance. Will also be used in the `ActorPath` for the `ClusterSingletonManager` and optionally, the `ClusterSingletonProxy` created by this method. + +* `options` __ClusterSingletonOptions__ + + Optional. The set of options for configuring the `ClusterSingletonProxy`. + +* `singletonManagerPath` __string__ + + Optional. By default Akka.Hosting will assume the `ClusterSingletonManager` is hosted at "/user/{singletonName}" - but if for some reason the path is different you can use this property to override that value. + +## WithClusterClientReceptionist Method + +Configures a [Cluster Client](https://getakka.net/articles/clustering/cluster-client.html) `ClusterClientReceptionist` for the `ActorSystem` + +```csharp +public static AkkaConfigurationBuilder WithClusterClientReceptionist( + this AkkaConfigurationBuilder builder, + string name = "receptionist", + string role = null); +``` + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `name` __string__ + +Actor name of the ClusterReceptionist actor under the system path, by default it is "/system/receptionist" + +* `role` __string__ + +Checks that the receptionist only start on members tagged with this role. All members are used if set to _null_. + +## WithClusterClient Method + +Creates a [Cluster Client](https://getakka.net/articles/clustering/cluster-client.html) and adds it to the `ActorRegistry` using the given `TKey`. + +## Overloads + +```csharp +public static AkkaConfigurationBuilder WithClusterClient( + this AkkaConfigurationBuilder builder, + IList initialContacts); +``` + +```csharp +public static AkkaConfigurationBuilder WithClusterClient( + this AkkaConfigurationBuilder builder, + IEnumerable
initialContactAddresses, + string receptionistActorName = "receptionist"); +``` + +```csharp +public static AkkaConfigurationBuilder WithClusterClient( + this AkkaConfigurationBuilder builder, + IEnumerable initialContacts); +``` + +### Parameters + +* `builder` __AkkaConfigurationBuilder__ + + The builder instance being configured. + +* `initialContacts` __IList__, __IEnumerable__ + + List of `ClusterClientReceptionist` actor path in `ActorPath` or `string` form that will be used as a seed to discover all of the receptionists in the cluster. + +* `initialContactAddresses` __IEnumerable
__ + + List of node addresses where the `ClusterClientReceptionist` are located that will be used as seed to discover all of the receptionists in the cluster. + +* `receptionistActorName` __string__ + + The name of the `ClusterClientReceptionist` actor. Defaults to "receptionist" \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs b/src/contrib/hosting/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs new file mode 100644 index 00000000000..f3239ce9909 --- /dev/null +++ b/src/contrib/hosting/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs @@ -0,0 +1,205 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Actor.Setup; +using Akka.Cluster.SBR; +using Akka.Hosting; +using Akka.Hosting.Coordination; + +namespace Akka.Cluster.Hosting.SBR +{ + public abstract class SplitBrainResolverOption: IHoconOption + { + public static readonly SplitBrainResolverOption Default = new KeepMajorityOption(); + + /// + /// if the is defined the decision is based only on members with that + /// + public string? Role { get; set; } + + public abstract string ConfigPath { get; } + + public Type Class => typeof(SplitBrainResolverProvider); + + public abstract void Apply(AkkaConfigurationBuilder builder, Setup? setup = null); + } + + /// + /// + /// Down the unreachable nodes if the number of remaining nodes are greater than or equal to the given + /// . Otherwise down the reachable nodes, i.e. it will shut down that side of the partition. + /// In other words, the defines the minimum number of nodes that the cluster must have + /// to be operational. If there are unreachable nodes when starting up the cluster, before reaching this limit, + /// the cluster may shutdown itself immediately. This is not an issue if you start all nodes at approximately + /// the same time. + /// + /// + /// Note that you must not add more members to the cluster than ' * 2 - 1', because then + /// both sides may down each other and thereby form two separate clusters. For example, + /// configured to 3 in a 6 node cluster may result in a split where each side consists of 3 nodes each, + /// i.e. each side thinks it has enough nodes to continue by itself. A warning is logged if this recommendation is violated. + /// + /// + public sealed class StaticQuorumOption : SplitBrainResolverOption + { + public override string ConfigPath => SplitBrainResolverSettings.StaticQuorumName; + + /// + /// Minimum number of nodes that the cluster must have + /// + public int? QuorumSize { get; set; } = 0; + + public override void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + var sb = new StringBuilder("akka.cluster {"); + sb.AppendLine($"downing-provider-class = \"{Class.AssemblyQualifiedName}\""); + sb.AppendLine("split-brain-resolver {"); + sb.AppendLine($"active-strategy = {ConfigPath}"); + + var innerSb = new StringBuilder(); + if (Role != null) + innerSb.AppendLine($"role = {Role}"); + if(QuorumSize != null) + innerSb.AppendLine($"quorum-size = {QuorumSize}"); + + if (innerSb.Length > 0) + { + sb.AppendLine($"{ConfigPath} {{"); + sb.Append(innerSb); + sb.Append("}"); + } + + sb.Append("}}"); + + builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); + } + } + + /// + /// Down the unreachable nodes if the current node is in the majority part based the last known membership + /// information. Otherwise down the reachable nodes, i.e. the own part. If the the parts are of equal size the part + /// containing the node with the lowest address is kept. + /// Note that if there are more than two partitions and none is in majority each part will shutdown itself, + /// terminating the whole cluster. + /// + public sealed class KeepMajorityOption : SplitBrainResolverOption + { + public override string ConfigPath => SplitBrainResolverSettings.KeepMajorityName; + + public override void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + var sb = new StringBuilder("akka.cluster {"); + sb.AppendLine($"downing-provider-class = \"{Class.AssemblyQualifiedName}\""); + sb.AppendLine("split-brain-resolver {"); + sb.AppendLine($"active-strategy = {ConfigPath}"); + + if (Role != null) + sb.AppendLine($"{ConfigPath}.role = {Role}"); + + sb.Append("}}"); + + builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); + } + } + + /// + /// + /// Down the part that does not contain the oldest member (current singleton). + /// + /// When is true: + /// + /// If the oldest node crashes the others will remove it from the cluster. + /// If oldest node is partitioned from all other nodes, the oldest will down itself and keep all other nodes running. + /// The strategy will not down the single oldest node when it is the only remaining node in the cluster. + /// + /// When is false and the oldest node crashes, all other nodes will down themselves, + /// i.e. shutdown the whole cluster together with the oldest node. + /// + public sealed class KeepOldestOption : SplitBrainResolverOption + { + public override string ConfigPath => SplitBrainResolverSettings.KeepOldestName; + + /// + /// Enable downing of the oldest node when it is partitioned from all other nodes + /// + public bool? DownIfAlone { get; set; } = true; + + public override void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + var sb = new StringBuilder("akka.cluster {"); + sb.AppendLine($"downing-provider-class = \"{Class.AssemblyQualifiedName}\""); + sb.AppendLine("split-brain-resolver {"); + sb.AppendLine($"active-strategy = {ConfigPath}"); + + var innerSb = new StringBuilder(); + if (Role != null) + innerSb.AppendLine($"role = {Role}"); + if(DownIfAlone != null) + innerSb.AppendLine($"down-if-alone = {DownIfAlone.ToHocon()}"); + + if (innerSb.Length > 0) + { + sb.AppendLine($"{ConfigPath} {{"); + sb.Append(innerSb); + sb.Append("}"); + } + + sb.Append("}}"); + + builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); + } + } + + /// + /// Keep the part that can acquire the lease, and down the other part. + /// Best effort is to keep the side that has most nodes, i.e. the majority side. + /// This is achieved by adding a delay before trying to acquire the lease on the + /// minority side. + /// + public sealed class LeaseMajorityOption : SplitBrainResolverOption + { + public override string ConfigPath => SplitBrainResolverSettings.LeaseMajorityName; + + /// + /// An class instance that extends , used to configure the lease provider used in this + /// strategy. + /// + public LeaseOptionBase? LeaseImplementation { get; set; } + + /// + /// The name of the lease. + /// + /// The recommended format for the lease name is "{service-name}-akka-sbr". + /// When lease-name is not defined, the name will be set to "{actor-system-name}-akka-sbr" + /// + public string? LeaseName { get; set; } + + public override void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + if (LeaseImplementation is null) + throw new NullReferenceException($"{nameof(LeaseMajorityOption)}.{nameof(LeaseImplementation)} must not be null"); + + var sb = new StringBuilder("akka.cluster {"); + sb.AppendLine($"downing-provider-class = \"{Class.AssemblyQualifiedName}\""); + sb.AppendLine("split-brain-resolver {"); + sb.AppendLine($"active-strategy = {ConfigPath}"); + + sb.AppendLine($"{ConfigPath} {{"); + sb.AppendLine($"lease-implementation = {LeaseImplementation.ConfigPath}"); + if (Role != null) + sb.AppendLine($"role = {Role}"); + if(LeaseName != null) + sb.AppendLine($"lease-name = {LeaseName}"); + + sb.Append("}}}"); + + builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); + } + + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj new file mode 100644 index 00000000000..1627d261c21 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj @@ -0,0 +1,32 @@ + + + + $(NetTestVersion) + Exe + false + enable + + + + + + + + + + + + + + + + + Always + + + + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/AsyncCompat.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/AsyncCompat.cs new file mode 100644 index 00000000000..ecbe5d03512 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/AsyncCompat.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace Akka.Hosting.TestKit.Tests; + +internal static class AsyncCompat +{ + public static Task ToTask(this Task task) => task; + + public static Task ToTask(this ValueTask task) => task.AsTask(); +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/DiPropsFailTest.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/DiPropsFailTest.cs new file mode 100644 index 00000000000..3eb43eb0a56 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/DiPropsFailTest.cs @@ -0,0 +1,53 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2023 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Actor; +using Akka.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +// Regression test for https://github.com/akkadotnet/Akka.Hosting/issues/343 +public class DiPropsFailTest: TestKit +{ + public DiPropsFailTest(XunitTestOutputHelper output) : base(nameof(DiPropsFailTest), output) + {} + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddLogging(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { } + + [Fact] + public void DiTest() + { + var actor = Sys.ActorOf(NonRootActorWithDi.Props(Sys)); + actor.Tell("test"); + ExpectMsg("test"); + } + + private class NonRootActorWithDi: ReceiveActor + { + public static Props Props(ActorSystem system) => DependencyResolver.For(system).Props(); + + public NonRootActorWithDi(ILogger log) + { + ReceiveAny(msg => + { + log.LogInformation("Received {Msg}", msg); + Sender.Tell(msg); + }); + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/GlobalUsings.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..9ce1d3a87b3 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using XunitTestOutputHelper = Xunit.ITestOutputHelper; diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs new file mode 100644 index 00000000000..c5d57ec0b14 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs @@ -0,0 +1,62 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.TestKit.TestActors; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.TestKit.Tests +{ + public class HostingSpecSpec: TestKit + { + private enum Echo + { } + + public HostingSpecSpec(XunitTestOutputHelper output) + : base(nameof(HostingSpecSpec), output, logLevel: LogLevel.Debug) + { + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithActors((system, registry) => + { + var echo = system.ActorOf(Props.Create(() => new SimpleEchoActor())); + registry.Register(echo); + }); + } + + [Fact] + public void ActorTest() + { + var echo = ActorRegistry.Get(); + var probe = CreateTestProbe(); + + echo.Tell("TestMessage", probe); + var msg = probe.ExpectMsg("TestMessage"); + Log.Info(msg); + } + + private class SimpleEchoActor : ReceiveActor + { + public SimpleEchoActor() + { + var log = Context.GetLogger(); + + ReceiveAny(msg => + { + log.Info($"Received {msg}"); + Sender.Tell(msg); + }); + } + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/LoggerSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/LoggerSpec.cs new file mode 100644 index 00000000000..a2b5c7ffb7e --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/LoggerSpec.cs @@ -0,0 +1,81 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting.TestKit.Internals; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.TestKit.Tests; + +public class LoggerSpec: TestKit +{ + public LoggerSpec(XunitTestOutputHelper output): base(output: output, logLevel: LogLevel.Debug) + { + } + + internal override async Task LoggerHook(ActorSystem system, IActorRegistry registry) + { + var extSystem = (ExtendedActorSystem)system; + var logger = extSystem.SystemActorOf(Props.Create(() => new MockLogger()), "log-test"); + registry.Register(logger); + await logger.Ask(new InitializeLogger(system.EventStream)); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact(DisplayName = "TestKit ILoggerFactory logger should log messages")] + public void TestKitLoggerFactoryLoggerTest() + { + var loggerActor = ActorRegistry.Get(); + loggerActor.Tell(TestActor); + + var logger = Event.Logging.GetLogger(Sys, "log-test"); + + logger.Debug("debug"); + ExpectMsg(i => i.Message.ToString() == "debug"); + + logger.Info("info"); + ExpectMsg(i => i.Message.ToString() == "info"); + + logger.Warning("warn"); + ExpectMsg(i => i.Message.ToString() == "warn"); + + logger.Error("err"); + ExpectMsg(i => i.Message.ToString() == "err"); + } + + private class MockLogger: TestKitLoggerFactoryLogger + { + private IActorRef? _probe; + + protected override bool Receive(object message) + { + switch (message) + { + case IActorRef actor: + _probe = actor; + return true; + default: + return base.Receive(message); + } + } + + protected override void Log(LogEvent log, ActorPath path) + { + if(log.LogSource.StartsWith("log-test")) + _probe.Tell(log); + base.Log(log, path); + } + } + +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/NoImplicitSenderSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/NoImplicitSenderSpec.cs new file mode 100644 index 00000000000..096f19eca8c --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/NoImplicitSenderSpec.cs @@ -0,0 +1,75 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Dsl; +using Akka.TestKit; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +public class NoImplicitSenderSpec : TestKit, INoImplicitSender +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + + } + + [Fact] + public void When_Not_ImplicitSender_then_testActor_is_not_sender() + { + var echoActor = Sys.ActorOf(c => c.ReceiveAny((m, ctx) => TestActor.Tell(ctx.Sender))); + echoActor.Tell("message"); + var actorRef = ExpectMsg(); + actorRef.Should().Be(Sys.DeadLetters); + } + +} + +public class ImplicitSenderSpec : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + + } + + [Fact] + public void ImplicitSender_should_have_testActor_as_sender() + { + var echoActor = Sys.ActorOf(c => c.ReceiveAny((m, ctx) => TestActor.Tell(ctx.Sender))); + echoActor.Tell("message"); + ExpectMsg(actorRef => Equals(actorRef, TestActor)); + + //Test that it works after we know that context has been changed + echoActor.Tell("message"); + ExpectMsg(actorRef => Equals(actorRef, TestActor)); + + } + + + [Fact] + public void ImplicitSender_should_not_change_when_creating_Testprobes() + { + //Verifies that bug #459 has been fixed + var testProbe = CreateTestProbe(); + TestActor.Tell("message"); + ReceiveOne(); + LastSender.Should().Be(TestActor); + } + + [Fact] + public void ImplicitSender_should_not_change_when_creating_TestActors() + { + var testActor2 = CreateTestActor("test2"); + TestActor.Tell("message"); + ReceiveOne(); + LastSender.Should().Be(TestActor); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/ParallelAmbientContextSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/ParallelAmbientContextSpec.cs new file mode 100644 index 00000000000..0842fba665e --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/ParallelAmbientContextSpec.cs @@ -0,0 +1,83 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Internal; +using Akka.TestKit; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +public abstract class ParallelAmbientContextSpecBase : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public async Task Implicit_sender_should_resolve_to_own_TestActor() + { + TestActor.Tell("ping"); + await ExpectMsgAsync( + "ping", + TimeSpan.FromSeconds(5), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(TestActor, LastSender); + + await Task.Yield(); + TestActor.Tell("ping-after-yield"); + await ExpectMsgAsync( + "ping-after-yield", + TimeSpan.FromSeconds(5), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(TestActor, LastSender); + } +} + +public class ParallelAmbientContextSpec01 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec02 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec03 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec04 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec05 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec06 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec07 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec08 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec09 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec10 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec11 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec12 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec13 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec14 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec15 : ParallelAmbientContextSpecBase { } +public class ParallelAmbientContextSpec16 : ParallelAmbientContextSpecBase { } + +public abstract class ParallelNoImplicitSenderSpecBase : TestKit, INoImplicitSender +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public async Task Current_should_be_null_both_pre_and_post_await() + { + Assert.Null(InternalCurrentActorCellKeeper.Current); + await Task.Yield(); + Assert.Null(InternalCurrentActorCellKeeper.Current); + await Task.Yield(); + Assert.Null(InternalCurrentActorCellKeeper.Current); + } +} + +public class ParallelNoImplicitSenderSpec01 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec02 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec03 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec04 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec05 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec06 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec07 : ParallelNoImplicitSenderSpecBase { } +public class ParallelNoImplicitSenderSpec08 : ParallelNoImplicitSenderSpecBase { } diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..d7439b5d804 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +using Xunit; + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("b21496c0-a536-4953-9253-d2d0d526e42d")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] + +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass)] diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/StrictSerializationSpecs.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/StrictSerializationSpecs.cs new file mode 100644 index 00000000000..61a95c3e058 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/StrictSerializationSpecs.cs @@ -0,0 +1,56 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2026 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Actor; +using Akka.Configuration; +using FluentAssertions; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.TestKit.Tests; + +public class StrictSerializationSpecsEnabled : TestKit +{ + public StrictSerializationSpecsEnabled(XunitTestOutputHelper output) + : base(nameof(StrictSerializationSpecsEnabled), output, logLevel: LogLevel.Information) + { + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithStrictSerialization(); + } + + [Fact(DisplayName = "WithStrictSerialization() should set allow-unregistered-types to off")] + public void ShouldSetAllowUnregisteredTypesOff() + { + var hocon = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); + var value = hocon.GetString("allow-unregistered-types"); + value.Should().Be("off"); + } +} + +public class StrictSerializationSpecsDisabled : TestKit +{ + public StrictSerializationSpecsDisabled(XunitTestOutputHelper output) + : base(nameof(StrictSerializationSpecsDisabled), output, logLevel: LogLevel.Information) + { + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithStrictSerialization(false); + } + + [Fact(DisplayName = "WithStrictSerialization(false) should set allow-unregistered-types to on")] + public void ShouldSetAllowUnregisteredTypesOn() + { + var hocon = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); + var value = hocon.GetString("allow-unregistered-types"); + value.Should().Be("on"); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRecoveryRegistrySpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRecoveryRegistrySpec.cs new file mode 100644 index 00000000000..6cff4e4c47c --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRecoveryRegistrySpec.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Hosting; +using Akka.TestKit; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +file sealed class CachedProbeForwarder : ReceiveActor +{ + private readonly IActorRef _cachedProbe; + + public sealed class Send + { + public Send(string message) + { + Message = message; + } + + public string Message { get; } + } + + public sealed class GetCachedProbe + { + public static readonly GetCachedProbe Instance = new(); + + private GetCachedProbe() + { + } + } + + public CachedProbeForwarder(IRequiredActor probe) + { + _cachedProbe = probe.ActorRef; + + Receive(send => _cachedProbe.Tell(send.Message)); + Receive(_ => Sender.Tell(_cachedProbe)); + } +} + +sealed class RecoveryTestKit : Akka.Hosting.TestKit.TestKit +{ + public RecoveryTestKit(ITestOutputHelper output) + : base($"recovery-{Guid.NewGuid():N}", output: output, startupTimeout: TimeSpan.FromSeconds(10), logLevel: LogLevel.Error) + { + } + + public IActorRef Forwarder => ActorRegistry.Get(); + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithActors((system, registry, resolver) => + { + var forwarder = system.ActorOf(resolver.Props(), "cached-probe-forwarder"); + registry.Register(forwarder); + }); + } +} + +public class TestActorRecoveryRegistrySpec +{ + private readonly ITestOutputHelper _output; + + public TestActorRecoveryRegistrySpec(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task Cached_required_TestProbe_reference_should_survive_TestActor_recovery() + { + await using var kit = new RecoveryTestKit(_output); + await kit.InitializeAsync(); + + var initialTestActor = kit.TestActor; + var cachedProbe = await kit.Forwarder.Ask(CachedProbeForwarder.GetCachedProbe.Instance, TimeSpan.FromSeconds(3)); + cachedProbe.Should().NotBe(ActorRefs.Nobody); + cachedProbe.Should().NotBe(initialTestActor); + + kit.Forwarder.Tell(new CachedProbeForwarder.Send("before-recovery")); + await kit.ExpectMsgAsync("before-recovery", TimeSpan.FromSeconds(3)); + + initialTestActor.Tell(PoisonPill.Instance); + await WaitUntilProbeStopsAsync(kit, initialTestActor); + + await kit.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(200)); + + await kit.ForceReinitializeTestActorAsync(); + kit.TestActor.Should().NotBe(initialTestActor); + + kit.Forwarder.Tell(new CachedProbeForwarder.Send("after-recovery")); + await kit.ExpectMsgAsync("after-recovery", TimeSpan.FromSeconds(3)); + } + + private static async Task WaitUntilProbeStopsAsync(RecoveryTestKit kit, IActorRef probe) + { + var deadline = DateTime.UtcNow.AddSeconds(3); + + while (DateTime.UtcNow < deadline) + { + try + { + await kit.Sys.ActorSelection(probe.Path).ResolveOne(TimeSpan.FromMilliseconds(150)); + await Task.Delay(50); + } + catch (ActorNotFoundException) + { + return; + } + } + + Assert.Fail($"Probe [{probe.Path}] did not terminate within the expected window."); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/BossActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/BossActor.cs new file mode 100644 index 00000000000..5c5e9f1d94c --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/BossActor.cs @@ -0,0 +1,55 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using Akka.Actor; +using Akka.TestKit; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class BossActor : TActorBase +{ + private TestActorRef _child; + + public BossActor() + { + _child = new TestActorRef(Context.System, Props.Create(), Self, "child"); + } + + protected override SupervisorStrategy SupervisorStrategy() + { + return new OneForOneStrategy(maxNrOfRetries: 5, withinTimeRange: TimeSpan.FromSeconds(1), localOnlyDecider: ex => ex is ActorKilledException ? Directive.Restart : Directive.Escalate); + } + + protected override bool ReceiveMessage(object message) + { + if(message is string && ((string)message) == "sendKill") + { + _child.Tell(Kill.Instance); + return true; + } + return false; + } + + private class InternalActor : TActorBase + { + protected override void PreRestart(Exception reason, object message) + { + TestActorRefSpec.Counter--; + } + + protected override void PostRestart(Exception reason) + { + TestActorRefSpec.Counter--; + } + + protected override bool ReceiveMessage(object message) + { + return true; + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/FsmActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/FsmActor.cs new file mode 100644 index 00000000000..297f82ec395 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/FsmActor.cs @@ -0,0 +1,56 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public enum TestFsmState +{ + First, + Last +} + +public class FsmActor : FSM +{ + private readonly IActorRef _replyActor; + + public FsmActor(IActorRef replyActor) + { + _replyActor = replyActor; + + When(TestFsmState.First, e => + { + if (e.FsmEvent.Equals("check")) + { + _replyActor.Tell("first"); + } + else if (e.FsmEvent.Equals("next")) + { + return GoTo(TestFsmState.Last); + } + + return Stay(); + }); + + When(TestFsmState.Last, e => + { + if (e.FsmEvent.Equals("check")) + { + _replyActor.Tell("last"); + } + else if (e.FsmEvent.Equals("next")) + { + return GoTo(TestFsmState.First); + } + + return Stay(); + }); + + StartWith(TestFsmState.First, "foo"); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs new file mode 100644 index 00000000000..b86eaabc700 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; +using Akka.Event; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class Logger : ActorBase +{ + private int _count; + private string? _msg; + protected override bool Receive(object message) + { + if(message is Warning { Message: string } warning) + { + _count++; + _msg = (string)warning.Message; + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/NestingActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/NestingActor.cs new file mode 100644 index 00000000000..b180c5b4e70 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/NestingActor.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; +using Akka.TestKit; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class NestingActor : ActorBase +{ + private readonly IActorRef _nested; + + public NestingActor(bool createTestActorRef) + { + _nested = createTestActorRef ? Context.ActorOf() : new TestActorRef(Context.System, Props.Create(), null, null); + } + + protected override bool Receive(object message) + { + Sender.Tell(_nested, Self); + return true; + } + + private class NestedActor : ActorBase + { + protected override bool Receive(object message) + { + return true; + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/PersistActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/PersistActor.cs new file mode 100644 index 00000000000..96b821193c0 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/PersistActor.cs @@ -0,0 +1,72 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Persistence; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests +{ + using System; + using Actor; + + public class PersistActor : UntypedPersistentActor + { + public PersistActor(IActorRef probe) + { + _probe = probe; + } + + private readonly IActorRef _probe; + + public override string PersistenceId => "foo"; + + protected override void OnCommand(object message) + { + switch (message) + { + case WriteMessage msg: + Persist(msg.Data, _ => + { + _probe.Tell("ack"); + }); + + break; + + default: + return; + } + } + + protected override void OnRecover(object message) + { + _probe.Tell(message); + } + + protected override void OnPersistFailure(Exception cause, object @event, long sequenceNr) + { + _probe.Tell("failure"); + + base.OnPersistFailure(cause, @event, sequenceNr); + } + + protected override void OnPersistRejected(Exception cause, object @event, long sequenceNr) + { + _probe.Tell("rejected"); + + base.OnPersistRejected(cause, @event, sequenceNr); + } + + public class WriteMessage + { + public string Data { get; } + + public WriteMessage(string data) + { + Data = data; + } + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs new file mode 100644 index 00000000000..57d8e1dcb48 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs @@ -0,0 +1,44 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using Akka.Actor; +using Akka.TestKit; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class ReplyActor : TActorBase +{ + private IActorRef? _replyTo; + + protected override bool ReceiveMessage(object message) + { + var strMessage = message as string; + switch(strMessage) + { + case "complexRequest": + _replyTo = Sender; + var worker = new TestActorRef(System, Props.Create()); + worker.Tell("work"); + return true; + case "complexRequest2": + var worker2 = new TestActorRef(System, Props.Create()); + worker2.Tell(Sender, Self); + return true; + case "workDone": + if (_replyTo is null) + throw new NullReferenceException("_replyTo is null, make sure that \"complexRequest\" is sent first"); + + _replyTo.Tell("complexReply", Self); + return true; + case "simpleRequest": + Sender.Tell("simpleReply", Self); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/SenderActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/SenderActor.cs new file mode 100644 index 00000000000..420ec2d0bd1 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/SenderActor.cs @@ -0,0 +1,44 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class SenderActor : TActorBase +{ + private readonly IActorRef _replyActor; + + public SenderActor(IActorRef replyActor) + { + _replyActor = replyActor; + } + + protected override bool ReceiveMessage(object message) + { + var strMessage = message as string; + switch(strMessage) + { + case "complex": + _replyActor.Tell("complexRequest", Self); + return true; + case "complex2": + _replyActor.Tell("complexRequest2", Self); + return true; + case "simple": + _replyActor.Tell("simpleRequest", Self); + return true; + case "complexReply": + TestActorRefSpec.Counter--; + return true; + case "simpleReply": + TestActorRefSpec.Counter--; + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/SnapshotActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/SnapshotActor.cs new file mode 100644 index 00000000000..fe4fe86c4d0 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/SnapshotActor.cs @@ -0,0 +1,100 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +namespace Akka.Persistence.TestKit.Tests +{ + using System; + using Actor; + + public class SnapshotActor : UntypedPersistentActor + { + public SnapshotActor(IActorRef probe) + { + _probe = probe; + } + + private readonly IActorRef _probe; + + public override string PersistenceId => "bar"; + + protected override void OnCommand(object message) + { + switch (message) + { + case "save": + SaveSnapshot(message); + return; + + case DeleteOne del: + DeleteSnapshot(del.SequenceNr); + return; + + case DeleteMany del: + DeleteSnapshots(del.Criteria); + return; + + case SaveSnapshotSuccess _: + case SaveSnapshotFailure _: + case DeleteSnapshotSuccess _: + case DeleteSnapshotFailure _: + case DeleteSnapshotsSuccess _: + case DeleteSnapshotsFailure _: + _probe.Tell(message); + return; + + default: + return; + } + } + + protected override void OnRecover(object message) + { + if (message is SnapshotOffer snapshot) + { + _probe.Tell(message); + } + } + + protected override void OnRecoveryFailure(Exception reason, object message) + { + _probe.Tell(new RecoveryFailure(reason, message)); + base.OnRecoveryFailure(reason, message); + } + + public class DeleteOne + { + public DeleteOne(long sequenceNr) + { + SequenceNr = sequenceNr; + } + + public long SequenceNr { get; } + } + + public class DeleteMany + { + public DeleteMany(SnapshotSelectionCriteria criteria) + { + Criteria = criteria; + } + + public SnapshotSelectionCriteria Criteria { get; } + } + + public class RecoveryFailure + { + public RecoveryFailure(Exception reason, object message) + { + Reason = reason; + Message = message; + } + + public Exception Reason { get; } + public object Message { get; } + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TActorBase.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TActorBase.cs new file mode 100644 index 00000000000..4bf03ac47d2 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TActorBase.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System.Threading; +using Akka.Actor; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +// ReSharper disable once InconsistentNaming +public abstract class TActorBase : ActorBase +{ + protected sealed override bool Receive(object message) + { + var currentThread = Thread.CurrentThread; + if(currentThread != TestActorRefSpec.Thread) + TestActorRefSpec.OtherThread = currentThread; + return ReceiveMessage(message); + } + + protected abstract bool ReceiveMessage(object message); + + protected ActorSystem System + { + get { return ((LocalActorRef)Self).Cell.System; } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs new file mode 100644 index 00000000000..9bb108d285e --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs @@ -0,0 +1,239 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Dispatch; +using Akka.TestKit; +using Akka.TestKit.Internal; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests +{ + public class TestActorRefSpec : TestKit + { + public static int Counter = 4; + public static readonly Thread Thread = Thread.CurrentThread; + public static Thread? OtherThread; + + public TestActorRefSpec() + { + } + + private TimeSpan DefaultTimeout => Dilated(TestKitSettings.DefaultTimeout); + + protected override Config Config => "test-dispatcher1.type=\"Akka.Dispatch.PinnedDispatcherConfigurator, Akka\""; + + private void AssertThread() + { + Assert.True(OtherThread == null || OtherThread == Thread, "Thread"); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + protected override async Task BeforeTestStart() + { + await base.BeforeTestStart(); + OtherThread = null; + } + + [Fact] + public void TestActorRef_name_must_start_with_double_dollar_sign() + { + //Looking at the scala code, this might not be obvious that the name starts with $$ + //object TestActorRef (TestActorRef.scala) contain this code: + // private[testkit] def randomName: String = { + // val l = number.getAndIncrement() + // "$" + akka.util.Helpers.base64(l) + // } + //So it adds one $. The second is added by akka.util.Helpers.base64(l) which by default + //creates a StringBuilder and adds adds $. Hence, 2 $$ + var testActorRef = new TestActorRef(Sys, Props.Create()); + + Assert.Equal("$$", testActorRef.Path.Name.Substring(0, 2)); + } + + [Fact] + public async Task TestActorRef_must_support_nested_Actor_creation_when_used_with_TestActorRef() + { + var a = new TestActorRef(Sys, Props.Create(() => new NestingActor(true))); + Assert.NotNull(a); + var nested = await a.Ask("any", DefaultTimeout); + Assert.NotNull(nested); + Assert.NotSame(a, nested); + } + + [Fact] + public async Task TestActorRef_must_support_nested_Actor_creation_when_used_with_ActorRef() + { + var a = new TestActorRef(Sys, Props.Create(() => new NestingActor(false))); + Assert.NotNull(a); + var nested = await a.Ask("any", DefaultTimeout); + Assert.NotNull(nested); + Assert.NotSame(a, nested); + } + + [Fact] + public void TestActorRef_must_support_reply_via_sender() + { + var serverRef = new TestActorRef(Sys, Props.Create()); + var clientRef = new TestActorRef(Sys, Props.Create(() => new SenderActor(serverRef))); + + Counter = 4; + clientRef.Tell("complex"); + clientRef.Tell("simple"); + clientRef.Tell("simple"); + clientRef.Tell("simple"); + Counter.Should().Be(0); + + Counter = 4; + clientRef.Tell("complex2"); + clientRef.Tell("simple"); + clientRef.Tell("simple"); + clientRef.Tell("simple"); + Counter.Should().Be(0); + + AssertThread(); + } + + [Fact] + public void TestActorRef_must_stop_when_sent_a_PoisonPill() + { + //TODO: Should have this surrounding all code EventFilter[ActorKilledException]() intercept { + var probe = CreateTestProbe(); + var a = new TestActorRef(Sys, Props.Create(), null, "will-be-killed"); + var actorRef = (InternalTestActorRef)a.Ref; + probe.Watch(actorRef); + Sys.ActorOf(Props.Create(() => new WatchAndForwardActor(a, TestActor)), "forwarder"); + + a.Tell(PoisonPill.Instance); + ExpectMsg(w => w.Terminated.ActorRef == a, TimeSpan.FromSeconds(10), $"that the terminated actor was the one killed, i.e. {a.Path}"); + probe.ExpectTerminated(actorRef); + AssertThread(); + } + + [Fact] + public void TestActorRef_must_restart_when_killed() + { + //TODO: Should have this surrounding all code EventFilter[ActorKilledException]() intercept { + Counter = 2; + var boss = new TestActorRef(Sys, Props.Create()); + + boss.Tell("sendKill"); + Assert.Equal(0, Counter); + AssertThread(); + } + + [Fact] + public async Task TestActorRef_must_support_futures() + { + var worker = new TestActorRef(Sys, Props.Create()); + var task = worker.Ask("work"); + Assert.True(task.IsCompleted, "Task should be completed"); + var result = await task.WaitAsync(DefaultTimeout); //Using a timeout to stop the test if there is something wrong with the code + Assert.Equal("workDone", result); + } + + [Fact] + public void TestActorRef_must_allow_access_to_internals() + { + var actorRef = new TestActorRef(Sys, Props.Create()); + actorRef.Tell("Hejsan!"); + var actor = actorRef.UnderlyingActor; + Assert.Equal("Hejsan!", actor.ReceivedString); + } + + [Fact] + public void TestActorRef_must_set_ReceiveTimeout_to_None() + { + var a = new TestActorRef(Sys, Props.Create()); + ((IInternalActor)a.UnderlyingActor).ActorContext.ReceiveTimeout.Should().Be(null); + } + + [Fact] + public void TestActorRef_must_set_CallingThreadDispatcher() + { + var a = new TestActorRef(Sys, Props.Create()); + var actorRef = (InternalTestActorRef)a.Ref; + Assert.IsType(actorRef.Cell.Dispatcher); + } + + [Fact] + public void TestActorRef_must_allow_override_of_dispatcher() + { + var a = new TestActorRef(Sys, Props.Create().WithDispatcher("test-dispatcher1")); + var actorRef = (InternalTestActorRef)a.Ref; + Assert.IsType(actorRef.Cell.Dispatcher); + } + + [Fact] + public void TestActorRef_must_proxy_receive_for_the_underlying_actor_without_sender() + { + var a = new TestActorRef(Sys, Props.Create()); + var actorRef = (InternalTestActorRef)a.Ref; + Watch(actorRef); + a.Receive("work"); + ExpectTerminated(actorRef); + } + + [Fact] + public void TestActorRef_must_proxy_receive_for_the_underlying_actor_with_sender() + { + var a = new TestActorRef(Sys, Props.Create()); + var probe = CreateTestProbe(); + var actorRef = (InternalTestActorRef)a.Ref; + probe.Watch(actorRef); + a.Receive("work", TestActor); //This will stop the actor + ExpectMsg("workDone"); + probe.ExpectTerminated(actorRef); + } + + [Fact] + public void TestFsmActorRef_must_proxy_receive_for_underlying_actor_with_sender() + { + var a = new TestFSMRef(Sys, Props.Create(() => new FsmActor(TestActor))); + a.Receive("check"); + ExpectMsg("first"); + + // verify that we can change state + a.SetState(TestFsmState.Last); + a.Receive("check"); + ExpectMsg("last"); + } + + [Fact] + public void BugFix1709_TestFsmActorRef_must_work_with_Fsms_with_constructor_arguments() + { + var a = ActorOfAsTestFSMRef(Props.Create(() => new FsmActor(TestActor))); + a.Receive("check"); + ExpectMsg("first"); + + // verify that we can change state + a.SetState(TestFsmState.Last); + a.Receive("check"); + ExpectMsg("last"); + } + + private class SaveStringActor : TActorBase + { + public string? ReceivedString { get; private set; } + + protected override bool ReceiveMessage(object message) + { + ReceivedString = message as string; + return true; + } + } + } +} + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs new file mode 100644 index 00000000000..d103f97fd91 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs @@ -0,0 +1,123 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.TestKit; +using Akka.TestKit.TestActors; +using Akka.Util.Internal; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests +{ + public class TestProbeSpec : TestKit + { + [Fact] + public void TestProbe_should_equal_underlying_Ref() + { + var p = CreateTestProbe(); + p.Equals(p.Ref).Should().BeTrue(); + p.Ref.Equals(p).Should().BeTrue(); + var hs = new HashSet {p, p.Ref}; + hs.Count.Should().Be(1); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + /// + /// Should be able to receive a message from a + /// if we're deathwatching it and it terminates. + /// + [Fact] + public void TestProbe_should_send_Terminated_when_killed() + { + var p = CreateTestProbe(); + Watch(p); + Sys.Stop(p); + ExpectTerminated(p); + } + + /// + /// If we deathwatch the underlying actor ref or TestProbe itself, it shouldn't matter. + /// + /// They should be equivalent either way. + /// + [Fact] + public void TestProbe_underlying_Ref_should_be_equivalent_to_TestProbe() + { + var p = CreateTestProbe(); + Watch(p.Ref); + Sys.Stop(p); + ExpectTerminated(p); + } + + /// + /// Should be able to receive a message from a + /// if we're deathwatching it and it terminates. + /// + [Fact] + public void TestProbe_underlying_Ref_should_send_Terminated_when_killed() + { + var p = CreateTestProbe(); + Watch(p.Ref); + Sys.Stop(p.Ref); + ExpectTerminated(p.Ref); + } + + [Fact] + public void TestProbe_should_create_a_child_when_invoking_ChildActorOf() + { + var probe = CreateTestProbe(); + var child = probe.ChildActorOf(Props.Create()); + child.Path.Parent.Should().Be(probe.Ref.Path); + var namedChild = probe.ChildActorOf("actorName"); + namedChild.Path.Name.Should().Be("actorName"); + } + + [Fact] + public async Task TestProbe_restart_a_failing_child_if_the_given_supervisor_says_so() + { + var probe = CreateTestProbe(); + var restartWatcher = CreateTestProbe(); + var child = probe.ChildActorOf(Props.Create(() => new FailingActor(restartWatcher)), SupervisorStrategy.DefaultStrategy); + + // Send two messages that will cause failures and restarts + child.Tell("hello"); + child.Tell("hello"); + + // Wait for exactly 2 restart notifications + await restartWatcher.ExpectMsgAsync("restarted"); + await restartWatcher.ExpectMsgAsync("restarted"); + } + + class FailingActor : ActorBase + { + private readonly IActorRef _restartWatcher; + + public FailingActor(IActorRef restartWatcher) + { + _restartWatcher = restartWatcher; + } + + protected override bool Receive(object message) + { + throw new Exception("Simulated failure"); + } + + protected override void PostRestart(Exception reason) + { + _restartWatcher.Tell("restarted"); + base.PostRestart(reason); + } + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WatchAndForwardActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WatchAndForwardActor.cs new file mode 100644 index 00000000000..0306bc9e4d3 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WatchAndForwardActor.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class WatchAndForwardActor : ActorBase +{ + private readonly IActorRef _forwardToActor; + + public WatchAndForwardActor(IActorRef watchedActor, IActorRef forwardToActor) + { + _forwardToActor = forwardToActor; + Context.Watch(watchedActor); + } + + protected override bool Receive(object message) + { + var terminated = message as Terminated; + if(terminated != null) + _forwardToActor.Tell(new WrappedTerminated(terminated), Sender); + else + _forwardToActor.Tell(message, Sender); + return true; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WorkerActor.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WorkerActor.cs new file mode 100644 index 00000000000..832849b2fe1 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WorkerActor.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class WorkerActor : TActorBase +{ + protected override bool ReceiveMessage(object message) + { + if((message as string) == "work") + { + Sender.Tell("workDone"); + Context.Stop(Self); + return true; + + } + //TODO: case replyTo: Promise[_] ⇒ replyTo.asInstanceOf[Promise[Any]].success("complexReply") + if(message is IActorRef) + { + ((IActorRef)message).Tell("complexReply", Self); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WrappedTerminated.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WrappedTerminated.cs new file mode 100644 index 00000000000..b940dedd3d8 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorRefTests/WrappedTerminated.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; + +namespace Akka.Hosting.TestKit.Tests.TestActorRefTests; + +public class WrappedTerminated +{ + private readonly Terminated _terminated; + + public WrappedTerminated(Terminated terminated) + { + _terminated = terminated; + } + + public Terminated Terminated { get { return _terminated; } } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorStartupDeadlockSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorStartupDeadlockSpec.cs new file mode 100644 index 00000000000..ee77fd7d5b3 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestActorStartupDeadlockSpec.cs @@ -0,0 +1,133 @@ +using Akka.TestKit; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting.TestKit.Tests; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Actor; +using Hosting; +using Akka.Hosting.TestKit; +using Xunit; + +file sealed class StartupPinger(IRequiredActor testActorReq) : ReceiveActor +{ + protected override void PreStart() + { + // TestProbe is registered before user WithActors callbacks execute, so we can + // use the synchronous ActorRef here and avoid blocking actor startup. + testActorReq.ActorRef.Tell("startup-ping"); + } +} + +file sealed class HostedTestKitRunner : TestKit, IAsyncLifetime // TestKit already implements IAsyncLifetime, but we expose it here for clarity +{ + public HostedTestKitRunner(XunitTestOutputHelper output) + : base($"{Guid.NewGuid():N}", startupTimeout: TimeSpan.FromSeconds(20), output: output, logLevel: LogLevel.Error) + { + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + // WithActors runs during ActorSystem creation + // TestProbe should already be registered by the first startup hook + builder.WithActors((system, _, resolver) => + { + system.ActorOf(resolver.Props(), "pinger"); + }); + } + + // Optional convenience wrappers so the test code reads cleanly + public Task StartAsync() => InitializeAsync().ToTask(); + public Task StopAsync() => DisposeAsync().ToTask(); + + public Task ExpectStartupAsync(TimeSpan? timeout = null) + => ExpectMsgAsync("startup-ping", timeout ?? TimeSpan.FromSeconds(5)).AsTask(); +} + +public class TestActorStartupDeadlockSpec +{ + private readonly XunitTestOutputHelper _output; + + public TestActorStartupDeadlockSpec(XunitTestOutputHelper output) + { + _output = output; + } + + // Give the overall test plenty of time; we enforce our own short, per-step timeouts below. + [Fact(Timeout = 30000)] // Increased timeout for 30 parallel hosts + public async Task parallel_host_start_should_not_deadlock() + { + // Per-runner bounded timeouts + var startTimeout = TimeSpan.FromSeconds(7); // pre-fix should trip this quickly + var expectTimeout = TimeSpan.FromSeconds(10); // Increased timeout for high concurrency + var stopTimeout = TimeSpan.FromSeconds(5); + var concurrentHosts = 30; + + // Spin up N independent hosts concurrently inside the same theory + var runners = Enumerable.Range(0, concurrentHosts) + .Select(_ => RunOneAsync()) + .ToArray(); + + await Task.WhenAll(runners); + return; + + async Task RunOneAsync() + { + var id = Guid.NewGuid().ToString("N").Substring(0, 8); + _output.WriteLine($"[{id}] Starting runner"); + var kit = new HostedTestKitRunner(_output); + + // --- START (bounded) --- + _output.WriteLine($"[{id}] Calling StartAsync"); + var startTask = kit.StartAsync(); + var startDone = await Task.WhenAny(startTask, Task.Delay(startTimeout)); + if (startDone != startTask) + { + _output.WriteLine($"[{id}] StartAsync timed out after {startTimeout}"); + // Fail fast with a clear message rather than timing out the entire test method + Assert.Fail( + $"Host did not start within {startTimeout}. " + + "This indicates the known startup deadlock (TestKit initialized inline on the startup thread)."); + } + _output.WriteLine($"[{id}] StartAsync completed"); + // propagate any exception from StartAsync (e.g., watchdog TimeoutException) + try + { + await startTask; + } + catch (Exception ex) + { + if (ex.Message.StartsWith("Timeout waiting for test actor")) + _output.WriteLine($"Original issue detected: {ex.Message}"); + + throw; + } + + try + { + // --- EXPECT (bounded) --- + _output.WriteLine($"[{id}] Expecting startup ping"); + var expectTask = kit.ExpectStartupAsync(expectTimeout); + var expectDone = await Task.WhenAny(expectTask, Task.Delay(expectTimeout)); + if (expectDone != expectTask) + { + _output.WriteLine($"[{id}] ExpectStartupAsync timed out after {expectTimeout}"); + Assert.Fail($"Did not receive startup ping within {expectTimeout}."); + } + _output.WriteLine($"[{id}] Received startup ping"); + await expectTask; + } + finally + { + // --- STOP (bounded) --- + var stopTask = kit.StopAsync(); + var stopDone = await Task.WhenAny(stopTask, Task.Delay(stopTimeout)); + if (stopDone == stopTask) + await stopTask; + // else: swallow to avoid hanging the test process on shutdown in pre-fix scenarios + } + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs new file mode 100644 index 00000000000..39a9a33ce7b --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs @@ -0,0 +1,343 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Event; +using Akka.TestKit; +using FluentAssertions; +using Xunit; +using Xunit.Sdk; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests +{ + public abstract class AllTestForEventFilterBase : EventFilterTestBase where TLogEvent : LogEvent + { + // ReSharper disable ConvertToLambdaExpression + private EventFilterFactory? _testingEventFilter; + + protected AllTestForEventFilterBase(LogLevel logLevel, ITestOutputHelper? output = null) + : base(logLevel, output) + { + } + + protected override async Task BeforeTestStart() + { + await base.BeforeTestStart(); + LogLevel = Event.Logging.LogLevelFor(); + // ReSharper disable once VirtualMemberCallInConstructor + _testingEventFilter = CreateTestingEventFilter(); + } + + protected new LogLevel LogLevel { get; private set; } + protected abstract EventFilterFactory CreateTestingEventFilter(); + + protected void LogMessage(string message) + { + Log.Log(LogLevel, message); + } + + protected override void SendRawLogEventMessage(object message) + { + PublishMessage(message, "test"); + } + + protected abstract void PublishMessage(object message, string source); + + [Fact] + public void Single_message_is_intercepted() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel).ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + + [Fact] + public void Can_intercept_messages_when_start_is_specified() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, start: "what").ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + [Fact] + public void Do_not_intercept_messages_when_start_does_not_match() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, start: "what").ExpectOne(() => + { + LogMessage("let-me-thru"); + LogMessage("whatever"); + }); + ExpectMsg(err => (string)err.Message == "let-me-thru"); + TestSuccessful = true; + } + + [Fact] + public void Can_intercept_messages_when_message_is_specified() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, message: "whatever").ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + [Fact] + public void Do_not_intercept_messages_when_message_does_not_match() + { + EventFilter.ForLogLevel(LogLevel, message: "whatever").ExpectOne(() => + { + LogMessage("let-me-thru"); + LogMessage("whatever"); + }); + ExpectMsg(err => (string)err.Message == "let-me-thru"); + TestSuccessful = true; + } + + [Fact] + public void Can_intercept_messages_when_contains_is_specified() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, contains: "ate").ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + [Fact] + public void Do_not_intercept_messages_when_contains_does_not_match() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, contains: "eve").ExpectOne(() => + { + LogMessage("let-me-thru"); + LogMessage("whatever"); + }); + ExpectMsg(err => (string)err.Message == "let-me-thru"); + TestSuccessful = true; + } + + + [Fact] + public void Can_intercept_messages_when_source_is_specified() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, source: LogSource.FromType(GetType(), Sys)).ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + [Fact] + public void Do_not_intercept_messages_when_source_does_not_match() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel, source: "expected-source").ExpectOne(() => + { + PublishMessage("message", source: "expected-source"); + PublishMessage("message", source: "let-me-thru"); + }); + ExpectMsg(err => err.LogSource == "let-me-thru"); + TestSuccessful = true; + } + + [Fact] + public void Specified_numbers_of_messages_and_be_intercepted() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel).Expect(2, () => + { + LogMessage("whatever"); + LogMessage("whatever"); + }); + TestSuccessful = true; + } + + [Fact] + public void Expect_0_events_Should_work() + { + this.Invoking(_ => + { + EventFilter.Error().Expect(0, () => + { + Log.Error("something"); + }); + }).Should().Throw("Expected 0 events"); + } + + [Fact] + public async Task ExpectAsync_0_events_Should_work() + { + Exception? ex = null; + try + { + await EventFilter.Error().ExpectAsync(0, async () => + { + await Task.Delay(100); // bug only happens when error is not logged instantly + Log.Error("something"); + }); + } + catch (Exception e) + { + ex = e; + } + + ex.Should().NotBeNull("Expected 0 errors logged, but there are error logs"); + } + + /// issue: InternalExpectAsync does not await actionAsync() - causing actionAsync to run as a detached task #5537 + [Fact] + public async Task ExpectAsync_should_await_actionAsync() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + await Assert.ThrowsAnyAsync(async () => + { + await _testingEventFilter.ForLogLevel(LogLevel).ExpectAsync(0, actionAsync: async () => + { + Assert.False(true); + await Task.CompletedTask; + }); + }); + } + + // issue: InterceptAsync seems to run func() as a detached task #5586 + [Fact] + public async Task InterceptAsync_should_await_func() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + await Assert.ThrowsAnyAsync(async () => + { + await _testingEventFilter.ForLogLevel(LogLevel).ExpectAsync(0, async () => + { + Assert.False(true); + await Task.CompletedTask; + }, TimeSpan.FromSeconds(.1)); + }); + } + + [Fact] + public void Messages_can_be_muted() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel).Mute(() => + { + LogMessage("whatever"); + LogMessage("whatever"); + }); + TestSuccessful = true; + } + + + [Fact] + public void Messages_can_be_muted_from_now_on() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + var unmutableFilter = _testingEventFilter.ForLogLevel(LogLevel).Mute(); + LogMessage("whatever"); + LogMessage("whatever"); + unmutableFilter.Unmute(); + TestSuccessful = true; + } + + [Fact] + public void Messages_can_be_muted_from_now_on_with_using() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + using(_testingEventFilter.ForLogLevel(LogLevel).Mute()) + { + LogMessage("whatever"); + LogMessage("whatever"); + } + TestSuccessful = true; + } + + + [Fact] + public void Make_sure_async_works() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter.ForLogLevel(LogLevel).Expect(1, TimeSpan.FromSeconds(2), () => + { + Task.Delay(TimeSpan.FromMilliseconds(10)).ContinueWith(_ => { LogMessage("whatever"); }); + }); + } + + [Fact] + public void Chain_many_filters() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + _testingEventFilter + .ForLogLevel(LogLevel,message:"Message 1").And + .ForLogLevel(LogLevel,message:"Message 3") + .Expect(2,() => + { + LogMessage("Message 1"); + LogMessage("Message 2"); + LogMessage("Message 3"); + + }); + ExpectMsg(m => (string) m.Message == "Message 2"); + } + + + [Fact] + public void Should_timeout_if_too_few_messages() + { + if (_testingEventFilter is null) + throw new NullReferenceException("_testingEventFilter should not be null, check CreateTestingEventFilter implementation."); + + Invoking(() => + { + _testingEventFilter.ForLogLevel(LogLevel).Expect(2, TimeSpan.FromMilliseconds(50), () => + { + LogMessage("whatever"); + }); + }).Should().Throw().WithMessage("timeout*"); + } + + [Fact] + public void Should_log_when_not_muting() + { + const string message = "This should end up in the log since it's not filtered"; + LogMessage(message); + ExpectMsg( msg => (string)msg.Message == message); + } + + // ReSharper restore ConvertToLambdaExpression + + } +} + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase_Instances.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase_Instances.cs new file mode 100644 index 00000000000..0aa33b9e9d5 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase_Instances.cs @@ -0,0 +1,131 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Event; +using Akka.TestKit; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests; + +public class EventFilterDebugTests : AllTestForEventFilterBase +{ + public EventFilterDebugTests() : base(LogLevel.DebugLevel){} + + protected override EventFilterFactory CreateTestingEventFilter() + { + return EventFilter; + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Debug(source,GetType(),message)); + } +} + +public class CustomEventFilterDebugTests : AllTestForEventFilterBase +{ + public CustomEventFilterDebugTests() : base(LogLevel.DebugLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return CreateEventFilter(Sys); + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Debug(source, GetType(), message)); + } +} + +public class EventFilterInfoTests : AllTestForEventFilterBase +{ + public EventFilterInfoTests() : base(LogLevel.InfoLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return EventFilter; + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Info(source, GetType(), message)); + } +} + +public class CustomEventFilterInfoTests : AllTestForEventFilterBase +{ + public CustomEventFilterInfoTests() : base(LogLevel.InfoLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return CreateEventFilter(Sys); + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Info(source, GetType(), message)); + } +} + + +public class EventFilterWarningTests : AllTestForEventFilterBase +{ + public EventFilterWarningTests() : base(LogLevel.WarningLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return EventFilter; + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Warning(source, GetType(), message)); + } +} + +public class CustomEventFilterWarningTests : AllTestForEventFilterBase +{ + public CustomEventFilterWarningTests() : base(LogLevel.WarningLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return CreateEventFilter(Sys); + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Warning(source, GetType(), message)); + } +} + +public class EventFilterErrorTests : AllTestForEventFilterBase +{ + public EventFilterErrorTests() : base(LogLevel.ErrorLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return EventFilter; + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Error(null, source, GetType(), message)); + } +} + +public class CustomEventFilterErrorTests : AllTestForEventFilterBase +{ + public CustomEventFilterErrorTests() : base(LogLevel.ErrorLevel) { } + + protected override EventFilterFactory CreateTestingEventFilter() + { + return CreateEventFilter(Sys); + } + + protected override void PublishMessage(object message, string source) + { + Sys.EventStream.Publish(new Error(null, source, GetType(), message)); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs new file mode 100644 index 00000000000..c1953db44e8 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests +{ + public class ConfigTests : TestKit + { + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void TestEventListener_is_in_config_by_default() + { + var configLoggers = Sys.Settings.Config.GetStringList("akka.loggers", new string[] { }); + configLoggers.Any(logger => logger.Contains("Akka.TestKit.TestEventListener")).Should().BeTrue(); + configLoggers.Any(logger => logger.Contains("Akka.Event.DefaultLogger")).Should().BeFalse(); + } + } +} + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/CustomEventFilterTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/CustomEventFilterTests.cs new file mode 100644 index 00000000000..0e1a1f63a73 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/CustomEventFilterTests.cs @@ -0,0 +1,62 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Event; +using Akka.TestKit; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests; + +public abstract class CustomEventFilterTestsBase : EventFilterTestBase +{ + // ReSharper disable ConvertToLambdaExpression + public CustomEventFilterTestsBase() : base(Event.LogLevel.ErrorLevel) { } + + protected override void SendRawLogEventMessage(object message) + { + Sys.EventStream.Publish(new Error(null, "CustomEventFilterTests", GetType(), message)); + } + + protected abstract EventFilterFactory CreateTestingEventFilter(); + + [Fact] + public void Custom_filter_should_match() + { + var eventFilter = CreateTestingEventFilter(); + eventFilter.Custom(logEvent => logEvent is Error && (string) logEvent.Message == "whatever").ExpectOne(() => + { + Log.Error("whatever"); + }); + } + + [Fact] + public void Custom_filter_should_match2() + { + var eventFilter = CreateTestingEventFilter(); + eventFilter.Custom(logEvent => (string)logEvent.Message == "whatever").ExpectOne(() => + { + Log.Error("whatever"); + }); + } + // ReSharper restore ConvertToLambdaExpression +} + +public class CustomEventFilterTests : CustomEventFilterTestsBase +{ + protected override EventFilterFactory CreateTestingEventFilter() + { + return EventFilter; + } +} + +public class CustomEventFilterCustomFilterTests : CustomEventFilterTestsBase +{ + protected override EventFilterFactory CreateTestingEventFilter() + { + return CreateEventFilter(Sys); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/DeadLettersEventFilterTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/DeadLettersEventFilterTests.cs new file mode 100644 index 00000000000..2acc37545b7 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/DeadLettersEventFilterTests.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.TestKit; +using Akka.TestKit.TestActors; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests; + +public abstract class DeadLettersEventFilterTestsBase : EventFilterTestBase +{ + private IActorRef? _deadActor; + + // ReSharper disable ConvertToLambdaExpression + protected DeadLettersEventFilterTestsBase() : base(Event.LogLevel.ErrorLevel) + { + } + + protected override async Task BeforeTestStart() + { + await base.BeforeTestStart(); + _deadActor = Sys.ActorOf(BlackHoleActor.Props, "dead-actor"); + Watch(_deadActor); + Sys.Stop(_deadActor); + ExpectTerminated(_deadActor); + } + + protected override void SendRawLogEventMessage(object message) + { + Sys.EventStream.Publish(new Error(null, "DeadLettersEventFilterTests", GetType(), message)); + } + + protected abstract EventFilterFactory CreateTestingEventFilter(); + + [Fact] + public void Should_be_able_to_filter_dead_letters() + { + var eventFilter = CreateTestingEventFilter(); + eventFilter.DeadLetter().ExpectOne(() => + { + _deadActor.Tell("whatever"); + }); + } + + + // ReSharper restore ConvertToLambdaExpression +} + +public class DeadLettersEventFilterTests : DeadLettersEventFilterTestsBase +{ + protected override EventFilterFactory CreateTestingEventFilter() + { + return EventFilter; + } +} + +public class DeadLettersCustomEventFilterTests : DeadLettersEventFilterTestsBase +{ + protected override EventFilterFactory CreateTestingEventFilter() + { + return CreateEventFilter(Sys); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs new file mode 100644 index 00000000000..92c1e886ded --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs @@ -0,0 +1,84 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Event; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests +{ + public abstract class EventFilterTestBase : TestKit + { + private readonly LogLevel _logLevel; + + /// + /// Used to signal that the test was successful and that we should ensure no more messages were logged + /// + protected bool TestSuccessful; + + protected EventFilterTestBase(LogLevel logLevel, ITestOutputHelper? output = null) : base(output: output) + { + _logLevel = logLevel; + } + + protected abstract void SendRawLogEventMessage(object message); + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.ConfigureLoggers(logger => + { + logger.LogLevel = _logLevel; + logger.ClearLoggers(); + logger.AddLogger(); + }); + } + + protected override async Task BeforeTestStart() + { + await base.BeforeTestStart(); + + //We send a ForwardAllEventsTo containing message to the TestEventListenerToForwarder logger (configured as a logger above). + //It should respond with an "OK" message when it has received the message. + var initLoggerMessage = new ForwardAllEventsTestEventListener.ForwardAllEventsTo(TestActor); + // ReSharper disable once DoNotCallOverridableMethodsInConstructor + SendRawLogEventMessage(initLoggerMessage); + try + { + await ExpectMsgAsync("OK", TimeSpan.FromSeconds(10)); + } + catch (Exception e) + { + throw new Exception( + "Failed to receive an OK signal from ForwardAllEventsTestEventListener logger during test start " + + $"inside EventFilterTestBase. Running loggers: [{string.Join(", ", Sys.Settings.Loggers)}]", e); + } + //From now on we know that all messages will be forwarded to TestActor + } + + protected override async Task AfterAllAsync() + { + //After every test we make sure no uncatched messages have been logged + if(TestSuccessful) + { + EnsureNoMoreLoggedMessages(); + } + await base.AfterAllAsync(); + } + + private void EnsureNoMoreLoggedMessages() + { + //We log a Finished message. When it arrives to TestActor we know no other message has been logged. + //If we receive something else it means another message was logged, and ExpectMsg will fail + const string message = "<>"; + SendRawLogEventMessage(message); + ExpectMsg(err => (string) err.Message == message,hint: "message to be \"" + message + "\""); + } + + } +} + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ExceptionEventFilterTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ExceptionEventFilterTests.cs new file mode 100644 index 00000000000..9a8c0255234 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ExceptionEventFilterTests.cs @@ -0,0 +1,174 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using Akka.Actor; +using Akka.Event; +using FluentAssertions; +using Xunit; +using Xunit.Sdk; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests; + +public class ExceptionEventFilterTests : EventFilterTestBase +{ + public ExceptionEventFilterTests() + : base(Event.LogLevel.ErrorLevel) + { + } + + public class SomeException : Exception { } + + protected override void SendRawLogEventMessage(object message) + { + Sys.EventStream.Publish(new Error(null, nameof(ExceptionEventFilterTests), GetType(), message)); + } + + [Fact] + public void SingleExceptionIsIntercepted() + { + EventFilter.Exception() + .ExpectOne(() => Log.Error(new SomeException(), "whatever")); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + } + + [Fact] + public void CanInterceptMessagesWhenStartIsSpecified() + { + EventFilter.Exception(start: "what") + .ExpectOne(() => Log.Error(new SomeException(), "whatever")); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + } + + [Fact] + public void DoNotInterceptMessagesWhenStartDoesNotMatch() + { + EventFilter.Exception(start: "this is clearly not in message"); + Log.Error(new SomeException(), "whatever"); + ExpectMsg(err => (string)err.Message == "whatever"); + } + + [Fact] + public void CanInterceptMessagesWhenMessageIsSpecified() + { + EventFilter.Exception(message: "whatever") + .ExpectOne(() => Log.Error(new SomeException(), "whatever")); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + } + + [Fact] + public void DoNotInterceptMessagesWhenMessageDoesNotMatch() + { + EventFilter.Exception(message: "this is clearly not the message"); + Log.Error(new SomeException(), "whatever"); + ExpectMsg(err => (string)err.Message == "whatever"); + } + + [Fact] + public void CanInterceptMessagesWhenContainsIsSpecified() + { + EventFilter.Exception(contains: "ate") + .ExpectOne(() => Log.Error(new SomeException(), "whatever")); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + } + + [Fact] + public void DoNotInterceptMessagesWhenContainsDoesNotMatch() + { + EventFilter.Exception(contains: "this is clearly not in the message"); + Log.Error(new SomeException(), "whatever"); + ExpectMsg(err => (string)err.Message == "whatever"); + } + + + [Fact] + public void CanInterceptMessagesWhenSourceIsSpecified() + { + EventFilter.Exception(source: LogSource.Create(this, Sys).Source) + .ExpectOne(() => + { + Log.Error(new SomeException(), "whatever"); + }); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + } + + [Fact] + public void DoNotInterceptMessagesWhenSourceDoesNotMatch() + { + EventFilter.Exception(source: "this is clearly not the source"); + Log.Error(new SomeException(), "whatever"); + ExpectMsg(err => (string)err.Message == "whatever"); + } + + + [Fact] + public void SpecifiedNumbersOfExceptionsCanBeIntercepted() + { + EventFilter.Exception() + .Expect(2, () => + { + Log.Error(new SomeException(), "whatever"); + Log.Error(new SomeException(), "whatever"); + }); + ExpectNoMsg(TimeSpan.FromMilliseconds(100)); + } + + [Fact] + public void ShouldFailIfMoreExceptionsThenSpecifiedAreLogged() + { + Invoking(() => + EventFilter.Exception().Expect(2, () => + { + Log.Error(new SomeException(), "whatever"); + Log.Error(new SomeException(), "whatever"); + Log.Error(new SomeException(), "whatever"); + })) + .Should().Throw().WithMessage("*1 message too many*"); + } + + [Fact] + public void ShouldReportCorrectMessageCount() + { + var toSend = "Eric Cartman"; + var actor = ActorOf( ExceptionTestActor.Props() ); + + EventFilter + .Exception(source: actor.Path.ToString()) + // expecting 2 because the same exception is logged in PostRestart + .Expect(2, () => actor.Tell( toSend )); + } + + internal sealed class ExceptionTestActor : UntypedActor + { + private ILoggingAdapter Log { get; } = Context.GetLogger(); + + protected override void PostRestart(Exception reason) + { + Log.Error(reason, "[PostRestart]"); + base.PostRestart(reason); + } + + protected override void OnReceive( object message ) + { + switch (message) + { + case string msg: + throw new InvalidOperationException( "I'm sailing away. Set an open course" ); + + default: + Unhandled( message ); + break; + } + } + + public static Props Props() + { + return Actor.Props.Create( () => new ExceptionTestActor() ); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs new file mode 100644 index 00000000000..d28e1e7466f --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs @@ -0,0 +1,44 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Actor; +using Akka.Event; +using Akka.TestKit; + +namespace Akka.Hosting.TestKit.Tests.TestEventListenerTests; + +public class ForwardAllEventsTestEventListener : TestEventListener +{ + private IActorRef? _forwarder; + + protected override void Print(LogEvent m) + { + if(m.Message is ForwardAllEventsTo to) + { + _forwarder = to.Forwarder; + _forwarder.Tell("OK"); + } + else if(_forwarder != null) + { + _forwarder.Forward(m); + } + else + { + base.Print(m); + } + } + + public class ForwardAllEventsTo + { + public ForwardAllEventsTo(IActorRef forwarder) + { + Forwarder = forwarder; + } + + public IActorRef Forwarder { get; } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestFSMRefTests/TestFSMRefSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestFSMRefTests/TestFSMRefSpec.cs new file mode 100644 index 00000000000..30bab56b69e --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestFSMRefTests/TestFSMRefSpec.cs @@ -0,0 +1,91 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestFSMRefTests; + +public class TestFSMRefSpec : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + + } + + [Fact] + public void A_TestFSMRef_must_allow_access_to_internal_state() + { + var fsm = ActorOfAsTestFSMRef("test-fsm-ref-1"); + + fsm.StateName.Should().Be(1); + fsm.StateData.Should().Be(""); + + fsm.Tell("go"); + fsm.StateName.Should().Be(2); + fsm.StateData.Should().Be("go"); + + fsm.SetState(1); + fsm.StateName.Should().Be(1); + fsm.StateData.Should().Be("go"); + + fsm.SetStateData("buh"); + fsm.StateName.Should().Be(1); + fsm.StateData.Should().Be("buh"); + + fsm.SetStateTimeout(TimeSpan.FromMilliseconds(100)); + Within(TimeSpan.FromMilliseconds(80), TimeSpan.FromMilliseconds(500), () => + AwaitCondition(() => fsm is { StateName: 2, StateData: "timeout" }) + ); + } + + [Fact] + public void A_TestFSMRef_must_allow_access_to_timers() + { + var fsm = ActorOfAsTestFSMRef("test-fsm-ref-2"); + fsm.IsTimerActive("test").Should().Be(false); + fsm.SetTimer("test", 12, TimeSpan.FromMilliseconds(10), true); + fsm.IsTimerActive("test").Should().Be(true); + fsm.CancelTimer("test"); + fsm.IsTimerActive("test").Should().Be(false); + } + + private class StateTestFsm : FSM + { + public StateTestFsm() + { + StartWith(1, ""); + When(1, e => + { + var fsmEvent = e.FsmEvent; + if(Equals(fsmEvent, "go")) + return GoTo(2).Using("go"); + if(fsmEvent is StateTimeout) + return GoTo(2).Using("timeout"); + return null; + }); + When(2, e => + { + var fsmEvent = e.FsmEvent; + if(Equals(fsmEvent, "back")) + return GoTo(1).Using("back"); + return null; + }); + } + } + private class TimerTestFsm : FSM + { + public TimerTestFsm() + { + StartWith(1, ""); + When(1, e => Stay()); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs new file mode 100644 index 00000000000..625134d22d2 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs @@ -0,0 +1,39 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Configuration; +using Xunit; +using Xunit.Sdk; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class AwaitAssertTests : TestKit +{ + protected override Config Config { get; } = "akka.test.timefactor=2"; + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void AwaitAssert_must_not_throw_any_exception_when_assertion_is_valid() + { + AwaitAssert(() => Assert.Equal("foo", "foo")); + } + + [Fact] + public void AwaitAssert_must_throw_exception_when_assertion_is_invalid() + { + Within(TimeSpan.FromMilliseconds(300), TimeSpan.FromSeconds(1), () => + { + Assert.Throws(() => + AwaitAssert(() => Assert.Equal("foo", "bar"), TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(300))); + }); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs new file mode 100644 index 00000000000..4207dda54be --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs @@ -0,0 +1,88 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Akka.Configuration; +using Xunit; +using Xunit.Sdk; +using FluentAssertions; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class DilatedTests : TestKit +{ + private const int TimeFactor = 4; + private const int Timeout = 1000; + private const int ExpectedTimeout = Timeout * TimeFactor; + // Upper-bound slack for GC pauses and scheduling. This project runs its test collections in + // parallel (see its xunit.runner.json), so a wall-clock measurement here sees more contention + // than the equivalent spec in Akka.TestKit.Tests, which runs sequentially with a 1 s margin. + private const int Margin = 2000; + private const int DiffDelta = 100; + + protected override Config Config { get; } = $"akka.test.timefactor={TimeFactor}"; + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void Dilates_correctly_using_timeFactor() + { + Assert.Equal(Dilated(TimeSpan.FromMilliseconds(Timeout)), TimeSpan.FromMilliseconds(ExpectedTimeout)); + } + + [Fact] + public async Task AwaitConditionAsync_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + await Awaiting(() => AwaitConditionAsync(() => Task.FromResult(false), TimeSpan.FromMilliseconds(Timeout))) + .Should().ThrowAsync(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + [Fact] + public async Task ReceiveNAsync_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + await Awaiting(async () => await ReceiveNAsync(42, TimeSpan.FromMilliseconds(Timeout)).ToListAsync()) + .Should().ThrowAsync(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + [Fact] + public async Task ExpectMsgAllOfAsync_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + await Awaiting(async () => await ExpectMsgAllOfAsync(TimeSpan.FromMilliseconds(Timeout), new[]{"1", "2"}).ToListAsync()) + .Should().ThrowAsync(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + [Fact] + public async Task FishForMessageAsync_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + await Awaiting(async () => await FishForMessageAsync(_=>false, TimeSpan.FromMilliseconds(Timeout))) + .Should().ThrowAsync(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + private static void AssertDilated(double diff, string? message = null) + { + Assert.True(diff >= ExpectedTimeout - DiffDelta, message); + Assert.True(diff < ExpectedTimeout + Margin, message); // margin for GC + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs new file mode 100644 index 00000000000..74f50e3d37e --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs @@ -0,0 +1,61 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Xunit; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class ExpectTests : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void ExpectMsgAllOf_should_receive_correct_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("3"); + TestActor.Tell("4"); + ExpectMsgAllOf(new []{"3", "1", "4", "2"}).Should() + .BeEquivalentTo(new[] { "1", "2", "3", "4" }, opt => opt.WithStrictOrdering()); + } + + [Fact] + public void ExpectMsgAllOf_should_fail_when_receiving_unexpected() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("Totally unexpected"); + TestActor.Tell("3"); + Invoking(() => ExpectMsgAllOf(new []{"3", "1", "2"} )) + .Should().Throw(); + } + + [Fact] + public void ExpectMsgAllOf_should_timeout_when_not_receiving_any_messages() + { + Invoking(() => ExpectMsgAllOf(TimeSpan.FromMilliseconds(100), new []{"3", "1", "2"} )) + .Should().Throw(); + } + + [Fact] + public void ExpectMsgAllOf_should_timeout_if_to_few_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + Invoking(() => ExpectMsgAllOf(TimeSpan.FromMilliseconds(100), new[]{"3", "1", "2"} )) + .Should().Throw(); + } + +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs new file mode 100644 index 00000000000..e4200aa6ecd --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using Akka.Actor; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class IgnoreMessagesTests : TestKit +{ + public class IgnoredMessage + { + public IgnoredMessage(string? ignoreMe = null) + { + IgnoreMe = ignoreMe; + } + + public string? IgnoreMe { get; } + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void IgnoreMessages_should_ignore_messages() + { + IgnoreMessages(o => o is 1); + TestActor.Tell(1); + TestActor.Tell("1"); + string.Equals((string)ReceiveOne(), "1").Should().BeTrue(); + HasMessages.Should().BeFalse(); + } + + [Fact] + public void IgnoreMessages_should_ignore_messages_T() + { + IgnoreMessages(); + + TestActor.Tell("1"); + TestActor.Tell(new IgnoredMessage(), TestActor); + TestActor.Tell("2"); + ReceiveN(2).Should().BeEquivalentTo(new[] { "1", "2" }, opt => opt.WithStrictOrdering()); + HasMessages.Should().BeFalse(); + } + + [Fact] + public void IgnoreMessages_should_ignore_messages_T_with_Func() + { + IgnoreMessages(m => String.IsNullOrWhiteSpace(m.IgnoreMe)); + + var msg = new IgnoredMessage("not ignored!"); + + TestActor.Tell("1"); + TestActor.Tell(msg, TestActor); + TestActor.Tell("2"); + ReceiveN(3).Should().BeEquivalentTo(new object[] { "1", msg, "2" }, opt => opt.WithStrictOrdering()); + HasMessages.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs new file mode 100644 index 00000000000..13dd0441e64 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs @@ -0,0 +1,278 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Xunit; +using Xunit.Sdk; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class ReceiveTests : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void ReceiveN_should_receive_correct_number_of_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("3"); + TestActor.Tell("4"); + ReceiveN(3).Should().BeEquivalentTo(new[] { "1", "2", "3" }, opt => opt.WithStrictOrdering()); + ReceiveN(1).Should().BeEquivalentTo(new[] { "4" }); + } + + [Fact] + public void ReceiveN_should_timeout_if_no_messages() + { + Invoking(() => ReceiveN(3, TimeSpan.FromMilliseconds(10))) + .Should().Throw(); + } + + [Fact] + public void ReceiveN_should_timeout_if_to_few_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + Invoking(() => ReceiveN(3, TimeSpan.FromMilliseconds(100))) + .Should().Throw(); + } + + + [Fact] + public void FishForMessage_should_return_matched_message() + { + TestActor.Tell(1); + TestActor.Tell(2); + TestActor.Tell(10); + TestActor.Tell(20); + FishForMessage(i => i >= 10).Should().Be(10); + } + + [Fact] + public void FishForMessage_should_timeout_if_no_messages() + { + Invoking(() => FishForMessage(_ => false, TimeSpan.FromMilliseconds(10))) + .Should().Throw(); + } + + [Fact] + public void FishForMessage_should_timeout_if_to_few_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + Invoking(() => FishForMessage(_ => false, TimeSpan.FromMilliseconds(100))) + .Should().Throw(); + } + + [Fact] + public async Task FishForMessage_should_fill_the_all_messages_param_if_not_null() + { + await Task.Run(delegate + { + var probe = base.CreateTestProbe("probe"); + probe.Tell("1"); + probe.Tell(2); + probe.Tell("3"); + probe.Tell(4); + var allMessages = new ArrayList(); + probe.FishForMessage(isMessage: s => s == "3", allMessages: allMessages); + allMessages.Should().BeEquivalentTo(new ArrayList { "1", 2 }); + }); + } + + [Fact] + public async Task FishForMessage_should_clear_the_all_messages_param_if_not_null_before_filling_it() + { + await Task.Run(delegate + { + var probe = base.CreateTestProbe("probe"); + probe.Tell("1"); + probe.Tell(2); + probe.Tell("3"); + probe.Tell(4); + var allMessages = new ArrayList() { "pre filled data" }; + probe.FishForMessage(isMessage: x => x == "3", allMessages: allMessages); + allMessages.Should().BeEquivalentTo(new ArrayList { "1", 2 }); + }); + } + + [Fact] + public async Task FishUntilMessageAsync_should_succeed_with_good_input() + { + var probe = CreateTestProbe("probe"); + probe.Ref.Tell(1d, TestActor); + await probe.FishUntilMessageAsync(max: TimeSpan.FromMilliseconds(10)); + } + + + [Fact] + public async Task FishUntilMessageAsync_should_fail_with_bad_input() + { + var probe = CreateTestProbe("probe"); + probe.Ref.Tell(3, TestActor); + Func func = () => probe.FishUntilMessageAsync(max: TimeSpan.FromMilliseconds(10)); + await func.Should().ThrowAsync(); + } + + [Fact] + public async Task WaitForRadioSilenceAsync_should_succeed_immediately_with_null_good_input() + { + var probe = CreateTestProbe("probe"); + var messages = await probe.WaitForRadioSilenceAsync(max: TimeSpan.FromMilliseconds(0)); + messages.Should().BeEquivalentTo(new ArrayList()); + } + + [Fact] + public async Task WaitForRadioSilenceAsync_should_succeed_immediately_with_good_pre_input() + { + var probe = CreateTestProbe("probe"); + probe.Ref.Tell(1, TestActor); + var messages = await probe.WaitForRadioSilenceAsync(max: TimeSpan.FromMilliseconds(0)); + messages.Should().BeEquivalentTo(new ArrayList { 1 }); + } + + [Fact] + public async Task WaitForRadioSilenceAsync_should_succeed_later_with_good_post_input() + { + var probe = CreateTestProbe("probe"); + var task = probe.WaitForRadioSilenceAsync(); + probe.Ref.Tell(1, TestActor); + var messages = await task; + messages.Should().BeEquivalentTo(new ArrayList { 1 }); + } + + [Fact] + public async Task WaitForRadioSilenceAsync_should_reset_timer_twice_only() + { + var probe = CreateTestProbe("probe"); + var max = TimeSpan.FromMilliseconds(3000); + var halfMax = TimeSpan.FromMilliseconds(max.TotalMilliseconds / 2); + var doubleMax = TimeSpan.FromMilliseconds(max.TotalMilliseconds * 2); + var task = probe.WaitForRadioSilenceAsync(max: max, maxMessages: 2); + await Task.Delay(halfMax); + probe.Ref.Tell(1, TestActor); + await Task.Delay(halfMax); + probe.Ref.Tell(2, TestActor); + await Task.Delay(doubleMax); + probe.Ref.Tell(3, TestActor); + var messages = await task; + messages.Should().BeEquivalentTo(new ArrayList { 1, 2 }); + } + + [Fact] + public async Task WaitForRadioSilenceAsync_should_fail_immediately_with_bad_input() + { + var probe = CreateTestProbe("probe"); + probe.Ref.Tell(3, TestActor); + await Awaiting(() => probe.WaitForRadioSilenceAsync(max: TimeSpan.FromMilliseconds(0), maxMessages: 0)) + .Should().ThrowAsync(); + } + + [Fact] + public void ReceiveWhile_Filter_should_on_a_timeout_return_no_messages() + { + ReceiveWhile(_ => _, TimeSpan.FromMilliseconds(10)).Count.Should().Be(0); + } + + [Fact] + public void ReceiveWhile_Filter_should_break_on_function_returning_null_and_return_correct_messages() + { + TestActor.Tell("1"); + TestActor.Tell(2); + TestActor.Tell("3"); + TestActor.Tell(99999.0); + TestActor.Tell(4); + ReceiveWhile(_ => (_ is double ? null : _.ToString())!) + .Should().BeEquivalentTo(new[] { "1", "2", "3" }, opt => opt.WithStrictOrdering()); + } + + [Fact] + public void ReceiveWhile_Filter_should_not_consume_last_message_that_didnt_match() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell(4711); + ReceiveWhile(_ => (_ is string ? _ : null)!); + ExpectMsg(4711); + } + + [Fact] + public void ReceiveWhile_Predicate_should_on_a_timeout_return_no_messages() + { + ReceiveWhile(_ => false, TimeSpan.FromMilliseconds(10)).Count.Should().Be(0); + } + + [Fact] + public void ReceiveWhile_Predicate_should_break_when_predicate_returns_false_and_return_correct_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("3"); + TestActor.Tell("-----------"); + TestActor.Tell("4"); + ReceiveWhile(s => s.Length == 1) + .Should().BeEquivalentTo(new[] { "1", "2", "3" }, opt => opt.WithStrictOrdering()); + } + + [Fact] + public void + ReceiveWhile_Predicate_should_break_when_type_is_wrong_and_we_dont_ignore_those_and_return_correct_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("3"); + TestActor.Tell(4); + TestActor.Tell("5"); + ReceiveWhile(s => s.Length == 1, shouldIgnoreOtherMessageTypes: false) + .Should().BeEquivalentTo(new[] { "1", "2", "3" }, opt => opt.WithStrictOrdering()); + } + + [Fact] + public void + ReceiveWhile_Predicate_should_continue_when_type_is_other_but_we_ignore_other_types_and_return_correct_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("3"); + TestActor.Tell(4); + TestActor.Tell("5"); + ReceiveWhile(s => s.Length == 1, shouldIgnoreOtherMessageTypes: true) + .Should().BeEquivalentTo(new[] { "1", "2", "3", "5" }, opt => opt.WithStrictOrdering()); + } + + [Fact] + public void ReceiveWhile_Predicate_should_not_consume_last_message_that_didnt_match() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell(4711); + TestActor.Tell("3"); + TestActor.Tell("4"); + TestActor.Tell("5"); + TestActor.Tell(6); + TestActor.Tell("7"); + TestActor.Tell("8"); + + var received = ReceiveWhile(_ => _ is string); + received.Should().BeEquivalentTo(new[] { "1", "2" }, opt => opt.WithStrictOrdering()); + + ExpectMsg(4711); + + received = ReceiveWhile(_ => _ is string); + received.Should().BeEquivalentTo(new[] { "3", "4", "5" }, opt => opt.WithStrictOrdering()); + + ExpectMsg(6); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/RemainingTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/RemainingTests.cs new file mode 100644 index 00000000000..26c2b7b4d87 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/RemainingTests.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class RemainingTests : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + + } + + [Fact] + public void Throw_if_remaining_is_called_outside_Within() + { + Assert.Throws(() => Remaining); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.cs new file mode 100644 index 00000000000..0f108bcf064 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestKitBaseTests; + +public class WithinTests : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + + } + + [Fact] + public async Task Within_should_increase_max_timeout_by_the_provided_epsilon_value() + { + // Explicit 1s timeout keeps block duration predictable; 3s Within max absorbs + // Windows CI scheduler jitter on windows-2025-vs2026 runners. + await WithinAsync(TimeSpan.FromSeconds(3), + async () => await ExpectNoMsgAsync(TimeSpan.FromSeconds(1)), + TimeSpan.FromMilliseconds(50)); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs new file mode 100644 index 00000000000..ea4427e0540 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Akka.TestKit; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +// ReSharper disable once InconsistentNaming +public class TestKit_Config_Tests : TestKit +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public void DefaultValues_should_be_correct() + { + TestKitSettings.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(5)); + TestKitSettings.SingleExpectDefault.Should().Be(TimeSpan.FromSeconds(3)); + TestKitSettings.TestEventFilterLeeway.Should().Be(TimeSpan.FromSeconds(3)); + TestKitSettings.TestTimeFactor.Should().Be(1); + var callingThreadDispatcherTypeName = typeof(CallingThreadDispatcherConfigurator).FullName + ", " + typeof(CallingThreadDispatcher).GetTypeInfo().Assembly.GetName().Name; + Assert.False(Sys.Settings.Config.IsEmpty); + Sys.Settings.Config.GetString("akka.test.calling-thread-dispatcher.type", null).Should().Be(callingThreadDispatcherTypeName); + Sys.Settings.Config.GetString("akka.test.test-actor.dispatcher.type", null).Should().Be(callingThreadDispatcherTypeName); + CallingThreadDispatcher.Id.Should().Be("akka.test.calling-thread-dispatcher"); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestPersistenceTestKistTests/TestJournalSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestPersistenceTestKistTests/TestJournalSpec.cs new file mode 100644 index 00000000000..da5aa0be12e --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestPersistenceTestKistTests/TestJournalSpec.cs @@ -0,0 +1,155 @@ +using Akka.Actor; +using Akka.Configuration; +using Akka.Hosting.TestKit.Tests.TestActorRefTests; +using Akka.Persistence; +using Akka.Persistence.TestKit; +using Akka.TestKit; +using FluentAssertions; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestPersistenceTestKistTests; + +public class TestJournalSpec : PersistenceTestKit +{ + private TestProbe _probe = null!; + + public TestJournalSpec(ITestOutputHelper output) : base(nameof(TestJournalSpec), output) + { + } + + // Expect should be passing by default, need to make them less sensitive to timing + protected override Config? Config => "akka.test.single-expect-default = 30s"; + + protected override Task BeforeTestStart() + { + _probe = CreateTestProbe(); + return Task.CompletedTask; + } + + [Fact] + public void must_have_journal_and_snapshot() + { + Journal.Should().NotBeNull(); + JournalActorRef.Should().NotBeNull(); + Snapshots.Should().NotBeNull(); + SnapshotsActorRef.Should().NotBeNull(); + } + + [Fact] + public async Task must_return_ack_after_new_write_interceptor_is_set() + { + JournalActorRef.Tell(new TestJournal.UseWriteInterceptor(null), TestActor); + + await ExpectMsgAsync(TimeSpan.FromSeconds(3)); + } + + [Fact] + public async Task works_as_memory_journal_by_default() + { + var actor = ActorOf(() => new PersistActor(_probe)); + await _probe.ExpectMsgAsync(); + + await Journal.OnWrite.Pass(); + actor.Tell(new PersistActor.WriteMessage("write"), TestActor); + + await _probe.ExpectMsgAsync("ack"); + } + + [Fact] + public async Task must_recover_restarted_actor() + { + var actor = ActorOf(() => new PersistActor(_probe)); + await WatchAsync(actor); + await _probe.ExpectMsgAsync(); + + await Journal.OnRecovery.Pass(); + actor.Tell(new PersistActor.WriteMessage("1"), TestActor); + await _probe.ExpectMsgAsync("ack"); + actor.Tell(new PersistActor.WriteMessage("2"), TestActor); + await _probe.ExpectMsgAsync("ack"); + + await actor.GracefulStop(TimeSpan.FromSeconds(1)); + await ExpectTerminatedAsync(actor); + + ActorOf(() => new PersistActor(_probe)); + await _probe.ExpectMsgAsync("1"); + await _probe.ExpectMsgAsync("2"); + await _probe.ExpectMsgAsync(); + } + + [Fact] + public async Task when_fail_on_write_is_set_all_writes_to_journal_will_fail() + { + var actor = ActorOf(() => new PersistActor(_probe)); + await WatchAsync(actor); + await _probe.ExpectMsgAsync(); + + await Journal.OnWrite.Fail(); + actor.Tell(new PersistActor.WriteMessage("write"), TestActor); + + await _probe.ExpectMsgAsync("failure"); + await ExpectTerminatedAsync(actor); + } + + [Fact] + public async Task must_recover_failed_actor() + { + var actor = ActorOf(() => new PersistActor(_probe)); + await WatchAsync(actor); + await _probe.ExpectMsgAsync(); + + await Journal.OnRecovery.Pass(); + actor.Tell(new PersistActor.WriteMessage("1"), TestActor); + await _probe.ExpectMsgAsync("ack"); + actor.Tell(new PersistActor.WriteMessage("2"), TestActor); + await _probe.ExpectMsgAsync("ack"); + + await Journal.OnWrite.Fail(); + actor.Tell(new PersistActor.WriteMessage("3"), TestActor); + + await _probe.ExpectMsgAsync("failure"); + await ExpectTerminatedAsync(actor); + + ActorOf(() => new PersistActor(_probe)); + await _probe.ExpectMsgAsync("1"); + await _probe.ExpectMsgAsync("2"); + await _probe.ExpectMsgAsync(); + } + + [Fact] + public async Task when_reject_on_write_is_set_all_writes_to_journal_will_be_rejected() + { + var actor = ActorOf(() => new PersistActor(_probe)); + await WatchAsync(actor); + await _probe.ExpectMsgAsync(); + + await Journal.OnWrite.Reject(); + actor.Tell(new PersistActor.WriteMessage("write"), TestActor); + + await _probe.ExpectMsgAsync("rejected"); + } + + [Fact] + public async Task journal_must_reset_state_to_pass() + { + await WithJournalWrite(write => write.Fail(), async () => + { + var actor = ActorOf(() => new PersistActor(_probe)); + await WatchAsync(actor); + await _probe.ExpectMsgAsync(); + + actor.Tell(new PersistActor.WriteMessage("write"), TestActor); + await _probe.ExpectMsgAsync("failure"); + await ExpectTerminatedAsync(actor); + }); + + var actor2 = ActorOf(() => new PersistActor(_probe)); + await WatchAsync(actor2); + + await _probe.ExpectMsgAsync(); + actor2.Tell(new PersistActor.WriteMessage("write"), TestActor); + await _probe.ExpectMsgAsync("ack"); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestPersistenceTestKistTests/TestSnapshotStoreSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestPersistenceTestKistTests/TestSnapshotStoreSpec.cs new file mode 100644 index 00000000000..e3fc3b8e90c --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestPersistenceTestKistTests/TestSnapshotStoreSpec.cs @@ -0,0 +1,110 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using Xunit; + +namespace Akka.Hosting.TestKit.Tests.TestPersistenceTestKistTests; + +using Actor; +using Akka.Persistence; +using Akka.Persistence.TestKit; +using Akka.Persistence.TestKit.Tests; +using Akka.TestKit; +using System; +using System.Threading.Tasks; +using Xunit; + +public sealed class TestSnapshotStoreSpec : PersistenceTestKit +{ + public TestSnapshotStoreSpec(ITestOutputHelper output) : base(nameof(TestSnapshotStoreSpec), output: output) + { + } + + protected override Task BeforeTestStart() + { + _probe = CreateTestProbe(); + return Task.CompletedTask; + } + + private TestProbe _probe = null!; + + [Fact] + public async Task send_ack_after_load_interceptor_is_set() + { + SnapshotsActorRef.Tell(new TestSnapshotStore.UseLoadInterceptor(null), TestActor); + await ExpectMsgAsync(); + } + + [Fact] + public async Task send_ack_after_save_interceptor_is_set() + { + SnapshotsActorRef.Tell(new TestSnapshotStore.UseSaveInterceptor(null), TestActor); + await ExpectMsgAsync(); + } + + [Fact] + public async Task send_ack_after_delete_interceptor_is_set() + { + SnapshotsActorRef.Tell(new TestSnapshotStore.UseDeleteInterceptor(null), TestActor); + await ExpectMsgAsync(); + } + + [Fact] + public async Task after_load_behavior_was_executed_store_is_back_to_pass_mode() + { + // create snapshot + var actor = ActorOf(() => new SnapshotActor(_probe)); + actor.Tell("save"); + await _probe.ExpectMsgAsync(); + await actor.GracefulStop(TimeSpan.FromSeconds(3)); + + await WithSnapshotLoad(load => load.Fail(), async () => + { + ActorOf(() => new SnapshotActor(_probe)); + await _probe.ExpectMsgAsync(); + }); + + ActorOf(() => new SnapshotActor(_probe)); + await _probe.ExpectMsgAsync(); + } + + [Fact] + public async Task after_save_behavior_was_executed_store_is_back_to_pass_mode() + { + // create snapshot + var actor = ActorOf(() => new SnapshotActor(_probe)); + + await WithSnapshotSave(save => save.Fail(), async () => + { + actor.Tell("save"); + await _probe.ExpectMsgAsync(); + }); + + actor.Tell("save"); + await _probe.ExpectMsgAsync(); + } + + [Fact] + public async Task after_delete_behavior_was_executed_store_is_back_to_pass_mode() + { + // create snapshot + var actor = ActorOf(() => new SnapshotActor(_probe)); + actor.Tell("save"); + + var success = await _probe.ExpectMsgAsync(); + var nr = success.Metadata.SequenceNr; + + await WithSnapshotDelete(del => del.Fail(), async () => + { + actor.Tell(new SnapshotActor.DeleteOne(nr), TestActor); + await _probe.ExpectMsgAsync(); + }); + + actor.Tell(new SnapshotActor.DeleteOne(nr), TestActor); + await _probe.ExpectMsgAsync(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs new file mode 100644 index 00000000000..d42075d04c0 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs @@ -0,0 +1,207 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using Akka.TestKit; +using Akka.TestKit.Configs; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +public class TestSchedulerTests : TestKit +{ + private IActorRef? _testReceiveActor; + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + + } + + protected override async Task BeforeTestStart() + { + await base.BeforeTestStart(); + _testReceiveActor = Sys.ActorOf(Props.Create(() => new TestReceiveActor()) + .WithDispatcher(CallingThreadDispatcher.Id)); + } + + protected override Config Config { get; } = TestConfigs.TestSchedulerConfig; + + [Fact] + public async Task Delivers_message_when_scheduled_time_reached() + { + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1))); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + await ExpectMsgAsync(); + } + + [Fact] + public async Task Does_not_deliver_message_prematurely() + { + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1))); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromMilliseconds(999)); + await ExpectNoMsgAsync(TimeSpan.FromMilliseconds(20)); + } + + [Fact] + public async Task Delivers_messages_scheduled_for_same_time_in_order_they_were_added() + { + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1), 1)); + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1), 2)); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + var firstId = (await ExpectMsgAsync()).Id; + var secondId = (await ExpectMsgAsync()).Id; + Assert.Equal(1, firstId); + Assert.Equal(2, secondId); + } + + [Fact] + public async Task Keeps_delivering_rescheduled_message() + { + _testReceiveActor.Tell(new RescheduleMessage(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5))); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + for (int i = 0; i < 500; i ++) + { + Scheduler.Advance(TimeSpan.FromSeconds(5)); + await ExpectMsgAsync(); + } + } + + [Fact] + public async Task Uses_initial_delay_to_schedule_first_rescheduled_message() + { + _testReceiveActor.Tell(new RescheduleMessage(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5))); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + await ExpectMsgAsync(); + } + + [Fact] + public async Task Doesnt_reschedule_cancelled() + { + _testReceiveActor.Tell(new CancelableMessage(TimeSpan.FromSeconds(1))); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + await ExpectMsgAsync(); + _testReceiveActor.Tell(new CancelMessage()); + Scheduler.Advance(TimeSpan.FromSeconds(1)); + await ExpectNoMsgAsync(TimeSpan.FromMilliseconds(20)); + } + + + [Fact] + public async Task Advance_to_takes_us_to_correct_time() + { + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1), 1)); + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(2), 2)); + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(3), 3)); + (await _testReceiveActor.Ask(new Identify(null), RemainingOrDefault).WaitAsync(RemainingOrDefault)) + .Should().BeOfType(); // verify that the ActorCell has started + + Scheduler.AdvanceTo(Scheduler.Now.AddSeconds(2)); + var firstId = (await ExpectMsgAsync()).Id; + var secondId = (await ExpectMsgAsync()).Id; + await ExpectNoMsgAsync(TimeSpan.FromMilliseconds(20)); + Assert.Equal(1, firstId); + Assert.Equal(2, secondId); + } + + private class TestReceiveActor : ReceiveActor + { + private Cancelable? _cancelable; + + public TestReceiveActor() + { + Receive(x => + { + Context.System.Scheduler.ScheduleTellOnce(x.ScheduleOffset, Sender, x, Self); + }); + + Receive(x => + { + Context.System.Scheduler.ScheduleTellRepeatedly(x.InitialOffset, x.ScheduleOffset, Sender, x, Self); + }); + + Receive(x => + { + _cancelable = new Cancelable(Context.System.Scheduler); + Context.System.Scheduler.ScheduleTellRepeatedly(x.ScheduleOffset, x.ScheduleOffset, Sender, x, Self, _cancelable); + }); + + Receive(_ => + { + if (_cancelable is null) + throw new NullReferenceException("_cancelable is null, actor has not received any CancelableMessage message"); + + _cancelable.Cancel(); + }); + + } + } + + private class CancelableMessage + { + public TimeSpan ScheduleOffset { get; } + public int Id { get; } + + public CancelableMessage(TimeSpan scheduleOffset, int id = 1) + { + ScheduleOffset = scheduleOffset; + Id = id; + } + } + + private class CancelMessage { } + + private class ScheduleOnceMessage + { + public TimeSpan ScheduleOffset { get; } + public int Id { get; } + + public ScheduleOnceMessage(TimeSpan scheduleOffset, int id = 1) + { + ScheduleOffset = scheduleOffset; + Id = id; + } + } + + private class RescheduleMessage + { + public TimeSpan InitialOffset { get; } + public TimeSpan ScheduleOffset { get; } + public int Id { get; } + + public RescheduleMessage(TimeSpan initialOffset, TimeSpan scheduleOffset, int id = 1) + { + InitialOffset = initialOffset; + ScheduleOffset = scheduleOffset; + Id = id; + } + } + + + private TestScheduler Scheduler => (TestScheduler)Sys.Scheduler; +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Tests/xunit.runner.json b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/xunit.runner.json new file mode 100644 index 00000000000..4ba486b0bb3 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Tests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.github.io/schema/current/xunit.runner.schema.json", + "longRunningTestSeconds": 60, + "parallelizeAssembly": true, + "parallelizeTestCollections": true +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/Akka.Hosting.TestKit.Xunit2.Tests.csproj b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/Akka.Hosting.TestKit.Xunit2.Tests.csproj new file mode 100644 index 00000000000..407f6b2d7e2 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/Akka.Hosting.TestKit.Xunit2.Tests.csproj @@ -0,0 +1,39 @@ + + + + + $(NetTestVersion) + false + enable + + + + + + + + + DiPropsFailTest.cs + + + HostingSpecSpec.cs + + + LoggerSpec.cs + + + NoImplicitSenderSpec.cs + + + TestActorStartupDeadlockSpec.cs + + + + + + + + + + + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/AsyncCompat.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/AsyncCompat.cs new file mode 100644 index 00000000000..ecbe5d03512 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/AsyncCompat.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace Akka.Hosting.TestKit.Tests; + +internal static class AsyncCompat +{ + public static Task ToTask(this Task task) => task; + + public static Task ToTask(this ValueTask task) => task.AsTask(); +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/GlobalUsings.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..030a0105c55 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using XunitTestOutputHelper = Xunit.Abstractions.ITestOutputHelper; diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/PersistenceSmokeSpec.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/PersistenceSmokeSpec.cs new file mode 100644 index 00000000000..1840f098dc9 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/PersistenceSmokeSpec.cs @@ -0,0 +1,27 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2026 .NET Foundation +// +// ----------------------------------------------------------------------- + +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Hosting.TestKit.Tests; + +public sealed class PersistenceSmokeSpec : PersistenceTestKit +{ + public PersistenceSmokeSpec(ITestOutputHelper output) : base(nameof(PersistenceSmokeSpec), output) + { + } + + [Fact] + public void should_initialize_persistence_testkit_services() + { + Journal.Should().NotBeNull(); + JournalActorRef.Should().NotBeNull(); + Snapshots.Should().NotBeNull(); + SnapshotsActorRef.Should().NotBeNull(); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/Properties/AssemblyInfo.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..e2336be89a6 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +using Xunit; + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("b21496c0-a536-4953-9253-d2d0d526e42d")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] + +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly, DisableTestParallelization = true)] diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/Akka.Hosting.TestKit.Xunit2.csproj b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/Akka.Hosting.TestKit.Xunit2.csproj new file mode 100644 index 00000000000..0ff99ecaf72 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/Akka.Hosting.TestKit.Xunit2.csproj @@ -0,0 +1,44 @@ + + + + TestKit for writing tests for Akka.NET using Akka.Hosting and xUnit v2. + $(NetStandardLibVersion) + enable + $(AkkaPackageTags);hosting;testkit;xunit + true + https://getakka.net/articles/hosting/index.html + true + false + + + + + + + + + + + + + + + + + Internals\TestKitLoggerFactoryLogger.cs + + + Internals\XUnitLogger.cs + + + Internals\XUnitLoggerProvider.cs + + + PersistenceTestKit.cs + + + TestKit.Shared.cs + + + + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/GlobalUsings.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/GlobalUsings.cs new file mode 100644 index 00000000000..71f2e3c630b --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using XunitAssertions = Akka.TestKit.Xunit2.XunitAssertions; +global using XunitTestOutputHelper = Xunit.Abstractions.ITestOutputHelper; diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/Properties/FriendsOf.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/Properties/FriendsOf.cs new file mode 100644 index 00000000000..b0636101ff6 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/Properties/FriendsOf.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Akka.Hosting.TestKit.Xunit2.Tests")] \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/TestKit.cs b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/TestKit.cs new file mode 100644 index 00000000000..93252c52811 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit.Xunit2/TestKit.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Threading.Tasks; +using Akka.Annotations; +using Xunit; + +namespace Akka.Hosting.TestKit +{ + public abstract partial class TestKit : IAsyncLifetime + { + [InternalApi] + public Task InitializeAsync() => InitializeAsyncCore(); + + public Task DisposeAsync() => DisposeAsyncCore(); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj b/src/contrib/hosting/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj new file mode 100644 index 00000000000..78899e77990 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj @@ -0,0 +1,26 @@ + + + + TestKit for writing tests for Akka.NET using Akka.Hosting and xUnit. + $(NetStandardLibVersion) + enable + $(AkkaPackageTags);hosting;testkit;xunit + true + https://getakka.net/articles/hosting/index.html + true + false + + + + + + + + + + + + + + + diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings b/src/contrib/hosting/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings new file mode 100644 index 00000000000..001520580b4 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/GlobalUsings.cs b/src/contrib/hosting/Akka.Hosting.TestKit/GlobalUsings.cs new file mode 100644 index 00000000000..a92fdc50ecd --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using XunitAssertions = Akka.TestKit.Xunit.XunitAssertions; +global using XunitTestOutputHelper = Xunit.ITestOutputHelper; diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/Internals/TestKitLoggerFactoryLogger.cs b/src/contrib/hosting/Akka.Hosting.TestKit/Internals/TestKitLoggerFactoryLogger.cs new file mode 100644 index 00000000000..1fb2ec97734 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/Internals/TestKitLoggerFactoryLogger.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using Akka.Actor; +using Akka.Event; +using Akka.Hosting.Logging; + +namespace Akka.Hosting.TestKit.Internals +{ + public class TestKitLoggerFactoryLogger: LoggerFactoryLogger + { + protected override bool Receive(object message) + { + switch (message) + { + case InitializeLogger init: + InternalLogger.Info($"{nameof(TestKitLoggerFactoryLogger)} started"); + ((EventStream)init.LoggingBus).Subscribe(Self); + // Only reply if there's an actual sender waiting (not NoSender) + if (!Sender.Equals(ActorRefs.NoSender)) + Sender.Tell(new LoggerInitialized()); + return true; + + default: + return base.Receive(message); + } + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/Internals/XUnitLogger.cs b/src/contrib/hosting/Akka.Hosting.TestKit/Internals/XUnitLogger.cs new file mode 100644 index 00000000000..36500ceb977 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/Internals/XUnitLogger.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting.TestKit.Internals +{ + public class XUnitLogger: ILogger + { + private const string NullFormatted = "[null]"; + + private readonly string _category; + private readonly XunitTestOutputHelper _helper; + private readonly LogLevel _logLevel; + + public XUnitLogger(string category, XunitTestOutputHelper 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}"; + + try + { + + _helper.WriteLine(msg); + } + catch + { + // no active xUnit test available + Console.WriteLine("No active xUnit test available, but logging was attempted. Message: " + msg); + } + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.None => false, + _ => logLevel >= _logLevel + }; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return NullScope.Instance; + } + + 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; + } + + private class NullScope : IDisposable + { + private NullScope() { } + public static NullScope Instance { get; } = new NullScope(); + public void Dispose() { } + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs b/src/contrib/hosting/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs new file mode 100644 index 00000000000..f3db073a8fd --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting.TestKit.Internals +{ + public class XUnitLoggerProvider : ILoggerProvider + { + private readonly XunitTestOutputHelper _helper; + private readonly LogLevel _logLevel; + + public XUnitLoggerProvider(XunitTestOutputHelper helper, LogLevel logLevel) + { + _helper = helper; + _logLevel = logLevel; + } + + public void Dispose() + { + // no-op + } + + public ILogger CreateLogger(string categoryName) + { + return new XUnitLogger(categoryName, _helper, _logLevel); + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/PersistenceTestKit.cs b/src/contrib/hosting/Akka.Hosting.TestKit/PersistenceTestKit.cs new file mode 100644 index 00000000000..7ac5f6f3991 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/PersistenceTestKit.cs @@ -0,0 +1,279 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Persistence.TestKit; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Akka.Hosting.TestKit; + +public abstract class PersistenceTestKit : TestKit +{ + public static readonly Config DefaultConfiguration = ConfigurationFactory.FromResource("Akka.Persistence.TestKit.config.conf"); + + /// + /// Create a new instance of the class. + /// A new system with the specified configuration will be created. + /// + public PersistenceTestKit(string? actorSystemName = null, XunitTestOutputHelper? output = null, TimeSpan? startupTimeout = null, LogLevel logLevel = LogLevel.Information) + : base(actorSystemName, output, startupTimeout, logLevel) + { + } + + /// + /// Actor reference to persistence Journal used by current actor system. + /// + public IActorRef JournalActorRef { get; private set; } = null!; + + /// + /// Actor reference to persistence Snapshot Store used by current actor system. + /// + public IActorRef SnapshotsActorRef { get; private set; } = null!; + + /// + /// Current journal IActorRef wrapped inside a TestJournal + /// + public ITestJournal Journal { get; private set; } = null!; + + /// + /// Current snapshot store IActorRef wrapped inside a TestSnapshotStore + /// + public ITestSnapshotStore Snapshots { get; private set; } = null!; + + /// + /// Execute delegate with Journal Behavior applied to Recovery operation. + /// + /// + /// After will be executed, Recovery behavior will be reverted back to normal. + /// + /// Delegate which will select Journal behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public async Task WithJournalRecovery(Func behaviorSelector, Func execution) + { + if (behaviorSelector == null) throw new ArgumentNullException(nameof(behaviorSelector)); + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + try + { + await behaviorSelector(Journal.OnRecovery); + await execution(); + } + finally + { + await Journal.OnRecovery.Pass(); + } + } + + /// + /// Execute delegate with Journal Behavior applied to Write operation. + /// + /// + /// After will be executed, Write behavior will be reverted back to normal. + /// + /// Delegate which will select Journal behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public async Task WithJournalWrite(Func behaviorSelector, Func execution) + { + if (behaviorSelector == null) throw new ArgumentNullException(nameof(behaviorSelector)); + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + try + { + await behaviorSelector(Journal.OnWrite); + await execution(); + } + finally + { + await Journal.OnWrite.Pass(); + } + } + + /// + /// Execute delegate with Journal Behavior applied to Recovery operation. + /// + /// + /// After will be executed, Recovery behavior will be reverted back to normal. + /// + /// Delegate which will select Journal behavior. + /// Delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public Task WithJournalRecovery(Func behaviorSelector, Action execution) + => WithJournalRecovery(behaviorSelector, () => + { + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + execution(); + return Task.FromResult(new object()); + }); + + /// + /// Execute delegate with Journal Behavior applied to Write operation. + /// + /// + /// After will be executed, Write behavior will be reverted back to normal. + /// + /// Delegate which will select Journal behavior. + /// Delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public Task WithJournalWrite(Func behaviorSelector, Action execution) + => WithJournalWrite(behaviorSelector, () => + { + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + execution(); + return Task.FromResult(new object()); + }); + + /// + /// Execute delegate with Snapshot Store Behavior applied to Save operation. + /// + /// + /// After will be executed, Save behavior will be reverted back to normal. + /// + /// Delegate which will select Snapshot Store behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public async Task WithSnapshotSave(Func behaviorSelector, Func execution) + { + if (behaviorSelector == null) throw new ArgumentNullException(nameof(behaviorSelector)); + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + try + { + await behaviorSelector(Snapshots.OnSave); + await execution(); + } + finally + { + await Snapshots.OnSave.Pass(); + } + } + + /// + /// Execute delegate with Snapshot Store Behavior applied to Load operation. + /// + /// + /// After will be executed, Load behavior will be reverted back to normal. + /// + /// Delegate which will select Snapshot Store behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public async Task WithSnapshotLoad(Func behaviorSelector, Func execution) + { + if (behaviorSelector == null) throw new ArgumentNullException(nameof(behaviorSelector)); + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + try + { + await behaviorSelector(Snapshots.OnLoad); + await execution(); + } + finally + { + await Snapshots.OnLoad.Pass(); + } + } + + /// + /// Execute delegate with Snapshot Store Behavior applied to Delete operation. + /// + /// + /// After will be executed, Delete behavior will be reverted back to normal. + /// + /// Delegate which will select Snapshot Store behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public async Task WithSnapshotDelete(Func behaviorSelector, Func execution) + { + if (behaviorSelector == null) throw new ArgumentNullException(nameof(behaviorSelector)); + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + try + { + await behaviorSelector(Snapshots.OnDelete); + await execution(); + } + finally + { + await Snapshots.OnDelete.Pass(); + } + } + + /// + /// Execute delegate with Snapshot Store Behavior applied to Save operation. + /// + /// + /// After will be executed, Save behavior will be reverted back to normal. + /// + /// Delegate which will select Snapshot Store behavior. + /// Delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public Task WithSnapshotSave(Func behaviorSelector, Action execution) + => WithSnapshotSave(behaviorSelector, () => + { + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + execution(); + return Task.FromResult(true); + }); + + /// + /// Execute delegate with Snapshot Store Behavior applied to Load operation. + /// + /// + /// After will be executed, Load behavior will be reverted back to normal. + /// + /// Delegate which will select Snapshot Store behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public Task WithSnapshotLoad(Func behaviorSelector, Action execution) + => WithSnapshotLoad(behaviorSelector, () => + { + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + execution(); + return Task.FromResult(true); + }); + + /// + /// Execute delegate with Snapshot Store Behavior applied to Delete operation. + /// + /// + /// After will be executed, Delete behavior will be reverted back to normal. + /// + /// Delegate which will select Snapshot Store behavior. + /// Async delegate which will be executed with applied Journal behavior. + /// which must be awaited. + public Task WithSnapshotDelete(Func behaviorSelector, Action execution) + => WithSnapshotDelete(behaviorSelector, () => + { + if (execution == null) throw new ArgumentNullException(nameof(execution)); + + execution(); + return Task.FromResult(true); + }); + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.AddHocon(DefaultConfiguration, HoconAddMode.Append); + + builder.AddStartup((system, registry) => + { + var persistenceExtension = Persistence.Persistence.Instance.Apply(system); + + JournalActorRef = persistenceExtension.JournalFor(null); + Journal = TestJournal.FromRef(JournalActorRef); + SnapshotsActorRef = persistenceExtension.SnapshotStoreFor(null); + Snapshots = TestSnapshotStore.FromRef(SnapshotsActorRef); + }); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/Properties/FriendsOf.cs b/src/contrib/hosting/Akka.Hosting.TestKit/Properties/FriendsOf.cs new file mode 100644 index 00000000000..55f3fc21e9b --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/Properties/FriendsOf.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Akka.Hosting.TestKit.Tests")] \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/TestKit.Shared.cs b/src/contrib/hosting/Akka.Hosting.TestKit/TestKit.Shared.cs new file mode 100644 index 00000000000..84ca7e348e7 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/TestKit.Shared.cs @@ -0,0 +1,453 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Internal; +using Akka.Actor.Setup; +using Akka.Annotations; +using Akka.Configuration; +using Akka.Event; +using Akka.Hosting.Logging; +using Akka.Hosting.TestKit.Internals; +using Akka.TestKit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.Sdk; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.TestKit +{ + public abstract partial class TestKit : TestKitBase + { + /// + /// Commonly used assertions used throughout the testkit. + /// + protected static XunitAssertions Assertions { get; } = new XunitAssertions(); + + private IHost? _host; + private IActorRef? _registeredTestProbe; + + public IHost Host + { + get + { + if (_host is null) + throw new XunitException("Test has not been initialized yet"); + + // Ensure implicit sender is set on current thread when accessing Host + EnsureImplicitSender(); + return _host; + } + } + + public ActorRegistry ActorRegistry => Host.Services.GetRequiredService(); + + /// + /// Ensures the implicit sender is set on the current thread. + /// Called automatically when accessing Host or Sys. + /// + private void EnsureImplicitSender() + { + if (this is not INoImplicitSender && InternalCurrentActorCellKeeper.Current == null && TestActor != null) + InternalCurrentActorCellKeeper.Current = (ActorCell)((ActorRefWithCell)TestActor).Underlying; + } + + public TimeSpan StartupTimeout { get; } + public string ActorSystemName { get; } + public XunitTestOutputHelper? Output { get; } + public LogLevel LogLevel { get; } + + private readonly TaskCompletionSource _initialized = new TaskCompletionSource(); + + protected TestKit(string? actorSystemName = null, XunitTestOutputHelper? output = null, TimeSpan? startupTimeout = null, + LogLevel logLevel = LogLevel.Information) + : base(Assertions) + { + ActorSystemName = actorSystemName ?? "test"; + Output = output; + LogLevel = logLevel; + StartupTimeout = startupTimeout ?? TimeSpan.FromSeconds(30); + } + + protected virtual void ConfigureHostConfiguration(IConfigurationBuilder builder) + { } + + protected virtual void ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder builder) + { } + + protected virtual void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { } + + protected virtual void ConfigureHostBuilder(IHostBuilder builder) + { } + + private void InternalConfigureServices(HostBuilderContext context, IServiceCollection services) + { + ConfigureServices(context, services); + + services.AddAkka(ActorSystemName, (builder, provider) => + { + builder.AddHocon(DefaultConfig, HoconAddMode.Prepend); + if (Config is { }) + builder.AddHocon(Config, HoconAddMode.Prepend); + + // Don't re-register TestEventListener here — DefaultConfig (prepended above) already + // configures it with a short type name. AddLogger produces a fully-qualified name, + // which makes InjectTopLevelFallback think the config changed and triggers a full + // serialization rebuild. Under CI load that rebuild can race with scheduler disposal. + builder.ConfigureLoggers(logger => + { + logger.LogLevel = ToAkkaLogLevel(LogLevel); + }); + + if (Output is { }) + { + builder.StartActors(async (system, registry) => { await LoggerHook(system, registry); }); + } + + // Register TestProbe using StartActors (not AddStartup) so it runs BEFORE user's WithActors + // This ensures TestProbe is available for any actors that depend on IRequiredActor + builder.StartActors((actorSystem, actorRegistry) => + { + // base.InitializeTest -> Akka.TestKit.TestKitBase.InitializeTest unconditionally calls + // SynchronizationContext.SetSynchronizationContext(new ActorCellKeepingSynchronizationContext(...)). + // This delegate runs on a host-startup thread inside _host.StartAsync(); SetSynchronizationContext + // is per-thread and is NOT unwound by await, and nothing here scrubs it. Left unbracketed, that + // SynchronizationContext leaks onto pool threads, escapes InitializeAsyncCore, and is captured by + // xUnit v3's CreateTestClassInstance -> [AkkaCleanAmbientContext].Before, which then pins the + // next sequentially-run test's continuations onto this (disposed) test's ActorCell. + // This delegate is synchronous, so same-thread save/restore fully contains the mutation. The + // correct per-test SynchronizationContext is installed later by [AkkaCleanAmbientContext].Before. + var savedContext = SynchronizationContext.Current; + try + { + // Initialize TestActor here to ensure it's available before user actors start + base.InitializeTest(actorSystem, (ActorSystemSetup)null!, null, null); + actorRegistry.Register(GetOrCreateRegisteredTestProbe(actorSystem)); + + // Set implicit sender on initialization thread + if (this is not INoImplicitSender) + InternalCurrentActorCellKeeper.Current = (ActorCell)((ActorRefWithCell)TestActor).Underlying; + } + finally + { + SynchronizationContext.SetSynchronizationContext(savedContext); + } + }); + + // User configuration comes AFTER TestProbe registration + // Their WithActors/StartActors will be added after ours + ConfigureAkka(builder, provider); + + builder.AddStartup((_, _) => { _initialized.TrySetResult(Done.Instance); }); + }); + } + + internal virtual Task LoggerHook(ActorSystem system, IActorRegistry registry) + { + var extSystem = (ExtendedActorSystem)system; + var loggerName = $"log-test-{Guid.NewGuid():N}"; + var logger = extSystem.SystemActorOf(Props.Create(() => new TestKitLoggerFactoryLogger()), loggerName); + // Fire and forget the logger initialization to avoid blocking + // The logger will eventually initialize itself + logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender); + return Task.CompletedTask; + } + + protected virtual Config? Config { get; } = null; + + protected virtual void ConfigureLogging(ILoggingBuilder builder) + { } + + protected abstract void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider); + + [InternalApi] + private async Task InitializeAsyncCore() + { + // Defense-in-depth for the SynchronizationContext leak contained at the base.InitializeTest + // call site above: SetSynchronizationContext is per-thread and not unwound by await, so this + // continuation can return to xUnit's CreateTestClassInstance on a thread carrying a stale SC. + // Restoring the entry SC on exit guarantees [AkkaCleanAmbientContext].Before captures a clean + // PreviousContext regardless of what the host-startup pipeline installs. + var entryContext = SynchronizationContext.Current; + try + { + var hostBuilder = new HostBuilder(); + if (Output != null) + { + hostBuilder.ConfigureLogging(logger => + { + logger.ClearProviders(); + logger.AddProvider(new XUnitLoggerProvider(Output, LogLevel)); + logger.AddFilter("Akka.*", LogLevel); + ConfigureLogging(logger); + }); + } + + hostBuilder + .ConfigureHostConfiguration(ConfigureHostConfiguration) + .ConfigureAppConfiguration(ConfigureAppConfiguration); + ConfigureHostBuilder(hostBuilder); + hostBuilder.ConfigureServices(InternalConfigureServices); + + _host = hostBuilder.Build(); + + using var cts = new CancellationTokenSource(StartupTimeout); + try + { + await _host.StartAsync(cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + throw new TimeoutException($"Host failed to start within {StartupTimeout.TotalSeconds} seconds"); + } + + // Wait for Akka initialization with timeout + var initializedTask = _initialized.Task; + var timeoutTask = Task.Delay(StartupTimeout, CancellationToken.None); + if (await Task.WhenAny(initializedTask, timeoutTask) == timeoutTask) + throw new TimeoutException($"Akka.NET failed to initialize within {StartupTimeout.TotalSeconds} seconds"); + + // The TestActor is created (via base.InitializeTest) inside a StartActors callback while + // remoting/clustering/etc. are concurrently spinning up their own /system actors. That + // concurrent startup intermittently terminates the freshly-created TestActor. Host startup + // is now complete, so the system is quiet — verify the TestActor survived and re-create it + // here (race-free) if it did not. See EnsureTestActorAliveAsync. + await EnsureTestActorAliveAsync(); + + await BeforeTestStart(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(entryContext); + } + } + + /// + /// Verifies that the survived host startup, and re-creates it + /// if it did not. + /// + /// The TestActor is an InternalTestActor created under /system on the + /// CallingThreadDispatcher via , + /// which runs inside a callback while + /// remoting, clustering and other extensions are concurrently creating their own /system actors. + /// That concurrent startup intermittently terminates the freshly-created TestActor, after which every + /// message sent to it dead-letters and ExpectMsg calls time out. + /// + /// + /// By the time this runs host startup has completed and the system is quiet, so re-creating the + /// TestActor here is race-free and deterministic. + /// + /// + private async Task EnsureTestActorAliveAsync() + { + const int maxAttempts = 3; + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + if (await IsTestActorAliveAsync()) + return; + + Sys.Log.Warning( + "TestActor [{0}] did not survive host startup; re-creating it (attempt {1}/{2}).", + TestActor.Path, attempt + 1, maxAttempts); + + // base.InitializeTest installs an ActorCellKeepingSynchronizationContext on the current + // thread; bracket it the same way the original call site does so it cannot leak. + var savedContext = SynchronizationContext.Current; + try + { + base.InitializeTest(Sys, (ActorSystemSetup)null!, null, null); + } + finally + { + SynchronizationContext.SetSynchronizationContext(savedContext); + } + + await RetargetRegisteredTestProbeAsync(Sys); + ActorRegistry.Register(GetOrCreateRegisteredTestProbe(Sys), overwrite: true); + } + + if (!await IsTestActorAliveAsync()) + throw new InvalidOperationException( + $"TestActor could not be kept alive across host startup after {maxAttempts} attempts."); + } + + private async Task IsTestActorAliveAsync() + { + try + { + await Sys.ActorSelection(TestActor.Path).ResolveOne(TimeSpan.FromSeconds(1)); + return true; + } + catch (ActorNotFoundException) + { + return false; + } + } + + private IActorRef GetOrCreateRegisteredTestProbe(ActorSystem system) + { + if (_registeredTestProbe == null) + { + _registeredTestProbe = system.ActorOf( + Props.Create(() => new StableTestProbeRef(TestActor)), + $"testProbe-registry-{Guid.NewGuid():N}"); + } + + return _registeredTestProbe; + } + + internal async Task ForceReinitializeTestActorAsync() + { + var savedContext = SynchronizationContext.Current; + try + { + base.InitializeTest(Sys, (ActorSystemSetup)null!, null, null); + } + finally + { + SynchronizationContext.SetSynchronizationContext(savedContext); + } + + await RetargetRegisteredTestProbeAsync(Sys); + ActorRegistry.Register(GetOrCreateRegisteredTestProbe(Sys), overwrite: true); + } + + private async Task RetargetRegisteredTestProbeAsync(ActorSystem system) + { + if (_registeredTestProbe == null) + { + _ = GetOrCreateRegisteredTestProbe(system); + return; + } + + _ = await _registeredTestProbe.Ask(new StableTestProbeRef.UpdateTarget(TestActor), TimeSpan.FromSeconds(3)); + } + + private sealed class StableTestProbeRef : ReceiveActor + { + public sealed class UpdateTarget + { + public UpdateTarget(IActorRef target) + { + Target = target; + } + + public IActorRef Target { get; } + } + + private IActorRef _target; + + public StableTestProbeRef(IActorRef initialTarget) + { + _target = initialTarget; + + Receive(update => + { + _target = update.Target; + Sender.Tell(Done.Instance); + }); + ReceiveAny(message => _target.Forward(message)); + } + } + + protected sealed override void InitializeTest(ActorSystem system, ActorSystemSetup config, string actorSystemName, + string testActorName) + { + // no-op, deferring InitializeTest after Host have ran + } + + /// + /// Override Sys property to ensure implicit sender is set when accessing the actor system + /// + public new ActorSystem Sys + { + get + { + EnsureImplicitSender(); + return base.Sys; + } + } + + protected virtual Task BeforeTestStart() + { + return Task.CompletedTask; + } + + /// + /// This method is called when a test ends. + /// + /// + /// If you override this, then make sure you either call base.AfterAllAsync() + /// to shut down the system. Otherwise a memory leak will occur. + /// + /// + protected virtual Task AfterAllAsync() + { + return Task.CompletedTask; + } + + private async Task DisposeAsyncCore() + { + Exception? exception = null; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await Task.WhenAny(Task.Delay(Timeout.Infinite, cts.Token), AfterAllAsync()); + if (cts.IsCancellationRequested) + throw new TimeoutException($"{nameof(AfterAllAsync)} took more than 5 seconds to execute, aborting."); + } + catch (Exception e) + { + exception = e; + } + finally + { + try + { + Shutdown(); + if (_host != null) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await _host.StopAsync(cts.Token); + } + } + catch (Exception shutdownException) + { + // A failed or timed-out shutdown must not mask the test's own outcome, and a + // teardown problem is not a test failure. Report it to the test output so it + // is visible when someone is chasing a leaked ActorSystem or a hung host. + Output?.WriteLine( + $"[{nameof(TestKit)}] Shutting down the ActorSystem/host in {nameof(DisposeAsyncCore)} failed: {shutdownException}"); + } + finally + { + _host?.Dispose(); + } + + if (exception is { }) + throw exception; + } + } + + private static Event.LogLevel ToAkkaLogLevel(LogLevel logLevel) + => logLevel switch + { + LogLevel.Trace => Event.LogLevel.DebugLevel, + LogLevel.Debug => Event.LogLevel.DebugLevel, + LogLevel.Information => Event.LogLevel.InfoLevel, + LogLevel.Warning => Event.LogLevel.WarningLevel, + LogLevel.Error => Event.LogLevel.ErrorLevel, + LogLevel.Critical => Event.LogLevel.ErrorLevel, + _ => Event.LogLevel.ErrorLevel + }; + } +} diff --git a/src/contrib/hosting/Akka.Hosting.TestKit/TestKit.cs b/src/contrib/hosting/Akka.Hosting.TestKit/TestKit.cs new file mode 100644 index 00000000000..a0a28b051fa --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.TestKit/TestKit.cs @@ -0,0 +1,23 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Annotations; +using Akka.TestKit.Xunit.Attributes; +using Xunit; + +namespace Akka.Hosting.TestKit +{ + [AkkaCleanAmbientContext] + public abstract partial class TestKit : IAsyncLifetime, IAsyncDisposable + { + [InternalApi] + public ValueTask InitializeAsync() => new(InitializeAsyncCore()); + + public ValueTask DisposeAsync() => new(DisposeAsyncCore()); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/ActorRegistrySpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/ActorRegistrySpecs.cs new file mode 100644 index 00000000000..953cd9093ae --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/ActorRegistrySpecs.cs @@ -0,0 +1,148 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Xunit; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.Tests; + +public class ActorRegistrySpecs +{ + [Fact] + public void Should_throw_upon_duplicate_registration() + { + // arrange + var registry = new ActorRegistry(); + registry.Register(Nobody.Instance); + + // act + + var register = () => registry.Register(Nobody.Instance); + + // assert + register.Should().Throw(); + } + + [Fact] + public void Should_not_throw_upon_duplicate_registration_when_overwrite_allowed() + { + // arrange + var registry = new ActorRegistry(); + registry.Register(Nobody.Instance); + + // act + + var register = () => registry.Register(Nobody.Instance, true); + + // assert + register.Should().NotThrow(); + } + + [Fact] + public void Should_throw_NullReferenceException_for_Null_IActorRef() + { + // arrange + var registry = new ActorRegistry(); + + // act + + var register = () => registry.Register(null!); // intentionally null for the test + + // assert + register.Should().Throw(); + } + + [Fact] + public void Should_throw_on_missing_entry_during_Get() + { + // arrange + var registry = new ActorRegistry(); + + // assert + registry.Invoking(x => x.Get()).Should().Throw(); + } + + [Fact] + public void Should_not_throw_on_missing_entry_during_TryGet() + { + // arrange + var registry = new ActorRegistry(); + + // assert + registry.Invoking(x => x.TryGet(out var actor)).Should().NotThrow(); + } + + [Fact] + public async Task Should_complete_GetAsync_upon_KeyRegistered() + { + // arrange + var registry = new ActorRegistry(); + + // act + var task = registry.GetAsync(); + task.IsCompletedSuccessfully.Should().BeFalse(); + + registry.Register(Nobody.Instance); + var result = await task; + + // assert + result.Should().Be(Nobody.Instance); + } + + [Fact] + public async Task Should_complete_multiple_GetAsync_upon_KeyRegistered() + { + // arrange + var registry = new ActorRegistry(); + + // act + var task1 = registry.GetAsync(); + var task2 = registry.GetAsync(); + var task3 = registry.GetAsync(); + + // validate that all three tasks are distinct + task1.Should().NotBe(task2).And.NotBe(task3); + + var aggregate = Task.WhenAll(task1, task2, task3); + + registry.Register(Nobody.Instance); + var result = await aggregate; + + // assert + result.First().Should().Be(Nobody.Instance); + } + + [Fact] + public void GetAsync_should_return_CompletedTask_if_Key_AlreadyExists() + { + // arrange + var registry = new ActorRegistry(); + registry.Register(Nobody.Instance); + + // act + var task = registry.GetAsync(); + + // assert + task.IsCompletedSuccessfully.Should().BeTrue(); + } + + [Fact] + public async Task GetAsync_should_Cancel_after_Timeout() + { + // arrange + var registry = new ActorRegistry(); + var cancellationTokenSource = new CancellationTokenSource(); + + // act + var task = registry.GetAsync(cancellationTokenSource.Token); + // assert + await Awaiting(async () => + { + cancellationTokenSource.Cancel(); + await task.WaitAsync(TimeSpan.FromSeconds(3)); + }).Should().ThrowAsync(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj b/src/contrib/hosting/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj new file mode 100644 index 00000000000..bb8a0b29e73 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj @@ -0,0 +1,35 @@ + + + + + $(NetTestVersion) + Exe + false + enable + + + + + + + + + + + + + + + + + + + + + + + + Always + + + diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Bugfix208Specs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Bugfix208Specs.cs new file mode 100644 index 00000000000..bf2207d30ee --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Bugfix208Specs.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Dsl; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Hosting.Tests; + +public class Bugfix208Specs : TestKit.TestKit +{ + private class MyTestActor : ReceiveActor + { + public record SetData(string Data); + + public record GetData(); + + private string _data = string.Empty; + + public MyTestActor() + { + Receive(s => + { + _data = s.Data; + }); + + Receive(g => + { + Sender.Tell(_data); + }); + } + } + + private class TestActorKey{} + + private class MyBackgroundService : BackgroundService + { + private readonly IRequiredActor _testActor; + + public MyBackgroundService(IRequiredActor requiredActor) + { + _testActor = requiredActor; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var myRef = await _testActor.GetAsync(stoppingToken); + myRef.Tell("BackgroundService started"); + } + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + services.AddHostedService(); + base.ConfigureServices(context, services); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithActors((system, registry, arg3) => + { + registry.Register(system.ActorOf(Props.Create(() => new MyTestActor()), "test-actor")); + }); + } + + /// + /// Reproduction for https://github.com/akkadotnet/Akka.Hosting/issues/208 + /// + [Fact] + public async Task ShouldStartHostedServiceThatDependsOnActor() + { + // arrange + var testActorRef = ActorRegistry.Get(); + + // act + + // assert + + // workaround for https://github.com/akkadotnet/Akka.Hosting/issues/265 + var attempts = 5; + do + { + attempts--; + try + { + var r = await testActorRef.Ask(new MyTestActor.GetData(), TimeSpan.FromMilliseconds(100)); + r.Should().Be("BackgroundService started"); + } + catch (Exception) + { + attempts--; + if (attempts == 0) + { + throw; + } + } + } while (attempts > 0); + + // await AwaitAssertAsync(async () => + // { + // var r = await testActorRef.Ask(new MyTestActor.GetData(), TimeSpan.FromMilliseconds(100)); + // r.Should().Be("BackgroundService started"); + // }, RemainingOrDefault, TimeSpan.FromMilliseconds(150)); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Configuration/ConfigurationHoconAdapterTest.cs b/src/contrib/hosting/Akka.Hosting.Tests/Configuration/ConfigurationHoconAdapterTest.cs new file mode 100644 index 00000000000..4141a79fcb9 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Configuration/ConfigurationHoconAdapterTest.cs @@ -0,0 +1,340 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Akka.Configuration; +using Akka.Hosting.Configuration; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.Configuration; +using Xunit; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.Tests.Configuration; + +public class ConfigurationHoconAdapterTest: IAsyncLifetime +{ + private const string ConfigSource = @"{ + ""akka"": { + ""actor.serialization-bindings"" : { + ""\""System.Int32\"""": ""json"" + }, + ""cluster"": { + ""roles"": [ ""front-end"", ""back-end"" ], + ""role"" : { + ""back-end"" : 5 + }, + ""app-version"": ""1.0.0"", + ""min-nr-of-members"": 99, + ""seed-nodes"": [ ""akka.tcp://system@somewhere.com:9999"" ], + ""log-info"": false, + ""log-info-verbose"": true + } + }, + ""test1"": ""test1 content"", + ""test2.a"": ""on"", + ""test2.b.c"": ""2s"", + ""test2.b.d"": ""test2.b.d content"", + ""test2.d"": ""test2.d content"", + ""test3"": ""3"", + ""test4"": 4 +}"; + + private readonly IConfigurationRoot _root; + + public ConfigurationHoconAdapterTest() + { + Environment.SetEnvironmentVariable("akka__TEST_VALUE_1__A", "A VALUE"); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_1__B", "B VALUE"); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_1__C__D", "D"); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_2__0", "ZERO"); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_2__22", "TWO"); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_2__1", "ONE"); + + Environment.SetEnvironmentVariable("akka__test_value_3__a", "a value"); + Environment.SetEnvironmentVariable("akka__test_value_3__b", "b value"); + Environment.SetEnvironmentVariable("akka__test_value_3__c__d", "d"); + Environment.SetEnvironmentVariable("akka__test_value_4__0", "zero"); + Environment.SetEnvironmentVariable("akka__test_value_4__22", "two"); + Environment.SetEnvironmentVariable("akka__test_value_4__1", "one"); + Environment.SetEnvironmentVariable("akka__actor__serialization_bindings2__\"System.Object\"", "hyperion"); + + // Issue #631 + // Double underscore cases + Environment.SetEnvironmentVariable("__MISE_SESSION", "some-random-string"); + // Quadruple underscore cases + Environment.SetEnvironmentVariable("MISE____SESSION", "some-random-string"); + Environment.SetEnvironmentVariable("____MISE_SESSION", "some-random-string"); + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ConfigSource)); + _root = new ConfigurationBuilder() + .AddJsonStream(stream) + .AddEnvironmentVariables() + .Build(); + } + + public ValueTask InitializeAsync() + { + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Environment.SetEnvironmentVariable("akka__TEST_VALUE_1__A", null); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_1__B", null); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_1__C__D", null); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_2__0", null); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_2__22", null); + Environment.SetEnvironmentVariable("akka__TEST_VALUE_2__1", null); + + Environment.SetEnvironmentVariable("akka__test_value_3__a", null); + Environment.SetEnvironmentVariable("akka__test_value_3__b", null); + Environment.SetEnvironmentVariable("akka__test_value_3__c__d", null); + Environment.SetEnvironmentVariable("akka__test_value_4__0", null); + Environment.SetEnvironmentVariable("akka__test_value_4__22", null); + Environment.SetEnvironmentVariable("akka__test_value_4__1", null); + Environment.SetEnvironmentVariable("akka__actor__serialization_bindings2__\"System.Object\"", null); + + Environment.SetEnvironmentVariable("__MISE_SESSION", null); + Environment.SetEnvironmentVariable("MISE____SESSION", null); + Environment.SetEnvironmentVariable("____MISE_SESSION", null); + + return ValueTask.CompletedTask; + } + + #region Adapter unit tests + + [Fact(DisplayName = "Normalized adaptor should read environment variable sourced configuration correctly")] + public void EnvironmentVariableTest() + { + var config = _root.ToHocon(); + // should be normalized + config.GetString("akka.test-value-1.a").Should().Be("A VALUE"); + config.GetString("akka.TEST-VALUE-1.A").Should().BeNull(); + + // should be normalized + config.GetString("akka.test-value-1.b").Should().Be("B VALUE"); + config.GetString("akka.TEST-VALUE-1.B").Should().BeNull(); + + // should be normalized + config.GetString("akka.test-value-1.c.d").Should().Be("D"); + config.GetString("akka.TEST-VALUE-1.C.D").Should().BeNull(); + + // should be normalized + var array = config.GetStringList("akka.test-value-2"); + array[0].Should().Be("ZERO"); + array[1].Should().Be("ONE"); + array[2].Should().Be("TWO"); + config.GetStringList("AKKA.TEST-VALUE-2").Should().BeEmpty(); + + // proper cased environment vars should read just fine + config.GetString("akka.test-value-3.a").Should().Be("a value"); + config.GetString("akka.test-value-3.b").Should().Be("b value"); + config.GetString("akka.test-value-3.c.d").Should().Be("d"); + array = config.GetStringList("akka.test-value-4"); + array[0].Should().Be("zero"); + array[1].Should().Be("one"); + array[2].Should().Be("two"); + + // edge case should also be normalized and not usable + var bindings = config.GetConfig("akka.actor.serialization-bindings2").AsEnumerable() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + bindings.ContainsKey("System.Object").Should().BeFalse(); + bindings.ContainsKey("system.object").Should().BeTrue(); + bindings["system.object"].GetString().Should().Be("hyperion"); + + // empty string keyed objects should be accessible by using "__" + config.GetString("__.mise-session").Should().Be("some-random-string"); + config.GetString("mise.__.session").Should().Be("some-random-string"); + config.GetString("__.__.mise-session").Should().Be("some-random-string"); + } + + [Fact(DisplayName = "Non-normalized adaptor should read environment variable sourced configuration correctly")] + public void EnvironmentVariableCaseSensitiveTest() + { + var config = _root.ToHocon(false); + + // should not be normalized + config.GetString("akka.TEST-VALUE-1.A").Should().Be("A VALUE"); + config.GetString("akka.test-value-1.a").Should().BeNull(); + + // should not be normalized + config.GetString("akka.TEST-VALUE-1.B").Should().Be("B VALUE"); + config.GetString("akka.test-value-1.b").Should().BeNull(); + + // should not be normalized + config.GetString("akka.TEST-VALUE-1.C.D").Should().Be("D"); + config.GetString("akka.test-value-1.c.d").Should().BeNull(); + + // should not be normalized + config.GetStringList("akka.test-value-2").Should().BeEmpty(); + var array = config.GetStringList("akka.TEST-VALUE-2"); + array[0].Should().Be("ZERO"); + array[1].Should().Be("ONE"); + array[2].Should().Be("TWO"); + + // proper cased environment vars should read just fine + config.GetString("akka.test-value-3.a").Should().Be("a value"); + config.GetString("akka.test-value-3.b").Should().Be("b value"); + config.GetString("akka.test-value-3.c.d").Should().Be("d"); + array = config.GetStringList("akka.test-value-4"); + array[0].Should().Be("zero"); + array[1].Should().Be("one"); + array[2].Should().Be("two"); + + // edge case should not be normalized and usable + var bindings = config.GetConfig("akka.actor.serialization-bindings2").AsEnumerable() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + bindings.ContainsKey("System.Object").Should().BeTrue(); + bindings["System.Object"].GetString().Should().Be("hyperion"); + + // empty string keyed objects should be accessible by using "__" + config.GetString("__.MISE-SESSION").Should().Be("some-random-string"); + config.GetString("MISE.__.SESSION").Should().Be("some-random-string"); + config.GetString("__.__.MISE-SESSION").Should().Be("some-random-string"); + } + + [Fact(DisplayName = "Non-normalized Adaptor should read quote enclosed key inside JSON settings correctly")] + public void NonNormalizedJsonQuotedKeyTest() + { + var config = _root.ToHocon(false); + var bindings = config.GetConfig("akka.actor.serialization-bindings").AsEnumerable() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + bindings.ContainsKey("System.Int32").Should().BeTrue(); + bindings["System.Int32"].GetString().Should().Be("json"); + } + + [Fact(DisplayName = "Normalized Adaptor should read quote enclosed key inside JSON settings incorrectly")] + public void NormalizedJsonQuotedKeyTest() + { + var config = _root.ToHocon(); + var bindings = config.GetConfig("akka.actor.serialization-bindings").AsEnumerable() + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + bindings.ContainsKey("System.Int32").Should().BeFalse(); + bindings.ContainsKey("system.int32").Should().BeTrue(); + bindings["system.int32"].GetString().Should().Be("json"); + } + + [Fact(DisplayName = "Adaptor should expand keys")] + public void EncodedKeyTest() + { + var config = _root.ToHocon(); + var test2 = config.GetConfig("test2"); + test2.Should().NotBeNull(); + test2.GetBoolean("a").Should().BeTrue(); + test2.GetTimeSpan("b.c").Should().Be(2.Seconds()); + test2.GetString("b.d").Should().Be("test2.b.d content"); + test2.GetString("d").Should().Be("test2.d content"); + } + + [Fact(DisplayName = "Adaptor should convert correctly")] + public void ArrayTest() + { + var config = _root.ToHocon(); + config.GetString("test1").Should().Be("test1 content"); + config.GetInt("test3").Should().Be(3); + config.GetInt("test4").Should().Be(4); + + config.GetStringList("akka.cluster.roles").Should().BeEquivalentTo("front-end", "back-end"); + config.GetInt("akka.cluster.role.back-end").Should().Be(5); + config.GetString("akka.cluster.app-version").Should().Be("1.0.0"); + config.GetInt("akka.cluster.min-nr-of-members").Should().Be(99); + config.GetStringList("akka.cluster.seed-nodes").Should() + .BeEquivalentTo("akka.tcp://system@somewhere.com:9999"); + config.GetBoolean("akka.cluster.log-info").Should().BeFalse(); + config.GetBoolean("akka.cluster.log-info-verbose").Should().BeTrue(); + } + + #endregion + + #region ToHocon unit tests + + [Fact(DisplayName = "Regression test: connection-string should convert to hocon properly")] + public void ConnectionStringShouldConvertToHoconProperly() + { + const string connectionString = "Server=COMPUTER1\\TEST;Database=BV_PROD_1;uid=**;pwd=--;TransparentNetworkIPResolution=False;Connection Timeout=180;Max Pool Size=120;Column Encryption Setting=Enabled;"; + var hoconString = $"connection-string = {connectionString.ToHocon()}"; + Config? cfg = null; + Invoking(() => cfg = ConfigurationFactory.ParseString(hoconString)) + .Should().NotThrow(); + + cfg!.GetString("connection-string").Should().Be(connectionString); + } + + [MemberData(nameof(IllegalCharacterGenerator))] + [Theory(DisplayName = "ToHocon(string) should add quotes to string with illegal characters")] + public void StringToHoconQuote(string c) + { + var testString = $"this is {c} a test"; + var hoconized = testString.ToHocon(); + + switch (c) + { + case "\\": + // backslash is a special case + hoconized.Should().Be("\"this is \\\\ a test\""); + break; + case "\"": + // quote is a special case + hoconized.Should().Be("\"this is \\\" a test\""); + break; + default: + hoconized.Should().Be($"\"{testString}\""); + break; + } + + var hoconString = $"test-string = {hoconized}"; + Config? cfg = null; + Invoking(() => cfg = ConfigurationFactory.ParseString(hoconString)) + .Should().NotThrow(); + + cfg!.GetString("test-string").Should().Be(testString); + } + + [MemberData(nameof(EscapeCharacterGenerator))] + [Theory(DisplayName = "ToHocon(string) should add backslash escape character on escapable characters")] + public void StringToHoconEscape(string escape, string expected) + { + var hoconSafe = escape.ToHocon(); + hoconSafe.Should().Be(expected); + + var hoconString = $"test-string = {$"a test {escape} string".ToHocon()}"; + Config? cfg = null; + Invoking(() => cfg = ConfigurationFactory.ParseString(hoconString)) + .Should().NotThrow(); + cfg!.GetString("test-string").Should().Be($"a test {escape} string"); + } + + public static IEnumerable IllegalCharacterGenerator() + { + const string illegals = "$\"{}[]:=,#`^?!@*&\\"; + foreach (var c in illegals) + { + yield return new [] { (object) $"{c}" }; + } + } + + public static IEnumerable EscapeCharacterGenerator() + { + yield return new[] { (object)"\\", "\"\\\\\"" }; + yield return new[] { (object)"\"", "\"\\\"\"" }; + yield return new[] { (object)"/", "\"\\/\"" }; + yield return new[] { (object)"\b", "\"\\b\"" }; + yield return new[] { (object)"\f", "\"\\f\"" }; + yield return new[] { (object)"\n", "\"\\n\"" }; + yield return new[] { (object)"\r", "\"\\r\"" }; + yield return new[] { (object)"\t", "\"\\t\"" }; + yield return new[] { (object)"\uFB2F", "\"\\ufb2f\"" }; + } + + #endregion +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/DISanityCheckSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/DISanityCheckSpecs.cs new file mode 100644 index 00000000000..08d505e6ee1 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/DISanityCheckSpecs.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.DependencyInjection; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using static Akka.Hosting.Tests.TestHelpers; + +namespace Akka.Hosting.Tests; + +public class DiSanityCheckSpecs +{ + public interface IMySingletonInterface{} + + public sealed class MySingletonImpl : IMySingletonInterface{} + + public sealed class SingletonActor : ReceiveActor + { + public sealed class GetSingleton + { + public static readonly GetSingleton Instance = new GetSingleton(); + private GetSingleton(){} + } + + private readonly IMySingletonInterface _singleton; + + public SingletonActor(IMySingletonInterface singleton) + { + _singleton = singleton; + + Receive(_ => + { + Sender.Tell(_singleton); + }); + } + } + + /// + /// Sanity check: things registered as singletons prior to the creation of the should + /// still be singletons when working with Akka.DependencyInjection. + /// + [Fact] + public async Task ShouldNotRecreateContainerMembersUsingActorDi() + { + // arrange + using var host = await StartHost(collection => + { + collection.AddAkka("MyActorSys", builder => + { + builder.WithActors((system, registry) => + { + var props = DependencyResolver.For(system).Props(); + var singletonActor = system.ActorOf(props, "singleton"); + registry.TryRegister(singletonActor); + }); + }); + }); + + // act + var singletonInstance = host.Services.GetRequiredService(); + var singletonActor = host.Services.GetRequiredService().Get(); + var singletonFromActor = + await singletonActor.Ask(SingletonActor.GetSingleton.Instance, TimeSpan.FromSeconds(3)); + + // assert + singletonFromActor.Should().Be(singletonInstance); + } + + /// + /// Sanity check: things registered as singletons prior to the creation of the should + /// still be singletons when working explicitly with the during initialization of actors. + /// + [Fact] + public async Task ShouldNotRecreateContainerMembersUsingServiceProviderDuringStart() + { + // arrange + using var host = await StartHost(collection => + { + collection.AddAkka("MyActorSys", (builder, sp) => + { + builder.WithActors((system, registry) => + { + var singleton = sp.GetRequiredService(); + var singletonActor = system.ActorOf(Props.Create(() => new SingletonActor(singleton)), "singleton"); + registry.TryRegister(singletonActor); + }); + }); + }); + + // act + var singletonInstance = host.Services.GetRequiredService(); + var singletonActor = host.Services.GetRequiredService().Get(); + var singletonFromActor = + await singletonActor.Ask(SingletonActor.GetSingleton.Instance, TimeSpan.FromSeconds(3)); + + // assert + singletonFromActor.Should().Be(singletonInstance); + } + + [Fact(DisplayName = "Should start actors correctly via DI using the built-in IDependencyResolver delegates")] + public async Task ShouldStartActorsViaDiUsingBuiltInResolver() + { + // arrange + using var host = await StartHost(collection => + { + collection.AddAkka("MyActorSys", (builder, sp) => + { + builder.WithActors((system, registry, resolver) => + { + var singletonActor = system.ActorOf(resolver.Props(), "singleton"); + registry.TryRegister(singletonActor); + }); + }); + }); + + // act + var singletonInstance = host.Services.GetRequiredService(); + var singletonActor = host.Services.GetRequiredService().Get(); + var singletonFromActor = + await singletonActor.Ask(SingletonActor.GetSingleton.Instance, TimeSpan.FromSeconds(3)); + + // assert + singletonFromActor.Should().Be(singletonInstance); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/ExtensionsSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/ExtensionsSpecs.cs new file mode 100644 index 00000000000..1905c8d7794 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/ExtensionsSpecs.cs @@ -0,0 +1,137 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.Tests; + +public class ExtensionsSpecs +{ + private readonly ITestOutputHelper _helper; + + public ExtensionsSpecs(ITestOutputHelper helper) + { + _helper = helper; + } + + public async Task StartHost(Action testSetup) + { + var host = new HostBuilder() + .ConfigureLogging(builder => + { + builder.AddProvider(new XUnitLoggerProvider(_helper, LogLevel.Information)); + }) + .ConfigureServices(service => + { + service.AddAkka("TestActorSystem", testSetup); + }).Build(); + + await host.StartAsync(); + return host; + } + + [Fact(DisplayName = "WithExtensions should not override extensions declared in HOCON")] + public async Task ShouldNotOverrideHocon() + { + using var host = await StartHost((builder, _) => + { + builder.AddHocon("akka.extensions = [\"Akka.Hosting.Tests.ExtensionsSpecs+FakeExtensionOneProvider, Akka.Hosting.Tests\"]", HoconAddMode.Append); + builder.WithExtensions(typeof(FakeExtensionTwoProvider)); + }); + + var system = host.Services.GetRequiredService(); + system.TryGetExtension(out _).Should().BeTrue(); + system.TryGetExtension(out _).Should().BeTrue(); + } + + [Fact(DisplayName = "WithExtensions should be able to be called multiple times")] + public async Task CanBeCalledMultipleTimes() + { + using var host = await StartHost((builder, _) => + { + builder.WithExtensions(typeof(FakeExtensionOneProvider)); + builder.WithExtensions(typeof(FakeExtensionTwoProvider)); + }); + + var system = host.Services.GetRequiredService(); + system.TryGetExtension(out _).Should().BeTrue(); + system.TryGetExtension(out _).Should().BeTrue(); + } + + [Fact(DisplayName = "WithExtensions with invalid type should throw")] + public void InvalidTypeShouldThrow() + { + Invoking(() => + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "mySystem"); + builder.WithExtensions(typeof(string)); + }).Should().Throw(); + } + + [Fact(DisplayName = "WithExtension should not override extensions declared in HOCON")] + public async Task WithExtensionShouldNotOverrideHocon() + { + using var host = await StartHost((builder, _) => + { + builder.AddHocon("akka.extensions = [\"Akka.Hosting.Tests.ExtensionsSpecs+FakeExtensionOneProvider, Akka.Hosting.Tests\"]", HoconAddMode.Append); + builder.WithExtension(); + }); + + var system = host.Services.GetRequiredService(); + system.TryGetExtension(out _).Should().BeTrue(); + system.TryGetExtension(out _).Should().BeTrue(); + } + + [Fact(DisplayName = "WithExtension should be able to be called multiple times")] + public async Task WithExtensionCanBeCalledMultipleTimes() + { + using var host = await StartHost((builder, _) => + { + builder.WithExtension(); + builder.WithExtension(); + }); + + var system = host.Services.GetRequiredService(); + system.TryGetExtension(out _).Should().BeTrue(); + system.TryGetExtension(out _).Should().BeTrue(); + } + + public class FakeExtensionOne: IExtension + { + } + + public class FakeExtensionOneProvider : ExtensionIdProvider + { + public override FakeExtensionOne CreateExtension(ExtendedActorSystem system) + { + return new FakeExtensionOne(); + } + } + + public class FakeExtensionTwo: IExtension + { + } + + public class FakeExtensionTwoProvider : ExtensionIdProvider + { + public override FakeExtensionTwo CreateExtension(ExtendedActorSystem system) + { + return new FakeExtensionTwo(); + } + } +} + diff --git a/src/contrib/hosting/Akka.Hosting.Tests/HealthChecks/HealthChecksSpec.cs b/src/contrib/hosting/Akka.Hosting.Tests/HealthChecks/HealthChecksSpec.cs new file mode 100644 index 00000000000..1e05bba8c22 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/HealthChecks/HealthChecksSpec.cs @@ -0,0 +1,233 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Hosting.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Hosting.Tests.HealthChecks; + +public class HealthChecksSpec : TestKit.TestKit +{ + public HealthChecksSpec(ITestOutputHelper output) + : base(output: output) + { + } + + private class FooActor : UntypedActor + { + protected override void OnReceive(object message) + { + } + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder + .WithActorSystemLivenessCheck(failureStatus: HealthStatus.Degraded, tags: ["custom", "liveness"]) // test custom parameters + .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"); + }, failureStatus: HealthStatus.Degraded, tags: ["foo", "actor"], timeout: TimeSpan.FromSeconds(30)); + } + + [Fact] + public async Task ShouldHaveDefaultHealthCheckRegistration() + { + // arrange + var configurationBuilder = Host.Services.GetRequiredService(); + + // act + + // assert + Assert.Equal(2, configurationBuilder.HealthChecks.Count); // 1 built-in, 1 custom + + // find the built-in implementation + var actorSystemHealthCheckRegistration = + configurationBuilder.HealthChecks.Values.Single(c => c.Factory(Host.Services) is ActorSystemLivenessCheck); + + // Verify the custom parameters we set in ConfigureAkka were applied + Assert.Equal(HealthStatus.Degraded, actorSystemHealthCheckRegistration.FailureStatus); + Assert.Contains("custom", actorSystemHealthCheckRegistration.Tags); + Assert.Contains("liveness", actorSystemHealthCheckRegistration.Tags); + + var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys) + { Registration = actorSystemHealthCheckRegistration.ToHealthCheckRegistration() }; + + // invoke the actorSystem liveness check + var healthCheck = actorSystemHealthCheckRegistration.Factory(Host.Services); + var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None); + + // assert - system is alive, health check should be healthy + Assert.Equal(HealthStatus.Healthy, healthCheckResult.Status); + } + + [Fact] + public async Task ShouldReturnAppropriateResults() + { + // arrange + var configurationBuilder = Host.Services.GetRequiredService(); + + // act + var customActorHealthCheck = + configurationBuilder.HealthChecks.Values.Single(c => c.Factory(Host.Services) is DelegateHealthCheck); + + // Verify custom parameters for the delegate health check + Assert.Equal(HealthStatus.Degraded, customActorHealthCheck.FailureStatus); + Assert.Contains("foo", customActorHealthCheck.Tags); + Assert.Contains("actor", customActorHealthCheck.Tags); + Assert.Equal(TimeSpan.FromSeconds(30), customActorHealthCheck.Timeout); + + var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys) + { Registration = customActorHealthCheck.ToHealthCheckRegistration() }; + + // should fail - target actor is not alive + await InvokeHealthCheck(HealthStatus.Unhealthy); + + // start the actor and register it + var fooActor = Sys.ActorOf(Props.Create(() => new FooActor()), "foo"); + ActorRegistry.Register(fooActor); + + // should succeed - target actor is around + await InvokeHealthCheck(HealthStatus.Healthy, 3000); + + // kill the target actor + await WatchAsync(fooActor); + fooActor.Tell(PoisonPill.Instance); + await ExpectTerminatedAsync(fooActor); + + // found in the registry, but dead + await InvokeHealthCheck(HealthStatus.Unhealthy, 3000); + return; + + async Task InvokeHealthCheck(HealthStatus expectedStatus, int waitMilliseconds = 1) + { + using var fastCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(waitMilliseconds)); + var healthCheck = customActorHealthCheck.Factory(Host.Services); + var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, fastCts.Token); + if (healthCheckResult.Description != null) + Output?.WriteLine(healthCheckResult.Description); + Assert.Equal(expectedStatus, healthCheckResult.Status); + } + } + + [Fact] + public async Task ShouldResolveDiHealthCheckFromContainer() + { + // arrange + var configurationBuilder = Host.Services.GetRequiredService(); + + // act - add a DI-resolved health check to the existing configuration + configurationBuilder.WithHealthCheck("test-di-health"); + + // assert + Assert.Equal(3, configurationBuilder.HealthChecks.Count); // 2 from base configuration + 1 DI-resolved + + // find the DI-resolved health check + var diHealthCheckRegistration = + configurationBuilder.HealthChecks.Values.Single(c => c.Name == "test-di-health"); + var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys) + { + Registration = diHealthCheckRegistration.ToHealthCheckRegistration() + }; + + // invoke the DI-resolved health check - TestDiHealthCheck is registered in ConfigureServices + var healthCheck = diHealthCheckRegistration.Factory(Host.Services); + var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None); + + // assert - health check should be healthy + Assert.Equal(HealthStatus.Healthy, healthCheckResult.Status); + Assert.Equal("Test DI health check is working with DI", healthCheckResult.Description); + } + + [Fact] + public async Task ShouldResolveDiHealthCheckWithRegistrationTemplate() + { + // arrange + var configurationBuilder = Host.Services.GetRequiredService(); + + // act - add a DI-resolved health check using the registration template pattern + configurationBuilder.WithHealthCheck( + "template-test", + HealthStatus.Degraded, + [ + "custom", "test" + ]); + + // assert + Assert.Equal(3, configurationBuilder.HealthChecks.Count); // 2 from base configuration + 1 DI-resolved + + // find the DI-resolved health check + var diHealthCheckRegistration = configurationBuilder.HealthChecks.Values.Single(c => c.Name == "template-test"); + Assert.Equal(HealthStatus.Degraded, diHealthCheckRegistration.FailureStatus); + Assert.Contains("custom", diHealthCheckRegistration.Tags); + Assert.Contains("test", diHealthCheckRegistration.Tags); + + var akkaHealthCheckContext = new AkkaHealthCheckContext(Sys) + { Registration = diHealthCheckRegistration.ToHealthCheckRegistration() }; + + // invoke the health check + var healthCheck = diHealthCheckRegistration.Factory(Host.Services); + var healthCheckResult = await healthCheck.CheckHealthAsync(akkaHealthCheckContext, CancellationToken.None); + + // assert + Assert.Equal(HealthStatus.Healthy, healthCheckResult.Status); + Assert.Equal("Test DI health check is working with DI", healthCheckResult.Description); + } + + /// + /// Test health check class that requires DI (simulates ILogger dependency) + /// + private class TestDiHealthCheck : IAkkaHealthCheck + { + private readonly IServiceProvider? _serviceProvider; + + public TestDiHealthCheck(IServiceProvider serviceProvider) + { + // Constructor with DI dependency + _serviceProvider = serviceProvider; + } + + public Task CheckHealthAsync(AkkaHealthCheckContext context, + CancellationToken cancellationToken = default) + { + // Verify that dependencies can be resolved (simulates ILogger usage) + var hasServiceProvider = _serviceProvider != null; + + return Task.FromResult(HealthCheckResult.Healthy("Test DI health check is working" + + (hasServiceProvider ? " with DI" : ""))); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/HoconSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/HoconSpecs.cs new file mode 100644 index 00000000000..9ea9371f8c5 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/HoconSpecs.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using static Akka.Hosting.Tests.TestHelpers; + +namespace Akka.Hosting.Tests; + +public class HoconSpecs +{ + [Fact] + public async Task Should_load_HOCON_from_file() + { + // arrange + using var host = await StartHost(collection => collection.AddAkka("Test", builder => + { + builder.AddHoconFile("test.hocon", HoconAddMode.Append); + })); + + // act + var sys = host.Services.GetRequiredService(); + var hocon = sys.Settings.Config; + + // assert + hocon.HasPath("petabridge.cmd").Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/HostingExtensionsSpec.cs b/src/contrib/hosting/Akka.Hosting.Tests/HostingExtensionsSpec.cs new file mode 100644 index 00000000000..2d6f991e9af --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/HostingExtensionsSpec.cs @@ -0,0 +1,53 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Linq; +using Akka.Event; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Akka.Hosting.Tests; + +public class HostingExtensionsSpec +{ + [Fact(DisplayName = "WithActorAskTimeout should inject proper HOCON")] + public void WithActorAskTimeoutTest() + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "fake") + .WithActorAskTimeout(10.Seconds()); + builder.Configuration.HasValue.Should().BeTrue(); + builder.Configuration.Value.GetTimeSpan("akka.actor.ask-timeout").Should().Be(10.Seconds()); + } + + [Fact(DisplayName = "WithActorAskTimeout should be able to infer infinite timespan")] + public void WithActorAskTimeoutInfiniteTest() + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "fake") + .WithActorAskTimeout(TimeSpan.Zero); + builder.Configuration.HasValue.Should().BeTrue(); + builder.Configuration.Value.GetString("akka.actor.ask-timeout").Should().Be("infinite"); + } + + [Fact(DisplayName = "ConfigureLogger WithLogFilter should inject LogFilterSetup")] + public void ConfigureLoggerWithLogFilterSetupTest() + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "fake") + .ConfigureLoggers(logger => + { + logger.WithLogFilter(filterBuilder => + { + filterBuilder.ExcludeMessageContaining("Test"); + }); + }); + var filterSetup = builder.Setups.OfType().First(); + filterSetup.Filters.Length.Should().Be(1); + filterSetup.Filters.Any(f => f is RegexLogMessageFilter).Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs new file mode 100644 index 00000000000..48f607541d2 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs @@ -0,0 +1,275 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Akka.Hosting.Logging; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.Tests.Logging; + +public class AkkaLogStateSpecs +{ + [Fact] + public void AkkaLogState_WithSemanticProperties_ShouldYieldAllProperties() + { + // Arrange + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded); + + var semanticProperties = new Dictionary + { + { "UserId", 123 }, + { "Action", "Login" } + }; + + var actorPath = "akka://test/user/myactor"; + var timestamp = DateTimeOffset.UtcNow; + var threadId = 42; + var logSource = "MyActor"; + var template = "User {UserId} performed {Action}"; + var formattedMessage = "User 123 performed Login"; + + // Act + var state = new AkkaLogState( + activityContext, + semanticProperties, + actorPath, + timestamp, + threadId, + logSource, + template, + formattedMessage); + + var items = state.ToList(); + + // Assert + // Should have: 3 trace context + 2 semantic props + 4 Akka metadata + 1 OriginalFormat = 10 + items.Should().HaveCount(10); + + // Verify trace context (should be ActivityTraceId/ActivitySpanId, not strings) + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceIdKey && kvp.Value is ActivityTraceId); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.SpanIdKey && kvp.Value is ActivitySpanId); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceFlagsKey && (int)kvp.Value! == (int)ActivityTraceFlags.Recorded); + + // Verify semantic properties + items.Should().Contain(kvp => kvp.Key == "UserId" && (int)kvp.Value! == 123); + items.Should().Contain(kvp => kvp.Key == "Action" && (string)kvp.Value! == "Login"); + + // Verify Akka metadata + items.Should().Contain(kvp => kvp.Key == "ActorPath" && (string)kvp.Value! == actorPath); + items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTimeOffset)kvp.Value! == timestamp); + items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == threadId); + items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == logSource); + + // Verify OriginalFormat + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}" && (string)kvp.Value! == template); + } + + [Fact] + public void AkkaLogState_WithoutTraceContext_ShouldOmitTraceProperties() + { + // Arrange + var activityContext = default(ActivityContext); // No trace context + + var semanticProperties = new Dictionary + { + { "Message", "Hello" } + }; + + // Act + var state = new AkkaLogState( + activityContext, + semanticProperties, + "akka://test/user/actor", + DateTimeOffset.UtcNow, + 1, + "Source", + "Template", + "Formatted"); + + var items = state.ToList(); + + // Assert + // Should have: 0 trace context + 1 semantic prop + 4 Akka metadata + 1 OriginalFormat = 6 + items.Should().HaveCount(6); + + // Should NOT contain trace context keys + items.Should().NotContain(kvp => kvp.Key == AkkaLogState.TraceIdKey); + items.Should().NotContain(kvp => kvp.Key == AkkaLogState.SpanIdKey); + items.Should().NotContain(kvp => kvp.Key == AkkaLogState.TraceFlagsKey); + } + + [Fact] + public void AkkaLogState_NonStructuredMessage_ShouldYieldMetadataProperties() + { + // Arrange + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.None); + var message = "Plain text message"; + var actorPath = "akka://test/user/myactor"; + var timestamp = DateTimeOffset.UtcNow; + var threadId = 42; + var logSource = "MyActor"; + + // Act + var state = new AkkaLogState(activityContext, actorPath, timestamp, threadId, logSource, message, message); + var items = state.ToList(); + + // Assert + // Should have: 3 trace context + 4 Akka metadata + 1 OriginalFormat = 8 + items.Should().HaveCount(8); + + // Verify trace context is present + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceIdKey); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.SpanIdKey); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceFlagsKey); + + // Verify Akka metadata + items.Should().Contain(kvp => kvp.Key == "ActorPath" && (string)kvp.Value! == actorPath); + items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTimeOffset)kvp.Value! == timestamp); + items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == threadId); + items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == logSource); + + // Verify OriginalFormat + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}" && (string)kvp.Value! == message); + } + + [Fact] + public void AkkaLogState_NonStructuredMessage_WithoutTraceContext_ShouldYieldMetadataAndOriginalFormat() + { + // Arrange + var activityContext = default(ActivityContext); + var message = "Plain text message"; + var actorPath = "akka://test/user/myactor"; + var timestamp = DateTimeOffset.UtcNow; + var threadId = 7; + var logSource = "MyActor"; + + // Act + var state = new AkkaLogState(activityContext, actorPath, timestamp, threadId, logSource, message, message); + var items = state.ToList(); + + // Assert + // Should have: 0 trace context + 4 Akka metadata + 1 OriginalFormat = 5 + items.Should().HaveCount(5); + items.Should().Contain(kvp => kvp.Key == "ActorPath" && (string)kvp.Value! == actorPath); + items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTimeOffset)kvp.Value! == timestamp); + items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == threadId); + items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == logSource); + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}" && (string)kvp.Value! == message); + } + + [Fact] + public void AkkaLogState_ToString_ShouldReturnFormattedMessage() + { + // Arrange + var semanticProperties = new Dictionary { { "Name", "World" } }; + var formattedMessage = "Hello World!"; + + var state = new AkkaLogState( + default, + semanticProperties, + "path", + DateTimeOffset.UtcNow, + 1, + "source", + "Hello {Name}!", + formattedMessage); + + // Act & Assert + state.ToString().Should().Be(formattedMessage); + } + + [Fact] + public void AkkaLogState_ShouldBeEnumerableMultipleTimes() + { + // Arrange + var semanticProperties = new Dictionary { { "Key", "Value" } }; + var state = new AkkaLogState( + default, + semanticProperties, + "path", + DateTimeOffset.UtcNow, + 1, + "source", + "template", + "formatted"); + + // Act - enumerate multiple times + var firstPass = state.ToList(); + var secondPass = state.ToList(); + + // Assert + firstPass.Should().BeEquivalentTo(secondPass); + } + + [Fact] + public void AkkaLogState_TraceContext_ShouldStoreStructsDirectly() + { + // Arrange - this test verifies we're not allocating strings for TraceId/SpanId + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded); + + var state = new AkkaLogState( + activityContext, + new Dictionary(), + "path", + DateTimeOffset.UtcNow, + 1, + "source", + "template", + "formatted"); + + // Act + var items = state.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + // Assert - values should be the actual struct types, not strings + items[AkkaLogState.TraceIdKey].Should().BeOfType(); + items[AkkaLogState.SpanIdKey].Should().BeOfType(); + items[AkkaLogState.TraceFlagsKey].Should().BeOfType(); + + // Verify the actual values match + ((ActivityTraceId)items[AkkaLogState.TraceIdKey]!).Should().Be(traceId); + ((ActivitySpanId)items[AkkaLogState.SpanIdKey]!).Should().Be(spanId); + ((int)items[AkkaLogState.TraceFlagsKey]!).Should().Be((int)ActivityTraceFlags.Recorded); + } + + [Fact] + public void AkkaLogState_EmptySemanticProperties_ShouldStillYieldAkkaMetadata() + { + // Arrange + var emptyProperties = new Dictionary(); + + var state = new AkkaLogState( + default, + emptyProperties, + "akka://test/user/actor", + DateTimeOffset.UtcNow, + 99, + "TestSource", + "template", + "formatted"); + + // Act + var items = state.ToList(); + + // Assert + // Should have: 0 trace context + 0 semantic props + 4 Akka metadata + 1 OriginalFormat = 5 + items.Should().HaveCount(5); + items.Should().Contain(kvp => kvp.Key == "ActorPath"); + items.Should().Contain(kvp => kvp.Key == "Timestamp"); + items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == 99); + items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == "TestSource"); + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}"); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs new file mode 100644 index 00000000000..ee4a4792d19 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs @@ -0,0 +1,326 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Akka.Hosting.Logging; +using FluentAssertions; +using OpenTelemetry.Logs; +using Xunit; + +namespace Akka.Hosting.Tests.Logging; + +public class AkkaTraceContextProcessorSpecs +{ + [Fact] + public void Processor_ShouldExtractTraceContext_FromActivityTraceIdAndSpanId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var traceFlags = ActivityTraceFlags.Recorded; + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId), + new(AkkaLogState.SpanIdKey, spanId), + new(AkkaLogState.TraceFlagsKey, (int)traceFlags) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + logRecord.TraceFlags.Should().Be(traceFlags); + } + + [Fact] + public void Processor_ShouldExtractTraceContext_FromStringRepresentation() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + + // Use string representation (backwards compatibility path) + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId.ToString()), + new(AkkaLogState.SpanIdKey, spanId.ToString()), + new(AkkaLogState.TraceFlagsKey, (int)ActivityTraceFlags.None) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + } + + [Fact] + public void Processor_ShouldSkip_WhenTraceIdAlreadySet() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var existingTraceId = ActivityTraceId.CreateRandom(); + var existingSpanId = ActivitySpanId.CreateRandom(); + var newTraceId = ActivityTraceId.CreateRandom(); + var newSpanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, newTraceId), + new(AkkaLogState.SpanIdKey, newSpanId), + new(AkkaLogState.TraceFlagsKey, (int)ActivityTraceFlags.Recorded) + }; + + var logRecord = CreateLogRecordWithExistingTrace(attributes, existingTraceId, existingSpanId); + + // Act + processor.OnEnd(logRecord); + + // Assert - should keep existing trace context + logRecord.TraceId.Should().Be(existingTraceId); + logRecord.SpanId.Should().Be(existingSpanId); + } + + [Fact] + public void Processor_ShouldSkip_WhenNoAttributes() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var logRecord = CreateLogRecord(null); + + // Act + processor.OnEnd(logRecord); + + // Assert - should remain default + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + logRecord.SpanId.Should().Be(default(ActivitySpanId)); + } + + [Fact] + public void Processor_ShouldSkip_WhenMissingTraceId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var spanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + // Missing TraceId + new(AkkaLogState.SpanIdKey, spanId), + new(AkkaLogState.TraceFlagsKey, 0) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert - should not set partial trace context + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + logRecord.SpanId.Should().Be(default(ActivitySpanId)); + } + + [Fact] + public void Processor_ShouldSkip_WhenMissingSpanId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId), + // Missing SpanId + new(AkkaLogState.TraceFlagsKey, 0) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert - should not set partial trace context + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + logRecord.SpanId.Should().Be(default(ActivitySpanId)); + } + + [Fact] + public void Processor_ShouldHandle_InvalidStringTraceId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var spanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, "invalid-trace-id"), // Invalid format + new(AkkaLogState.SpanIdKey, spanId), + new(AkkaLogState.TraceFlagsKey, 0) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert - should not crash, should not set invalid trace context + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + } + + [Fact] + public void Processor_ShouldHandle_UnexpectedValueTypes() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, 12345), // Wrong type (int instead of ActivityTraceId or string) + new(AkkaLogState.SpanIdKey, new object()), // Wrong type + new(AkkaLogState.TraceFlagsKey, "not-an-int") + }; + + var logRecord = CreateLogRecord(attributes); + + // Act - should not throw + var act = () => processor.OnEnd(logRecord); + + // Assert + act.Should().NotThrow(); + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + } + + [Fact] + public void Processor_ShouldDefaultTraceFlags_WhenNotProvided() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId), + new(AkkaLogState.SpanIdKey, spanId) + // No TraceFlags + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + logRecord.TraceFlags.Should().Be(ActivityTraceFlags.None); + } + + [Fact] + public void Processor_ShouldWork_WithAkkaLogState() + { + // Arrange - integration test with actual AkkaLogState + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded); + + var state = new AkkaLogState( + activityContext, + new Dictionary { { "Key", "Value" } }, + "akka://test/user/actor", + DateTimeOffset.UtcNow, + 1, + "TestSource", + "Template with {Key}", + "Template with Value"); + + // Convert state to attributes list (simulating what MEL does) + var attributes = new List>(); + foreach (var kvp in state) + { + attributes.Add(kvp); + } + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + logRecord.TraceFlags.Should().Be(ActivityTraceFlags.Recorded); + } + + /// + /// Creates a LogRecord instance using UnsafeAccessor to call the internal constructor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Constructor)] + private static extern LogRecord CreateLogRecordInstance(); + + /// + /// Sets the Attributes property on a LogRecord using UnsafeAccessor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Attributes")] + private static extern void SetAttributes(LogRecord record, IReadOnlyList>? value); + + /// + /// Sets the TraceId property on a LogRecord using UnsafeAccessor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TraceId")] + private static extern void SetTraceId(LogRecord record, ActivityTraceId value); + + /// + /// Sets the SpanId property on a LogRecord using UnsafeAccessor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_SpanId")] + private static extern void SetSpanId(LogRecord record, ActivitySpanId value); + + /// + /// Creates a LogRecord with the specified attributes. + /// + private static LogRecord CreateLogRecord(IReadOnlyList>? attributes) + { + var logRecord = CreateLogRecordInstance(); + + if (attributes != null) + { + SetAttributes(logRecord, attributes); + } + + return logRecord; + } + + /// + /// Creates a LogRecord with existing trace context set. + /// + private static LogRecord CreateLogRecordWithExistingTrace( + IReadOnlyList>? attributes, + ActivityTraceId traceId, + ActivitySpanId spanId) + { + var logRecord = CreateLogRecord(attributes); + + SetTraceId(logRecord, traceId); + SetSpanId(logRecord, spanId); + + return logRecord; + } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/CustomLoggerEnd2EndSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/CustomLoggerEnd2EndSpecs.cs new file mode 100644 index 00000000000..3b021e9b94a --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/CustomLoggerEnd2EndSpecs.cs @@ -0,0 +1,90 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Dispatch; +using Akka.Event; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.Tests.Logging; + +/// +/// End-to-end checks that a fully custom logger (one that replaces every built-in logger) plus a +/// custom can be wired up through ConfigureLoggers and +/// that message templates using both positional ({0}) and named ({myNum}) +/// placeholders survive the round trip without blowing up. +/// +public class CustomLoggerEnd2EndSpecs : TestKit.TestKit +{ + /// + /// Published on the for every the custom + /// logger actually received, so a test can assert on the logger's own output. + /// + public sealed class CapturedLogEntry + { + public CapturedLogEntry(LogLevel level, string message) + { + Level = level; + Message = message; + } + + public LogLevel Level { get; } + + public string Message { get; } + } + + /// + /// Minimal Akka.NET logger actor - stands in for a third party logger such as Serilog. + /// + public sealed class CapturingLogger : ReceiveActor, IRequiresMessageQueue + { + public CapturingLogger() + { + Receive(_ => Sender.Tell(new LoggerInitialized())); + Receive(e => + Context.System.EventStream.Publish(new CapturedLogEntry(e.LogLevel(), e.ToString()))); + } + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.ConfigureLoggers(setup => + { + setup.ClearLoggers(); + setup.AddLogger(); + setup.LogLevel = Event.LogLevel.DebugLevel; +#pragma warning disable CS0618 + setup.WithDefaultLogMessageFormatter(); +#pragma warning restore CS0618 + }); + } + + [Theory] + [InlineData(Event.LogLevel.DebugLevel, "test case {0}", new object[] { 1 })] + [InlineData(Event.LogLevel.DebugLevel, "test case {myNum}", new object[] { 1 })] + [InlineData(Event.LogLevel.InfoLevel, "test case {myNum} {myStr}", new object[] { 1, "foo" })] + public async Task ShouldHandleSemanticLogFormats(LogLevel level, string formatStr, object[] args) + { + Sys.EventStream.Subscribe(TestActor, typeof(LogEvent)); + Sys.EventStream.Subscribe(TestActor, typeof(CapturedLogEntry)); + + Sys.Log.Log(level, formatStr, args); + + // The log level is Debug, so unrelated system events (actor lifecycle, logger start-up) + // can land on the EventStream before ours does. Fish for the event this test emitted + // instead of asserting on whichever LogEvent arrives first. + // Formatting the event must not throw, regardless of the placeholder style used. + var logEvent = await FishForMessageAsync(e => e.ToString().Contains("test case")); + logEvent.LogLevel().Should().Be(level); + + // ...and the custom logger must have received and formatted the same event + var captured = await FishForMessageAsync(e => e.Message.Contains("test case")); + captured.Level.Should().Be(level); + } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs new file mode 100644 index 00000000000..b36b85c7a6f --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs @@ -0,0 +1,326 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Akka.Actor; +using Akka.Event; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +/// +/// Bug reproduction tests for semantic logging issue reported in Discord. +/// +/// Issue: When using ILoggingAdapter with named placeholders like {UserId}, the formatted +/// message output shows the raw template instead of substituted values. +/// +/// Example: +/// _logger.Info("User {UserId} logged in", 12345); +/// +/// Expected output: "User 12345 logged in" +/// Actual output: "User {UserId} logged in" +/// +/// Root cause: LoggerFactoryLogger.FormatMessage() uses string.Format() which only +/// supports positional placeholders ({0}, {1}), not named placeholders ({UserId}). +/// +public class Issue701SemanticLoggingRegressionSpecs : TestKit.TestKit +{ + private readonly BugReproTestSink _sink; + + public Issue701SemanticLoggingRegressionSpecs(ITestOutputHelper output) : base(output: output) + { + _sink = new BugReproTestSink(output); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.ConfigureLoggers(setup => + { + setup.LogLevel = Event.LogLevel.InfoLevel; + setup.ClearLoggers(); + setup.AddLoggerFactory(new BugReproTestLoggerFactory(_sink)); + }); + } + + private void AssertMetadata(BugReproLogEntry entry, string? format = null) + { + entry.State.Should().ContainKey("ActorPath"); + entry.State.Should().ContainKey("Timestamp"); + entry.State.Should().ContainKey("Thread"); + entry.State.Should().ContainKey("LogSource"); + entry.State.Should().ContainKey("{OriginalFormat}"); + + entry.State["ActorPath"].Should().BeOfType(); + entry.State["Timestamp"].Should().BeOfType(); + entry.State["Thread"].Should().BeOfType(); + entry.State["LogSource"].Should().BeOfType(); + entry.State["{OriginalFormat}"].Should().BeOfType(); + + if (format is not null) + entry.State["{OriginalFormat}"].Should().Be(format); + } + + /// + /// BUG REPRO: Named template placeholders should have values substituted in the formatted message. + /// This test demonstrates the bug where {UserId} is NOT replaced with the actual value. + /// + [Fact(DisplayName = "Named placeholders should be substituted in formatted message")] + public void NamedPlaceholdersShouldBeSubstitutedInFormattedMessage() + { + _sink.Clear(); + + // Log with named placeholder + Sys.Log.Info("User {UserId} logged in", 12345); + + AwaitCondition(() => _sink.Entries.Any(e => + e.Message.Contains("User") && (e.Message.Contains("12345") || e.Message.Contains("{UserId}")))); + + var entry = _sink.Entries.First(e => + e.Message.Contains("User") && (e.Message.Contains("12345") || e.Message.Contains("{UserId}"))); + + // BUG: The message should contain the substituted value "12345", not the raw placeholder "{UserId}" + entry.Message.Should().Contain("12345", + "the formatted message should contain the substituted value, not the raw template placeholder"); + entry.Message.Should().NotContain("{UserId}", + "the formatted message should NOT contain the raw template placeholder"); + + entry.State.Should().ContainKey("UserId"); + entry.State["UserId"].Should().Be(12345); + + AssertMetadata(entry, "User {UserId} logged in"); + } + + /// + /// Control test: Positional placeholders ({0}) should work correctly. + /// + [Fact(DisplayName = "Positional placeholders should be substituted correctly")] + public void PositionalPlaceholdersShouldBeSubstitutedCorrectly() + { + _sink.Clear(); + + // Log with positional placeholder + Sys.Log.Info("User {0} logged in", 12345); + + AwaitCondition(() => _sink.Entries.Any(e => + e.Message.Contains("User") && e.Message.Contains("logged in"))); + + var entry = _sink.Entries.First(e => + e.Message.Contains("User") && e.Message.Contains("logged in")); + + // Positional placeholders should work + entry.Message.Should().Contain("12345", + "positional placeholders should be substituted correctly"); + entry.Message.Should().NotContain("{0}", + "positional placeholders should be replaced"); + + entry.State.Should().ContainKey("0"); + entry.State["0"].Should().Be(12345); + + AssertMetadata(entry, "User {0} logged in"); + } + + /// + /// BUG REPRO: Multiple named placeholders should all be substituted. + /// This matches the Discord user's exact scenario. + /// + [Fact(DisplayName = "Multiple named placeholders")] + public void MultipleNamedPlaceholdersDiscordScenario() + { + _sink.Clear(); + + // Matches the bug log format: + // _logger.Info("Published callback event: {Event} | ActorId: {ActorId}", eventName, actorId) + Sys.Log.Info("Published callback event: {Event} | ActorId: {ActorId}", "UserLoggedIn", "actor-123"); + + AwaitCondition(() => _sink.Entries.Any(e => e.Message.Contains("Published callback event"))); + + var entry = _sink.Entries.First(e => e.Message.Contains("Published callback event")); + + // BUG: Values should be substituted + entry.Message.Should().Contain("UserLoggedIn", + "the Event value should be substituted"); + entry.Message.Should().Contain("actor-123", + "the ActorId value should be substituted"); + entry.Message.Should().NotContain("{Event}", + "should NOT contain raw {Event} placeholder"); + entry.Message.Should().NotContain("{ActorId}", + "should NOT contain raw {ActorId} placeholder"); + + entry.State.Should().ContainKey("Event"); + entry.State.Should().ContainKey("ActorId"); + entry.State["Event"].Should().Be("UserLoggedIn"); + entry.State["ActorId"].Should().Be("actor-123"); + + AssertMetadata(entry, "Published callback event: {Event} | ActorId: {ActorId}"); + } + + /// + /// Verify that structured properties ARE extracted correctly even when message formatting fails. + /// This shows that the semantic logging infrastructure works - only the message formatting is broken. + /// + /// This test demonstrates the contrast: + /// - State dictionary: Properties correctly extracted (UserId=12345, Email=user@example.com) + /// - Message string: Values NOT substituted (shows raw "{UserId}" instead of "12345") + /// + [Fact(DisplayName = "Properties extracted correctly but message formatting broken")] + public void PropertiesExtractedButMessageFormattingBroken() + { + _sink.Clear(); + + Sys.Log.Info("User {UserId} with email {Email} logged in", 12345, "user@example.com"); + + AwaitCondition(() => _sink.Entries.Any(e => + e.State.ContainsKey("UserId") || e.State.ContainsKey("Email"))); + + var entry = _sink.Entries.First(e => + e.State.ContainsKey("UserId") || e.State.ContainsKey("Email")); + + // WORKS: Properties ARE extracted correctly into state dictionary + entry.State.Should().ContainKey("UserId"); + entry.State.Should().ContainKey("Email"); + entry.State["UserId"].Should().Be(12345); + entry.State["Email"].Should().Be("user@example.com"); + + // BUG: Message should have substituted values, but it doesn't + entry.Message.Should().Contain("12345", + "the formatted message should contain the substituted UserId value"); + entry.Message.Should().Contain("user@example.com", + "the formatted message should contain the substituted Email value"); + + AssertMetadata(entry, "User {UserId} with email {Email} logged in"); + } + + /// + /// BUG REPRO: Mixed positional and named placeholders (edge case). + /// + [Fact(DisplayName = "Mixed positional and named placeholders")] + public void MixedPositionalAndNamedPlaceholders() + { + _sink.Clear(); + + // Mix of positional and named + Sys.Log.Info("User {UserId} action {0}", 12345, "Login"); + + AwaitCondition(() => _sink.Entries.Any(e => + e.Message.Contains("User") && e.Message.Contains("action"))); + + var entry = _sink.Entries.First(e => + e.Message.Contains("User") && e.Message.Contains("action")); + + // Both should be substituted + entry.Message.Should().Contain("12345", "UserId should be substituted"); + entry.Message.Should().Contain("Login", "action should be substituted"); + + entry.State.Should().ContainKey("UserId"); + entry.State.Should().ContainKey("0"); + entry.State["UserId"].Should().Be(12345); + entry.State["0"].Should().Be("Login"); + + AssertMetadata(entry, "User {UserId} action {0}"); + } + + /// + /// Regression test: Plain string logs (no template placeholders) should still include + /// Akka metadata (ActorPath, LogSource, Timestamp, Thread) as structured state properties. + /// Before fix, non-semantic logs lost all metadata due to AkkaLogState lightweight constructor + /// setting _hasSemanticProperties = false. + /// + [Fact(DisplayName = "Plain string logs should include Akka metadata")] + public void PlainStringLogsShouldIncludeMetadata() + { + _sink.Clear(); + + Sys.Log.Info("Server started successfully"); + + AwaitCondition(() => _sink.Entries.Any(e => e.Message.Contains("Server started successfully"))); + + var entry = _sink.Entries.First(e => e.Message.Contains("Server started successfully")); + + // Metadata should be present even for plain string logs + AssertMetadata(entry); + } + +} + +/// +/// Test sink that captures both the formatted message AND the structured state +/// +public class BugReproTestSink : ILogger +{ + private readonly ITestOutputHelper _output; + public ConcurrentQueue Entries { get; } = new(); + + public BugReproTestSink(ITestOutputHelper output) + { + _output = output; + } + + public void Clear() + { + Entries.Clear(); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + // Get the formatted message - this is where the bug manifests + var message = formatter(state, exception); + _output.WriteLine($"[{logLevel}] Message: \"{message}\""); + + // Capture state as dictionary + var stateDict = new Dictionary(); + if (state is IEnumerable> kvps) + { + foreach (var kvp in kvps) + { + stateDict[kvp.Key] = kvp.Value; + _output.WriteLine($" State[{kvp.Key}] = {kvp.Value}"); + } + } + + Entries.Enqueue(new BugReproLogEntry + { + LogLevel = logLevel, + Message = message, + State = stateDict, + Exception = exception + }); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable BeginScope(TState state) where TState : notnull => EmptyDisposable.Instance; +} + +public class BugReproLogEntry +{ + public LogLevel LogLevel { get; init; } + public string Message { get; init; } = string.Empty; + public Dictionary State { get; init; } = new(); + public Exception? Exception { get; init; } +} + +public class BugReproTestLoggerFactory : ILoggerFactory +{ + private readonly BugReproTestSink _sink; + + public BugReproTestLoggerFactory(BugReproTestSink sink) + { + _sink = sink; + } + + public void Dispose() { } + + public ILogger CreateLogger(string categoryName) => _sink; + + public void AddProvider(ILoggerProvider provider) { } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogMessageFormatterSpec.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogMessageFormatterSpec.cs new file mode 100644 index 00000000000..1b9c6c303a9 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogMessageFormatterSpec.cs @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2023 Lightbend Inc. +// Copyright (C) 2013-2023 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.Tests.Logging; + +public class LogMessageFormatterSpec +{ + private ITestOutputHelper _helper; + + public LogMessageFormatterSpec(ITestOutputHelper helper) + { + _helper = helper; + } + + [Fact(DisplayName = "ILogMessageFormatter should transform log messages")] + public async Task TransformMessagesTest() + { + using var host = await SetupHost(); + + try + { + var sys = host.Services.GetRequiredService(); + var testKit = new Akka.TestKit.Xunit.TestKit(sys); + + var probe = testKit.CreateTestProbe(); + sys.EventStream.Subscribe(probe, typeof(Error)); + sys.Log.Error("This is a test {0}", 1); + + var msg = probe.ExpectMsg(); + msg.Message.Should().BeAssignableTo(); + msg.ToString().Should().Contain("++TestLogMessageFormatter++"); + } + finally + { + await host.StopAsync(); + } + } + + [Fact(DisplayName = "Invalid LogMessageFormatter property should throw")] + public async Task InvalidLogMessageFormatterThrowsTest() + { + await Awaiting(async () => await SetupHost()) + .Should().ThrowAsync().WithMessage("*must have an empty constructor*"); + } + + [Fact(DisplayName = "SemanticLogMessageFormatter should be accepted (GitHub issue #703)")] + public async Task SemanticLogMessageFormatterShouldBeAcceptedTest() + { + // SemanticLogMessageFormatter has a private constructor - verify it doesn't throw +#pragma warning disable CS0618 + using var host = await SetupHost(); +#pragma warning restore CS0618 + + try + { + var sys = host.Services.GetRequiredService(); + sys.Settings.LogFormatter.Should().BeOfType(); + } + finally + { + await host.StopAsync(); + } + } + + [Fact(DisplayName = "DefaultLogMessageFormatter should be accepted")] + public async Task DefaultLogMessageFormatterShouldBeAcceptedTest() + { + // DefaultLogMessageFormatter has a private constructor - verify it doesn't throw +#pragma warning disable CS0618 + using var host = await SetupHost(); +#pragma warning restore CS0618 + + try + { + var sys = host.Services.GetRequiredService(); + sys.Settings.LogFormatter.Should().BeOfType(); + } + finally + { + await host.StopAsync(); + } + } + + private async Task SetupHost() where TFormatter : ILogMessageFormatter + { + var host = new HostBuilder() + .ConfigureLogging(builder => + { + builder.AddProvider(new XUnitLoggerProvider(_helper, LogLevel.Information)); + }) + .ConfigureServices(collection => + { + collection.AddAkka("TestSys", configurationBuilder => + { + configurationBuilder + .ConfigureLoggers(setup => + { + setup.LogLevel = Event.LogLevel.DebugLevel; + setup.AddLoggerFactory(); +#pragma warning disable CS0618 + setup.WithDefaultLogMessageFormatter(); +#pragma warning restore CS0618 + }); + }); + }).Build(); + await host.StartAsync(); + return host; + } +} + +public class TestLogMessageFormatter : ILogMessageFormatter +{ + public string Format(string format, params object[] args) + { + return string.Format($"++TestLogMessageFormatter++{format}", args); + } + + public string Format(string format, IEnumerable args) + => Format(format, args.ToArray()); +} + +public class InvalidLogMessageFormatter : ILogMessageFormatter +{ + public InvalidLogMessageFormatter(string doesNotMatter) + { + } + + public string Format(string format, params object[] args) + { + throw new NotImplementedException(); + } + + public string Format(string format, IEnumerable args) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.PlainStringLog_StateSnapshot.verified.txt b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.PlainStringLog_StateSnapshot.verified.txt new file mode 100644 index 00000000000..82c3f9926ce --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.PlainStringLog_StateSnapshot.verified.txt @@ -0,0 +1,8 @@ +LogLevel: Information +Message: [LEVEL][DateTime][Thread 0001][ActorSystem(test)] Server started successfully +State: + [{OriginalFormat}] = Server started successfully + [ActorPath] = akka://test/... + [LogSource] = LogSource + [Thread] = 1 + [Timestamp] = DateTime diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.PositionalPlaceholderLog_StateSnapshot.verified.txt b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.PositionalPlaceholderLog_StateSnapshot.verified.txt new file mode 100644 index 00000000000..1f97a54a892 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.PositionalPlaceholderLog_StateSnapshot.verified.txt @@ -0,0 +1,9 @@ +LogLevel: Information +Message: [LEVEL][DateTime][Thread 0001][ActorSystem(test)] User 99 logged in +State: + [{OriginalFormat}] = User {0} logged in + [0] = 99 + [ActorPath] = akka://test/... + [LogSource] = LogSource + [Thread] = 1 + [Timestamp] = DateTime diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.StructuredLog_StateSnapshot.verified.txt b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.StructuredLog_StateSnapshot.verified.txt new file mode 100644 index 00000000000..ccccb1906b3 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.StructuredLog_StateSnapshot.verified.txt @@ -0,0 +1,10 @@ +LogLevel: Information +Message: [LEVEL][DateTime][Thread 0001][ActorSystem(test)] User 12345 performed Login +State: + [{OriginalFormat}] = User {UserId} performed {Action} + [Action] = Login + [ActorPath] = akka://test/... + [LogSource] = LogSource + [Thread] = 1 + [Timestamp] = DateTime + [UserId] = 12345 diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.cs new file mode 100644 index 00000000000..cc2a0546bc8 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LogStateSnapshotSpecs.cs @@ -0,0 +1,170 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting; +using Microsoft.Extensions.Logging; +using VerifyXunit; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +/// +/// Verify snapshot tests for LoggerFactoryLogger structured state output. +/// Follows the same sanitization pattern as DefaultLogFormatSpec in Akka.NET. +/// +public class LogStateSnapshotSpecs : TestKit.TestKit +{ + private readonly SnapshotTestSink _sink; + + public LogStateSnapshotSpecs(ITestOutputHelper output) : base(output: output) + { + _sink = new SnapshotTestSink(output); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.ConfigureLoggers(setup => + { + setup.LogLevel = Event.LogLevel.InfoLevel; + setup.ClearLoggers(); + setup.AddLoggerFactory(new SnapshotTestLoggerFactory(_sink)); + }); + } + + [Fact] + public System.Threading.Tasks.Task StructuredLog_StateSnapshot() + { + _sink.Clear(); + Sys.Log.Info("User {UserId} performed {Action}", 12345, "Login"); + AwaitCondition(() => _sink.Entries.Any(e => e.Message.Contains("12345"))); + var entry = _sink.Entries.First(e => e.Message.Contains("12345")); + return Verifier.Verify(FormatEntry(entry)); + } + + [Fact] + public System.Threading.Tasks.Task PlainStringLog_StateSnapshot() + { + _sink.Clear(); + Sys.Log.Info("Server started successfully"); + AwaitCondition(() => _sink.Entries.Any(e => e.Message.Contains("Server started successfully"))); + var entry = _sink.Entries.First(e => e.Message.Contains("Server started successfully")); + return Verifier.Verify(FormatEntry(entry)); + } + + [Fact] + public System.Threading.Tasks.Task PositionalPlaceholderLog_StateSnapshot() + { + _sink.Clear(); + Sys.Log.Info("User {0} logged in", 99); + AwaitCondition(() => _sink.Entries.Any(e => e.Message.Contains("99"))); + var entry = _sink.Entries.First(e => e.Message.Contains("99")); + return Verifier.Verify(FormatEntry(entry)); + } + + private static string FormatEntry(SnapshotLogEntry entry) + { + var sb = new StringBuilder(); + sb.AppendLine($"LogLevel: {entry.LogLevel}"); + sb.AppendLine($"Message: {SanitizeMessage(entry.Message)}"); + sb.AppendLine("State:"); + foreach (var kvp in entry.State.OrderBy(k => k.Key)) + { + sb.AppendLine($" [{kvp.Key}] = {SanitizeValue(kvp.Key, kvp.Value)}"); + } + return sb.ToString(); + } + + private static string SanitizeMessage(string message) + { + message = Regex.Replace(message, + @"\[(DEBUG|INFO|WARNING|ERROR)\]", + "[LEVEL]"); + message = Regex.Replace(message, + @"\[\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\.\d{3}Z?\]", + "[DateTime]"); + message = Regex.Replace(message, + @"\[Thread \d+\]", + "[Thread 0001]"); + message = Regex.Replace(message, + @"\[akka://[^\]]+\]", + "[ActorPath]"); + return message; + } + + private static string SanitizeValue(string key, object? value) + { + return key switch + { + "ActorPath" => "akka://test/...", + "Timestamp" => "DateTime", + "Thread" => "1", + "LogSource" => "LogSource", + _ => value?.ToString() ?? "null" + }; + } +} + +internal class SnapshotLogEntry +{ + public LogLevel LogLevel { get; init; } + public string Message { get; init; } = string.Empty; + public Dictionary State { get; init; } = new(); +} + +internal class SnapshotTestSink : ILogger +{ + private readonly ITestOutputHelper _output; + public ConcurrentQueue Entries { get; } = new(); + + public SnapshotTestSink(ITestOutputHelper output) { _output = output; } + + public void Clear() => Entries.Clear(); + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + var message = formatter(state, exception); + _output.WriteLine($"[{logLevel}] Message: \"{message}\""); + + var stateDict = new Dictionary(); + if (state is IEnumerable> kvps) + { + foreach (var kvp in kvps) + { + stateDict[kvp.Key] = kvp.Value; + _output.WriteLine($" State[{kvp.Key}] = {kvp.Value}"); + } + } + + Entries.Enqueue(new SnapshotLogEntry + { + LogLevel = logLevel, + Message = message, + State = stateDict, + }); + } + + public bool IsEnabled(LogLevel logLevel) => true; + public IDisposable BeginScope(TState state) where TState : notnull => EmptyDisposable.Instance; +} + +internal class SnapshotTestLoggerFactory : ILoggerFactory +{ + private readonly SnapshotTestSink _sink; + public SnapshotTestLoggerFactory(SnapshotTestSink sink) { _sink = sink; } + public void Dispose() { } + public ILogger CreateLogger(string categoryName) => _sink; + public void AddProvider(ILoggerProvider provider) { } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs new file mode 100644 index 00000000000..bfa317235cc --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs @@ -0,0 +1,186 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; +using LogLevel = Akka.Event.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +public class LoggerConfigBuilderSpecs +{ + [Fact(DisplayName = "LoggerConfigBuilder should contain proper default configuration")] + public async Task LoggerSetupDefaultValues() + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test") + .ConfigureLoggers(_ => { }); + + builder.Configuration.HasValue.Should().BeFalse(); + + var host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddAkka(nameof(LoggerConfigBuilderSpecs), b => + { + b.ConfigureLoggers(_ => { }); + }); + }) + .Build(); + + await host.StartAsync(); + + try + { + var config = host.Services.GetRequiredService().Settings.Config; + config.GetString("akka.loglevel").Should().Be("INFO"); + config.GetBoolean("akka.log-config-on-start").Should().BeFalse(); + var loggers = config.GetStringList("akka.loggers"); + loggers.Count.Should().Be(1); + loggers[0].Should().Contain("Akka.Event.DefaultLogger"); + config.GetString("akka.logger-formatter").Should().Contain("SemanticLogMessageFormatter"); + + var debug = config.GetConfig("akka.actor.debug"); + debug.Should().NotBeNull(); + debug.GetBoolean("receive").Should().BeFalse(); + debug.GetBoolean("autoreceive").Should().BeFalse(); + debug.GetBoolean("lifecycle").Should().BeFalse(); + debug.GetBoolean("fsm").Should().BeFalse(); + debug.GetBoolean("event-stream").Should().BeFalse(); + debug.GetBoolean("unhandled").Should().BeFalse(); + debug.GetBoolean("router-misconfiguration").Should().BeFalse(); + debug.GetBoolean("log-timers").Should().BeFalse(); + + config.GetInt("akka.log-dead-letters").Should().Be(10); + config.GetBoolean("akka.log-dead-letters-during-shutdown").Should().BeFalse(); + config.GetTimeSpan("akka.log-dead-letters-suspend-duration").Should().Be(TimeSpan.FromMinutes(5)); + } + finally + { + await host.StopAsync(); + host.Dispose(); + } + } + + [Fact(DisplayName = "LoggerConfigBuilder should override config values")] + public void LoggerSetupOverrideValues() + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test") + .ConfigureLoggers(setup => + { + setup.LogLevel = LogLevel.WarningLevel; + setup.LogConfigOnStart = true; + setup.ClearLoggers(); + setup.DebugOptions = new DebugOptions + { + Receive = true, + AutoReceive = true, + LifeCycle = true, + EventStream = true, + FiniteStateMachine = true, + Unhandled = true, + RouterMisconfiguration = true + }; + setup.DeadLetterOptions = new DeadLetterOptions + { + LogCount = 99, + LogDuringShutdown = false, + LogSuspendDuration = TimeSpan.Zero + }; + }); + + builder.Configuration.HasValue.Should().BeTrue(); + var config = builder.Configuration.Value; + config.GetString("akka.loglevel").Should().Be("Warning"); + config.GetBoolean("akka.log-config-on-start").Should().BeTrue(); + var loggers = config.GetStringList("akka.loggers"); + loggers.Count.Should().Be(0); + + var debug = config.GetConfig("akka.actor.debug"); + debug.Should().NotBeNull(); + debug.GetBoolean("receive").Should().BeTrue(); + debug.GetBoolean("autoreceive").Should().BeTrue(); + debug.GetBoolean("lifecycle").Should().BeTrue(); + debug.GetBoolean("fsm").Should().BeTrue(); + debug.GetBoolean("event-stream").Should().BeTrue(); + debug.GetBoolean("unhandled").Should().BeTrue(); + debug.GetBoolean("router-misconfiguration").Should().BeTrue(); + + config.GetInt("akka.log-dead-letters").Should().Be(99); + config.GetBoolean("akka.log-dead-letters-during-shutdown").Should().BeFalse(); + config.GetString("akka.log-dead-letters-suspend-duration").Should().Be("infinite"); + } + + [Fact(DisplayName = "DeadLetterOptions should override log-dead-letters properly")] + public void DeadLetterOptionsTest() + { + var cfg = (Config)new DeadLetterOptions + { + ShouldLog = TriStateValue.All + }.ToString(); + cfg.GetBoolean("akka.log-dead-letters").Should().BeTrue(); + + cfg = new DeadLetterOptions + { + ShouldLog = TriStateValue.None + }.ToString(); + cfg.GetBoolean("akka.log-dead-letters").Should().BeFalse(); + + cfg = new DeadLetterOptions + { + LogCount = 10 + }.ToString(); + cfg.GetInt("akka.log-dead-letters").Should().Be(10); + + cfg = new DeadLetterOptions().ToString(); + cfg.IsEmpty.Should().BeTrue(); + } + + [Fact(DisplayName = "WithLogFilter should populate the LogFilterBuilder property")] + public void WithLogFilterPropertyTest() + { + var akkaBuilder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + var loggerConfigBuilder = new LoggerConfigBuilder(akkaBuilder) + .WithLogFilter(filterBuilder => + { + filterBuilder.ExcludeMessageContaining("Test"); + }); + loggerConfigBuilder.LogFilterBuilder.Should().NotBeNull(); + var filterSetup = loggerConfigBuilder.LogFilterBuilder!.Build(); + filterSetup.Filters.Length.Should().Be(1); + filterSetup.Filters.Any(f => f is RegexLogMessageFilter).Should().BeTrue(); + } + + [Fact(DisplayName = "WithLogFilter should append existing LogFilterBuilder property")] + public void WithLogFilterConcatTest() + { + var akkaBuilder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + var loggerConfigBuilder = new LoggerConfigBuilder(akkaBuilder) + { + LogFilterBuilder = new LogFilterBuilder() + .ExcludeSourceContaining("Test") + }; + loggerConfigBuilder + .WithLogFilter(filterBuilder => + { + filterBuilder.ExcludeMessageContaining("Test"); + }); + + loggerConfigBuilder.LogFilterBuilder.Should().NotBeNull(); + var filterSetup = loggerConfigBuilder.LogFilterBuilder.Build(); + filterSetup.Filters.Length.Should().Be(2); + filterSetup.Filters.Any(f => f is RegexLogMessageFilter).Should().BeTrue(); + filterSetup.Filters.Any(f => f is RegexLogSourceFilter).Should().BeTrue(); + } + +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs new file mode 100644 index 00000000000..d95be79e5e0 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting.Logging; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using static Akka.Hosting.Tests.TestHelpers; + +namespace Akka.Hosting.Tests.Logging; + +public class LoggerConfigEnd2EndSpecs : Akka.TestKit.Xunit.TestKit +{ + private class CustomLoggingProvider : ILoggerProvider + { + private readonly TestLogger _logger; + + public bool Created { get; private set; } + + public CustomLoggingProvider(TestLogger logger) + { + _logger = logger; + } + + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + { + if (categoryName.Contains(nameof(ActorSystem))) + { + Created = true; + _logger.LogInformation("ActorSystem logger created."); + } + return _logger; + } + } + + private readonly ITestOutputHelper _output; + private readonly TestLogger _logger; + + public LoggerConfigEnd2EndSpecs(ITestOutputHelper output) + { + _output = output; + _logger = new TestLogger(output); + } + + [Fact] + public async Task Should_configure_LoggerFactoryLogger() + { + var loggingProvider = new CustomLoggingProvider(_logger); + + // arrange + using var host = await StartHost(collection => + { + collection.AddLogging(builder => { builder.AddProvider(loggingProvider); }); + + collection.AddAkka("MySys", (builder, provider) => + { + builder.ConfigureLoggers(configBuilder => { configBuilder.AddLogger(); }); + builder.AddTestOutputLogger(_output); + }); + }); + + // Make sure that the logger has already been created + await AwaitConditionAsync(() => Task.FromResult(loggingProvider.Created)); + var actorSystem = host.Services.GetRequiredService(); + + // act + _logger.StartRecording(); + actorSystem.Log.Info("foo"); + + // assert + await AwaitAssertAsync(() => + _logger.Infos.Where(c => c.Contains("foo")).Should().HaveCount(1)); + } + + [Fact] + public async Task Should_ActorSystem_without_LoggerFactoryLogger() + { + // arrange + using var host = await StartHost(collection => + { + collection.AddAkka("MySys", (builder, provider) => { builder.AddTestOutputLogger(_output); }); + }); + + Action getActorSystem = () => + { + var actorSystem = host.Services.GetRequiredService(); + }; + + + // act + getActorSystem.Should().NotThrow(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs new file mode 100644 index 00000000000..541cdec4edc --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs @@ -0,0 +1,234 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +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.Logging; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +public class LoggerFactoryLoggerSpec: IAsyncLifetime +{ + private readonly TestLogger _logger; + private IHost? _host; + private IActorRef? _echo; + + public LoggerFactoryLoggerSpec(ITestOutputHelper helper) + { + _logger = new TestLogger(helper); + } + + public async ValueTask InitializeAsync() + { + _host = await SetupHost(_logger); + var registry = _host.Services.GetRequiredService(); + _echo = registry.Get(); + } + + public ValueTask DisposeAsync() + { + _host?.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact(DisplayName = "LoggerFactoryLogger should log events")] + public async Task LoggerShouldLogEvents() + { + await WaitUntilSilent(10.Seconds()); + + _logger.StopWhenReceives(">>>> error"); + _logger.StartRecording(); + var reply = await _echo.Ask(new Message(Event.LogLevel.DebugLevel, ">>>> debug")); + reply.Should().Be(">>>> debug"); + + reply = await _echo.Ask(new Message(Event.LogLevel.InfoLevel, ">>>> info")); + reply.Should().Be(">>>> info"); + + reply = await _echo.Ask(new Message(Event.LogLevel.WarningLevel, ">>>> warning")); + reply.Should().Be(">>>> warning"); + + reply = await _echo.Ask(new Message(Event.LogLevel.ErrorLevel, ">>>> error")); + reply.Should().Be(">>>> error"); + await WaitUntilLoggerStopsRecording(10.Seconds()); + + _logger.TotalLogs.Should().BeGreaterThan(0); + _logger.Debugs.Count(m => m.Contains(">>>> debug")).Should().Be(1); + _logger.Infos.Count(m => m.Contains(">>>> info")).Should().Be(1); + _logger.Warnings.Count(m => m.Contains(">>>> warning")).Should().Be(1); + _logger.Errors.Count(m => m.Contains(">>>> error")).Should().Be(1); + } + + [Fact(DisplayName = "LoggerFactoryLogger should log all events")] + public async Task LoggerShouldLogAllEvents() + { + var rnd = new Random(); + var allLevels = new[] + { + Event.LogLevel.DebugLevel, + Event.LogLevel.InfoLevel, + Event.LogLevel.WarningLevel, + Event.LogLevel.ErrorLevel, + }; + + await WaitUntilSilent(10.Seconds()); + + _logger.StopWhenReceives(">>>> STOP"); + _logger.StartRecording(); + string reply; + foreach (var i in Enumerable.Range(0, 500)) + { + reply = await _echo.Ask(new Message(allLevels[rnd.Next(0, 4)], $">>>> MESSAGE {i}")); + reply.Should().Be($">>>> MESSAGE {i}"); + } + + reply = await _echo.Ask(new Message(Event.LogLevel.InfoLevel, ">>>> STOP")); + reply.Should().Be(">>>> STOP"); + await WaitUntilLoggerStopsRecording(10.Seconds()); + + _logger.TotalLogs.Should().Be(501); + } + + private async Task WaitUntilLoggerStopsRecording(TimeSpan timeout) + { + var cts = new CancellationTokenSource(timeout); + try + { + while (_logger.Recording) + { + await Task.Delay(100, cts.Token); + if (cts.IsCancellationRequested) + throw new TimeoutException($"Waiting too long for logger to stop recording. Timeout: {timeout}"); + } + } + finally + { + cts.Dispose(); + } + } + + private async Task WaitUntilSilent(TimeSpan timeout) + { + var cts = new CancellationTokenSource(timeout); + try + { + int previousCount; + int count; + do + { + previousCount = _logger.ReceivedLogs; + await Task.Delay(200, cts.Token); + if(cts.IsCancellationRequested) + throw new TimeoutException($"Waiting too long for ActorSystem logging system to be silent. Timeout: {timeout}"); + + count = _logger.ReceivedLogs; + } while (previousCount != count); + } + finally + { + cts.Dispose(); + } + + } + + private static async Task SetupHost(TestLogger logger) + { + var host = new HostBuilder() + .ConfigureServices(collection => + { + collection.AddAkka("TestSys", configurationBuilder => + { + configurationBuilder + .ConfigureLoggers(setup => + { + setup.LogLevel = Event.LogLevel.DebugLevel; + setup.ClearLoggers(); + setup.AddLoggerFactory(new TestLoggerFactory(logger)); + }) + .WithActors((system, registry) => + { + var echo = system.ActorOf(Props.Create(() => new EchoActor()), "echo"); + registry.TryRegister(echo); // register for DI + }); + }); + }).Build(); + await host.StartAsync(); + return host; + } + + private class EchoActor: ReceiveActor + { + public EchoActor() + { + var log = Context.GetLogger(); + Receive(o => + { + switch (o.LogLevel) + { + case Event.LogLevel.DebugLevel: + log.Debug(o.Payload); + break; + case Event.LogLevel.InfoLevel: + log.Info(o.Payload); + break; + case Event.LogLevel.WarningLevel: + log.Warning(o.Payload); + break; + case Event.LogLevel.ErrorLevel: + log.Error(o.Payload); + break; + } + + Sender.Tell(o.Payload); + }); + } + } + + private class Message + { + public Message(Event.LogLevel logLevel, string payload) + { + LogLevel = logLevel; + Payload = payload; + } + + public Event.LogLevel LogLevel { get; } + public string Payload { get; } + } + + private class TestLoggerFactory: ILoggerFactory + { + private readonly TestLogger _logger; + + public TestLoggerFactory(TestLogger logger) + { + _logger = logger; + } + + public void Dispose() + { + // no-op + } + + public ILogger CreateLogger(string categoryName) => _logger; + + public void AddProvider(ILoggerProvider provider) + { + // no-op + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs new file mode 100644 index 00000000000..23eeffb057a --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs @@ -0,0 +1,411 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +/// +/// Tests for semantic logging functionality added in Akka.NET 1.5.56. +/// Verifies that structured properties from log message templates are +/// accessible in Microsoft.Extensions.Logging state dictionaries. +/// +public class SemanticLoggingSpecs : IAsyncLifetime +{ + private readonly SemanticTestLogger _logger; + private IHost? _host; + private IActorRef? _testActor; + + public SemanticLoggingSpecs(ITestOutputHelper helper) + { + _logger = new SemanticTestLogger(helper); + } + + public async ValueTask InitializeAsync() + { + _host = await SetupHost(_logger); + var registry = _host.Services.GetRequiredService(); + _testActor = registry.Get(); + } + + public ValueTask DisposeAsync() + { + _host?.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact(DisplayName = "Should extract named template properties and add to MEL state dictionary")] + public async Task NamedTemplatePropertiesTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor.Ask("User {UserId} with email {Email} logged in|12345|user@example.com"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("User") && e.Message.Contains("logged in")); + + entry.State.Should().ContainKey("UserId"); + entry.State.Should().ContainKey("Email"); + entry.State["UserId"].Should().Be(12345); + entry.State["Email"].Should().Be("user@example.com"); + }); + } + + [Fact(DisplayName = "Should extract positional template properties and add to MEL state dictionary")] + public async Task PositionalTemplatePropertiesTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor.Ask("User {0} logged in from {1}|Bob|192.168.1.1"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("User") && e.Message.Contains("logged in from")); + + entry.State.Should().ContainKey("0"); + entry.State.Should().ContainKey("1"); + entry.State["0"].Should().Be("Bob"); + entry.State["1"].Should().Be("192.168.1.1"); + }); + } + + [Fact(DisplayName = "Should handle multiple named properties in template")] + public async Task MultipleNamedPropertiesTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor.Ask("Order {OrderId} for customer {CustomerId}: {Amount} {Currency}|ORD-001|CUST-456|99.99|USD"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("Order") && e.Message.Contains("customer")); + + entry.State.Should().ContainKey("OrderId"); + entry.State.Should().ContainKey("CustomerId"); + entry.State.Should().ContainKey("Amount"); + entry.State.Should().ContainKey("Currency"); + entry.State["OrderId"].Should().Be("ORD-001"); + entry.State["CustomerId"].Should().Be("CUST-456"); + entry.State["Amount"].Should().Be(99.99); + entry.State["Currency"].Should().Be("USD"); + }); + } + + [Fact(DisplayName = "Should preserve Akka metadata properties alongside semantic logging properties")] + public async Task AkkaMetadataAndSemanticPropertiesTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor.Ask("User {UserId} action|999"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("User") && e.Message.Contains("action")); + + // Semantic property + entry.State.Should().ContainKey("UserId"); + entry.State["UserId"].Should().Be(999); + + // Akka metadata properties + entry.State.Should().ContainKey("ActorPath"); + entry.State.Should().ContainKey("LogSource"); + entry.State.Should().ContainKey("Thread"); + entry.State.Should().ContainKey("Timestamp"); + }); + } + + [Fact(DisplayName = "Should include {OriginalFormat} key per MEL convention")] + public async Task OriginalFormatKeyTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor.Ask("Processing item {ItemId}|42"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("Processing item")); + + // MEL convention key + entry.State.Should().ContainKey("{OriginalFormat}"); + entry.State["{OriginalFormat}"].Should().Be("Processing item {ItemId}"); + + // Semantic property + entry.State.Should().ContainKey("ItemId"); + entry.State["ItemId"].Should().Be(42); + }); + } + + [Fact(DisplayName = "Should handle format specifiers in named templates")] + public async Task FormatSpecifiersInTemplatesTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + // Template has format specifier :N2, but property name should be "Amount" + var reply = await _testActor.Ask("Total amount: {Amount:N2}|1234.5678"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("Total amount")); + + // Property name is "Amount" (format specifier removed by Akka's parser) + entry.State.Should().ContainKey("Amount"); + entry.State["Amount"].Should().Be(1234.5678); + + // Original format includes the specifier + entry.State["{OriginalFormat}"].Should().Be("Total amount: {Amount:N2}"); + }); + } + + [Fact(DisplayName = "Should handle empty/no properties gracefully")] + public async Task NoPropertiesTest() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor.Ask("No template properties here|"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("No template properties here")); + + // Should still have Akka metadata properties + entry.State.Should().ContainKey("ActorPath"); + entry.State.Should().ContainKey("LogSource"); + entry.State.Should().ContainKey("Thread"); + entry.State.Should().ContainKey("Timestamp"); + + // Should have {OriginalFormat} + entry.State.Should().ContainKey("{OriginalFormat}"); + }); + } + + private async Task WaitUntilSilent(TimeSpan timeout) + { + var cts = new CancellationTokenSource(timeout); + try + { + int previousCount; + int count; + do + { + previousCount = _logger.ReceivedLogs; + await Task.Delay(200, cts.Token); + if (cts.IsCancellationRequested) + return; + + count = _logger.ReceivedLogs; + } while (previousCount != count); + } + finally + { + cts.Dispose(); + } + } + + private async Task AwaitAssertAsync(Action assertion, TimeSpan? timeout = null, TimeSpan? interval = null) + { + var maxWait = timeout ?? TimeSpan.FromSeconds(3); + var checkInterval = interval ?? TimeSpan.FromMilliseconds(100); + var cts = new CancellationTokenSource(maxWait); + + Exception? lastException = null; + while (!cts.Token.IsCancellationRequested) + { + try + { + assertion(); + return; // Assertion passed + } + catch (Exception ex) + { + lastException = ex; + try + { + await Task.Delay(checkInterval, cts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + } + + // If we get here, we timed out - throw the last exception + throw new TimeoutException( + $"Assertion did not pass within {maxWait.TotalSeconds}s", + lastException); + } + + private static async Task SetupHost(SemanticTestLogger logger) + { + var host = new HostBuilder() + .ConfigureServices(collection => + { + collection.AddAkka("TestSys", configurationBuilder => + { + configurationBuilder + .ConfigureLoggers(setup => + { + setup.LogLevel = Event.LogLevel.DebugLevel; + setup.ClearLoggers(); + setup.AddLoggerFactory(new SemanticTestLoggerFactory(logger)); + }) + .WithActors((system, registry) => + { + var testActor = system.ActorOf(Props.Create(() => new TestActor()), "testActor"); + registry.TryRegister(testActor); + }); + }); + }).Build(); + await host.StartAsync(); + return host; + } + + /// + /// Actor that logs semantic logging messages based on received strings + /// Format: "template|arg1|arg2|..." + /// + private class TestActor : ReceiveActor + { + public TestActor() + { + var log = Context.GetLogger(); + Receive(message => + { + var parts = message.Split('|'); + var template = parts[0]; + var args = parts.Skip(1).Select(ParseArg).ToArray(); + + log.Info(template, args); + Sender.Tell("OK"); + }); + } + + private static object ParseArg(string arg) + { + if (string.IsNullOrEmpty(arg)) return string.Empty; + if (int.TryParse(arg, out var intVal)) return intVal; + if (double.TryParse(arg, out var doubleVal)) return doubleVal; + return arg; + } + } +} + +/// +/// Test logger that captures structured state for semantic logging assertions +/// +public class SemanticTestLogger : ILogger +{ + private readonly ITestOutputHelper _helper; + public readonly List LogEntries = new(); + public int ReceivedLogs { get; private set; } + + public SemanticTestLogger(ITestOutputHelper helper) + { + _helper = helper; + } + + public void Clear() + { + LogEntries.Clear(); + ReceivedLogs = 0; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + var message = formatter(state, exception); + _helper.WriteLine($"[{logLevel}] {message}"); + ReceivedLogs++; + + // Capture state as dictionary for semantic logging assertions + var stateDict = new Dictionary(); + if (state is IEnumerable> kvps) + { + foreach (var kvp in kvps) + { + stateDict[kvp.Key] = kvp.Value; + } + } + + LogEntries.Add(new LogEntry + { + LogLevel = logLevel, + Message = message, + State = stateDict, + Exception = exception + }); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable? BeginScope(TState state) where TState : notnull => EmptyDisposable.Instance; +} + +public class LogEntry +{ + public LogLevel LogLevel { get; set; } + public string Message { get; set; } = string.Empty; + public Dictionary State { get; set; } = new(); + public Exception? Exception { get; set; } +} + +/// +/// Test logger factory for semantic logging tests +/// +public class SemanticTestLoggerFactory : ILoggerFactory +{ + private readonly SemanticTestLogger _logger; + + public SemanticTestLoggerFactory(SemanticTestLogger logger) + { + _logger = logger; + } + + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) => _logger; + + public void AddProvider(ILoggerProvider provider) + { + } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/TestLogger.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/TestLogger.cs new file mode 100644 index 00000000000..917f2a5c6c4 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/TestLogger.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Akka.Hosting.Tests.Logging; + +public class TestLogger : ILogger +{ + private readonly ITestOutputHelper _helper; + public bool Recording { get; private set; } + private string? _stopsWhen; + + public readonly List Debugs = new(); + public readonly List Infos = new(); + public readonly List Warnings = new(); + public readonly List Errors = new(); + + public TestLogger(ITestOutputHelper helper) + { + _helper = helper; + } + + public int TotalLogs => Debugs.Count + Infos.Count + Warnings.Count + Errors.Count; + + public int ReceivedLogs { get; private set; } + + public void StartRecording() + { + _helper.WriteLine("Logger starts recording"); + Recording = true; + } + + public void StopWhenReceives(string message) + { + _stopsWhen = message; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + _helper.WriteLine($"[{logLevel}] {message}"); + ReceivedLogs++; + + if (!Recording) + return; + + if (!string.IsNullOrEmpty(_stopsWhen) && message.Contains(_stopsWhen)) + { + _helper.WriteLine("Logger stops recording"); + Recording = false; + } + + switch (logLevel) + { + case LogLevel.Debug: + Debugs.Add(message); + break; + case LogLevel.Information: + Infos.Add(message); + break; + case LogLevel.Warning: + Warnings.Add(message); + break; + case LogLevel.Error: + Errors.Add(message); + break; + default: + throw new Exception($"Unsupported LogLevel: {logLevel}"); + } + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return EmptyDisposable.Instance; + } +} + +public class EmptyDisposable : IDisposable +{ + public static readonly EmptyDisposable Instance = new EmptyDisposable(); + + private EmptyDisposable() + { + } + +#pragma warning disable CA1816 + public void Dispose() +#pragma warning restore CA1816 + { + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/Logging/WithContextSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/Logging/WithContextSpecs.cs new file mode 100644 index 00000000000..1dc3bc7fb78 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/Logging/WithContextSpecs.cs @@ -0,0 +1,283 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +/// +/// Tests for core Akka.NET WithContext() logging context enrichment +/// flowing through to Microsoft.Extensions.Logging state dictionaries. +/// +public class WithContextSpecs : IAsyncLifetime +{ + private readonly SemanticTestLogger _logger; + private IHost? _host; + private IActorRef? _testActor; + + public WithContextSpecs(ITestOutputHelper helper) + { + _logger = new SemanticTestLogger(helper); + } + + public async ValueTask InitializeAsync() + { + _host = await SetupHost(_logger); + var registry = _host.Services.GetRequiredService(); + _testActor = registry.Get(); + } + + public ValueTask DisposeAsync() + { + _host?.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact(DisplayName = "WithContext single property should appear in MEL state dictionary")] + public async Task WithContext_SingleProperty_AppearsInMelState() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + // Format: "ctx:Key=Value|template|arg1|arg2" + var reply = await _testActor!.Ask("ctx:TenantId=TENANT-001|Processing request"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("Processing request")); + + entry.State.Should().ContainKey("TenantId"); + entry.State["TenantId"].Should().Be("TENANT-001"); + }); + } + + [Fact(DisplayName = "WithContext multiple properties should all appear in MEL state dictionary")] + public async Task WithContext_MultipleProperties_AllAppearInState() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + // Multiple ctx: properties separated by commas + var reply = await _testActor!.Ask("ctx:TenantId=TENANT-002,CorrelationId=CORR-123,Region=us-east-1|Multi-context request"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("Multi-context request")); + + entry.State.Should().ContainKey("TenantId"); + entry.State.Should().ContainKey("CorrelationId"); + entry.State.Should().ContainKey("Region"); + entry.State["TenantId"].Should().Be("TENANT-002"); + entry.State["CorrelationId"].Should().Be("CORR-123"); + entry.State["Region"].Should().Be("us-east-1"); + }); + } + + [Fact(DisplayName = "WithContext combined with semantic template should have both context and template properties")] + public async Task WithContext_CombinedWithSemanticTemplate_BothAppearInState() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + var reply = await _testActor!.Ask("ctx:TenantId=TENANT-003|User {UserId} performed {Action}|42|login"); + reply.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + var entry = _logger.LogEntries.First(e => e.Message.Contains("User") && e.Message.Contains("performed")); + + // Context property + entry.State.Should().ContainKey("TenantId"); + entry.State["TenantId"].Should().Be("TENANT-003"); + + // Template properties + entry.State.Should().ContainKey("UserId"); + entry.State.Should().ContainKey("Action"); + entry.State["UserId"].Should().Be(42); + entry.State["Action"].Should().Be("login"); + }); + } + + [Fact(DisplayName = "WithContext should not pollute unrelated log events")] + public async Task WithContext_DoesNotPolluteUnrelatedLogs() + { + _logger.Clear(); + await WaitUntilSilent(5.Seconds()); + + // First: log with context + var reply1 = await _testActor!.Ask("ctx:SecretContext=should-not-leak|Context message with {Marker}|CTX"); + reply1.Should().Be("OK"); + + // Second: log without context + var reply2 = await _testActor!.Ask("Plain message with {Marker}|PLAIN"); + reply2.Should().Be("OK"); + + await AwaitAssertAsync(() => + { + _logger.LogEntries.Should().NotBeEmpty(); + + var contextEntry = _logger.LogEntries.First(e => + e.State.ContainsKey("Marker") && "CTX".Equals(e.State["Marker"])); + var plainEntry = _logger.LogEntries.First(e => + e.State.ContainsKey("Marker") && "PLAIN".Equals(e.State["Marker"])); + + contextEntry.State.Should().ContainKey("SecretContext"); + plainEntry.State.Should().NotContainKey("SecretContext", + "context properties should not leak to loggers without that context"); + }); + } + + private async Task WaitUntilSilent(TimeSpan timeout) + { + var cts = new CancellationTokenSource(timeout); + try + { + int previousCount; + int count; + do + { + previousCount = _logger.ReceivedLogs; + await Task.Delay(200, cts.Token); + if (cts.IsCancellationRequested) + return; + + count = _logger.ReceivedLogs; + } while (previousCount != count); + } + finally + { + cts.Dispose(); + } + } + + private async Task AwaitAssertAsync(Action assertion, TimeSpan? timeout = null, TimeSpan? interval = null) + { + var maxWait = timeout ?? TimeSpan.FromSeconds(3); + var checkInterval = interval ?? TimeSpan.FromMilliseconds(100); + var cts = new CancellationTokenSource(maxWait); + + Exception? lastException = null; + while (!cts.Token.IsCancellationRequested) + { + try + { + assertion(); + return; + } + catch (Exception ex) + { + lastException = ex; + try + { + await Task.Delay(checkInterval, cts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + } + + throw new TimeoutException( + $"Assertion did not pass within {maxWait.TotalSeconds}s", + lastException); + } + + private static async Task SetupHost(SemanticTestLogger logger) + { + var host = new HostBuilder() + .ConfigureServices(collection => + { + collection.AddAkka("WithContextTestSys", configurationBuilder => + { + configurationBuilder + .ConfigureLoggers(setup => + { + setup.LogLevel = Event.LogLevel.DebugLevel; + setup.ClearLoggers(); + setup.AddLoggerFactory(new SemanticTestLoggerFactory(logger)); + }) + .WithActors((system, registry) => + { + var testActor = system.ActorOf(Props.Create(() => new WithContextTestActor()), "withContextTestActor"); + registry.TryRegister(testActor); + }); + }); + }).Build(); + await host.StartAsync(); + return host; + } + + /// + /// Actor that supports WithContext() enrichment. + /// Message format: "ctx:Key1=Val1,Key2=Val2|template|arg1|arg2|..." + /// Or without context: "template|arg1|arg2|..." + /// + private class WithContextTestActor : ReceiveActor + { + public WithContextTestActor() + { + var log = Context.GetLogger(); + Receive(message => + { + ILoggingAdapter currentLog = log; + + // Parse ctx: prefix for WithContext properties + if (message.StartsWith("ctx:")) + { + var ctxEnd = message.IndexOf('|'); + var ctxPart = message.Substring(4, ctxEnd - 4); + message = message.Substring(ctxEnd + 1); + + foreach (var kvp in ctxPart.Split(',')) + { + var eqIdx = kvp.IndexOf('='); + var key = kvp.Substring(0, eqIdx); + var val = kvp.Substring(eqIdx + 1); + currentLog = currentLog.WithContext(key, val); + } + } + + var parts = message.Split('|'); + var template = parts[0]; + var args = parts.Skip(1).Select(ParseArg).ToArray(); + + if (args.Length > 0) + currentLog.Info(template, args); + else + currentLog.Info(template); + + Sender.Tell("OK"); + }); + } + + private static object ParseArg(string arg) + { + if (string.IsNullOrEmpty(arg)) return string.Empty; + if (int.TryParse(arg, out var intVal)) return intVal; + if (double.TryParse(arg, out var doubleVal)) return doubleVal; + return arg; + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting.Tests/RequiredActorSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/RequiredActorSpecs.cs new file mode 100644 index 00000000000..3f99e314c5a --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/RequiredActorSpecs.cs @@ -0,0 +1,174 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Hosting.Tests; + +public class RequiredActorSpecs +{ + public sealed class MyActorType : ReceiveActor + { + public MyActorType() + { + ReceiveAny(_ => Sender.Tell(_)); + } + } + + 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)); + } + } + + public class MissingActor{} + + public sealed class BadConsumer + { + private readonly IActorRef _actor; + + public BadConsumer(IRequiredActor actor) + { + _actor = actor.ActorRef; + } + + public async Task Say(string word) + { + return await _actor.Ask(word, TimeSpan.FromSeconds(3)); + } + } + + [Fact] + public async Task ShouldRetrieveRequiredActorFromIServiceProvider() + { + // arrange + 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(); + + // act + var myConsumer = host.Services.GetRequiredService(); + var input = "foo"; + var spoken = await myConsumer.Say(input); + + // assert + spoken.Should().Be(input); + } + + [Fact] + public async Task ShouldFailRetrieveRequiredActorWhenNotDefined() + { + // arrange + 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(); + + // act + Action shouldThrow = () => host.Services.GetRequiredService(); + + // assert + shouldThrow.Should().Throw(); + } + + [Fact] + public async Task ShouldNotCacheNobodyAfterWhenWaitedForRegistration() + { + // arrange + using var host = new HostBuilder() + .ConfigureServices(services => + { + services.AddAkka("MySys", (builder, _) => + { + builder.WithActors((system, registry) => + { + var actor = system.ActorOf(Props.Create(() => new MyActorType()), "myactor"); + registry.Register(actor); + }); + }); + }) + .Build(); + + var myRequiredActor = host.Services.GetRequiredService>(); + + var task = myRequiredActor.GetAsync(); + task.IsCompletedSuccessfully.Should().BeFalse(); + + await host.StartAsync(); + _ = await task; + + // act + var cachedActorRef = await myRequiredActor.GetAsync(); + + // assert + cachedActorRef.Should().NotBeOfType(); + } + + [Fact] + public async Task ShouldNotCacheNobodyBeforeRegistrationWithSyncActorRef() + { + // arrange + using var host = new HostBuilder() + .ConfigureServices(services => + { + services.AddAkka("MySys", (builder, _) => + { + builder.WithActors((system, registry) => + { + var actor = system.ActorOf(Props.Create(() => new MyActorType()), "myactor"); + registry.Register(actor); + }); + }); + }) + .Build(); + + var myRequiredActor = host.Services.GetRequiredService>(); + + Action shouldThrow = () => _ = myRequiredActor.ActorRef; + + shouldThrow.Should().Throw(); + + await host.StartAsync(); + + // act + var cachedActorRef = await myRequiredActor.GetAsync(); + + // assert + cachedActorRef.Should().NotBeOfType(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/SerializerRegistrationSpecs.cs b/src/contrib/hosting/Akka.Hosting.Tests/SerializerRegistrationSpecs.cs new file mode 100644 index 00000000000..f037956d434 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/SerializerRegistrationSpecs.cs @@ -0,0 +1,65 @@ +using System; +using Akka.Actor; +using Akka.Serialization; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Akka.Hosting.Tests +{ + public class SerializerRegistrationSpecs + { + public interface IUseCustomSerializer + { + } + + public sealed class CustomSerializedMessage : IUseCustomSerializer + { + public static readonly CustomSerializedMessage Instance = new CustomSerializedMessage(); + + private CustomSerializedMessage() + { + } + } + + public sealed class CustomSerializer : SerializerWithStringManifest + { + public CustomSerializer(ExtendedActorSystem system) : base(system) + { + } + + public override int Identifier => 435; + + public override byte[] ToBinary(object obj) + { + throw new NotImplementedException(); + } + + public override object FromBinary(byte[] bytes, string manifest) + { + throw new NotImplementedException(); + } + + public override string Manifest(object o) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void ShouldAddCustomSerializer() + { + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddAkka("TestSys", (builder, provider) => + { + builder.WithCustomSerializer("my-serializer", new[] { typeof(IUseCustomSerializer) }, + system => new CustomSerializer(system)); + }); + using var sp = serviceCollection.BuildServiceProvider(); + using var actorSystem = sp.GetRequiredService(); + var serializer = actorSystem.Serialization.FindSerializerFor(CustomSerializedMessage.Instance); + serializer.Should().BeOfType(); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/StartFailureSpec.cs b/src/contrib/hosting/Akka.Hosting.Tests/StartFailureSpec.cs new file mode 100644 index 00000000000..06b12f68054 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/StartFailureSpec.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.Tests; + +public class StartFailureSpec +{ + private readonly ITestOutputHelper _output; + + public StartFailureSpec(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task ShouldThrowWhenActorSystemFailedToStart() + { + // arrange + var host = new HostBuilder() + .ConfigureLogging(builder => + { + builder.ClearProviders(); + builder.AddProvider(new XUnitLoggerProvider(_output, LogLevel.Debug)); + }) + .ConfigureServices(services => + { + services.AddAkka("MySys", (builder, provider) => + { + builder.AddStartup((_, _) => throw new TestException("BOOM")); + }); + }) + .Build(); + + await Awaiting(async () => await host.StartAsync()).Should() + .ThrowExactlyAsync().WithMessage("BOOM"); + } + + private class TestException: Exception + { + public TestException(string? message) : base(message) + { + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/TestHelpers.cs b/src/contrib/hosting/Akka.Hosting.Tests/TestHelpers.cs new file mode 100644 index 00000000000..01af3b4ac2d --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/TestHelpers.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.TestKit.Xunit.Internals; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Hosting.Tests; + +public static class TestHelpers +{ + public static async Task StartHost(Action testSetup) + { + var host = new HostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(); + testSetup(services); + }).Build(); + + await host.StartAsync(); + return host; + } + + public static AkkaConfigurationBuilder AddTestOutputLogger(this AkkaConfigurationBuilder builder, + ITestOutputHelper output) + { + builder.WithActors((system, registry) => + { + var extSystem = (ExtendedActorSystem)system; + var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(output)), "log-test"); + logger.Tell(new InitializeLogger(system.EventStream)); + }); + + return builder; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/UtilSpec.cs b/src/contrib/hosting/Akka.Hosting.Tests/UtilSpec.cs new file mode 100644 index 00000000000..dd3bb25ea7d --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/UtilSpec.cs @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Configuration; +using FluentAssertions; +using Xunit; +using static FluentAssertions.FluentActions; + +namespace Akka.Hosting.Tests; + +public class UtilSpec +{ + [Fact(DisplayName = "Config.MoveTo() should move config to a new path properly")] + public void MoveToSpec() + { + var hocon = (Config)"a: 1, b: { c: 2 }"; + var moved = hocon.MoveTo("x.y.z"); + moved.GetInt("x.y.z.a").Should().Be(1); + moved.GetInt("x.y.z.b.c").Should().Be(2); + } + + [Fact(DisplayName = "HoconExtensions TimeSpan.ToHocon should work properly")] + public void TimeSpanToHoconSpec() + { + TimeSpan? nullTs = null; + Invoking(() => nullTs.ToHocon()).Should() + .Throw() + .WithMessage("Value can not be null", "Null value is not allowed"); + + var ts = TimeSpan.Zero; + ts.ToHocon().Should().Be("0"); + ts.ToHocon(true, false).Should().Be("0"); + ts.ToHocon(true, true).Should().Be("infinite"); + ts.ToHocon(false, false).Should().Be("0"); + Invoking(() => ts.ToHocon(false, true)).Should() + .Throw("Infinite value is not allowed", "Infinite value is not allowed, zero is considered as infinite"); + + ts = TimeSpan.FromTicks(-1); + ts.ToHocon(true, false).Should().Be("infinite"); + ts.ToHocon(true, true).Should().Be("infinite"); + Invoking(() => ts.ToHocon(false, true)).Should() + .Throw("Infinite value is not allowed", "Infinite value is not allowed"); + Invoking(() => ts.ToHocon(false, false)).Should() + .Throw("Infinite value is not allowed", "Infinite value is not allowed"); + + } + + [InlineData("$")] + [InlineData("\"")] + [InlineData("{")] + [InlineData("}")] + [InlineData("[")] + [InlineData("]")] + [InlineData(":")] + [InlineData("=")] + [InlineData(",")] + [InlineData("#")] + [InlineData("`")] + [InlineData("^")] + [InlineData("?")] + [InlineData("!")] + [InlineData("@")] + [InlineData("*")] + [InlineData("&")] + [InlineData("\\")] + [Theory(DisplayName = "HoconExtensions String.ToHocon should put illegal characters in quotes")] + public void StringToHoconTest(string input) + { + var result = input.ToHocon(); + + switch (input) + { + case "\"": + // special case for quote + result.Length.Should().Be(4); + break; + case "\\": + // special case for backslash + result.Length.Should().Be(4); + break; + default: + result.Length.Should().Be(3); + break; + } + + result.Should().StartWith("\"").And.EndWith("\""); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting.Tests/XUnitLogger.cs b/src/contrib/hosting/Akka.Hosting.Tests/XUnitLogger.cs new file mode 100644 index 00000000000..911646247b5 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/XUnitLogger.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Akka.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.Hosting.Tests/XUnitLoggerProvider.cs b/src/contrib/hosting/Akka.Hosting.Tests/XUnitLoggerProvider.cs new file mode 100644 index 00000000000..400b0fff0b9 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/XUnitLoggerProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Akka.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.Hosting.Tests/test.hocon b/src/contrib/hosting/Akka.Hosting.Tests/test.hocon new file mode 100644 index 00000000000..fd7163fd27d --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting.Tests/test.hocon @@ -0,0 +1,9 @@ +# See petabridge.cmd configuration options here: https://cmd.petabridge.com/articles/install/host-configuration.html +petabridge.cmd{ + # default IP address used to listen for incoming petabridge.cmd client connections + # should be a safe default as it listens on "all network interfaces". + host = "0.0.0.0" + + # default port number used to listen for incoming petabridge.cmd client connections + port = 9110 +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/ActorRegistry.cs b/src/contrib/hosting/Akka.Hosting/ActorRegistry.cs new file mode 100644 index 00000000000..d83c231179b --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/ActorRegistry.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Util; +using Microsoft.Extensions.Hosting; + +namespace Akka.Hosting +{ + /// + /// A strongly typed actor reference that can be used to send messages to an actor. + /// + /// The type key of the actor - corresponds to a matching entry inside the . + /// + /// Designed to be used in combination with dependency injection to get references to specific actors inside your application. + /// + public interface IRequiredActor + { + /// + /// The underlying actor resolved via using the given key. + /// + IActorRef ActorRef { get; } + + /// + /// When calling from inside another , actor registrations may not be + /// available at startup (due to Akka.NET itself being started asynchronously in another hosted service). + /// + /// Instead - you should call the GetAsync method to wait for that actor to be populated into the + /// by the AkkaService at startup. + /// + /// Optional cancellation token. + /// A Task that will return the using the given key. + Task GetAsync(CancellationToken cancellationToken = default); + } + + /// + /// INTERNAL API + /// + /// The type key of the actor - corresponds to a matching entry inside the . + public sealed class RequiredActor : IRequiredActor + { + private readonly IReadOnlyActorRegistry _registry; + + public RequiredActor(IReadOnlyActorRegistry registry) + { + _registry = registry; + } + + private IActorRef? _internalRef = null; + + /// + public IActorRef ActorRef + { + get + { + // attempt 1 - used cached value + if (_internalRef != null) + return _internalRef; + + // attempt 2 - synchronously check the registry (fast path) + if (_registry.TryGet(out var internalRef)) + { + return _internalRef = internalRef; + } + + + throw new MissingActorRegistryEntryException( + $"Unable to resolve actor type [{typeof(TActor)})] - if you're using IRequiredActor inside the constructor" + + $"of an IHostedService, consider using the GetAsync method instead so you can wait for the actor to be populated by the AkkaService (which runs in parallel.)"); + } + } + + /// + public async Task GetAsync(CancellationToken cancellationToken = default) + { + // attempt 1 - used cached value + if (_internalRef != null) + return _internalRef; + + // attempt 2 - synchronously check the registry (fast path) + if (_registry.TryGet(out var internalRef)) + { + return _internalRef = internalRef; + } + + // attempt 3 - wait for the actor to be registered + return _internalRef = await _registry.GetAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// INTERNAL API + /// + public sealed class ActorRegistryExtension : ExtensionIdProvider + { + public override ActorRegistry CreateExtension(ExtendedActorSystem system) + { + return new ActorRegistry(); + } + } + + /// + /// Generic exception. + /// + public class ActorRegistryException : Exception + { + public ActorRegistryException(string message) : base(message) + { + } + + public ActorRegistryException(string message, Exception innerException) : base(message, innerException) + { + } + } + + /// + /// Thrown when the same key is used twice in the registry and overwriting is not allowed. + /// + public sealed class DuplicateActorRegistryException : ActorRegistryException + { + public DuplicateActorRegistryException(string message) : base(message) + { + } + + public DuplicateActorRegistryException(string message, Exception innerException) : base(message, innerException) + { + } + } + + /// + /// Thrown when a user attempts to retrieve a non-existent key from the . + /// + public sealed class MissingActorRegistryEntryException : ActorRegistryException + { + public MissingActorRegistryEntryException(string message) : base(message) + { + } + + public MissingActorRegistryEntryException(string message, Exception innerException) : base(message, + innerException) + { + } + } + + /// + /// Used to implement "wait for actor" mechanics + /// + internal sealed class WaitForActorRegistration : IEquatable + { + public WaitForActorRegistration(Type key, TaskCompletionSource waiter) + { + Key = key; + Waiter = waiter; + } + + public Type Key { get; } + + public TaskCompletionSource Waiter { get; } + + public CancellationTokenRegistration CancellationRegistration { get; set; } + + public bool Equals(WaitForActorRegistration? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Key == other.Key; + } + + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is WaitForActorRegistration other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + return (Key.GetHashCode() * 397) ^ Waiter.GetHashCode(); + } + } + + public static bool operator ==(WaitForActorRegistration? left, WaitForActorRegistration? right) + { + return Equals(left, right); + } + + public static bool operator !=(WaitForActorRegistration? left, WaitForActorRegistration? right) + { + return !Equals(left, right); + } + } + + /// + /// Mutable, but thread-safe . + /// + /// + /// Should only be used for top-level actors that need to be accessed from inside or outside the . + /// + /// If you are adding every single actor in your to the registry you are definitely using it wrong. + /// + public class ActorRegistry : IActorRegistry, IExtension + { + private readonly ConcurrentDictionary _actorRegistrations = new(); + + /// + /// Thrown when the same value is inserted twice and overwriting is not allowed. + /// Thrown when a null is registered. + public void Register(IActorRef actor, bool overwrite = false) + { + if (actor == null) + throw new ArgumentNullException(nameof(actor), "Cannot register null actors"); + + if (!TryRegister(actor, overwrite)) + { + throw new DuplicateActorRegistryException( + $"An actor for type {typeof(TKey)} has already been registered. Call `Register(IActorRef, bool overwrite=true)` to avoid this error or use a different key."); + } + } + + /// + /// In the event that an actor is not available yet, typically during the very beginning of ActorSystem startup, + /// we can wait on that actor becoming available. + /// + /// + /// Have to store a collection of s here so each waiter gets its own cancellation token. + /// + private readonly ConcurrentDictionary> _actorWaiters = new(); + + /// + /// Attempts to register an actor with the registry. + /// + /// The to register. + /// If true, allows overwriting of a previous actor with the same key. Defaults to false. + /// true if the actor was set to this key in the registry, false otherwise. + public bool TryRegister(IActorRef actor, bool overwrite = false) + { + return TryRegister(typeof(TKey), actor, overwrite); + } + + /// + /// Attempts to register an actor with the registry. + /// + /// The key for a particular actor. + /// The to register. + /// If true, allows overwriting of a previous actor with the same key. Defaults to false. + /// true if the actor was set to this key in the registry, false otherwise. + public bool TryRegister(Type key, IActorRef actor, bool overwrite = false) + { + if (actor == null) + return false; + + if (!overwrite) + { + if (!_actorRegistrations.TryAdd(key, actor)) + return false; + } + else + _actorRegistrations[key] = actor; + + NotifyWaiters(key, _actorRegistrations[key]); + return true; + } + + /// + /// Try to retrieve an with the given . + /// + /// The bound , if any. Is set to if key is not found. + /// true if an actor with this key exists, false otherwise. + public bool TryGet(out IActorRef actor) + { + return TryGet(typeof(TKey), out actor); + } + + /// + /// Try to retrieve an with the given type. + /// + /// The key for a particular actor. + /// The bound , if any. Is set to if key is not found. + /// true if an actor with this key exists, false otherwise. + public bool TryGet(Type key, out IActorRef actor) + { + if (_actorRegistrations.ContainsKey(key)) + { + actor = _actorRegistrations[key]; + return true; + } + + actor = ActorRefs.Nobody; + return false; + } + + /// + public async Task GetAsync(CancellationToken ct = default) + { + return await GetAsync(typeof(TKey), ct).ConfigureAwait(false); + } + + /// + public async Task GetAsync(Type key, CancellationToken ct = default) + { + // try to get the populated actor first, if available + if (TryGet(key, out var storedActor)) + { + return storedActor; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var waitingRegistration = new WaitForActorRegistration(key, tcs); + var registration = ct.Register(CancelWaiter(key, ct, waitingRegistration), _actorWaiters); + waitingRegistration.CancellationRegistration = registration; + + var r = _actorWaiters.AddOrUpdate(key, + type => { return ImmutableHashSet.Empty.Add(waitingRegistration); }, + (type, set) => { return set.Add(waitingRegistration); }); + + var b = r; + + + return await tcs.Task.ConfigureAwait(false); + } + + private void NotifyWaiters(Type key, IActorRef value) + { + // remove the registrations and then iterate over them + if (_actorWaiters.TryRemove(key, out var registrations)) + { + foreach (var r in registrations) + { + r.Waiter.TrySetResult(value); + r.CancellationRegistration.Dispose(); + } + } + } + + private static Action CancelWaiter(Type key, CancellationToken ct, + WaitForActorRegistration waitingRegistration) + { + return dict => + { + if (dict is null) + return; + + // first step during timeout is to remove our registration + var d = (ConcurrentDictionary>)dict; + d.AddOrUpdate(key, type => ImmutableHashSet.Empty, + (type, set) => set.Remove(waitingRegistration)); + + // next, cancel the task + waitingRegistration.Waiter.TrySetCanceled(ct); + }; + } + + /// + /// Fetches the by key. + /// + /// The key type to retrieve this actor. + /// If found, the underlying . + /// If not found, returns . + public IActorRef Get() + { + if (TryGet(out var actor)) + return actor; + throw new MissingActorRegistryEntryException("No actor registered for key " + typeof(TKey)); + } + + /// + /// Allows enumerated access to the collection of all registered actors. + /// + /// + public IEnumerator> GetEnumerator() + { + return _actorRegistrations.GetEnumerator(); + } + + /// + /// Allows enumerated access to the collection of all registered actors. + /// + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public static ActorRegistry For(ActorSystem actorSystem) + { + return actorSystem.WithExtension(); + } + } + + /// + /// Represents a read-only collection of instances keyed by the actor name. + /// + public interface IReadOnlyActorRegistry : IEnumerable> + { + /// + /// Try to retrieve an with the given . + /// + /// The bound , if any. Is set to if key is not found. + /// true if an actor with this key exists, false otherwise. + bool TryGet(out IActorRef actor); + + /// + /// Try to retrieve an with the given . + /// + /// The key for a particular actor. + /// The bound , if any. Is set to if key is not found. + /// true if an actor with this key exists, false otherwise. + bool TryGet(Type key, out IActorRef actor); + + /// + /// Fetches the by key. + /// + /// The key type to retrieve this actor. + /// If found, the underlying . + /// If not found, returns . + IActorRef Get(); + + /// + /// Asynchronously fetches the by key. Task will complete when the actor is registered. + /// + /// The CancellationToken that can be used to cancel the GetAsync operation. + /// The key type to retrieve this actor. + /// A that will complete when the actor is registered or will throw + /// a in the event that the is invoked. + public Task GetAsync(CancellationToken ct = default); + + /// + /// Asynchronously fetches the by key. Task will complete when the actor is registered. + /// + /// The CancellationToken that can be used to cancel the GetAsync operation. + /// The key type to retrieve this actor. + /// A that will complete when the actor is registered or will throw + /// a in the event that the is invoked. + public Task GetAsync(Type key, CancellationToken ct = default); + } + + /// + /// An abstraction to allow instances to be injected to non-Akka classes (such as controllers and SignalR Hubs). + /// + /// + /// Should only be used for top-level actors that need to be accessed from inside or outside the . + /// + /// If you are adding every single actor in your to the registry you are definitely using it wrong. + /// + public interface IActorRegistry : IReadOnlyActorRegistry + { + /// + /// Registers an actor into the registry. Throws an exception upon failure. + /// + /// The bound , if any. Is set to if key is not found. + /// If true, allows overwriting of a previous actor with the same key. Defaults to false. + void Register(IActorRef actor, bool overwrite = false); + + /// + /// Attempts to register an actor with the registry. + /// + /// The bound , if any. Is set to if key is not found. + /// If true, allows overwriting of a previous actor with the same key. Defaults to false. + /// true if the actor was set to this key in the registry, false otherwise. + bool TryRegister(IActorRef actor, bool overwrite = false); + + /// + /// Attempts to register an actor with the registry. + /// + /// The key for a particular actor. + /// The bound , if any. Is set to if key is not found. + /// If true, allows overwriting of a previous actor with the same key. Defaults to false. + /// true if the actor was set to this key in the registry, false otherwise. + bool TryRegister(Type key, IActorRef actor, bool overwrite = false); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/Akka.Hosting.csproj b/src/contrib/hosting/Akka.Hosting/Akka.Hosting.csproj new file mode 100644 index 00000000000..c3d3c726b34 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Akka.Hosting.csproj @@ -0,0 +1,25 @@ + + + + Akka.NET Microsoft.Extensions.Hosting support. + $(NetStandardLibVersion) + enable + $(AkkaPackageTags);hosting;dependency injection + true + https://getakka.net/articles/hosting/index.html + + + + + + + + + + + + + + + + diff --git a/src/contrib/hosting/Akka.Hosting/AkkaConfigurationBuilder.cs b/src/contrib/hosting/Akka.Hosting/AkkaConfigurationBuilder.cs new file mode 100644 index 00000000000..251aeeb6072 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -0,0 +1,532 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Setup; +using Akka.Annotations; +using Akka.Configuration; +using Akka.DependencyInjection; +using Akka.Hosting.HealthChecks; +using Akka.Hosting.Logging; +using Akka.Serialization; +using Akka.Util; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting +{ + /// + /// Delegate used to configure how to merge new HOCON in with the previous HOCON + /// that has already been added to the . + /// + public delegate Config HoconConfigurator(Config currentConfig, Config configToAdd); + + /// + /// Describes how to add a new section to our existing HOCON. + /// + public enum HoconAddMode + { + /// + /// Appends this HOCON to the back as a fallback. + /// + Append, + + /// + /// Prepend this HOCON to the front, overriding current values without deleting them. + /// + Prepend, + + /// + /// Replace all current HOCON with this HOCON instead. + /// + /// + /// WARNING: this is a destructive action. If you are writing a plugin or extension, never + /// call a method with this value directly. Always allow the user to choose. + /// + Replace + } + + /// + /// Delegate used to instantiate s once the has booted. + /// + public delegate Task ActorStarter(ActorSystem system, IActorRegistry registry); + + public delegate Task ActorStarterWithResolver(ActorSystem system, IActorRegistry registry, IDependencyResolver resolver); + + public delegate Task StartupTask(ActorSystem system, IActorRegistry registry); + + /// + /// Used to help populate a upon starting the , + /// if any are added to the builder; + /// + internal sealed class SerializerRegistration + { + public SerializerRegistration(string id, ImmutableHashSet typeBindings, + Func serializerFactory) + { + Id = id; + TypeBindings = typeBindings; + SerializerFactory = serializerFactory; + } + + public string Id { get; } + + public Func SerializerFactory { get; } + + public ImmutableHashSet TypeBindings { get; } + } + + public sealed class AkkaConfigurationBuilder + { + internal readonly string ActorSystemName; + internal readonly IServiceCollection ServiceCollection; + internal readonly HashSet Serializers = new(); + + // we use a name / registration dictionary to make health check registrations unique by name + internal readonly Dictionary HealthChecks = new(); + internal readonly List Extensions = new(); + + /// + /// INTERNAL API. + /// + /// + /// Do NOT modify this field directly. This field is exposed only for testing purposes and is subject to change in the future. + /// + /// Use the provided method instead. + /// + [InternalApi] + public readonly HashSet Setups = new(); + + /// + /// The currently configured . + /// + public Option ActorRefProvider { get; private set; } = Option.None; + + /// + /// The current HOCON configuration. + /// + public Option Configuration { get; private set; } = Option.None; + + /// + /// INTERNAL API. + /// + /// Used to hold a reference to the being started. + /// + internal Option Sys { get; set; } = Option.None; + + private readonly List _actorStarters = new(); + private readonly List _startupTasks = new(); + private bool _complete; + + public AkkaConfigurationBuilder(IServiceCollection serviceCollection, string actorSystemName) + { + ServiceCollection = serviceCollection; + ActorSystemName = actorSystemName; + } + + internal AkkaConfigurationBuilder AddSetup(Setup setup) + { + if (_complete) return this; + + // we will recreate our own BootstrapSetup later - just extract the parts for now. + if (setup is BootstrapSetup bootstrapSetup) + { + if (bootstrapSetup.Config.HasValue) + Configuration = Configuration.HasValue + ? Configuration.FlatSelect(c => bootstrapSetup.Config.Value.WithFallback(c)) + : bootstrapSetup.Config; + ActorRefProvider = bootstrapSetup.ActorRefProvider; + return this; + } + + // don't apply the diSetup + if (setup is DependencyResolverSetup) + { + return this; + } + + Setups.Add(setup); + return this; + } + + internal AkkaConfigurationBuilder WithActorRefProvider(ProviderSelection provider) + { + if (provider == null) + throw new ArgumentNullException(nameof(provider)); + if (_complete) return this; + ActorRefProvider = provider; + return this; + } + + internal AkkaConfigurationBuilder AddHoconConfiguration(HoconConfigurator configurator, Config newHocon) + { + if (newHocon == null) + throw new ArgumentNullException(nameof(newHocon)); + if (configurator == null) + throw new ArgumentNullException(nameof(configurator)); + + if (_complete) return this; + Configuration = configurator(Configuration.GetOrElse(Config.Empty), newHocon); + return this; + } + + internal AkkaConfigurationBuilder AddHoconConfiguration(Config newHocon, HoconAddMode addMode) + { + return addMode switch + { + HoconAddMode.Append => AddHoconConfiguration((config, add) => config.WithFallback(add), newHocon), + HoconAddMode.Prepend => AddHoconConfiguration((config, add) => add.WithFallback(config), newHocon), + HoconAddMode.Replace => AddHoconConfiguration((config, add) => add.WithFallback(config), newHocon), + _ => throw new ArgumentOutOfRangeException(nameof(addMode), addMode, null) + }; + } + + private static ActorStarter ToAsyncStarter(Action nonAsyncStarter) + { + Task Starter(ActorSystem f, IActorRegistry registry) + { + nonAsyncStarter(f, registry); + return Task.CompletedTask; + } + + return Starter; + } + + private static ActorStarter ToAsyncStarter( + Action nonAsyncStarter) + { + Task Starter(ActorSystem f, IActorRegistry registry) + { + nonAsyncStarter(f, registry, DependencyResolver.For(f).Resolver); + return Task.CompletedTask; + } + + return Starter; + } + + private static StartupTask ToAsyncStartup(Action nonAsyncStartup) + { + Task Startup(ActorSystem f, IActorRegistry registry) + { + nonAsyncStartup(f, registry); + return Task.CompletedTask; + } + + return Startup; + } + + public AkkaConfigurationBuilder StartActors(Action starter) + { + if (_complete) return this; + _actorStarters.Add(ToAsyncStarter(starter)); + return this; + } + + public AkkaConfigurationBuilder StartActors(Action starter) + { + if (_complete) return this; + _actorStarters.Add(ToAsyncStarter(starter)); + return this; + } + + public AkkaConfigurationBuilder StartActors(ActorStarter starter) + { + if (_complete) return this; + _actorStarters.Add(starter); + return this; + } + + public AkkaConfigurationBuilder StartActors(ActorStarterWithResolver starter) + { + if (_complete) return this; + + Task Starter1(ActorSystem f, IActorRegistry registry) => starter(f, registry, DependencyResolver.For(f).Resolver); + + _actorStarters.Add(Starter1); + return this; + } + + /// + /// Adds a delegate that will be executed exactly once for application initialization + /// once the and all actors is started in this process. + /// + /// A delegate that will be run after all actors + /// have been instantiated. + /// The same instance originally passed in. + public AkkaConfigurationBuilder AddStartup(Action startupTask) + { + if (_complete) return this; + _startupTasks.Add(ToAsyncStartup(startupTask)); + return this; + } + + /// + /// Adds a delegate that will be executed exactly once for application initialization + /// once the and all actors is started in this process. + /// + /// A delegate that will be run after all actors + /// have been instantiated. + /// The same instance originally passed in. + public AkkaConfigurationBuilder AddStartup(StartupTask startupTask) + { + if (_complete) return this; + _startupTasks.Add(startupTask); + return this; + } + + public AkkaConfigurationBuilder WithCustomSerializer( + string serializerIdentifier, IEnumerable boundTypes, + Func serializerFactory) + { + var serializerRegistration = new SerializerRegistration(serializerIdentifier, + boundTypes.ToImmutableHashSet(), serializerFactory); + Serializers.Add(serializerRegistration); + return this; + } + + /// + /// Adds a list of Akka.NET extensions that will be started automatically when the + /// starts up. + /// + /// + /// + /// // Starts distributed pub-sub, cluster metrics, and cluster bootstrap extensions at start-up + /// builder.WithExtensions( + /// typeof(DistributedPubSubExtensionProvider), + /// typeof(ClusterMetricsExtensionProvider), + /// typeof(ClusterBootstrapProvider)); + /// + /// + /// An array of extension providers that will be automatically started + /// when the starts + /// This instance, for fluent building pattern + public AkkaConfigurationBuilder WithExtensions(params Type[] extensions) + { + foreach (var extension in extensions) + { + if (!typeof(IExtensionId).IsAssignableFrom(extension)) + throw new ConfigurationException($"Type must extends {nameof(IExtensionId)}: [{extension.FullName}]"); + + var typeInfo = extension.GetTypeInfo(); + if (typeInfo.IsAbstract || !typeInfo.IsClass) + throw new ConfigurationException("Type class must not be abstract or static"); + + if (Extensions.Contains(extension)) + continue; + Extensions.Add(extension); + } + return this; + } + + public AkkaConfigurationBuilder WithExtension() where T : IExtensionId + { + var type = typeof(T); + if (Extensions.Contains(type)) + return this; + + var typeInfo = type.GetTypeInfo(); + if (typeInfo.IsAbstract || !typeInfo.IsClass) + throw new ConfigurationException("Type class must not be abstract or static"); + + Extensions.Add(type); + + return this; + } + + /// + /// Registers an with the . + /// + /// The health check registration. + public AkkaConfigurationBuilder WithHealthCheck(AkkaHealthCheckRegistration registration) + { + HealthChecks[registration.Name] = registration; + return this; + } + + /// + /// Registers a DI-resolved health check with the . + /// The health check type must be registered in the DI container. + /// + /// The healthcheck name. + /// + /// 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. + /// An optional representing the timeout of the check. + /// The type of the health check that implements . + /// The same instance originally passed in. + public AkkaConfigurationBuilder WithHealthCheck(string name, HealthStatus? failureStatus = null, + IEnumerable? tags = null, TimeSpan? timeout = null) where T : class, IAkkaHealthCheck + { + // Create a health check instance that will be resolved from DI when needed + var registration = new AkkaHealthCheckRegistration(name, GetServiceOrCreateInstance, failureStatus, tags, timeout); + + HealthChecks[registration.Name] = registration; + return this; + + static T GetServiceOrCreateInstance(IServiceProvider sp) => ActivatorUtilities.GetServiceOrCreateInstance(sp); + } + + internal void Bind() + { + // register as singleton - not interested in supporting multi-Sys use cases + ServiceCollection.AddSingleton(ActorSystemFactory()); + + ServiceCollection.AddSingleton(sp => + { + return ActorRegistry.For(sp.GetRequiredService()); + }); + + ServiceCollection.AddSingleton(sp => + { + return sp.GetRequiredService(); + }); + + ServiceCollection.AddSingleton(sp => + { + return sp.GetRequiredService(); + }); + + ServiceCollection.AddSingleton(typeof(IRequiredActor<>), typeof(RequiredActor<>)); + + // Automatically register all Akka.NET health checks with the HealthCheckServiceOptions + ServiceCollection.AddOptions() // creates an OptionsBuilder + .PostConfigure((opts, akka) => + { + // Ensure the user’s builder delegate has run + // and health checks are available on `akka` + foreach (var reg in akka.HealthChecks.Select(h => h.Value.ToHealthCheckRegistration())) + opts.Registrations.Add(reg); + }); + } + + /// + /// Configure extensions + /// + private void AddExtensions() + { + if (Extensions.Count == 0) + return; + + // check to see if there are any existing extensions set up inside the current HOCON configuration + if (Configuration.HasValue) + { + var listedExtensions = Configuration.Value.GetStringList("akka.extensions"); + foreach (var listedExtension in listedExtensions) + { + var trimmed = listedExtension.Trim(); + + // sanity check, we should not get any empty entries + if (string.IsNullOrWhiteSpace(trimmed)) + continue; + + var type = Type.GetType(trimmed); + if (type != null) + Extensions.Add(type); + } + } + + AddHoconConfiguration( + $"akka.extensions = [{string.Join(", ", Extensions.Select(s => $"\"{s.AssemblyQualifiedName}\""))}]", + HoconAddMode.Prepend); + } + + private static Func ActorSystemFactory() + { + return sp => + { + var config = sp.GetRequiredService(); + + /* + * Build setups + */ + + // Add auto-started akka extensions, if any. + config.AddExtensions(); + + // check to see if we need a LoggerSetup + var hasLoggerSetup = config.Setups.Any(c => c is LoggerFactorySetup); + if (!hasLoggerSetup) + { + var logger = sp.GetService(); + + // on the off-chance that we're not running with ILogger support enabled + // (should be a rare case that only comes up during testing) + if (logger != null) + { + var loggerSetup = new LoggerFactorySetup(logger); + config.AddSetup(loggerSetup); + } + } + + var diSetup = DependencyResolverSetup.Create(sp); + var bootstrapSetup = BootstrapSetup.Create().WithConfig(config.Configuration.GetOrElse(Config.Empty)); + if (config.ActorRefProvider.HasValue) // only set the provider when explicitly required + { + bootstrapSetup = bootstrapSetup.WithActorRefProvider(config.ActorRefProvider.Value); + } + + var actorSystemSetup = bootstrapSetup.And(diSetup); + foreach (var setup in config.Setups) + { + actorSystemSetup = actorSystemSetup.And(setup); + } + + /* check to see if we have any custom serializers that need to be registered */ + if (config.Serializers.Count > 0) + { + var serializationSetup = SerializationSetup.Create(system => + config.Serializers + .Select(r => + SerializerDetails.Create(r.Id, r.SerializerFactory(system), r.TypeBindings)) + .ToImmutableHashSet()); + + actorSystemSetup = actorSystemSetup.And(serializationSetup); + } + + /* + * Start ActorSystem + */ + var sys = ActorSystem.Create(config.ActorSystemName, actorSystemSetup); + + return sys; + }; + } + + internal Task StartAsync(IServiceProvider sp) + { + return StartAsync(sp.GetRequiredService()); + } + + internal async Task StartAsync(ActorSystem sys) + { + if (_complete) return sys; + _complete = true; + + /* + * Start Actors + */ + + var registry = ActorRegistry.For(sys); + + foreach (var starter in _actorStarters) + { + await starter(sys, registry).ConfigureAwait(false); + } + + foreach (var startupTask in _startupTasks) + { + await startupTask(sys, registry).ConfigureAwait(false); + } + + return sys; + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/AkkaHostedService.cs b/src/contrib/hosting/Akka.Hosting/AkkaHostedService.cs new file mode 100644 index 00000000000..14c5c82b526 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/AkkaHostedService.cs @@ -0,0 +1,102 @@ +using System; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting +{ + /// + /// INTERNAL API + /// + /// + /// Open for modification in cases where users need fine-grained control over startup and + /// DI - however, extend at your own risk. Look at the Akka.Hosting source code for ideas on how to extend this. + /// + [InternalApi] + // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global + public class AkkaHostedService : IHostedService + { + protected ActorSystem? ActorSystem; + protected CoordinatedShutdown? CoordinatedShutdown; // grab a reference to CoordinatedShutdown early + protected readonly IServiceProvider ServiceProvider; + protected readonly AkkaConfigurationBuilder ConfigurationBuilder; + protected readonly IHostApplicationLifetime? HostApplicationLifetime; + protected readonly ILogger Logger; + + public AkkaHostedService(AkkaConfigurationBuilder configurationBuilder, IServiceProvider serviceProvider, + ILogger logger, IHostApplicationLifetime? applicationLifetime) + { + ConfigurationBuilder = configurationBuilder; + HostApplicationLifetime = applicationLifetime; + ServiceProvider = serviceProvider; + Logger = logger; + } + + public virtual async Task StartAsync(CancellationToken cancellationToken) + { + try + { + ActorSystem = ServiceProvider.GetRequiredService(); + CoordinatedShutdown = CoordinatedShutdown.Get(ActorSystem); + await ConfigurationBuilder.StartAsync(ActorSystem); + + async Task TerminationHook() + { + await ActorSystem.WhenTerminated.ConfigureAwait(false); + + /* + * Set a non-zero exit code in the event that we get a known, confirmed unclean shutdown + * from the ActorSystem / CoordinatedShutdown + */ + switch (CoordinatedShutdown.ShutdownReason) + { + case CoordinatedShutdown.ClusterDowningReason _: + case CoordinatedShutdown.ClusterLeavingReason _: + Environment.ExitCode = -1; + break; + } + + HostApplicationLifetime?.StopApplication(); + } + + // terminate the application if the Sys is terminated first + // this can happen in instances such as Akka.Cluster membership changes +#pragma warning disable CS4014 + TerminationHook(); +#pragma warning restore CS4014 + } + catch (Exception ex) + { + Logger.Log(LogLevel.Critical, ex, "Unable to start AkkaHostedService - shutting down application"); + + // resolve https://github.com/akkadotnet/Akka.Hosting/issues/470 - never allow failures to be silent + Console.WriteLine($"Unable to start AkkaHostedService - shutting down application.\nCause: {ex}"); + + // Best effort to perform a clean stop + var capturedException = ExceptionDispatchInfo.Capture(ex); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await StopAsync(cts.Token); + capturedException.Throw(); + } + } + + public virtual async Task StopAsync(CancellationToken cancellationToken) + { + // ActorSystem may have failed to start - skip shutdown sequence if that's the case + // so error message doesn't get conflated. + if (CoordinatedShutdown == null) + { + return; + } + + // run full CoordinatedShutdown on the Sys + await CoordinatedShutdown.Run(CoordinatedShutdown.ClrExitReason.Instance) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/AkkaHostingExtensions.cs b/src/contrib/hosting/Akka.Hosting/AkkaHostingExtensions.cs new file mode 100644 index 00000000000..e18fc116adc --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/AkkaHostingExtensions.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Setup; +using Akka.Configuration; +using Akka.DependencyInjection; +using Akka.Hosting.Configuration; +using Akka.Hosting.HealthChecks; +using Akka.Streams; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Akka.Hosting +{ + /// + /// Extension methods for configuring Akka.NET inside a Microsoft.Extensions.Hosting setup. + /// + public static class AkkaHostingExtensions + { + /// + /// Registers an to this instance and creates a + /// that can be used to configure its + /// behavior and Sys spawning. + /// + /// The service collection to which we are binding Akka.NET. + /// The name of the that will be instantiated. + /// A configuration delegate. + /// The instance. + /// + /// Starts a background that runs the + /// and manages its lifecycle in accordance with Akka.NET best practices. + /// + public static IServiceCollection AddAkka(this IServiceCollection services, string actorSystemName, Action builder) + { + return AddAkka(services, actorSystemName, (configurationBuilder, provider) => + { + builder(configurationBuilder); + }); + } + + /// + /// Registers an to this instance and creates a + /// that can be used to configure its + /// behavior and Sys spawning. + /// + /// The service collection to which we are binding Akka.NET. + /// The name of the that will be instantiated. + /// A configuration delegate that accepts an . + /// The instance. + /// + /// Starts a background that runs the + /// and manages its lifecycle in accordance with Akka.NET best practices. + /// + public static IServiceCollection AddAkka(this IServiceCollection services, string actorSystemName, Action builder) + { + return AddAkka(services, actorSystemName, builder); + } + + public static IServiceCollection AddAkka(this IServiceCollection services, string actorSystemName, Action builder) where T:AkkaHostedService + { + var b = new AkkaConfigurationBuilder(services, actorSystemName); + + // add the default Akka.Streams configuration by default - hurts nothing, but + // ensures that StreamRefs work correctly out of the box in case users + // haven't attempted to materialize a stream yet + b.AddHocon(ActorMaterializer.DefaultConfig(), HoconAddMode.Append); + + services.AddSingleton(sp => + { + builder(b, sp); + return b; + }); + + // registers the hosted services and begins execution + b.Bind(); + + if (Util.IsRunningInMaui) + { + // blow up Maui users who are about to footgun + throw new PlatformNotSupportedException( + "Due to https://github.com/dotnet/maui/issues/2244, normal Akka.Hosting.AddAkka method will not work." + + "Instead, you need to install Akka.Hosting.Maui and use the AddAkkaMaui extension method instead."); + } + else + { + // start the IHostedService which will run Akka.NET + services.AddHostedService(); + } + + return services; + } + + /// + /// Adds a new to this builder. + /// + /// The builder instance being configured. + /// A new instance. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder AddSetup(this AkkaConfigurationBuilder builder, Setup setup) + { + return builder.AddSetup(setup); + } + + /// + /// Adds a element to the being configured. + /// + /// The builder instance being configured. + /// The HOCON to add. + /// The - defaults to appending this HOCON as a fallback. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder AddHocon(this AkkaConfigurationBuilder builder, Config hocon, HoconAddMode addMode) + { + return builder.AddHoconConfiguration(hocon, addMode); + } + + /// + /// Converts an into HOCON instance and adds it to the + /// being configured.
+ /// NOTES:
+ /// + /// All variable name are automatically converted to lower case. + /// All "." (period) in the key will be treated as a HOCON object key separator + /// 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:
+ /// JSON configuration: + /// + /// { + /// "akka.cluster": { + /// "roles": [ "front-end", "back-end" ], + /// "min-nr-of-members": 3, + /// "log-info": true + /// } + /// } + /// + /// and environment variables: + /// + /// AKKA__CLUSTER__ROLES__0=front-end + /// AKKA__CLUSTER__ROLES__1=back-end + /// AKKA__CLUSTER__MIN_NR_OF_MEMBERS=3 + /// AKKA__CLUSTER__LOG_INFO=true + /// + /// is equivalent to HOCON configuration of: + /// + /// akka { + /// cluster { + /// roles: [ front-end, back-end ] + /// min-nr-of-members: 3 + /// log-info: true + /// } + /// } + /// + ///
+ /// The builder instance being configured. + /// The instance to be converted to HOCON . + /// The - defaults to appending this HOCON as a fallback. + /// + /// The same instance originally passed in. + public static AkkaConfigurationBuilder AddHocon( + this AkkaConfigurationBuilder builder, + IConfiguration configuration, + HoconAddMode addMode, + bool normalizeKeys = true) + { + return builder.AddHoconConfiguration(configuration.ToHocon(normalizeKeys), addMode); + } + + /// + /// Automatically loads the given HOCON file from + /// and inserts it into the s' configuration. + /// + /// The builder instance being configured. + /// The path to the HOCON file. Can be relative or absolute. + /// The - defaults to appending this HOCON as a fallback. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder AddHoconFile(this AkkaConfigurationBuilder builder, string hoconFilePath, HoconAddMode addMode) + { + var hoconText = ConfigurationFactory.ParseString(File.ReadAllText(hoconFilePath)); + return AddHocon(builder, hoconText, addMode); + } + + public static AkkaConfigurationBuilder WithActorAskTimeout(this AkkaConfigurationBuilder builder, TimeSpan timeout) + { + return AddHocon(builder, $"akka.actor.ask-timeout = {timeout.ToHocon(true, true)}", HoconAddMode.Prepend); + } + + /// + /// Enables strict serialization mode, which disables the System.Object serialization fallback. + /// When strict serialization is enabled, Akka.NET will throw a SerializationException if + /// no explicit serializer binding exists for a type, instead of silently falling back to the + /// default JSON serializer. + /// + /// This is useful for security (preventing arbitrary type deserialization) and for catching + /// missing serializer registrations during development. + /// + /// + /// Requires Akka.NET 1.5.66+. + /// + /// + /// The builder instance being configured. + /// Whether to enable strict serialization. Defaults to true. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithStrictSerialization(this AkkaConfigurationBuilder builder, bool enabled = true) + { + return AddHocon(builder, $"akka.actor.serialization-settings.allow-unregistered-types = {(enabled ? "off" : "on")}", HoconAddMode.Prepend); + } + + /// + /// Configures the for this . Can be used to + /// configure whether or not Akka, Akka.Remote, or Akka.Cluster starts. + /// + /// The builder instance being configured. + /// A . + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithActorRefProvider(this AkkaConfigurationBuilder builder, + ProviderSelection providerSelection) + { + return builder.WithActorRefProvider(providerSelection); + } + + /// + /// Adds a delegate that will be used exactly once to instantiate + /// actors once the is started in this process. + /// + /// The builder instance being configured. + /// A delegate + /// for configuring and starting actors. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder builder, Action actorStarter) + { + return builder.StartActors(actorStarter); + } + + /// + /// Adds a delegate that will be used exactly once to instantiate + /// actors once the is started in this process. + /// + /// The builder instance being configured. + /// A delegate for starting and configuring actors. + /// The same instance originally passed in. + /// + /// This method supports Akka.DependencyInjection directly by making the 's immediately available. + /// + public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder builder, Action actorStarter) + { + return builder.StartActors(actorStarter); + } + + /// + /// Adds a delegate that will be used exactly once to instantiate + /// actors once the is started in this process. + /// + /// The builder instance being configured. + /// A delegate + /// for configuring and starting actors. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder builder, ActorStarterWithResolver actorStarter) + { + return builder.StartActors(actorStarter); + } + + /// + /// Adds a delegate that will be used exactly once to instantiate + /// actors once the is started in this process. + /// + /// The builder instance being configured. + /// A delegate + /// for configuring and starting actors. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder builder, ActorStarter actorStarter) + { + return builder.StartActors(actorStarter); + } + + /// + /// Registers a named instance. + /// + /// The builder instance being configured. + /// The unique name for this health check. + /// The health check implementation. + /// + /// 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. + /// An optional representing the per-invocation timeout of the check. + /// + /// If you need more detailed configuration for a health check, such as tags or default failure status, + /// please use the method. + /// + public static AkkaConfigurationBuilder WithHealthCheck(this AkkaConfigurationBuilder builder, string name, IAkkaHealthCheck healthCheck, + HealthStatus? failureStatus = null, + IEnumerable? tags = null, TimeSpan? timeout = null) + { + var registration = new AkkaHealthCheckRegistration(name, healthCheck, failureStatus, tags, timeout); + return builder.WithHealthCheck(registration); + } + + /// + /// Registers a named instance. + /// + /// The builder instance being configured. + /// The unique name for this health check. + /// A health checking function. + /// + /// 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. + /// An optional representing the per-invocation timeout of the check. + /// + /// If you need more detailed configuration for a health check, such as tags or default failure status, + /// please use the method. + /// + public static AkkaConfigurationBuilder WithHealthCheck(this AkkaConfigurationBuilder builder, string name, + Func> healthCheck, + HealthStatus? failureStatus = null, + IEnumerable? tags = null, TimeSpan? timeout = null) + { + var healthCheckImpl = new DelegateHealthCheck(healthCheck); + return builder.WithHealthCheck(name, healthCheckImpl, failureStatus, tags, timeout); + } + + /// + /// Default health check for the liveness - if the is + /// terminated, this check will return an unhealthy status. + /// + /// 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. + /// + /// See for more details on how this check works. You can create a custom + /// if you want to customize this. + /// + public static AkkaConfigurationBuilder WithActorSystemLivenessCheck(this AkkaConfigurationBuilder builder, + HealthStatus? failureStatus = null, + IEnumerable? tags = null) + { + string[] defaultTags = ["akka", "liveness"]; + + var actorSystemHealthCheck = new AkkaHealthCheckRegistration("akka.actorsystem", + new ActorSystemLivenessCheck(), failureStatus ?? HealthStatus.Unhealthy, tags ?? defaultTags); + return builder.WithHealthCheck(actorSystemHealthCheck); + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting/AkkaOpenTelemetryExtensions.cs b/src/contrib/hosting/Akka.Hosting/AkkaOpenTelemetryExtensions.cs new file mode 100644 index 00000000000..9563a9468e9 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/AkkaOpenTelemetryExtensions.cs @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using Akka.Hosting.Logging; +using OpenTelemetry.Logs; + +namespace Akka.Hosting +{ + /// + /// Extension methods for integrating Akka.NET logging with OpenTelemetry. + /// + public static class AkkaOpenTelemetryExtensions + { + /// + /// Adds the Akka.NET trace correlation processor to the OpenTelemetry logging pipeline. + /// + /// The OpenTelemetry logger options. + /// The options instance for chaining. + /// + /// + /// This processor extracts trace context (TraceId, SpanId, TraceFlags) from Akka.NET + /// log events and applies them to OpenTelemetry + /// instances. This enables proper trace correlation for logs emitted from actor code, + /// solving the problem that doesn't + /// flow across actor mailbox boundaries. + /// + /// + /// Important: This processor should be registered before any exporters + /// to ensure the trace context is applied before logs are exported. + /// + /// + /// + /// builder.Logging.AddOpenTelemetry(options => + /// { + /// options.SetResourceBuilder(ResourceBuilder.CreateDefault() + /// .AddService("my-service")); + /// + /// // Register Akka trace correlation FIRST (before exporters) + /// options.AddAkkaTraceCorrelation(); + /// + /// options.AddOtlpExporter(); + /// }); + /// + /// + /// + public static OpenTelemetryLoggerOptions AddAkkaTraceCorrelation(this OpenTelemetryLoggerOptions options) + { + options.AddProcessor(new AkkaTraceContextProcessor()); + return options; + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting/AkkaOptions.cs b/src/contrib/hosting/Akka.Hosting/AkkaOptions.cs new file mode 100644 index 00000000000..626f9954452 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/AkkaOptions.cs @@ -0,0 +1,148 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Actor; +using Akka.Event; + +namespace Akka.Hosting; + +public enum TriStateValue +{ + None, + All, + Some +} + +public class DeadLetterOptions +{ + /// + /// Flag to indicate if dead letter should be logged, or published to as + /// , , or . + /// If set to , only the first N number of dead letters will be logged, + /// where N is equal to . + /// + public TriStateValue ShouldLog { get; set; } = TriStateValue.Some; + + /// + /// Number of dead letter messages to be logged. Only effective if is set to + /// + /// + public int? LogCount { get; set; } + + /// + /// Flag to indicate that log letters should not be logged while the is shutting down. + /// + public bool? LogDuringShutdown { get; set; } + + /// + /// Time delay to re-enable dead letter logging when is set to + /// . Suspends logging forever if set to less than or equal to 0, + /// + public TimeSpan? LogSuspendDuration { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + + if (ShouldLog is not TriStateValue.Some) + { + switch (ShouldLog) + { + case TriStateValue.All: + sb.AppendLine("log-dead-letters = on"); + break; + case TriStateValue.None: + sb.AppendLine("log-dead-letters = off"); + break; + default: + throw new IndexOutOfRangeException($"Unknown TriStateValue: {ShouldLog}"); + } + } else if(LogCount is not null) + sb.AppendLine($"log-dead-letters = {LogCount.Value}"); + + if (LogDuringShutdown is not null) + sb.AppendLine($"log-dead-letters-during-shutdown = {LogDuringShutdown.ToHocon()}"); + if (LogSuspendDuration is not null) + sb.AppendLine($"log-dead-letters-suspend-duration = {LogSuspendDuration.ToHocon(allowInfinite: true, zeroIsInfinite: true)}"); + + if(sb.Length == 0) + return string.Empty; + + sb.Insert(0, "akka {"); + sb.AppendLine("}"); + return sb.ToString(); + } +} + +public class DebugOptions +{ + /// + /// Enable logging of any received message at level. + /// + public bool? Receive { get; set; } + + /// + /// Enable logging of all messages + /// (for example, , , etc). + /// + public bool? AutoReceive { get; set; } + + /// + /// Enable logging of actor lifecycle changes + /// + public bool? LifeCycle { get; set; } + + /// + /// Enable logging of for events, transitions + /// and timers. + /// + public bool? FiniteStateMachine { get; set; } + + /// + /// Enable logging of subscription changes on the + /// + /// + public bool? EventStream { get; set; } + + /// + /// Enable logging of unhandled messages + /// + public bool? Unhandled { get; set; } + + /// + /// Enable logging of misconfigured routers + /// + public bool? RouterMisconfiguration { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + if (Receive is { }) + sb.AppendLine($"receive = {Receive.ToHocon()}"); + if(AutoReceive is { }) + sb.AppendLine($"autoreceive = {AutoReceive.ToHocon()}"); + if (LifeCycle is { }) + sb.AppendLine($"lifecycle = {LifeCycle.ToHocon()}"); + if (FiniteStateMachine is { }) + sb.AppendLine($"fsm = {FiniteStateMachine.ToHocon()}"); + if (EventStream is { }) + sb.AppendLine($"event-stream = {EventStream.ToHocon()}"); + if (Unhandled is { }) + sb.AppendLine($"unhandled = {Unhandled.ToHocon()}"); + if (RouterMisconfiguration is { }) + sb.AppendLine($"router-misconfiguration = {RouterMisconfiguration.ToHocon()}"); + + if(sb.Length == 0) + return string.Empty; + + sb.Insert(0, "akka.actor.debug {"); + sb.AppendLine("}"); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/Configuration/ConfigurationHoconAdapter.cs b/src/contrib/hosting/Akka.Hosting/Configuration/ConfigurationHoconAdapter.cs new file mode 100644 index 00000000000..738cc8b42cb --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Configuration/ConfigurationHoconAdapter.cs @@ -0,0 +1,132 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Linq; +using Akka.Configuration; +using Akka.Configuration.Hocon; +using Akka.Util.Internal; +using Microsoft.Extensions.Configuration; + +namespace Akka.Hosting.Configuration +{ + public static class ConfigurationHoconAdapter + { + public static Config ToHocon(this IConfiguration config, bool normalizeKeys = true) + { + var rootObject = new HoconObject(); + if (config is IConfigurationSection section) + { + var value = section.ExpandKey(rootObject, normalizeKeys); + value.AppendValue(section.ToHoconElement(normalizeKeys)); + } + else + { + foreach (var child in config.GetChildren()) + { + var value = child.ExpandKey(rootObject, normalizeKeys); + value.AppendValue(child.ToHoconElement(normalizeKeys)); + } + } + + var rootValue = new HoconValue(); + rootValue.AppendValue(rootObject); + return new Config(new HoconRoot(rootValue)); + } + + private static HoconValue ExpandKey(this IConfigurationSection config, HoconObject parent, bool normalizeKeys) + { + // Sanitize configuration brought in from environment variables, + // "__" are already converted to ":" by the environment configuration provider. + var sanitized = (normalizeKeys ? config.Key.ToLowerInvariant() : config.Key).Replace("_", "-"); + var keys = sanitized.SplitDottedPathHonouringQuotes().ToList(); + + // HOCON does not support objects with empty key name. + // The most probable source for this is a double underscore prefixed environment variable, which + // confuses MS.EXT.Configuration.EnvironmentVariables. Just create a temporary name for it. + if(keys.Count == 0) + return parent.GetOrCreateKey("__"); + + // No need to expand the chain + if (keys.Count == 1) + { + return parent.GetOrCreateKey(keys[0]); + } + + var currentObj = parent; + while (keys.Count > 1) + { + var key = keys.Pop(); + var currentValue = currentObj.GetOrCreateKey(key); + if (currentValue.IsObject()) + { + currentObj = currentValue.GetObject(); + } + else + { + currentObj = new HoconObject(); + currentValue.AppendValue(currentObj); + } + } + + return currentObj.GetOrCreateKey(keys[0]); + } + + private static IHoconElement ToHoconElement(this IConfigurationSection config, bool normalizeKeys) + { + if (config.IsArray()) + { + var array = new HoconArray(); + foreach (var child in config.GetChildren().OrderBy(c => int.Parse(c.Key))) + { + var value = new HoconValue(); + var element = child.ToHoconElement(normalizeKeys); + value.AppendValue(element); + array.Add(value); + } + return array; + } + + if (config.IsObject()) + { + var rootObject = new HoconObject(); + foreach (var child in config.GetChildren()) + { + var value = child.ExpandKey(rootObject, normalizeKeys); + value.AppendValue(child.ToHoconElement(normalizeKeys)); + } + return rootObject; + } + + // Need to back-convert "True" and "False" to "on" and "off" + return new HoconLiteral + { + Value = config.Value switch + { + "True" => "on", + "False" => "off", + _ => config.Value + } + }; + } + + private static string Pop(this IList list) + { + var first = list.First(); + list.RemoveAt(0); + return first; + } + + private static bool IsObject(this IConfigurationSection config) + => config.GetChildren().Any() && config.Value == null; + + private static bool IsArray(this IConfiguration config) + { + var children = config.GetChildren().ToArray(); + return children.Length > 0 && children.All(c => int.TryParse(c.Key, out _)); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/Coordination/LeaseOptionBase.cs b/src/contrib/hosting/Akka.Hosting/Coordination/LeaseOptionBase.cs new file mode 100644 index 00000000000..688dd2ebebd --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Coordination/LeaseOptionBase.cs @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Actor.Setup; + +namespace Akka.Hosting.Coordination +{ + public abstract class LeaseOptionBase : IHoconOption + { + public abstract string ConfigPath { get; } + public abstract Type Class { get; } + public abstract void Apply(AkkaConfigurationBuilder builder, Setup? setup = null); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/HealthChecks/ActorSystemCheck.cs b/src/contrib/hosting/Akka.Hosting/HealthChecks/ActorSystemCheck.cs new file mode 100644 index 00000000000..bd2ba9f3ba9 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/HealthChecks/ActorSystemCheck.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Hosting.HealthChecks; + +/// +/// Checks to see if the is alive or not. +/// +public sealed class ActorSystemLivenessCheck : IAkkaHealthCheck +{ + public Task CheckHealthAsync(AkkaHealthCheckContext context, CancellationToken cancellationToken = default) + { + if (context.ActorSystem.WhenTerminated.IsCompleted) + { + return Task.FromResult(new HealthCheckResult(status: context.Registration.FailureStatus, description: "ActorSystem has terminated.")); + } + + return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, description: "ActorSystem is running.")); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/HealthChecks/AkkaHealthCheckExtensions.cs b/src/contrib/hosting/Akka.Hosting/HealthChecks/AkkaHealthCheckExtensions.cs new file mode 100644 index 00000000000..7c8a539cf86 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/HealthChecks/AkkaHealthCheckExtensions.cs @@ -0,0 +1,28 @@ +using System; +using Akka.Actor; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Hosting.HealthChecks; + +internal static class AkkaHealthCheckExtensions +{ + public const string AkkaTag = "akka"; + + /// + /// Converts an to a + /// + /// the original Akka.NET health check registration. + public static HealthCheckRegistration ToHealthCheckRegistration(this AkkaHealthCheckRegistration registration) + { + // func for lazily instantiating the health check registration + Func adapter = provider => + new HealthCheckAdapter(registration.Factory(provider), provider.GetRequiredService()); + + var tags = registration.Tags; + tags.Add(AkkaTag); + + return new HealthCheckRegistration(registration.Name, adapter, registration.FailureStatus, tags, + registration.Timeout); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/HealthChecks/DelegateHealthCheck.cs b/src/contrib/hosting/Akka.Hosting/HealthChecks/DelegateHealthCheck.cs new file mode 100644 index 00000000000..478d4ce5099 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/HealthChecks/DelegateHealthCheck.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Hosting.HealthChecks; + +/// +/// INTERNAL API +/// +/// Delegate-based health check implementation. Lowest ceremony API for defining health checks on the +/// . +/// +internal sealed class DelegateHealthCheck : IAkkaHealthCheck +{ + private readonly Func> _healthCheckFunc; + + public DelegateHealthCheck(Func> healthCheckFunc) + { + _healthCheckFunc = healthCheckFunc ?? throw new ArgumentNullException(nameof(healthCheckFunc)); + } + + public Task CheckHealthAsync(AkkaHealthCheckContext context, CancellationToken cancellationToken = default) + { + return _healthCheckFunc(context.ActorSystem, context.ActorRegistry, cancellationToken); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/HealthChecks/HealthCheckAdapter.cs b/src/contrib/hosting/Akka.Hosting/HealthChecks/HealthCheckAdapter.cs new file mode 100644 index 00000000000..133500e2f16 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/HealthChecks/HealthCheckAdapter.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Hosting.HealthChecks; + +/// +/// INTERNAL API +/// +/// Adapter wrapper around the to make it API-compatible with +/// +internal sealed class HealthCheckAdapter : IHealthCheck +{ + private readonly IAkkaHealthCheck _healthCheck; + private readonly ActorSystem _actorSystem; + public HealthCheckAdapter(IAkkaHealthCheck healthCheck, ActorSystem actorSystem) + { + _healthCheck = healthCheck; + _actorSystem = actorSystem; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken()) + { + var akkaHealthCheckContext = new AkkaHealthCheckContext(_actorSystem) + { + Registration = context.Registration + }; + + return _healthCheck.CheckHealthAsync(akkaHealthCheckContext, cancellationToken); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/HoconExtensions.cs b/src/contrib/hosting/Akka.Hosting/HoconExtensions.cs new file mode 100644 index 00000000000..6b3e205898a --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/HoconExtensions.cs @@ -0,0 +1,145 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Akka.Configuration; + +namespace Akka.Hosting +{ + public static class HoconExtensions + { + private static readonly Regex EscapeRegex = new ("[][$\"\\\\{}:=,#`^?!@*&]{1}", RegexOptions.Compiled); + + public static string ToHocon(this string? text) + { + // nullable literal value support + if (text is null) + return "null"; + + text = text + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("/", "\\/") + .Replace("\b", "\\b") + .Replace("\f", "\\f") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + + var needReplacement = new HashSet(text.Where(c => c > 255)); + foreach (var c in needReplacement) + { + text = text.Replace($"{c}", $"\\u{(int)c:x4}"); + } + + // double quote support + if (EscapeRegex.IsMatch(text) && !text.IsQuoted()) + text = $"\"{text}\""; + + if (text == string.Empty) + text = "\"\""; + + return text; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsQuoted(this string str) + { + if (str.Length < 2) + return false; + if (str.Length == 2 && str == "\"\"") + return true; + + return str[0] == '"' && str[1] != '"' && str[str.Length - 1] == '"' && str[str.Length - 2] != '"'; + } + + public static string ToHocon(this bool? value) + { + if (value is null) + throw new ConfigurationException("Value can not be null"); + + return value.Value ? "on" : "off"; + } + + public static string ToHocon(this bool value) + => value ? "on" : "off"; + + public static string ToHocon(this TimeSpan? value, bool allowInfinite = false, bool zeroIsInfinite = false) + { + if (value is null) + throw new ConfigurationException("Value can not be null"); + return value.Value.ToHocon(allowInfinite, zeroIsInfinite); + } + + public static string ToHocon(this TimeSpan value, bool allowInfinite = false, bool zeroIsInfinite = false) + { + if (!allowInfinite) + { + if ((zeroIsInfinite && value <= TimeSpan.Zero) || (!zeroIsInfinite && value < TimeSpan.Zero)) + throw new ConfigurationException("Infinite value is not allowed"); + } + + if ((zeroIsInfinite && value <= TimeSpan.Zero) || (!zeroIsInfinite && value < TimeSpan.Zero)) + return "infinite"; + + return value.TotalMilliseconds.ToString(CultureInfo.InvariantCulture); + } + + public static string ToHocon(this float? value) + { + if(value is null) + throw new ConfigurationException("Value can not be null"); + return ToHocon(value.Value); + } + + public static string ToHocon(this float value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + public static string ToHocon(this double? value) + { + if(value is null) + throw new ConfigurationException("Value can not be null"); + return ToHocon(value.Value); + } + + public static string ToHocon(this double value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + public static string ToHocon(this int? value) + { + if(value is null) + throw new ConfigurationException("Value can not be null"); + return ToHocon(value.Value); + } + + public static string ToHocon(this int value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + public static string ToHocon(this long? value) + { + if(value is null) + throw new ConfigurationException("Value can not be null"); + return ToHocon(value.Value); + } + + public static string ToHocon(this long value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/IAkkaHealthCheck.cs b/src/contrib/hosting/Akka.Hosting/IAkkaHealthCheck.cs new file mode 100644 index 00000000000..d08d7d31f51 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/IAkkaHealthCheck.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Hosting; + +/// +/// The registration record used to track this Akka.HealthCheck inside the +/// +/// +/// These will all get converted into s by the , +/// and a default "akka.net" tag will be added to each of those registrations for filtering purposes. +/// +public sealed class AkkaHealthCheckRegistration +{ + private Func _healthCheck; + private string _name; + private TimeSpan _timeout; + + /// + /// Creates a new for an existing + /// + /// The healthcheck name. + /// The instance. + /// + /// 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. + /// Thrown if or is null. + /// Thrown if a negative timeout other than is used. + public AkkaHealthCheckRegistration(string name, IAkkaHealthCheck instance, HealthStatus? failureStatus, + IEnumerable? tags) : this(name, instance, failureStatus, tags, default) + { + + } + + /// + /// Creates a new for an existing + /// + /// The healthcheck name. + /// The instance. + /// + /// 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. + /// An optional representing the timeout of the check. + /// Thrown if or is null. + /// Thrown if a negative timeout other than is used. + public AkkaHealthCheckRegistration(string name, IAkkaHealthCheck instance, HealthStatus? failureStatus, + IEnumerable? tags, TimeSpan? timeout) + { + if(name == null) throw new ArgumentNullException(nameof(name)); + if(instance == null) throw new ArgumentNullException(nameof(instance)); + + if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + _name = name; + FailureStatus = failureStatus ?? HealthStatus.Unhealthy; + Tags = new HashSet(tags ?? [], StringComparer.OrdinalIgnoreCase); + _healthCheck = _ => instance; + Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan; + } + + /// + /// Creates a new template for use with DI-resolved health checks. + /// This constructor is intended for use with generic health check registration methods. + /// + /// The healthcheck name. + /// The DI-enabled factory. + /// + /// 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. + /// An optional representing the timeout of the check. + /// Thrown if is null. + /// Thrown if a negative timeout other than is used. + internal AkkaHealthCheckRegistration(string name, Func factory, HealthStatus? failureStatus, + IEnumerable? tags, TimeSpan? timeout) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + if(factory == null) throw new ArgumentNullException(nameof(factory)); + + if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + _name = name; + FailureStatus = failureStatus ?? HealthStatus.Unhealthy; + Tags = new HashSet(tags ?? [], StringComparer.OrdinalIgnoreCase); + _healthCheck = factory; + Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan; + } + + /// + /// Gets or sets the healthcheck name. + /// + public string Name + { + get => _name; + set => _name = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets the + /// + /// + public Func Factory + { + get => _healthCheck; + set => _healthCheck = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// A set of optional tags used for filtering healthchecks by source. + /// + public ISet Tags { get; } + + /// + /// Gets or sets the that should be reported upon failure of the health check. + /// + public HealthStatus FailureStatus { get; set; } + + /// + /// Gets or sets the timeout used for the test. + /// + public TimeSpan Timeout + { + get => _timeout; + set + { + if (value <= TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _timeout = value; + } + } + +} + +/// +/// Akka.NET health check invocation context. Provides access to the current Akka environment +/// along with the settings for this health check, via . +/// +public sealed class AkkaHealthCheckContext +{ + public AkkaHealthCheckContext(ActorSystem actorSystem) + { + ActorSystem = actorSystem; + } + + /// + /// The that this health check is associated with. + /// + public ActorSystem ActorSystem { get; } + + /// + /// The belonging to the current . + /// + public ActorRegistry ActorRegistry => ActorRegistry.For(ActorSystem); + + /// + /// The health check registration data. + /// + /// Expected to be not-null in live environments, but can be null during testing. + public HealthCheckRegistration Registration { get; set; } = default!; +} + +/// +/// Healthcheck aimed at testing the health of Akka.NET-specific resources. +/// +public interface IAkkaHealthCheck +{ + /// + /// Performs a health-check using information readily available from the and . + /// + /// This can include checking the health status of plugins like Akka.Persistence or Akka.Cluster; or even messaging + /// specific actors and awaiting a response from them. + /// + /// The context associated with the current health-check execution. + /// A cancellation token that will be used to abort the healthcheck operation. + /// + Task CheckHealthAsync(AkkaHealthCheckContext context, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/IDiscoveryOptions.cs b/src/contrib/hosting/Akka.Hosting/IDiscoveryOptions.cs new file mode 100644 index 00000000000..259fb97a33a --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/IDiscoveryOptions.cs @@ -0,0 +1,5 @@ +namespace Akka.Hosting; + +public interface IDiscoveryOptions: IHoconOption +{ +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/IHoconOption.cs b/src/contrib/hosting/Akka.Hosting/IHoconOption.cs new file mode 100644 index 00000000000..44ff9dc7c08 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/IHoconOption.cs @@ -0,0 +1,89 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Actor.Setup; + +namespace Akka.Hosting +{ + /// + /// + /// Standardized interface template for a common HOCON configuration pattern where a configuration takes + /// a HOCON config path and the config path contains a class FQCN property and other settings that + /// are needed by said class. + /// + /// + /// The pattern looks like this: + /// + /// # This HOCON property references to a config block below + /// akka.discovery.method = akka.discovery.config + /// + /// akka.discovery.config { + /// class = "Akka.Discovery.Config.ConfigServiceDiscovery, Akka.Discovery" + /// # other options goes here + /// } + /// + /// + /// + /// + /// Example implementation for the pattern described in the summary + /// + /// // The base class for the option + /// public abstract class DiscoveryOptionBase : IOption + /// { } + /// + /// // The actual option implementation + /// public class ConfigOption : DiscoveryOptionBase + /// { + /// // Actual option implementation here + /// public void Apply(AkkaConfigurationBuilder builder) + /// { + /// // Modifies Akka.NET configuration either via HOCON or setup class + /// builder.AddHocon($"akka.discovery.method = {ConfigPath}", HoconAddMode.Prepend); + /// + /// // Rest of configuration goes here + /// } + /// } + /// + /// // Akka.Hosting extension implementation + /// public static AkkaConfigurationBuilder WithDiscovery( + /// this AkkaConfigurationBuilder builder, + /// DiscoveryOptionBase discOption) + /// { + /// var setup = new DiscoverySetup(); + /// + /// // gets called here + /// discOption.Apply(builder, setup); + /// } + /// + /// + public interface IHoconOption + { + /// + /// The HOCON value of the HOCON path property + /// + string ConfigPath { get; } + + /// + /// The class that will be used for the HOCON class FQCN value + /// + Type Class { get; } + + /// + /// Apply this option to the + /// + /// + /// The to be applied to + /// + /// + /// The to be applied to, if needed. + /// + /// + /// Thrown when requires a setup but it was null + /// + void Apply(AkkaConfigurationBuilder builder, Setup? setup = null); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/LoggerConfigBuilder.cs b/src/contrib/hosting/Akka.Hosting/LoggerConfigBuilder.cs new file mode 100644 index 00000000000..fd7a443404b --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/LoggerConfigBuilder.cs @@ -0,0 +1,176 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Akka.Configuration; +using Akka.Dispatch; +using Akka.Event; + +namespace Akka.Hosting +{ + public sealed class LoggerConfigBuilder + { + private readonly List _loggers = []; + private Type? _logMessageFormatter; + internal AkkaConfigurationBuilder Builder { get; } + + internal LoggerConfigBuilder(AkkaConfigurationBuilder builder) + { + Builder = builder; + } + + /// + /// + /// Log level used by the configured loggers. + /// + /// Defaults to LogLevel.InfoLevel + /// + public LogLevel? LogLevel { get; set; } + + /// + /// + /// Log the complete configuration at INFO level when the actor system is started. + /// This is useful when you are uncertain of what configuration is being used by the ActorSystem. + /// + /// Defaults to false. + /// + public bool? LogConfigOnStart { get; set; } + + public DeadLetterOptions? DeadLetterOptions { get; set; } + + public DebugOptions? DebugOptions { get; set; } + + public LogFilterBuilder? LogFilterBuilder { get; set; } + + [Obsolete("Use the WithDefaultLogMessageFormatter method instead")] + public Type LogMessageFormatter + { + get => _logMessageFormatter ?? typeof(SemanticLogMessageFormatter); + set + { + if (!typeof(ILogMessageFormatter).IsAssignableFrom(value)) + throw new ConfigurationException($"{nameof(LogMessageFormatter)} must implement {nameof(ILogMessageFormatter)}"); + + // Built-in formatters use private constructors with singleton Instance properties; + // Akka.NET's Settings.cs handles these as special cases at runtime. + if (value != typeof(SemanticLogMessageFormatter) && value != typeof(DefaultLogMessageFormatter)) + { + var ctor = value.GetConstructor([]); + if (ctor is null) + throw new ConfigurationException($"{nameof(LogMessageFormatter)} Type must have an empty constructor"); + } + + _logMessageFormatter = value; + } + } + + /// + /// Clear all loggers currently registered. + /// + /// This instance + public LoggerConfigBuilder ClearLoggers() + { + _loggers.Clear(); + return this; + } + + /// + /// Register a logger + /// + /// This instance + public LoggerConfigBuilder AddLogger() where T: IRequiresMessageQueue + { + var logger = typeof(T); + _loggers.Add(logger); + return this; + } + + /// + /// Sets the formatter used by the logger. + /// + /// + /// As of Akka.NET 1.5.58, is the default formatter + /// and is configured automatically. This method is only needed if you have a custom + /// implementation. + /// + [Obsolete("SemanticLogMessageFormatter is now the default. Only use this method if you have a custom ILogMessageFormatter implementation.")] + public LoggerConfigBuilder WithDefaultLogMessageFormatter() where T: ILogMessageFormatter + { +#pragma warning disable CS0618 // Type or member is obsolete + LogMessageFormatter = typeof(T); +#pragma warning restore CS0618 // Type or member is obsolete + return this; + } + + public LoggerConfigBuilder WithLogFilter(Action filterBuilder) + { + LogFilterBuilder ??= new LogFilterBuilder(); + filterBuilder(LogFilterBuilder); + return this; + } + + /// + /// INTERNAL API + /// + /// Used by logger extensions that needed to perform specific tasks before registering a logger type, + /// such as setting up a Setup object with the builder + /// + /// The logger + internal void AddLogger(Type logger) + { + _loggers.Add(logger); + } + + private Config? ToConfig() + { + var sb = new StringBuilder(); + + if(LogLevel is not null) + sb .Append("akka.loglevel=").AppendLine(ParseLogLevel(LogLevel).ToHocon()); + + if(_loggers.Count > 0) + sb.Append("akka.loggers=[").Append(string.Join(",", _loggers.Select(t => $"\"{t.AssemblyQualifiedName}\""))).AppendLine("]"); + + if(LogConfigOnStart is not null) + sb.Append("akka.log-config-on-start=").AppendLine(LogConfigOnStart.Value.ToHocon()); + + if(_logMessageFormatter is not null) + sb.Append("akka.logger-formatter=").AppendLine(_logMessageFormatter.AssemblyQualifiedName.ToHocon()); + + if (DebugOptions is not null) + sb.AppendLine(DebugOptions.ToString()); + + if (DeadLetterOptions is not null) + sb.AppendLine(DeadLetterOptions.ToString()); + + return sb.Length > 0 ? ConfigurationFactory.ParseString(sb.ToString()) : null; + } + + internal AkkaConfigurationBuilder Build(AkkaConfigurationBuilder builder) + { + var config = ToConfig(); + if(config is not null) + builder.AddHoconConfiguration(config, HoconAddMode.Prepend); + if (LogFilterBuilder is not null) + builder.AddSetup(LogFilterBuilder.Build()); + + return builder; + } + + private static string ParseLogLevel(LogLevel? logLevel) + => logLevel switch + { + Akka.Event.LogLevel.DebugLevel => "Debug", + Akka.Event.LogLevel.InfoLevel => "Info", + Akka.Event.LogLevel.WarningLevel => "Warning", + Akka.Event.LogLevel.ErrorLevel => "Error", + _ => throw new ConfigurationException($"Unknown {nameof(LogLevel)} enum value: {logLevel}") + }; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/Logging/AkkaLogState.cs b/src/contrib/hosting/Akka.Hosting/Logging/AkkaLogState.cs new file mode 100644 index 00000000000..1b8fa10cbbb --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Logging/AkkaLogState.cs @@ -0,0 +1,152 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Akka.Hosting.Logging +{ + /// + /// Log state struct that carries both structured log properties and OpenTelemetry trace context. + /// Implements for compatibility with Microsoft.Extensions.Logging. + /// + /// + /// + /// This struct is designed for minimal allocations - it holds references to existing data + /// and yields values lazily via the enumerator rather than copying into a new collection. + /// + /// + /// This struct is used to propagate trace context (TraceId, SpanId, TraceFlags) from the + /// originating actor thread to the logging infrastructure, solving the problem that + /// doesn't flow across actor mailbox boundaries because + /// it uses . + /// + /// + internal readonly struct AkkaLogState : IEnumerable> + { + /// + /// Key for the trace ID in the log state dictionary. + /// + public const string TraceIdKey = "Akka.TraceId"; + + /// + /// Key for the span ID in the log state dictionary. + /// + public const string SpanIdKey = "Akka.SpanId"; + + /// + /// Key for the trace flags in the log state dictionary. + /// + public const string TraceFlagsKey = "Akka.TraceFlags"; + + private readonly ActivityContext _activityContext; + private readonly IReadOnlyDictionary? _semanticProperties; + private readonly string _actorPath; + private readonly DateTimeOffset _timestamp; + private readonly int _threadId; + private readonly string _logSource; + private readonly string _template; + private readonly string _formattedMessage; + + /// + /// Creates an with trace context, semantic properties, and Akka metadata. + /// + /// The activity context containing trace correlation IDs. + /// Structured properties from the log message template (referenced, not copied). + /// The path of the actor that generated the log. + /// The timestamp when the log was created. + /// The managed thread ID of the originating thread. + /// The source of the log event. + /// The message template string. + /// The pre-formatted message for display. + public AkkaLogState( + ActivityContext activityContext, + IReadOnlyDictionary semanticProperties, + string actorPath, + DateTimeOffset timestamp, + int threadId, + string logSource, + string template, + string formattedMessage) + { + _activityContext = activityContext; + _semanticProperties = semanticProperties; + _actorPath = actorPath; + _timestamp = timestamp; + _threadId = threadId; + _logSource = logSource; + _template = template; + _formattedMessage = formattedMessage; + } + + /// + /// Creates an with trace context and Akka metadata for non-structured messages. + /// + /// The activity context containing trace correlation IDs. + /// The path of the actor that generated the log. + /// The timestamp when the log was created. + /// The managed thread ID of the originating thread. + /// The source of the log event. + /// The message template string (for {OriginalFormat}). + /// The pre-formatted message for display. + public AkkaLogState( + ActivityContext activityContext, + string actorPath, + DateTimeOffset timestamp, + int threadId, + string logSource, + string template, + string formattedMessage) + { + _activityContext = activityContext; + _semanticProperties = null; + _actorPath = actorPath; + _timestamp = timestamp; + _threadId = threadId; + _logSource = logSource; + _template = template; + _formattedMessage = formattedMessage; + } + + /// + public IEnumerator> GetEnumerator() + { + // Yield trace context if present (store structs directly, no ToString() allocation) + if (_activityContext.TraceId != default) + { + yield return new KeyValuePair(TraceIdKey, _activityContext.TraceId); + yield return new KeyValuePair(SpanIdKey, _activityContext.SpanId); + yield return new KeyValuePair(TraceFlagsKey, (int)_activityContext.TraceFlags); + } + + // Yield semantic properties if present (referenced, not copied) + if (_semanticProperties != null) + { + foreach (var prop in _semanticProperties) + { + yield return new KeyValuePair(prop.Key, prop.Value); + } + } + + // Yield Akka metadata + yield return new KeyValuePair("ActorPath", _actorPath); + yield return new KeyValuePair("Timestamp", _timestamp); + yield return new KeyValuePair("Thread", _threadId); + yield return new KeyValuePair("LogSource", _logSource); + + // Yield OriginalFormat for MEL convention + yield return new KeyValuePair("{OriginalFormat}", _template); + } + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public override string ToString() => _formattedMessage; + } +} diff --git a/src/contrib/hosting/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs b/src/contrib/hosting/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs new file mode 100644 index 00000000000..654e5a06781 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs @@ -0,0 +1,171 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Diagnostics; +using System.Reflection; +using OpenTelemetry; +using OpenTelemetry.Logs; + +namespace Akka.Hosting.Logging +{ + /// + /// OpenTelemetry log processor that extracts trace context from Akka.NET log events + /// and applies it to the . + /// + /// + /// + /// This processor solves the problem that doesn't flow + /// across actor mailbox boundaries because it uses . + /// + /// + /// When emits logs, it includes the original + /// captured at log creation time as attributes + /// (Akka.TraceId, Akka.SpanId, Akka.TraceFlags). This processor extracts those + /// attributes and sets them on the so that the log + /// is properly correlated with the originating trace. + /// + /// + public sealed class AkkaTraceContextProcessor : BaseProcessor + { + /// + public override void OnEnd(LogRecord data) + { + // Skip if TraceId is already set (e.g., from Activity.Current that happened to be present) + if (data.TraceId != default) + { + return; + } + + // Try to extract trace context from Akka log state attributes + var attributes = data.Attributes; + if (attributes == null) + { + return; + } + + ActivityTraceId? traceId = null; + ActivitySpanId? spanId = null; + ActivityTraceFlags traceFlags = ActivityTraceFlags.None; + + foreach (var attr in attributes) + { + switch (attr.Key) + { + case AkkaLogState.TraceIdKey: + traceId = ExtractTraceId(attr.Value); + break; + + case AkkaLogState.SpanIdKey: + spanId = ExtractSpanId(attr.Value); + break; + + case AkkaLogState.TraceFlagsKey when attr.Value is int flagsInt: + traceFlags = (ActivityTraceFlags)flagsInt; + break; + } + } + + // Only set trace context if we found both TraceId and SpanId + if (traceId.HasValue && spanId.HasValue) + { + SetTraceContext(data, traceId.Value, spanId.Value, traceFlags); + } + } + + private static ActivityTraceId? ExtractTraceId(object? value) + { + // Handle ActivityTraceId directly (no allocation path) + if (value is ActivityTraceId traceId) + { + return traceId; + } + + // Fallback: handle string representation (for backwards compatibility) + if (value is string traceIdStr) + { + return TryParseTraceId(traceIdStr); + } + + return null; + } + + private static ActivitySpanId? ExtractSpanId(object? value) + { + // Handle ActivitySpanId directly (no allocation path) + if (value is ActivitySpanId spanId) + { + return spanId; + } + + // Fallback: handle string representation (for backwards compatibility) + if (value is string spanIdStr) + { + return TryParseSpanId(spanIdStr); + } + + return null; + } + + // LogRecord's TraceId, SpanId and TraceFlags setters are internal, so they are set through + // reflection. The property lookups happen once. If a SetValue call ever fails (an OpenTelemetry + // release changing those setters), we stop trying for the rest of the process rather than pay + // for a failing reflection call on every log record. The trace context attributes remain in + // the log state either way, so nothing is lost except the strongly typed fields. + private static readonly PropertyInfo? TraceIdProperty = typeof(LogRecord).GetProperty("TraceId"); + private static readonly PropertyInfo? SpanIdProperty = typeof(LogRecord).GetProperty("SpanId"); + private static readonly PropertyInfo? TraceFlagsProperty = typeof(LogRecord).GetProperty("TraceFlags"); + private static volatile bool _traceContextSettersUnavailable; + + private static void SetTraceContext(LogRecord record, ActivityTraceId traceId, ActivitySpanId spanId, ActivityTraceFlags traceFlags) + { + if (_traceContextSettersUnavailable) + return; + + try + { + TraceIdProperty?.SetValue(record, traceId); + SpanIdProperty?.SetValue(record, spanId); + TraceFlagsProperty?.SetValue(record, traceFlags); + } + catch (Exception ex) when (ex is TargetInvocationException or TargetException or ArgumentException or MethodAccessException) + { + _traceContextSettersUnavailable = true; + } + } + + private static ActivityTraceId? TryParseTraceId(string traceIdStr) + { + // ActivityTraceId.CreateFromString requires exactly 32 lowercase hex characters and throws + // on anything else, so validate first instead of catching. + return IsLowercaseHex(traceIdStr.AsSpan(), 32) + ? ActivityTraceId.CreateFromString(traceIdStr.AsSpan()) + : null; + } + + private static ActivitySpanId? TryParseSpanId(string spanIdStr) + { + // ActivitySpanId.CreateFromString requires exactly 16 lowercase hex characters. + return IsLowercaseHex(spanIdStr.AsSpan(), 16) + ? ActivitySpanId.CreateFromString(spanIdStr.AsSpan()) + : null; + } + + private static bool IsLowercaseHex(ReadOnlySpan value, int expectedLength) + { + if (value.Length != expectedLength) + return false; + + foreach (var c in value) + { + if (!(c is >= '0' and <= '9' or >= 'a' and <= 'f')) + return false; + } + + return true; + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting/Logging/LoggerFactoryLogger.cs b/src/contrib/hosting/Akka.Hosting/Logging/LoggerFactoryLogger.cs new file mode 100644 index 00000000000..c6f25444473 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Logging/LoggerFactoryLogger.cs @@ -0,0 +1,132 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Akka.Actor; +using Akka.Configuration; +using Akka.Dispatch; +using Akka.Event; +using Microsoft.Extensions.Logging; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.Logging +{ + public class LoggerFactoryLogger: ActorBase, IRequiresMessageQueue + { + /// + /// only used when we're shutting down / spinning up + /// + protected readonly ILoggingAdapter InternalLogger = Akka.Event.Logging.GetLogger(Context.System.EventStream, nameof(LoggerFactoryLogger)); + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _akkaLogger; + + public LoggerFactoryLogger() + { + var setup = Context.System.Settings.Setup.Get(); + if (!setup.HasValue) + throw new ConfigurationException( + $"Could not start {nameof(LoggerFactoryLogger)}, the required setup class " + + $"{nameof(LoggerFactorySetup)} could not be found. Have you added this to the ActorSystem setup?"); + _loggerFactory = setup.Value.LoggerFactory; + _akkaLogger = _loggerFactory.CreateLogger(); + } + + protected override void PostStop() + { + InternalLogger.Info($"{nameof(LoggerFactoryLogger)} stopped"); + } + + protected override bool Receive(object message) + { + switch (message) + { + case InitializeLogger _: + InternalLogger.Info($"{nameof(LoggerFactoryLogger)} started"); + Sender.Tell(new LoggerInitialized()); + return true; + + case LogEvent logEvent: + Log(logEvent, Sender.Path); + return true; + + default: + return false; + } + } + + protected virtual void Log(LogEvent log, ActorPath path) + { + var logLevel = GetLogLevel(log.LogLevel()); + + // Capture ActivityContext (Akka.NET 1.5.59+) for trace correlation + var activityContext = log.ActivityContext ?? default; + + // Use semantic logging to extract structured properties + if (log.TryGetProperties(out var properties) && properties is not null) + { + var formattedMessage = SafeFormat(log); + + // Include trace context and structured properties in AkkaLogState + var state = new AkkaLogState( + activityContext, + properties, + path.ToString(), + log.Timestamp, + log.Thread.ManagedThreadId, + log.LogSource, + log.GetTemplate(), + formattedMessage); + + _akkaLogger.Log(logLevel, new EventId(), state, log.Cause, + (s, ex) => formattedMessage); + } + else + { + var formattedMessage = SafeFormat(log); + var state = new AkkaLogState( + activityContext, + path.ToString(), + log.Timestamp, + log.Thread.ManagedThreadId, + log.LogSource, + log.GetTemplate(), + formattedMessage); + _akkaLogger.Log(logLevel, new EventId(), state, log.Cause, + (s, ex) => formattedMessage); + } + } + + private static string SafeFormat(LogEvent log) + { + try + { + return log.ToString(); + } + catch + { + if (log.Message is LogMessage msg) + return $"Received a malformed formatted message. Log level: [{log.LogLevel()}], Template: [{msg.Format}], args: [{string.Join(",", msg.Unformatted())}]"; + + return $"Received a malformed formatted message. Log level: [{log.LogLevel()}], Message: [{log.Message}]"; + } + } + + private static LogLevel GetLogLevel(Event.LogLevel level) + { + return level switch + { + Event.LogLevel.DebugLevel => LogLevel.Debug, + Event.LogLevel.InfoLevel => LogLevel.Information, + Event.LogLevel.WarningLevel => LogLevel.Warning, + Event.LogLevel.ErrorLevel => LogLevel.Error, + _ => LogLevel.Error + }; + } + } +} diff --git a/src/contrib/hosting/Akka.Hosting/Logging/LoggerFactorySetup.cs b/src/contrib/hosting/Akka.Hosting/Logging/LoggerFactorySetup.cs new file mode 100644 index 00000000000..40a549a3a50 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Logging/LoggerFactorySetup.cs @@ -0,0 +1,22 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using Akka.Actor.Setup; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting.Logging +{ + public class LoggerFactorySetup : Setup + { + public LoggerFactorySetup(ILoggerFactory loggerFactory) + { + LoggerFactory = loggerFactory; + } + + public ILoggerFactory LoggerFactory { get; } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/LoggingExtensions.cs b/src/contrib/hosting/Akka.Hosting/LoggingExtensions.cs new file mode 100644 index 00000000000..7c9cfb61400 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/LoggingExtensions.cs @@ -0,0 +1,68 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Event; +using Akka.Hosting.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting +{ + public static class LoggingExtensions + { + /// + /// Fluent interface to configure the Akka.NET logger system + /// + /// The being configured + /// An action that can be used to modify the logging configuration + /// The original instance + public static AkkaConfigurationBuilder ConfigureLoggers(this AkkaConfigurationBuilder builder, Action configurator) + { + var setup = new LoggerConfigBuilder(builder); + configurator(setup); + return setup.Build(builder); + } + + /// + /// Add the default Akka.NET logger that sinks all log events to the console + /// + /// The instance + /// the original used to configure the logger system + public static LoggerConfigBuilder AddDefaultLogger(this LoggerConfigBuilder configBuilder) + { + configBuilder.AddLogger(); + return configBuilder; + } + + /// + /// Add the logger that sinks all log events to the default + /// instance registered in the host + /// + /// The instance + /// the original used to configure the logger system + public static LoggerConfigBuilder AddLoggerFactory(this LoggerConfigBuilder configBuilder) + { + configBuilder.AddLogger(typeof(LoggerFactoryLogger)); + return configBuilder; + } + + /// + /// Add the logger that sinks all log events to the provided + /// + /// The instance + /// The instance to be used as the log sink + /// the original used to configure the logger system + public static LoggerConfigBuilder AddLoggerFactory(this LoggerConfigBuilder configBuilder, ILoggerFactory loggerFactory) + { + var builder = configBuilder.Builder; + builder.AddSetup(new LoggerFactorySetup(loggerFactory)); + configBuilder.AddLogger(typeof(LoggerFactoryLogger)); + return configBuilder; + } + + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/Properties/FriendsOf.cs b/src/contrib/hosting/Akka.Hosting/Properties/FriendsOf.cs new file mode 100644 index 00000000000..4fe7a7f41ae --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Properties/FriendsOf.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Akka.Hosting.Tests")] +[assembly: InternalsVisibleTo("Akka.Hosting.Maui")] \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Hosting/Util.cs b/src/contrib/hosting/Akka.Hosting/Util.cs new file mode 100644 index 00000000000..0140e2b9864 --- /dev/null +++ b/src/contrib/hosting/Akka.Hosting/Util.cs @@ -0,0 +1,54 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Linq; +using Akka.Configuration; +using Akka.Configuration.Hocon; +using Akka.Util.Internal; + +namespace Akka.Hosting +{ + public static class Util + { + // HACK: MAUI runtime detection + private static bool? _runningInMaui; + internal static bool IsRunningInMaui + { + get + { + _runningInMaui ??= AppDomain.CurrentDomain.GetAssemblies().Any(asm => asm?.GetName()?.Name?.StartsWith("Microsoft.Maui") ?? false); + return _runningInMaui.Value; + } + } + + public static Config MoveTo(this Config config, string path) + { + var rootObj = new HoconObject(); + var rootValue = new HoconValue(); + rootValue.Values.Add(rootObj); + + var lastObject = rootObj; + + var keys = path.SplitDottedPathHonouringQuotes().ToArray(); + for (var i = 0; i < keys.Length - 1; i++) + { + var key = keys[i]; + var innerObject = new HoconObject(); + var innerValue = new HoconValue(); + innerValue.Values.Add(innerObject); + + lastObject.GetOrCreateKey(key); + lastObject.Items[key] = innerValue; + lastObject = innerObject; + } + lastObject.Items[keys[keys.Length - 1]] = config.Root; + + return new Config(new HoconRoot(rootValue)); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj new file mode 100644 index 00000000000..37ea5269378 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -0,0 +1,24 @@ + + + + + $(NetTestVersion) + Exe + false + enable + + + + + + + + + + + + + + + + diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/BuilderOptionsAccessSpec.cs b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/BuilderOptionsAccessSpec.cs new file mode 100644 index 00000000000..a6c243e70ee --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/BuilderOptionsAccessSpec.cs @@ -0,0 +1,220 @@ +using System; +using Akka.Configuration; +using Akka.Hosting; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; +using static Akka.Persistence.Hosting.Tests.UnifiedApiTestResources; + +namespace Akka.Persistence.Hosting.Tests; + +/// +/// Tests that verify builders expose their options, eliminating the need +/// to pass options explicitly to extension methods like WithConnectivityCheck(). +/// +/// This resolves the API design issue identified in https://github.com/akkadotnet/Akka.Hosting/issues/690 +/// +public sealed class BuilderOptionsAccessSpec +{ + private readonly ITestOutputHelper _output; + + public BuilderOptionsAccessSpec(ITestOutputHelper output) + { + _output = output; + } + + [Fact(DisplayName = "Journal builder should expose JournalOptions via Options property")] + public void JournalBuilderShouldExposeOptions() + { + // Arrange + var services = new ServiceCollection(); + var builder = new AkkaConfigurationBuilder(services, "test-system"); + var journalOptions = new TestJournalOptions(isDefault: true) + { + Identifier = "test-journal" + }; + + AkkaPersistenceJournalBuilder? capturedBuilder = null; + + // Act + builder.WithJournal(journalOptions, journal => + { + capturedBuilder = journal; + }); + + // Assert + capturedBuilder.Should().NotBeNull("builder should be passed to callback"); + capturedBuilder!.Options.Should().NotBeNull("Options should be set"); + capturedBuilder.Options!.Should().BeSameAs(journalOptions, "Options should reference the original options instance"); + capturedBuilder.Options!.Identifier.Should().Be("test-journal"); + + _output.WriteLine($"✓ Journal builder correctly exposes options with identifier: {capturedBuilder.Options!.Identifier}"); + } + + [Fact(DisplayName = "Snapshot builder should expose SnapshotOptions via Options property")] + public void SnapshotBuilderShouldExposeOptions() + { + // Arrange + var services = new ServiceCollection(); + var builder = new AkkaConfigurationBuilder(services, "test-system"); + var snapshotOptions = new TestSnapshotOptions(isDefault: true) + { + Identifier = "test-snapshot" + }; + + AkkaPersistenceSnapshotBuilder? capturedBuilder = null; + + // Act + builder.WithSnapshot(snapshotOptions, snapshot => + { + capturedBuilder = snapshot; + }); + + // Assert + capturedBuilder.Should().NotBeNull("builder should be passed to callback"); + capturedBuilder!.Options.Should().NotBeNull("Options should be set"); + capturedBuilder.Options!.Should().BeSameAs(snapshotOptions, "Options should reference the original options instance"); + capturedBuilder.Options!.Identifier.Should().Be("test-snapshot"); + + _output.WriteLine($"✓ Snapshot builder correctly exposes options with identifier: {capturedBuilder.Options!.Identifier}"); + } + + [Fact(DisplayName = "WithJournalAndSnapshot should expose both journal and snapshot options")] + public void WithJournalAndSnapshotShouldExposeBothOptions() + { + // Arrange + var services = new ServiceCollection(); + var builder = new AkkaConfigurationBuilder(services, "test-system"); + var journalOptions = new TestJournalOptions(isDefault: true) + { + Identifier = "test-journal" + }; + var snapshotOptions = new TestSnapshotOptions(isDefault: true) + { + Identifier = "test-snapshot" + }; + + AkkaPersistenceJournalBuilder? capturedJournalBuilder = null; + AkkaPersistenceSnapshotBuilder? capturedSnapshotBuilder = null; + + // Act + builder.WithJournalAndSnapshot( + journalOptions, + snapshotOptions, + journal => { capturedJournalBuilder = journal; }, + snapshot => { capturedSnapshotBuilder = snapshot; }); + + // Assert + capturedJournalBuilder.Should().NotBeNull("journal builder should be passed to callback"); + capturedJournalBuilder!.Options.Should().NotBeNull("journal Options should be set"); + capturedJournalBuilder.Options.Should().BeSameAs(journalOptions); + + capturedSnapshotBuilder.Should().NotBeNull("snapshot builder should be passed to callback"); + capturedSnapshotBuilder!.Options.Should().NotBeNull("snapshot Options should be set"); + capturedSnapshotBuilder.Options.Should().BeSameAs(snapshotOptions); + + _output.WriteLine($"✓ Both builders correctly expose their options"); + } + + [Fact(DisplayName = "Legacy parameterless constructor should have null Options (backward compatibility)")] + public void LegacyParameterlessConstructorShouldHaveNullOptions() + { + // Arrange + var services = new ServiceCollection(); + var akkaBuilder = new AkkaConfigurationBuilder(services, "test-system"); + + // Act - using the parameterless constructor directly (legacy usage) + var journalBuilder = new AkkaPersistenceJournalBuilder("legacy-journal", akkaBuilder); + var snapshotBuilder = new AkkaPersistenceSnapshotBuilder("legacy-snapshot", akkaBuilder); + + // Assert + journalBuilder.Options.Should().BeNull("parameterless constructor should result in null Options for backward compatibility"); + snapshotBuilder.Options.Should().BeNull("parameterless constructor should result in null Options for backward compatibility"); + + _output.WriteLine($"✓ Legacy constructors correctly maintain backward compatibility with null Options"); + } + + [Fact(DisplayName = "WithInMemoryJournal should work without options (backward compatibility)")] + public void WithInMemoryJournalShouldWorkWithoutOptions() + { + // Arrange + var services = new ServiceCollection(); + var builder = new AkkaConfigurationBuilder(services, "test-system"); + + AkkaPersistenceJournalBuilder? capturedBuilder = null; + + // Act + builder.WithInMemoryJournal(journal => + { + capturedBuilder = journal; + }); + + // Assert + capturedBuilder.Should().NotBeNull("builder should be passed to callback"); + capturedBuilder!.Options.Should().BeNull("WithInMemoryJournal doesn't have options, so Options should be null"); + capturedBuilder.JournalId.Should().Be("inmem"); + + _output.WriteLine($"✓ WithInMemoryJournal correctly works without options (backward compatibility maintained)"); + } + + /// + /// Demonstrates the improved API ergonomics - extension methods can now access options + /// from the builder without requiring them as explicit parameters. + /// + [Fact(DisplayName = "Extension methods can access connection details from builder options")] + public void ExtensionMethodsCanAccessConnectionDetailsFromBuilderOptions() + { + // Arrange + var services = new ServiceCollection(); + var builder = new AkkaConfigurationBuilder(services, "test-system"); + + // Simulate a concrete options class with connection details + var journalOptions = new TestJournalOptionsWithConnectionString(isDefault: true) + { + Identifier = "test-journal", + ConnectionString = "Server=localhost;Database=test;User=sa;Password=pass123" + }; + + string? capturedConnectionString = null; + + // Act + builder.WithJournal(journalOptions, journal => + { + // Extension method can now access connection string from builder.Options + // WITHOUT requiring it as an explicit parameter! + if (journal.Options is TestJournalOptionsWithConnectionString options) + { + capturedConnectionString = options.ConnectionString; + } + }); + + // Assert + capturedConnectionString.Should().NotBeNull("extension method should be able to access connection string from builder options"); + capturedConnectionString.Should().Be("Server=localhost;Database=test;User=sa;Password=pass123"); + + _output.WriteLine($"✓ Extension methods can access connection details from builder without explicit parameters"); + _output.WriteLine($" Connection string: {capturedConnectionString}"); + } + + /// + /// Test helper class simulating a real-world options class with connection string + /// + private sealed class TestJournalOptionsWithConnectionString : JournalOptions + { + public TestJournalOptionsWithConnectionString(bool isDefault = true) : base(isDefault) + { + Identifier = "test-journal-with-conn"; + } + + public override string Identifier { get; set; } + + protected override Config InternalDefaultConfig => + ConfigurationFactory.ParseString(""" + class = "Akka.Persistence.Journal.MemoryJournal, Akka.Persistence" + plugin-dispatcher = "akka.actor.default-dispatcher" + """); + + public string? ConnectionString { get; set; } + } +} diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/EventAdapterRuntimeInvocationSpecs.cs b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/EventAdapterRuntimeInvocationSpecs.cs new file mode 100644 index 00000000000..6aa6c390ce1 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/EventAdapterRuntimeInvocationSpecs.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Persistence.Journal; +using Akka.Persistence.Query; +using Akka.Persistence.Query.InMemory; +using Akka.Streams; +using Akka.Streams.Dsl; +using FluentAssertions; +using Xunit; + +namespace Akka.Persistence.Hosting.Tests; + +/// +/// Regression test for https://github.com/akkadotnet/Akka.Persistence.Sql/issues/552 +/// Verifies that event adapters configured via the NEW callback API are actually invoked at runtime +/// by checking that events are tagged and appear in EventsByTag queries. +/// +public class EventAdapterRuntimeInvocationSpecs : Akka.Hosting.TestKit.TestKit +{ + private readonly ITestOutputHelper _output; + + public EventAdapterRuntimeInvocationSpecs(ITestOutputHelper output) + { + _output = output; + } + + #region Test Events and Actors + + public sealed class TestEvent + { + public TestEvent(string data) { Data = data; } + public string Data { get; } + } + + /// + /// Event adapter that tags TestEvent instances + /// + public sealed class TestEventTagger : IWriteEventAdapter + { + public TestEventTagger(ExtendedActorSystem system) { } + + public string Manifest(object evt) => string.Empty; + + public object ToJournal(object evt) + { + return evt switch + { + TestEvent => new Tagged(evt, new[] { "test-tag" }), + _ => evt + }; + } + } + + public sealed class TestPersistentActor : ReceivePersistentActor + { + private readonly List _events = new(); + + public sealed class SaveEvent + { + public SaveEvent(string data) { Data = data; } + public string Data { get; } + } + + public sealed class GetEvents + { + public static readonly GetEvents Instance = new(); + private GetEvents() { } + } + + public TestPersistentActor(string persistenceId) + { + PersistenceId = persistenceId; + + Command(cmd => + { + var evt = new TestEvent(cmd.Data); + Persist(evt, _ => + { + _events.Add(cmd.Data); + Sender.Tell("OK"); + }); + }); + + Command(_ => + { + Sender.Tell(_events.ToArray()); + }); + + Recover(evt => + { + _events.Add(evt.Data); + }); + } + + public override string PersistenceId { get; } + } + + #endregion + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + // Use NEW callback API to add event adapters + builder.WithInMemoryJournal( + journalBuilder: journal => + { + journal.AddWriteEventAdapter( + "test-tagger", + new[] { typeof(TestEvent) }); + }); + } + + [Fact] + public async Task EventAdapter_Should_Tag_Events_And_Appear_In_EventsByTag_Query() + { + // Verify adapter is in HOCON configuration + var config = Sys.Settings.Config; + var journalConfig = config.GetConfig("akka.persistence.journal.inmem"); + + _output.WriteLine("=== HOCON Configuration ==="); + _output.WriteLine(journalConfig.ToString()); + + journalConfig.HasPath("event-adapters").Should().BeTrue("event-adapters should be in HOCON"); + journalConfig.HasPath("event-adapter-bindings").Should().BeTrue("event-adapter-bindings should be in HOCON"); + + // Create persistent actor + var actor = Sys.ActorOf(Props.Create(() => new TestPersistentActor("test-1"))); + + // Persist 3 events + await actor.Ask(new TestPersistentActor.SaveEvent("event-1"), TimeSpan.FromSeconds(3)); + await actor.Ask(new TestPersistentActor.SaveEvent("event-2"), TimeSpan.FromSeconds(3)); + await actor.Ask(new TestPersistentActor.SaveEvent("event-3"), TimeSpan.FromSeconds(3)); + + // CRITICAL: Use Persistence Query to verify events were tagged + var queries = Sys.ReadJournalFor(InMemoryReadJournal.Identifier); + var materializer = Sys.Materializer(); + + await AwaitAssertAsync(async () => + { + var taggedEvents = await queries + .CurrentEventsByTag("test-tag", Offset.NoOffset()) + .RunWith(Sink.Seq(), materializer); + + _output.WriteLine($"Found {taggedEvents.Count()} events with tag 'test-tag'"); + + taggedEvents.Count().Should().Be(3, + "event adapter should have tagged 3 events - if this fails, the adapter was not invoked at runtime"); + + // Verify the events are the correct type + taggedEvents.All(e => e.Event is TestEvent).Should().BeTrue(); + }); + } +} diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs new file mode 100644 index 00000000000..0712733990e --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs @@ -0,0 +1,207 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Persistence.Journal; +using Akka.Util; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Persistence.Hosting.Tests; + +public class EventAdapterSpecs: Akka.Hosting.TestKit.TestKit +{ + public static async Task StartHost(Action testSetup) + { + var host = new HostBuilder() + .ConfigureServices(testSetup).Build(); + + await host.StartAsync(); + return host; + } + + // Mock SQL Server journal options for testing event adapters + private sealed class MockSqlServerJournalOptions : JournalOptions + { + public MockSqlServerJournalOptions() : base(isDefault: false) + { + Identifier = "sql-server"; + } + + public override string Identifier { get; set; } + + protected override Config InternalDefaultConfig => + ConfigurationFactory.ParseString(@" + class = ""Akka.Persistence.Journal.MemoryJournal, Akka.Persistence"" + plugin-dispatcher = ""akka.actor.default-dispatcher"" + "); + } + + public sealed class Event1{ } + public sealed class Event2{ } + + public sealed class EventMapper1 : IWriteEventAdapter + { + public string Manifest(object evt) + { + return string.Empty; + } + + public object ToJournal(object evt) + { + return evt; + } + } + + public sealed class Tagger : IWriteEventAdapter + { + public string Manifest(object evt) + { + return string.Empty; + } + + public object ToJournal(object evt) + { + if (evt is Tagged t) + return t; + return new Tagged(evt, new[] { "foo" }); + } + } + + public sealed class ReadAdapter : IReadEventAdapter + { + public IEventSequence FromJournal(object evt, string manifest) + { + return new SingleEventSequence(evt); + } + } + + public sealed class ComboAdapter : IEventAdapter + { + public string Manifest(object evt) + { + return string.Empty; + } + + public object ToJournal(object evt) + { + return evt; + } + + public IEventSequence FromJournal(object evt, string manifest) + { + return new SingleEventSequence(evt); + } + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + // Using the new unified API: WithJournal(options, builder) + var journalOptions = new MockSqlServerJournalOptions(); + builder.WithJournal(journalOptions, journalBuilder => + { + journalBuilder.AddWriteEventAdapter("mapper1", new Type[] { typeof(Event1) }); + journalBuilder.AddReadEventAdapter("reader1", new Type[] { typeof(Event1) }); + journalBuilder.AddEventAdapter("combo", boundTypes: new Type[] { typeof(Event2) }); + journalBuilder.AddWriteEventAdapter("tagger", + boundTypes: new Type[] { typeof(Event1), typeof(Event2) }); + }); + } + + [Fact] + public void Should_use_correct_EventAdapter_bindings() + { + // act + var config = Sys.Settings.Config; + var sqlPersistenceJournal = config.GetConfig("akka.persistence.journal.sql-server"); + + // assert + sqlPersistenceJournal.GetStringList($"event-adapter-bindings.\"{typeof(Event1).TypeQualifiedName()}\"").Should() + .BeEquivalentTo("mapper1", "reader1", "tagger"); + sqlPersistenceJournal.GetStringList($"event-adapter-bindings.\"{typeof(Event2).TypeQualifiedName()}\"").Should() + .BeEquivalentTo("combo", "tagger"); + + sqlPersistenceJournal.GetString("event-adapters.mapper1").Should().Be(typeof(EventMapper1).TypeQualifiedName()); + sqlPersistenceJournal.GetString("event-adapters.reader1").Should().Be(typeof(ReadAdapter).TypeQualifiedName()); + sqlPersistenceJournal.GetString("event-adapters.combo").Should().Be(typeof(ComboAdapter).TypeQualifiedName()); + sqlPersistenceJournal.GetString("event-adapters.tagger").Should().Be(typeof(Tagger).TypeQualifiedName()); + } +} + +/// +/// Regression test for https://github.com/akkadotnet/Akka.Hosting/issues/665 +/// Verifies that the deprecated Adapters property is ignored and does not configure event adapters +/// +public class DeprecatedAdaptersPropertySpec : Akka.Hosting.TestKit.TestKit +{ + private sealed class TestJournalOptions : JournalOptions + { + public TestJournalOptions() : base(isDefault: true) + { + Identifier = "test-journal"; + } + + public override string Identifier { get; set; } + + protected override Config InternalDefaultConfig => + ConfigurationFactory.ParseString(@" + class = ""Akka.Persistence.Journal.MemoryJournal, Akka.Persistence"" + plugin-dispatcher = ""akka.actor.default-dispatcher"" + "); + } + + public sealed class DeprecatedAdapter : IWriteEventAdapter + { + public string Manifest(object evt) => string.Empty; + public object ToJournal(object evt) => evt; + } + + public sealed class CallbackAdapter : IWriteEventAdapter + { + public string Manifest(object evt) => string.Empty; + public object ToJournal(object evt) => evt; + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var journalOptions = new TestJournalOptions(); + +#pragma warning disable 618, 619 + // Attempt to use the deprecated Adapters property + journalOptions.Adapters = new AkkaPersistenceJournalBuilder("test-journal", builder); + journalOptions.Adapters.AddWriteEventAdapter("deprecated-adapter", + new[] { typeof(string) }); +#pragma warning restore 618, 619 + + // Use the callback pattern (the correct way) + builder.WithJournal(journalOptions, journal => + journal.AddWriteEventAdapter("callback-adapter", + new[] { typeof(int) })); + } + + [Fact] + public void Deprecated_Adapters_property_should_be_ignored() + { + var config = Sys.Settings.Config; + var journalConfig = config.GetConfig("akka.persistence.journal.test-journal"); + + // The deprecated adapter should NOT be registered + journalConfig.HasPath("event-adapters.deprecated-adapter").Should().BeFalse( + "adapters configured via the deprecated Adapters property should be ignored"); + + // The callback adapter SHOULD be registered + journalConfig.HasPath("event-adapters.callback-adapter").Should().BeTrue( + "adapters configured via the callback pattern should work"); + journalConfig.GetString("event-adapters.callback-adapter") + .Should().Be(typeof(CallbackAdapter).TypeQualifiedName()); + + // Verify bindings + journalConfig.HasPath($"event-adapter-bindings.\"{typeof(string).TypeQualifiedName()}\"") + .Should().BeFalse("deprecated adapter bindings should not exist"); + journalConfig.GetStringList($"event-adapter-bindings.\"{typeof(int).TypeQualifiedName()}\"") + .Should().BeEquivalentTo(new[] { "callback-adapter" }, "callback adapter bindings should exist"); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/HoconKeyValidatorSpec.cs b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/HoconKeyValidatorSpec.cs new file mode 100644 index 00000000000..295c1a5d749 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/HoconKeyValidatorSpec.cs @@ -0,0 +1,35 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace Akka.Persistence.Hosting.Tests; + +public class HoconKeyValidatorSpec +{ + [MemberData(nameof(StringFactory))] + [Theory(DisplayName = "HOCON key validator should detect illegal characters")] + public void ValidatorTest(string input, string[] illegals) + { + var illegalChars = input.IsIllegalHoconKey(); + illegalChars.Length.Should().Be(illegals.Length); + illegalChars.Should().BeEquivalentTo(illegals); + } + + public static IEnumerable StringFactory() + { + yield return new object[] { "a.:\u0020", new []{".", ":", "\\u0020"} }; + yield return new object[] { "a.b.c:d:", new []{".", ":"} }; + yield return new object[] { "a..c::", new []{".", ":"} }; + yield return new object[] { "\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006", new []{"\\u00A0", "\\u1680", "\\u2000", "\\u2001", "\\u2002", "\\u2003", "\\u2004", "\\u2005", "\\u2006"} }; + yield return new object[] { "=,#`^", new []{"=", ",", "#", "`", "^"} }; + yield return new object[] { "[x]y{z}", new []{"[", "]", "{", "}"} }; + yield return new object[] { "-_%()'~|+01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ<>", Array.Empty() }; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs new file mode 100644 index 00000000000..4919581e1b4 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Persistence.Hosting.Tests +{ + public class InMemoryPersistenceSpecs: Akka.Hosting.TestKit.TestKit + { + + private readonly ITestOutputHelper _output; + + public InMemoryPersistenceSpecs(ITestOutputHelper output) + { + _output = output; + } + + public sealed class MyPersistenceActor : ReceivePersistentActor + { + private List _values = new List(); + + public MyPersistenceActor(string persistenceId) + { + PersistenceId = persistenceId; + + Recover(offer => + { + if (offer.Snapshot is IEnumerable ints) + { + _values = new List(ints); + } + }); + + Recover(i => + { + _values.Add(i); + }); + + Command(i => + { + Persist(i, i1 => + { + _values.Add(i); + if (LastSequenceNr % 2 == 0) + { + SaveSnapshot(_values); + } + Sender.Tell("ACK"); + }); + }); + + Command(str => str.Equals("getall"), s => + { + Sender.Tell(_values.ToArray()); + }); + + Command(s => {}); + } + + public override string PersistenceId { get; } + } + + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder + .WithInMemoryJournal() + .WithInMemorySnapshotStore() + .StartActors((system, registry) => + { + var myActor = system.ActorOf(Props.Create(() => new MyPersistenceActor("ac1")), "actor1"); + registry.Register(myActor); + }); + } + + [Fact] + public async Task Should_Start_ActorSystem_wth_InMemory_Persistence() + { + // arrange + var myPersistentActor = ActorRegistry.Get(); + + // act + var resp1 = await myPersistentActor.Ask(1, TimeSpan.FromSeconds(3)); + var resp2 = await myPersistentActor.Ask(2, TimeSpan.FromSeconds(3)); + var snapshot = await myPersistentActor.Ask("getall", TimeSpan.FromSeconds(3)); + + // assert + snapshot.Should().BeEquivalentTo(new[] {1, 2}); + + // kill + recreate actor with same PersistentId + await myPersistentActor.GracefulStop(TimeSpan.FromSeconds(3)); + var myPersistentActor2 = Sys.ActorOf(Props.Create(() => new MyPersistenceActor("ac1")), "actor1a"); + + var snapshot2 = await myPersistentActor2.Ask("getall", TimeSpan.FromSeconds(3)); + snapshot2.Should().BeEquivalentTo(new[] {1, 2}); + + // validate configs + var config = Sys.Settings.Config; + config.GetString("akka.persistence.journal.plugin").Should().Be("akka.persistence.journal.inmem"); + config.GetString("akka.persistence.snapshot-store.plugin").Should().Be("akka.persistence.snapshot-store.inmem"); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting.Tests/UnifiedApiSpecs.cs b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/UnifiedApiSpecs.cs new file mode 100644 index 00000000000..c446aa662e1 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting.Tests/UnifiedApiSpecs.cs @@ -0,0 +1,371 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Persistence.Journal; +using Akka.Util; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Persistence.Hosting.Tests; + +/// +/// Shared test resources for unified API specs +/// +public static class UnifiedApiTestResources +{ + // Mock journal options for testing + public sealed class TestJournalOptions : JournalOptions + { + public TestJournalOptions(bool isDefault = true) : base(isDefault) + { + Identifier = "test-journal"; + } + + public override string Identifier { get; set; } + + protected override Config InternalDefaultConfig => + ConfigurationFactory.ParseString(""" + + class = "Akka.Persistence.Journal.MemoryJournal, Akka.Persistence" + plugin-dispatcher = "akka.actor.default-dispatcher" + + """); + } + + // Mock snapshot options for testing + public sealed class TestSnapshotOptions : SnapshotOptions + { + public TestSnapshotOptions(bool isDefault = true) : base(isDefault) + { + Identifier = "test-snapshot"; + } + + public override string Identifier { get; set; } + + protected override Config InternalDefaultConfig => + ConfigurationFactory.ParseString(@" + class = ""Akka.Persistence.Snapshot.MemorySnapshotStore, Akka.Persistence"" + plugin-dispatcher = ""akka.actor.default-dispatcher"" + "); + } + + // Test event adapters + public sealed class TestEvent + { + } + + public sealed class TestWriteAdapter : IWriteEventAdapter + { + public string Manifest(object evt) => string.Empty; + public object ToJournal(object evt) => evt; + } + + public sealed class TestReadAdapter : IReadEventAdapter + { + public IEventSequence FromJournal(object evt, string manifest) + => new SingleEventSequence(evt); + } +} + +/// +/// Test WithJournal(JournalOptions) without builder +/// +public sealed class JournalOptionsOnlySpec : Akka.Hosting.TestKit.TestKit +{ + public JournalOptionsOnlySpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var options = new UnifiedApiTestResources.TestJournalOptions { IsDefaultPlugin = true }; + builder.WithJournal(options); + } + + [Fact] + public void WithJournal_should_configure_journal_options_only() + { + var config = Sys.Settings.Config; + config.GetString("akka.persistence.journal.plugin") + .Should().Be("akka.persistence.journal.test-journal"); + // The plugin should be configured even if class property isn't set in the minimal config + config.HasPath("akka.persistence.journal.test-journal") + .Should().BeTrue(); + } +} + +/// +/// Test WithJournal(JournalOptions, builder) with event adapters +/// +public sealed class JournalWithAdaptersSpec : Akka.Hosting.TestKit.TestKit +{ + public JournalWithAdaptersSpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var options = new UnifiedApiTestResources.TestJournalOptions { IsDefaultPlugin = true }; + builder.WithJournal(options, journal => journal + .AddWriteEventAdapter("test-adapter", + [typeof(UnifiedApiTestResources.TestEvent)]) + .AddReadEventAdapter("test-reader", + [typeof(UnifiedApiTestResources.TestEvent)])); + } + + [Fact] + public void WithJournal_with_builder_should_configure_options_and_adapters() + { + var config = Sys.Settings.Config; + + // assert - journal is configured (checking that at least the path exists) + config.HasPath("akka.persistence.journal.test-journal") + .Should().BeTrue(); + + // assert - adapters are configured + var journalConfig = config.GetConfig("akka.persistence.journal.test-journal"); + journalConfig.GetString("event-adapters.test-adapter") + .Should().Be(typeof(UnifiedApiTestResources.TestWriteAdapter).TypeQualifiedName()); + journalConfig.GetString("event-adapters.test-reader") + .Should().Be(typeof(UnifiedApiTestResources.TestReadAdapter).TypeQualifiedName()); + + var bindings = journalConfig.GetStringList( + $"event-adapter-bindings.\"{typeof(UnifiedApiTestResources.TestEvent).TypeQualifiedName()}\""); + bindings.Should().BeEquivalentTo("test-adapter", "test-reader"); + } +} + +/// +/// Test WithSnapshot(SnapshotOptions) without builder +/// +public sealed class SnapshotOptionsOnlySpec : Akka.Hosting.TestKit.TestKit +{ + public SnapshotOptionsOnlySpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var options = new UnifiedApiTestResources.TestSnapshotOptions { IsDefaultPlugin = true }; + builder.WithSnapshot(options); + } + + [Fact] + public void WithSnapshot_should_configure_snapshot_options_only() + { + var config = Sys.Settings.Config; + config.GetString("akka.persistence.snapshot-store.plugin") + .Should().Be("akka.persistence.snapshot-store.test-snapshot"); + config.HasPath("akka.persistence.snapshot-store.test-snapshot") + .Should().BeTrue(); + } +} + +/// +/// Test WithJournalAndSnapshot without builders +/// +public sealed class JournalAndSnapshotWithoutBuildersSpec : Akka.Hosting.TestKit.TestKit +{ + public JournalAndSnapshotWithoutBuildersSpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var journalOptions = new UnifiedApiTestResources.TestJournalOptions { IsDefaultPlugin = true }; + var snapshotOptions = new UnifiedApiTestResources.TestSnapshotOptions { IsDefaultPlugin = true }; + builder.WithJournalAndSnapshot(journalOptions, snapshotOptions); + } + + [Fact] + public void WithJournalAndSnapshot_should_configure_both_without_builders() + { + var config = Sys.Settings.Config; + config.GetString("akka.persistence.journal.plugin") + .Should().Be("akka.persistence.journal.test-journal"); + config.GetString("akka.persistence.snapshot-store.plugin") + .Should().Be("akka.persistence.snapshot-store.test-snapshot"); + } +} + +/// +/// Test WithJournalAndSnapshot with builder actions +/// +public sealed class JournalAndSnapshotWithBuildersSpec : Akka.Hosting.TestKit.TestKit +{ + public JournalAndSnapshotWithBuildersSpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var journalOptions = new UnifiedApiTestResources.TestJournalOptions { IsDefaultPlugin = true }; + var snapshotOptions = new UnifiedApiTestResources.TestSnapshotOptions { IsDefaultPlugin = true }; + + builder.WithJournalAndSnapshot( + journalOptions, + snapshotOptions, + journal => journal + .AddWriteEventAdapter("adapter", + [typeof(UnifiedApiTestResources.TestEvent)]) + .WithHealthCheck(), + snapshot => snapshot + .WithHealthCheck()); + } + + [Fact] + public async Task WithJournalAndSnapshot_with_builders_should_configure_everything() + { + var config = Sys.Settings.Config; + + // assert - both plugins configured + config.HasPath("akka.persistence.journal.test-journal") + .Should().BeTrue(); + config.HasPath("akka.persistence.snapshot-store.test-snapshot") + .Should().BeTrue(); + + // assert - adapters configured + var journalConfig = config.GetConfig("akka.persistence.journal.test-journal"); + journalConfig.GetString("event-adapters.adapter") + .Should().Be(typeof(UnifiedApiTestResources.TestWriteAdapter).TypeQualifiedName()); + + // assert - health checks are registered and return real results + var healthCheckService = Host.Services.GetRequiredService(); + var result = await healthCheckService.CheckHealthAsync(); + + result.Status.Should().Be(HealthStatus.Healthy, + "both journal and snapshot health checks should be healthy"); + + // Verify both health checks are present + var journalCheck = result.Entries.FirstOrDefault(e => e.Key.Contains("test-journal")); + journalCheck.Should().NotBeNull("journal health check should be registered"); + journalCheck.Value.Status.Should().Be(HealthStatus.Healthy, + "journal health check should return healthy status"); + + var snapshotCheck = result.Entries.FirstOrDefault(e => e.Key.Contains("test-snapshot")); + snapshotCheck.Should().NotBeNull("snapshot health check should be registered"); + snapshotCheck.Value.Status.Should().Be(HealthStatus.Healthy, + "snapshot health check should return healthy status"); + } +} + +/// +/// Regression test for https://github.com/akkadotnet/Akka.Hosting/issues/666 +/// Ensures journal health checks are registered even without event adapters +/// +public sealed class JournalHealthCheckWithoutAdaptersSpec : Akka.Hosting.TestKit.TestKit +{ + public JournalHealthCheckWithoutAdaptersSpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var journalOptions = new UnifiedApiTestResources.TestJournalOptions { IsDefaultPlugin = true }; + + // Configure journal with health check but WITHOUT any event adapters + // This is the regression case from issue #666 + builder.WithJournal( + journalOptions, + journal => journal.WithHealthCheck()); + } + + [Fact] + public async Task Journal_health_check_should_be_registered_without_event_adapters() + { + // assert - journal plugin configured + var config = Sys.Settings.Config; + config.HasPath("akka.persistence.journal.test-journal") + .Should().BeTrue(); + + // assert - health check is registered even without event adapters + var healthCheckService = Host.Services.GetRequiredService(); + var result = await healthCheckService.CheckHealthAsync(); + + result.Status.Should().Be(HealthStatus.Healthy, + "journal health check should be healthy"); + + // Verify journal health check is present + var journalCheck = result.Entries.FirstOrDefault(e => e.Key.Contains("test-journal")); + journalCheck.Should().NotBeNull("journal health check should be registered even without event adapters"); + journalCheck.Value.Status.Should().Be(HealthStatus.Healthy, + "journal health check should return healthy status"); + } +} + +/// +/// Test null builder actions work correctly +/// +public sealed class NullBuilderActionsSpec : Akka.Hosting.TestKit.TestKit +{ + public NullBuilderActionsSpec(ITestOutputHelper output) : base(output: output) + { + } + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + base.ConfigureServices(context, services); + services.AddHealthChecks(); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + var journalOptions = new UnifiedApiTestResources.TestJournalOptions { IsDefaultPlugin = true }; + var snapshotOptions = new UnifiedApiTestResources.TestSnapshotOptions { IsDefaultPlugin = false }; + + // Test that passing null for builder actions works + builder.WithJournal(journalOptions, configureBuilder: null); + builder.WithSnapshot(snapshotOptions, configureBuilder: null); + } + + [Fact] + public void Null_builder_actions_should_work() + { + var config = Sys.Settings.Config; + config.GetString("akka.persistence.journal.plugin") + .Should().Be("akka.persistence.journal.test-journal"); + config.HasPath("akka.persistence.snapshot-store.test-snapshot") + .Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj b/src/contrib/hosting/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj new file mode 100644 index 00000000000..a6bad30cb1f --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj @@ -0,0 +1,17 @@ + + + + Akka.Persistence Microsoft.Extensions.Hosting support. + $(NetStandardLibVersion) + enable + $(AkkaPackageTags);hosting;persistence;event sourcing + true + https://getakka.net/articles/hosting/index.html + + + + + + + + diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs b/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs new file mode 100644 index 00000000000..5b58bf359de --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs @@ -0,0 +1,307 @@ +using System; +using Akka.Hosting; +using Akka.Persistence.Journal; +using Akka.Actor; + +#nullable enable +namespace Akka.Persistence.Hosting +{ + public enum PersistenceMode + { + /// + /// Sets both the akka.persistence.journal and the akka.persistence.snapshot-store to use this plugin. + /// + Both, + + /// + /// Sets ONLY the akka.persistence.journal to use this plugin. + /// + Journal, + + /// + /// Sets ONLY the akka.persistence.snapshot-store to use this plugin. + /// + SnapshotStore, + } + + /// + /// The set of options for generic Akka.Persistence. + /// + public static class AkkaPersistenceHostingExtensions + { + /// + /// A generic way to add both journal and snapshot store configuration to the + /// + /// The builder instance being configured. + /// The specific journal options instance used to configure the journal. For example, an instance of SqlServerJournalOptions + /// The specific snapshot store options instance used to configure the snapshot store. For example, an instance of SqlServerSnapshotOptions + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithJournalAndSnapshot( + this AkkaConfigurationBuilder builder, + JournalOptions journalOptions, + SnapshotOptions snapshotOptions) + => WithJournalAndSnapshot(builder, journalOptions, snapshotOptions, + configureJournal: null, configureSnapshot: null); + + /// + /// A generic way to add both journal and snapshot store configuration to the with support for event adapters and health checks. + /// + /// The builder instance being configured. + /// The specific journal options instance used to configure the journal. For example, an instance of SqlServerJournalOptions + /// The specific snapshot store options instance used to configure the snapshot store. For example, an instance of SqlServerSnapshotOptions + /// Optional action to configure event adapters and health checks for the journal. + /// Optional action to configure health checks for the snapshot store. + /// The same instance originally passed in. + /// + /// + /// + /// builder.WithJournalAndSnapshot( + /// new SqlServerJournalOptions + /// { + /// ConnectionString = "...", + /// IsDefaultPlugin = true + /// }, + /// new SqlServerSnapshotOptions + /// { + /// ConnectionString = "...", + /// IsDefaultPlugin = true + /// }, + /// journal => journal + /// .AddWriteEventAdapter<MyAdapter>("adapter", new[] { typeof(MyEvent) }) + /// .WithHealthCheck(HealthStatus.Degraded), + /// snapshot => snapshot + /// .WithHealthCheck()); + /// + /// + public static AkkaConfigurationBuilder WithJournalAndSnapshot( + this AkkaConfigurationBuilder builder, + JournalOptions journalOptions, + SnapshotOptions snapshotOptions, + Action? configureJournal, + Action? configureSnapshot) + { + + + return builder.WithJournal(journalOptions, configureJournal) + .WithSnapshot(snapshotOptions, configureSnapshot); + } + + /// + /// A generic way to add journal configuration to the + /// + /// The builder instance being configured. + /// The specific journal options instance used to configure the journal. For example, an instance of SqlServerJournalOptions + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithJournal( + this AkkaConfigurationBuilder builder, + JournalOptions journalOptions) + => WithJournal(builder, journalOptions, configureBuilder: null); + + /// + /// A generic way to add journal configuration to the with support for event adapters and health checks. + /// + /// The builder instance being configured. + /// The specific journal options instance used to configure the journal. For example, an instance of SqlServerJournalOptions + /// Optional action to configure event adapters and health checks for this journal. + /// The same instance originally passed in. + /// + /// + /// + /// builder.WithJournal( + /// new SqlServerJournalOptions + /// { + /// ConnectionString = "...", + /// IsDefaultPlugin = true + /// }, + /// journal => journal + /// .AddWriteEventAdapter<MyAdapter>("adapter", new[] { typeof(MyEvent) }) + /// .WithHealthCheck(HealthStatus.Degraded)); + /// + /// + public static AkkaConfigurationBuilder WithJournal( + this AkkaConfigurationBuilder builder, + JournalOptions journalOptions, + Action? configureBuilder) + { + if (journalOptions is null) + throw new ArgumentNullException(nameof(journalOptions)); + + // Apply the options configuration + builder.AddHocon(journalOptions.ToConfig(), HoconAddMode.Prepend); + builder.AddHocon(journalOptions.DefaultConfig, HoconAddMode.Append); + + // Apply the builder configuration (adapters + health checks) if provided + if (configureBuilder != null) + { + var jBuilder = new AkkaPersistenceJournalBuilder(journalOptions.Identifier, builder, journalOptions); + configureBuilder(jBuilder); + jBuilder.Build(); + } + + return builder; + } + + /// + /// A generic way to add snapshot store configuration to the + /// + /// The builder instance being configured. + /// The specific snapshot store options instance used to configure the snapshot store. For example, an instance of SqlServerSnapshotOptions + /// The same instance originally passed in. + /// + public static AkkaConfigurationBuilder WithSnapshot( + this AkkaConfigurationBuilder builder, + SnapshotOptions snapshotOptions) + => WithSnapshot(builder, snapshotOptions, configureBuilder: null); + + /// + /// A generic way to add snapshot store configuration to the with support for health checks. + /// + /// The builder instance being configured. + /// The specific snapshot store options instance used to configure the snapshot store. For example, an instance of SqlServerSnapshotOptions + /// Optional action to configure health checks for this snapshot store. + /// The same instance originally passed in. + /// + /// + /// + /// builder.WithSnapshot( + /// new SqlServerSnapshotOptions + /// { + /// ConnectionString = "...", + /// IsDefaultPlugin = true + /// }, + /// snapshot => snapshot + /// .WithHealthCheck(HealthStatus.Degraded)); + /// + /// + public static AkkaConfigurationBuilder WithSnapshot( + this AkkaConfigurationBuilder builder, + SnapshotOptions snapshotOptions, + Action? configureBuilder) + { + if (snapshotOptions is null) + throw new ArgumentNullException(nameof(snapshotOptions)); + + // Apply the options configuration + builder.AddHocon(snapshotOptions.ToConfig(), HoconAddMode.Prepend); + builder.AddHocon(snapshotOptions.DefaultConfig, HoconAddMode.Append); + + // Apply the builder configuration (health checks) if provided + if (configureBuilder != null) + { + var sBuilder = new AkkaPersistenceSnapshotBuilder(snapshotOptions.Identifier, builder, snapshotOptions); + configureBuilder(sBuilder); + sBuilder.Build(); + } + + return builder; + } + + /// + /// Used to configure a specific Akka.Persistence.Journal instance, primarily to support s. + /// + /// The builder instance being configured. + /// The id of the journal. i.e. if you want to apply this adapter to the `akka.persistence.journal.sql-server` journal, just type `sql-server`. + /// Configuration method for configuring the journal. + /// The same instance originally passed in. + /// + /// This method can be called multiple times for different s. + /// + [Obsolete("Use WithJournal(journalOptions, configureBuilder) instead to combine options configuration with event adapters and health checks. This method will be removed in v1.6.")] + public static AkkaConfigurationBuilder WithJournal( + this AkkaConfigurationBuilder builder, + string journalId, + Action journalBuilder) + { + var jBuilder = new AkkaPersistenceJournalBuilder(journalId, builder); + journalBuilder(jBuilder); + + // build and inject the HOCON + jBuilder.Build(); + return builder; + } + + public static AkkaConfigurationBuilder WithInMemoryJournal(this AkkaConfigurationBuilder builder) + { + return WithInMemoryJournal(builder, _ => { }); + } + + public static AkkaConfigurationBuilder WithInMemoryJournal( + this AkkaConfigurationBuilder builder, + Action journalBuilder, + string journalId = "inmem", + bool isDefaultPlugin = true) + { + + var jBuilder = new AkkaPersistenceJournalBuilder(journalId, builder); + journalBuilder(jBuilder); + + // build and inject the HOCON + jBuilder.Build(); + + var liveConfig = + $$""" + {{(isDefaultPlugin ? $"akka.persistence.journal.plugin = akka.persistence.journal.{journalId}" : "")}} + akka.persistence.journal.{{journalId}} { + class = "Akka.Persistence.Journal.MemoryJournal, Akka.Persistence" + plugin-dispatcher = "akka.actor.default-dispatcher" + } + """; + + return builder.AddHocon(liveConfig, HoconAddMode.Prepend); + } + + public static AkkaConfigurationBuilder WithInMemorySnapshotStore( + this AkkaConfigurationBuilder builder, + string snapshotStoreId = "inmem", + bool isDefaultPlugin = true) + { + var liveConfig = + $$""" + {{(isDefaultPlugin ? $"akka.persistence.snapshot-store.plugin = akka.persistence.snapshot-store.{snapshotStoreId}" : "")}} + akka.persistence.snapshot-store.{{snapshotStoreId}} { + class = "Akka.Persistence.Snapshot.MemorySnapshotStore, Akka.Persistence" + plugin-dispatcher = "akka.actor.default-dispatcher" + } + """; + + return builder.AddHocon(liveConfig, HoconAddMode.Prepend); + } + + /// + /// Adds the Akka.NET v1.4 to v1.5 Akka.Cluster.Sharding persistence event migration adapter to a journal. + /// + /// The builder instance being configured. + /// The specific journal options instance used by Akka.Cluster.Sharding persistence. For example, an instance of SqlServerJournalOptions + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithClusterShardingJournalMigrationAdapter( + this AkkaConfigurationBuilder builder, + JournalOptions journalOptions) + => builder.WithClusterShardingJournalMigrationAdapter(journalOptions.PluginId); + + /// + /// Adds the Akka.NET v1.4 to v1.5 Akka.Cluster.Sharding persistence event migration adapter to a journal. + /// + /// The builder instance being configured. + /// The specific journal identifier used by Akka.Cluster.Sharding persistence. For example, "akka.persistence.journal.sql-server" + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithClusterShardingJournalMigrationAdapter( + this AkkaConfigurationBuilder builder, + string journalId) + { + var config = $$""" + {{journalId}} { + event-adapters { + coordinator-migration = "Akka.Cluster.Sharding.OldCoordinatorStateMigrationEventAdapter, Akka.Cluster.Sharding" + } + + event-adapter-bindings { + "Akka.Cluster.Sharding.ShardCoordinator+IDomainEvent, Akka.Cluster.Sharding" = coordinator-migration + } + } + """; + return builder.AddHocon(config, HoconAddMode.Prepend); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceJournalBuilder.cs b/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceJournalBuilder.cs new file mode 100644 index 00000000000..8aacdb5f102 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceJournalBuilder.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Persistence.Journal; +using Akka.Util; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Persistence.Hosting; + +/// +/// Used to help build journal configurations +/// +public sealed class AkkaPersistenceJournalBuilder +{ + internal readonly string JournalId; + internal readonly AkkaConfigurationBuilder Builder; + internal readonly Dictionary> Bindings = new Dictionary>(); + internal readonly Dictionary Adapters = new Dictionary(); + internal readonly HashSet HealthCheckRegistrations = []; + + /// + /// The instance used to configure this journal. + /// This property allows extension methods to access journal configuration details + /// (such as connection strings) without requiring them as explicit parameters. + /// + public JournalOptions? Options { get; } + + public AkkaPersistenceJournalBuilder(string journalId, AkkaConfigurationBuilder builder) + { + JournalId = journalId; + Builder = builder; + Options = null; + } + + /// + /// Constructor that accepts journal options for improved extension method ergonomics. + /// + /// The journal identifier + /// The Akka configuration builder + /// The journal options instance + public AkkaPersistenceJournalBuilder(string journalId, AkkaConfigurationBuilder builder, JournalOptions options) + { + JournalId = journalId; + Builder = builder; + Options = options; + } + + /// + /// Uses the built-in journal health check on the Akka.Persistence.Journal. + /// + /// Default status to return when the plugin reports + /// or . Defaults to degraded. + /// Optional name to add to the health check. + /// Custom tags for the health check. If null, defaults to ["akka", "persistence", "journal"]. + /// The current builder instance for method chaining. + public AkkaPersistenceJournalBuilder WithHealthCheck(HealthStatus unHealthyStatus = HealthStatus.Degraded, + string? name = null, + IEnumerable? tags = null) + { + var registration = AddDefaultHealthCheck(name, unHealthyStatus, tags); + HealthCheckRegistrations.Add(registration); + return this; + } + + /// + /// For Akka.Persistence plugins that have custom health checks (see https://github.com/akkadotnet/Akka.Hosting/issues/678) + /// + /// The custom health check registration. + /// The current builder instance for method chaining. + public AkkaPersistenceJournalBuilder WithCustomHealthCheck(AkkaHealthCheckRegistration registration) + { + HealthCheckRegistrations.Add(registration); + return this; + } + + public AkkaPersistenceJournalBuilder AddEventAdapter(string eventAdapterName, + IEnumerable boundTypes) where TAdapter : IEventAdapter + { + AddAdapter(eventAdapterName, boundTypes); + + return this; + } + + public AkkaPersistenceJournalBuilder AddReadEventAdapter(string eventAdapterName, + IEnumerable boundTypes) where TAdapter : IReadEventAdapter + { + AddAdapter(eventAdapterName, boundTypes); + + return this; + } + + public AkkaPersistenceJournalBuilder AddWriteEventAdapter(string eventAdapterName, + IEnumerable boundTypes) where TAdapter : IWriteEventAdapter + { + AddAdapter(eventAdapterName, boundTypes); + + return this; + } + + private void AddAdapter(string eventAdapterName, IEnumerable boundTypes) + { + Adapters[eventAdapterName] = typeof(TAdapter); + foreach (var t in boundTypes) + { + if (!Bindings.ContainsKey(t)) + Bindings[t] = new HashSet(); + Bindings[t].Add(eventAdapterName); + } + } + + private AkkaHealthCheckRegistration AddDefaultHealthCheck(string? name, HealthStatus unHealthyStatus, IEnumerable? tags) + { + var pluginId = $"akka.persistence.journal.{JournalId}"; + var healthCheckTags = tags?.ToList() ?? ["akka", "persistence", "journal"]; + var registration = new AkkaHealthCheckRegistration( + name ?? pluginId, + new JournalHealthCheck(pluginId), + unHealthyStatus, + healthCheckTags); + return registration; + } + + /// + /// INTERNAL API - Builds the HOCON and then injects it. + /// + internal void Build() + { + // add the health checks if specified - do this FIRST before any early returns + foreach(var hc in HealthCheckRegistrations) + Builder.WithHealthCheck(hc); + + // useless configuration - don't bother. + if (Adapters.Count == 0 || Bindings.Count == 0) + return; + + var adapters = new StringBuilder() + .Append($"akka.persistence.journal.{JournalId}").Append("{"); + + AppendAdapters(adapters); + + adapters.AppendLine("}"); + + var finalHocon = ConfigurationFactory.ParseString(adapters.ToString()); + Builder.AddHocon(finalHocon, HoconAddMode.Prepend); + } + + internal void AppendAdapters(StringBuilder sb) + { + // useless configuration - don't bother. + if (Adapters.Count == 0 || Bindings.Count == 0) + return; + + sb.AppendLine("event-adapters {"); + foreach (var kv in Adapters) + { + sb.AppendLine($"{kv.Key} = \"{kv.Value.TypeQualifiedName()}\""); + } + + sb.AppendLine("}").AppendLine("event-adapter-bindings {"); + foreach (var kv in Bindings) + { + sb.AppendLine($"\"{kv.Key.TypeQualifiedName()}\" = [{string.Join(",", kv.Value)}]"); + } + + sb.AppendLine("}"); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceSnapshotBuilder.cs b/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceSnapshotBuilder.cs new file mode 100644 index 00000000000..9afad7f47cc --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/AkkaPersistenceSnapshotBuilder.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Linq; +using Akka.Hosting; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Persistence.Hosting; + +/// +/// Used to help build snapshot store configurations +/// +public sealed class AkkaPersistenceSnapshotBuilder +{ + internal readonly string SnapshotStoreId; + internal readonly AkkaConfigurationBuilder Builder; + internal readonly HashSet HealthCheckRegistrations = []; + + /// + /// The instance used to configure this snapshot store. + /// This property allows extension methods to access snapshot store configuration details + /// (such as connection strings) without requiring them as explicit parameters. + /// + public SnapshotOptions? Options { get; } + + public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, AkkaConfigurationBuilder builder) + { + SnapshotStoreId = snapshotStoreId; + Builder = builder; + Options = null; + } + + /// + /// Constructor that accepts snapshot options for improved extension method ergonomics. + /// + /// The snapshot store identifier + /// The Akka configuration builder + /// The snapshot options instance + public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, AkkaConfigurationBuilder builder, SnapshotOptions options) + { + SnapshotStoreId = snapshotStoreId; + Builder = builder; + Options = options; + } + + /// + /// Uses the built-in snapshot store health check on the Akka.Persistence.SnapshotStore. + /// + /// Default status to return when the plugin reports + /// or . Defaults to degraded. + /// Optional name to add to the health check. + /// Custom tags for the health check. If null, defaults to ["akka", "persistence", "snapshot-store"]. + /// The current builder instance for method chaining. + public AkkaPersistenceSnapshotBuilder WithHealthCheck(HealthStatus unHealthyStatus = HealthStatus.Degraded, + string? name = null, + IEnumerable? tags = null) + { + var registration = AddDefaultHealthCheck(name, unHealthyStatus, tags); + HealthCheckRegistrations.Add(registration); + return this; + } + + /// + /// For Akka.Persistence plugins that have custom health checks (see https://github.com/akkadotnet/Akka.Hosting/issues/678) + /// + /// The custom health check registration. + /// The current builder instance for method chaining. + public AkkaPersistenceSnapshotBuilder WithCustomHealthCheck(AkkaHealthCheckRegistration registration) + { + HealthCheckRegistrations.Add(registration); + return this; + } + + /// + /// Backward-compatible overload for external plugins that use the 2-parameter version. + /// + internal AkkaHealthCheckRegistration AddHealthCheck(string? name, HealthStatus unHealthyStatus) + { + return AddDefaultHealthCheck(name, unHealthyStatus, tags: null); + } + + internal AkkaHealthCheckRegistration AddDefaultHealthCheck(string? name, HealthStatus unHealthyStatus, IEnumerable? tags) + { + var pluginId = $"akka.persistence.snapshot-store.{SnapshotStoreId}"; + var healthCheckTags = tags?.ToList() ?? ["akka", "persistence", "snapshot-store"]; + var registration = new AkkaHealthCheckRegistration( + name ?? pluginId, + new SnapshotStoreHealthCheck(pluginId), + unHealthyStatus, + healthCheckTags); + return registration; + } + + /// + /// INTERNAL API - Registers health checks if configured. + /// + internal void Build() + { + // add the health checks if specified + foreach(var hc in HealthCheckRegistrations) + Builder.WithHealthCheck(hc); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/Extensions.cs b/src/contrib/hosting/Akka.Persistence.Hosting/Extensions.cs new file mode 100644 index 00000000000..b07200808f6 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/Extensions.cs @@ -0,0 +1,41 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2023 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Data; +using System.Runtime.CompilerServices; +using Akka.Hosting; + +namespace Akka.Persistence.Hosting +{ + public static class Extensions + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ToHocon(this IsolationLevel? level) + { + if (level is null) + throw new ArgumentNullException(nameof(level)); + return level.Value.ToHocon(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ToHocon(this IsolationLevel level) + { + return level switch + { + IsolationLevel.Unspecified => "unspecified".ToHocon(), + IsolationLevel.ReadCommitted => "read-committed".ToHocon(), + IsolationLevel.ReadUncommitted => "read-uncommitted".ToHocon(), + IsolationLevel.RepeatableRead => "repeatable-read".ToHocon(), + IsolationLevel.Serializable => "serializable".ToHocon(), + IsolationLevel.Snapshot => "snapshot".ToHocon(), + IsolationLevel.Chaos => "chaos".ToHocon(), + _ => throw new IndexOutOfRangeException($"Unknown IsolationLevel value: {level}"), + }; + } + + } +} diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/HealthChecks.cs b/src/contrib/hosting/Akka.Persistence.Hosting/HealthChecks.cs new file mode 100644 index 00000000000..28abe5e3276 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/HealthChecks.cs @@ -0,0 +1,64 @@ +using System.Threading; +using System.Threading.Tasks; +using Akka.Hosting; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Persistence.Hosting; + +internal static class HealthCheckExt +{ + public static HealthCheckResult ToHealthCheckResult(this PersistenceHealthCheckResult persistenceHealthStatus) + => new(persistenceHealthStatus.Status.ToHealthStatus(), persistenceHealthStatus.Description, + persistenceHealthStatus.Exception, persistenceHealthStatus.Data); + + public static HealthStatus ToHealthStatus(this PersistenceHealthStatus persistenceHealthStatus) => persistenceHealthStatus switch + { + PersistenceHealthStatus.Healthy => HealthStatus.Healthy, + PersistenceHealthStatus.Degraded => HealthStatus.Degraded, + _ => HealthStatus.Unhealthy + }; +} + +/// +/// INTERNAL API +/// +/// Leverages internal Akka.Persistence APIs to perform a health check on a journal. +/// +internal sealed class JournalHealthCheck : IAkkaHealthCheck +{ + private readonly string _journalPluginId; + + public JournalHealthCheck(string journalPluginId) + { + _journalPluginId = journalPluginId; + } + + public async Task CheckHealthAsync(AkkaHealthCheckContext context, CancellationToken cancellationToken = default) + { + var persistence = Persistence.Instance.Apply(context.ActorSystem); + var journalResult = await persistence.CheckJournalHealthAsync(_journalPluginId, cancellationToken); + return journalResult.ToHealthCheckResult(); + } +} + +/// +/// INTERNAL API +/// +/// Leverages internal Akka.Persistence APIs to perform a health check on the snapshot store. +/// +internal sealed class SnapshotStoreHealthCheck : IAkkaHealthCheck +{ + private readonly string _snapshotStorePluginId; + + public SnapshotStoreHealthCheck(string snapshotStorePluginId) + { + _snapshotStorePluginId = snapshotStorePluginId; + } + + public async Task CheckHealthAsync(AkkaHealthCheckContext context, CancellationToken cancellationToken = default) + { + var persistence = Persistence.Instance.Apply(context.ActorSystem); + var ssResult = await persistence.CheckSnapshotStoreHealthAsync(_snapshotStorePluginId, cancellationToken); + return ssResult.ToHealthCheckResult(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/JournalOptions.cs b/src/contrib/hosting/Akka.Persistence.Hosting/JournalOptions.cs new file mode 100644 index 00000000000..2a9545b90bd --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/JournalOptions.cs @@ -0,0 +1,130 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Akka.Annotations; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Persistence.Journal; + +#nullable enable +namespace Akka.Persistence.Hosting +{ + /// + /// Base class for all journal options class. If you're writing an options class for SQL plugins, use + /// instead. + /// + public abstract class JournalOptions + { + protected JournalOptions(bool isDefault) + { + IsDefaultPlugin = isDefault; + } + + public bool IsDefaultPlugin { get; set; } + + /// + /// NOTE If you're implementing an option class for new Akka.Hosting persistence, you need to override + /// this property and provide a default value. The default value have to be the default journal configuration + /// identifier name (i.e. "sql-server" or "postgresql" + /// + public abstract string Identifier { get; set; } + + /// + /// + /// Should corresponding journal be initialized automatically, if applicable. + /// + /// Default: false + /// + public bool AutoInitialize { get; set; } = false; + + /// + /// + /// Default serializer used as manifest serializer when applicable and payload serializer when no + /// specific binding overrides are specified + /// + /// Default: null + /// + public string? Serializer { get; set; } + + /// + /// + /// The default configuration for this journal. This must be the actual configuration block for this journal. + /// + /// Example: + /// + /// protected override Config InternalDefaultConfig = PostgreSqlPersistence.DefaultConfiguration() + /// .GetConfig("akka.persistence.journal.postgresql"); + /// + /// + protected abstract Config InternalDefaultConfig { get; } + + /// + /// The default HOCON configuration for this specific snapshot store configuration identifier, normalized to the + /// plugin identifier name + /// + public Config DefaultConfig => InternalDefaultConfig.MoveTo(PluginId); + + /// + /// The journal adapter builder, use this builder to add custom journal + /// , , or + /// + [Obsolete("Use the configureBuilder callback parameter in WithJournal() instead. This property will be removed in v1.6.0. See https://github.com/akkadotnet/Akka.Hosting/issues/665")] + public AkkaPersistenceJournalBuilder Adapters { get; set; } = new ("", null!); + + public string PluginId => $"akka.persistence.journal.{Identifier}"; + + /// + /// The chain config builder. + /// The top caps the configuration with the outer `akka.persistence.journal.{id}` HOCON block. + /// If you need to add more properties into this block, append them __BEFORE__ calling base.Build(). + /// If you need to add more properties outside of this block, append them __AFTER__ calling base.Build(). + /// + /// instance from lower chain + /// The fully built containing the `akka.persistence.journal.{id}` HOCON block. + /// Thrown when is null or whitespace + protected virtual StringBuilder Build(StringBuilder sb) + { + if(string.IsNullOrWhiteSpace(Identifier)) + throw new Exception($"Invalid {GetType()}, {nameof(Identifier)} is null or whitespace"); + + var illegalChars = Identifier.IsIllegalHoconKey(); + if (illegalChars.Length > 0) + { + throw new Exception($"Invalid {GetType()}, {nameof(Identifier)} contains illegal character(s) {string.Join(", ", illegalChars)}"); + } + + sb.Insert(0, $"{PluginId} {{{Environment.NewLine}"); + sb.AppendLine($"auto-initialize = {AutoInitialize.ToHocon()}"); + sb.AppendLine($"serializer = {Serializer.ToHocon()}"); + // Adapters property is deprecated - use the callback pattern in WithJournal() instead + // See https://github.com/akkadotnet/Akka.Hosting/issues/665 + sb.AppendLine("}"); + + if (IsDefaultPlugin) + sb.AppendLine($"akka.persistence.journal.plugin = {PluginId}"); + + return sb; + } + + /// + /// Transforms the journal options class into a HOCON instance + /// + /// The equivalence of this options instance + public Config ToConfig() + => ToString(); + + /// + /// Transforms the journal options class into a HOCON string + /// + /// The HOCON string representation of this options instance + public sealed override string ToString() + => Build(new StringBuilder()).ToString(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/Properties/FriendsOf.cs b/src/contrib/hosting/Akka.Persistence.Hosting/Properties/FriendsOf.cs new file mode 100644 index 00000000000..f454ac80b86 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/Properties/FriendsOf.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Akka.Persistence.Hosting.Tests")] \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/README.md b/src/contrib/hosting/Akka.Persistence.Hosting/README.md new file mode 100644 index 00000000000..aa4c231085b --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/README.md @@ -0,0 +1,54 @@ +# Akka.Persistence.Hosting + +## Akka.Persistence Extension Method + +### WithJournal() Method + +Used to configure a specific Akka.Persistence.Journal instance, primarily to support [Event Adapters](https://getakka.net/articles/persistence/event-adapters.html). + +```csharp +public static AkkaConfigurationBuilder WithJournal( + this AkkaConfigurationBuilder builder, + string journalId, + Action journalBuilder); +``` + +### Parameters + +* `journalId` __string__ + + The id of the journal. i.e. if you want to apply this adapter to the `akka.persistence.journal.sql` journal, just use `"sql"`. + +* `journalBuilder` __Action\__ + + Configuration method for configuring the journal. + +### WithInMemoryJournal() Method + +Add an in-memory journal to the `ActorSystem`, usually for testing purposes. + +```csharp +public static AkkaConfigurationBuilder WithInMemoryJournal( + this AkkaConfigurationBuilder builder); +``` + +```csharp +public static AkkaConfigurationBuilder WithInMemoryJournal( + this AkkaConfigurationBuilder builder, + Action journalBuilder); +``` + +### Parameters + +* `journalBuilder` __Action\__ + + Configuration method for configuring the journal. + +### WithInMemorySnapshotStore() Method + +Add an in-memory snapshot store to the `ActorSystem`, usually for testing purposes. + +```csharp +public static AkkaConfigurationBuilder WithInMemorySnapshotStore( + this AkkaConfigurationBuilder builder); +``` diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/SnapshotOptions.cs b/src/contrib/hosting/Akka.Persistence.Hosting/SnapshotOptions.cs new file mode 100644 index 00000000000..c8c008ab416 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/SnapshotOptions.cs @@ -0,0 +1,116 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Configuration; +using Akka.Hosting; + +namespace Akka.Persistence.Hosting +{ + /// + /// Base class for all snapshot store options class. If you're writing an options class for SQL plugins, use + /// instead. + /// + public abstract class SnapshotOptions + { + protected SnapshotOptions(bool isDefault) + { + IsDefaultPlugin = isDefault; + } + + public bool IsDefaultPlugin { get; set; } + + /// + /// NOTE If you're implementing an option class for new Akka.Hosting persistence, you need to override + /// this property and provide a default value. The default value have to be the default journal configuration + /// identifier name (i.e. "sql-server" or "postgresql" + /// + public abstract string Identifier { get; set; } + + /// + /// + /// Should corresponding snapshot store be initialized automatically, if applicable. + /// + /// Default: false + /// + public bool AutoInitialize { get; set; } = false; + + /// + /// + /// Default serializer used as manifest serializer when applicable and payload serializer when no + /// specific binding overrides are specified + /// + /// Default: null + /// + public string? Serializer { get; set; } + + /// + /// + /// The default configuration for this snapshot store. This must be the actual configuration block for this journal. + /// + /// Example: + /// + /// protected override Config InternalDefaultConfig = PostgreSqlPersistence.DefaultConfiguration() + /// .GetConfig("akka.persistence.snapshot-store.postgresql"); + /// + /// + protected abstract Config InternalDefaultConfig { get; } + + /// + /// The default HOCON configuration for this specific journal configuration identifier, normalized to the + /// plugin identifier name + /// + public Config DefaultConfig => InternalDefaultConfig.MoveTo(PluginId); + + public string PluginId => $"akka.persistence.snapshot-store.{Identifier}"; + + /// + /// The chain config builder. + /// The top caps the configuration with the outer `akka.persistence.snapshot-store.{id}` HOCON block. + /// If you need to add more properties into this block, append them __BEFORE__ calling base.Build(). + /// If you need to add more properties outside of this block, append them __AFTER__ calling base.Build(). + /// + /// instance from lower chain + /// The fully built containing the `akka.persistence.snapshot-store.{id}` HOCON block. + /// Thrown when is null, contains only whitespaces, or contains illegal characters + protected virtual StringBuilder Build(StringBuilder sb) + { + if(string.IsNullOrWhiteSpace(Identifier)) + throw new Exception($"Invalid {GetType()}, {nameof(Identifier)} is null or whitespace"); + + var illegalChars = Identifier.IsIllegalHoconKey(); + if (illegalChars.Length > 0) + { + throw new Exception($"Invalid {GetType()}, {nameof(Identifier)} contains illegal character(s) {string.Join(", ", illegalChars)}"); + } + + sb.Insert(0, $"{PluginId} {{{Environment.NewLine}"); + sb.AppendLine($"serializer = {Serializer.ToHocon()}"); + sb.AppendLine($"auto-initialize = {AutoInitialize.ToHocon()}"); + sb.AppendLine("}"); + + if (IsDefaultPlugin) + sb.AppendLine($"akka.persistence.snapshot-store.plugin = {PluginId}"); + + return sb; + } + + /// + /// Transforms the snapshot store options class into a HOCON instance + /// + /// The equivalence of this options instance + public Config ToConfig() + => ToString(); + + /// + /// Transforms the snapshot store options class into a HOCON string + /// + /// The HOCON string representation of this options instance + public sealed override string ToString() + => Build(new StringBuilder()).ToString(); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/SqlJournalOptions.cs b/src/contrib/hosting/Akka.Persistence.Hosting/SqlJournalOptions.cs new file mode 100644 index 00000000000..63b93c774a3 --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/SqlJournalOptions.cs @@ -0,0 +1,115 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Data; +using System.Text; +using Akka.Hosting; + +#nullable enable +namespace Akka.Persistence.Hosting +{ + /// + /// Base class for all SQL-based journal options class. If you're writing an options class for no-SQL or other kind + /// of plugins, use instead. + /// + public abstract class SqlJournalOptions: JournalOptions + { + protected SqlJournalOptions(bool isDefault): base(isDefault) + { + } + + /// + /// Connection string used for database access + /// + public string ConnectionString { get; set; } = ""; + + /// + /// + /// SQL commands timeout. + /// + /// Default: 30 seconds + /// + public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// + /// The database schema name to table corresponding with persistent journal + /// + /// NOTE When implementing this options, override this property with a valid default schema name for + /// the SQL database + /// + public abstract string SchemaName { get; set; } + + /// + /// + /// The database journal table name corresponding with persistent journal + /// + /// NOTE When implementing this options, override this property with a valid default table name for + /// the SQL database + /// + public abstract string TableName { get; set; } + + /// + /// + /// The database metadata table name corresponding with persistent journal + /// + /// NOTE When implementing this options, override this property with a valid default metadata table + /// name for the SQL database + /// + public abstract string MetadataTableName { get; set; } + + /// + /// + /// Uses the CommandBehavior.SequentialAccess when creating DB commands, providing a performance + /// improvement for reading large BLOBS. + /// + /// Default: false + /// + public abstract bool SequentialAccess { get; set; } + + /// + /// + /// The of all database read query. + /// + /// + /// documentation can be read + /// here + /// + /// NOTE: This is used primarily for backward compatibility, + /// you leave this empty for greenfield projects. + /// + public abstract IsolationLevel ReadIsolationLevel { get; set; } + + /// + /// + /// The of all database write query. + /// + /// + /// documentation can be read + /// here + /// + /// NOTE: This is used primarily for backward compatibility, + /// you leave this empty for greenfield projects. + /// + public abstract IsolationLevel WriteIsolationLevel { get; set; } + + protected override StringBuilder Build(StringBuilder sb) + { + sb.AppendLine($"connection-string = {ConnectionString.ToHocon()}"); + sb.AppendLine($"connection-timeout = {ConnectionTimeout.ToHocon()}"); + sb.AppendLine($"schema-name = {SchemaName.ToHocon()}"); + sb.AppendLine($"table-name = {TableName.ToHocon()}"); + sb.AppendLine($"metadata-table-name = {MetadataTableName.ToHocon()}"); + sb.AppendLine($"sequential-access = {SequentialAccess.ToHocon()}"); + + sb.AppendLine($"read-isolation-level = {ReadIsolationLevel.ToHocon()}"); + sb.AppendLine($"write-isolation-level = {WriteIsolationLevel.ToHocon()}"); + + return base.Build(sb); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/SqlSnapshotOptions.cs b/src/contrib/hosting/Akka.Persistence.Hosting/SqlSnapshotOptions.cs new file mode 100644 index 00000000000..0d56f5f28ae --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/SqlSnapshotOptions.cs @@ -0,0 +1,104 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Data; +using System.Text; +using Akka.Hosting; + +#nullable enable +namespace Akka.Persistence.Hosting +{ + /// + /// Base class for all SQL-based snapshot-store options class. If you're writing an options class for no-SQL or + /// other kind of plugins, use instead. + /// + public abstract class SqlSnapshotOptions: SnapshotOptions + { + protected SqlSnapshotOptions(bool isDefault): base(isDefault) + { + } + + /// + /// Connection string used for database access. + /// + public string ConnectionString { get; set; } = ""; + + /// + /// SQL commands timeout. + /// Default: 30 seconds + /// + public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// + /// The database schema name to table corresponding with persistent snapshot store + /// + /// NOTE When implementing this options, override this property with a valid default schema name for + /// the SQL database + /// + public abstract string SchemaName { get; set; } + + /// + /// + /// The database snapshot store table name corresponding with persistent journal + /// + /// NOTE When implementing this options, override this property with a valid default table name for + /// the SQL database + /// + public abstract string TableName { get; set; } + + /// + /// + /// Uses the CommandBehavior.SequentialAccess when creating DB commands, providing a performance + /// improvement for reading large BLOBS. + /// + /// Default: false + /// + public abstract bool SequentialAccess { get; set; } + + /// + /// + /// The of all database read query. + /// + /// + /// documentation can be read + /// here + /// + /// NOTE: This is used primarily for backward compatibility, + /// you leave this empty for greenfield projects. + /// + public abstract IsolationLevel ReadIsolationLevel { get; set; } + + /// + /// + /// The of all database write query. + /// + /// + /// documentation can be read + /// here + /// + /// NOTE: This is used primarily for backward compatibility, + /// you leave this empty for greenfield projects. + /// + public abstract IsolationLevel WriteIsolationLevel { get; set; } + + protected override StringBuilder Build(StringBuilder sb) + { + sb.AppendLine($"connection-string = {ConnectionString.ToHocon()}"); + sb.AppendLine($"connection-timeout = {ConnectionTimeout.ToHocon()}"); + sb.AppendLine($"schema-name = {SchemaName.ToHocon()}"); + sb.AppendLine($"table-name = {TableName.ToHocon()}"); + sb.AppendLine($"sequential-access = {SequentialAccess.ToHocon()}"); + + sb.AppendLine($"read-isolation-level = {ReadIsolationLevel.ToHocon()}"); + sb.AppendLine($"write-isolation-level = {WriteIsolationLevel.ToHocon()}"); + + return base.Build(sb); + } + + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Persistence.Hosting/Utils.cs b/src/contrib/hosting/Akka.Persistence.Hosting/Utils.cs new file mode 100644 index 00000000000..b3457ce0cca --- /dev/null +++ b/src/contrib/hosting/Akka.Persistence.Hosting/Utils.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Linq; + +namespace Akka.Persistence.Hosting +{ + internal static class Utils + { + // This illegal character list is conservative. Normally '.' and '/' is allowed in a HOCON literal, but we'll + // ban it just because it'll make our life harder in the future. + private const string IllegalChars = "$\"{}[]:=,#`^?!@*&\\./"; + + public static string[] IsIllegalHoconKey(this string s) + { + var illegals = new List(); + foreach (var c in s) + { + if(IllegalChars.Contains(c)) + illegals.Add($"{c}"); + else if(char.IsWhiteSpace(c)) + illegals.Add($"\\u{(int)c:X4}"); + } + + return illegals.Distinct().ToArray(); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj b/src/contrib/hosting/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj new file mode 100644 index 00000000000..e413430c230 --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj @@ -0,0 +1,28 @@ + + + + + $(NetTestVersion) + Exe + false + enable + + + + + + + + + + + + + + + + + Always + + + diff --git a/src/contrib/hosting/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs b/src/contrib/hosting/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs new file mode 100644 index 00000000000..639b6bc62e3 --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs @@ -0,0 +1,680 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Remote.Transport.DotNetty; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Akka.Remote.Hosting.Tests; + +public class RemoteConfigurationSpecs +{ + [Fact(DisplayName = "Empty WithRemoting should return default remoting settings")] + public async Task EmptyWithRemotingConfigTest() + { + // arrange + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting(port:0); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var config = actorSystem.Settings.Config; + var transportFailureDetector = config.GetConfig("akka.remote.transport-failure-detector"); + var watchFailureDetector = config.GetConfig("akka.remote.watch-failure-detector"); + var adapters = config.GetStringList("akka.remote.enabled-transports"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + adapters.Count.Should().Be(1); + adapters[0].Should().Be("akka.remote.dot-netty.tcp"); + + tcpConfig.GetString("hostname").Should().BeEmpty(); + tcpConfig.GetInt("port").Should().Be(0); + tcpConfig.GetString("public-hostname").Should().BeEmpty(); + tcpConfig.GetInt("public-port").Should().Be(0); + tcpConfig.GetByteSize("send-buffer-size").Should().Be(256000); + tcpConfig.GetByteSize("receive-buffer-size").Should().Be(256000); + tcpConfig.GetByteSize("maximum-frame-size").Should().Be(128000); + tcpConfig.GetBoolean("enable-ssl").Should().BeFalse(); + + transportFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(4.Seconds()); + transportFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(120.Seconds()); + + watchFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.Seconds()); + watchFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(10.Seconds()); + watchFailureDetector.GetDouble("threshold").Should().Be(10.0); + watchFailureDetector.GetInt("max-sample-size").Should().Be(200); + watchFailureDetector.GetTimeSpan("min-std-deviation").Should().Be(100.Milliseconds()); + watchFailureDetector.GetTimeSpan("unreachable-nodes-reaper-interval").Should().Be(1.Seconds()); + watchFailureDetector.GetTimeSpan("expected-response-after").Should().Be(1.Seconds()); + } + + [Fact(DisplayName = "Empty WithRemoting should return default remoting settings")] + public async Task WithRemotingWithEmptyOptionsConfigTest() + { + // arrange + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting(new RemoteOptions(){ Port = 0}); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var config = actorSystem.Settings.Config; + var transportFailureDetector = config.GetConfig("akka.remote.transport-failure-detector"); + var watchFailureDetector = config.GetConfig("akka.remote.watch-failure-detector"); + var adapters = config.GetStringList("akka.remote.enabled-transports"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + adapters.Count.Should().Be(1); + adapters[0].Should().Be("akka.remote.dot-netty.tcp"); + + tcpConfig.GetString("hostname").Should().BeEmpty(); + tcpConfig.GetInt("port").Should().Be(0); + tcpConfig.GetString("public-hostname").Should().BeEmpty(); + tcpConfig.GetInt("public-port").Should().Be(0); + tcpConfig.GetByteSize("send-buffer-size").Should().Be(256000); + tcpConfig.GetByteSize("receive-buffer-size").Should().Be(256000); + tcpConfig.GetByteSize("maximum-frame-size").Should().Be(128000); + tcpConfig.GetBoolean("enable-ssl").Should().BeFalse(); + + transportFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(4.Seconds()); + transportFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(120.Seconds()); + + watchFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.Seconds()); + watchFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(10.Seconds()); + watchFailureDetector.GetDouble("threshold").Should().Be(10.0); + watchFailureDetector.GetInt("max-sample-size").Should().Be(200); + watchFailureDetector.GetTimeSpan("min-std-deviation").Should().Be(100.Milliseconds()); + watchFailureDetector.GetTimeSpan("unreachable-nodes-reaper-interval").Should().Be(1.Seconds()); + watchFailureDetector.GetTimeSpan("expected-response-after").Should().Be(1.Seconds()); + } + + [Fact(DisplayName = "WithRemoting should override remote settings")] + public async Task WithRemotingConfigTest() + { + // arrange + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting("0.0.0.0", 0, "localhost", 12345); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var config = actorSystem.Settings.Config; + var adapters = config.GetStringList("akka.remote.enabled-transports"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + adapters.Count.Should().Be(1); + adapters[0].Should().Be("akka.remote.dot-netty.tcp"); + + tcpConfig.GetString("hostname").Should().Be("0.0.0.0"); + tcpConfig.GetInt("port").Should().Be(0); + tcpConfig.GetString("public-hostname").Should().Be("localhost"); + tcpConfig.GetInt("public-port").Should().Be(12345); + } + + [Fact(DisplayName = "WithRemoting with RemoteOptions should override remote settings")] + public async Task WithRemotingOptionsTest() + { + // arrange + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting(new RemoteOptions + { + HostName = "0.0.0.0", + Port = 0, + PublicHostName = "localhost", + PublicPort = 12345, + SendBufferSize = 1024000, + ReceiveBufferSize = 512000, + MaxFrameSize = 256000, + TransportFailureDetector = new DeadlineFailureDetectorOptions + { + HeartbeatInterval = 1.1.Seconds(), + AcceptableHeartbeatPause = 1.2.Seconds(), + }, + WatchFailureDetector = new PhiAccrualFailureDetectorOptions + { + HeartbeatInterval = 1.3.Seconds(), + AcceptableHeartbeatPause = 1.4.Seconds(), + Threshold = 1.5, + MaxSampleSize = 1, + MinStandardDeviation = 1.6.Seconds(), + UnreachableNodesReaperInterval = 1.7.Seconds(), + ExpectedResponseAfter = 1.8.Seconds() + } + }); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var config = actorSystem.Settings.Config; + var transportFailureDetector = config.GetConfig("akka.remote.transport-failure-detector"); + var watchFailureDetector = config.GetConfig("akka.remote.watch-failure-detector"); + var adapters = config.GetStringList("akka.remote.enabled-transports"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + adapters.Count.Should().Be(1); + adapters[0].Should().Be("akka.remote.dot-netty.tcp"); + + tcpConfig.GetString("hostname").Should().Be("0.0.0.0"); + tcpConfig.GetInt("port").Should().Be(0); + tcpConfig.GetString("public-hostname").Should().Be("localhost"); + tcpConfig.GetInt("public-port").Should().Be(12345); + tcpConfig.GetByteSize("send-buffer-size").Should().Be(1024000); + tcpConfig.GetByteSize("receive-buffer-size").Should().Be(512000); + tcpConfig.GetByteSize("maximum-frame-size").Should().Be(256000); + + transportFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.1.Seconds()); + transportFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(1.2.Seconds()); + + watchFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.3.Seconds()); + watchFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(1.4.Seconds()); + watchFailureDetector.GetDouble("threshold").Should().Be(1.5); + watchFailureDetector.GetInt("max-sample-size").Should().Be(1); + watchFailureDetector.GetTimeSpan("min-std-deviation").Should().Be(1.6.Seconds()); + watchFailureDetector.GetTimeSpan("unreachable-nodes-reaper-interval").Should().Be(1.7.Seconds()); + watchFailureDetector.GetTimeSpan("expected-response-after").Should().Be(1.8.Seconds()); + } + + [Fact(DisplayName = "WithRemoting should override remote settings that are overriden")] + public async Task WithRemotingConfigOverrideTest() + { + // arrange + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting(publicHostname: "localhost", publicPort:12345, port:0); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var config = actorSystem.Settings.Config; + var adapters = config.GetStringList("akka.remote.enabled-transports"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + adapters.Count.Should().Be(1); + adapters[0].Should().Be("akka.remote.dot-netty.tcp"); + + tcpConfig.GetString("hostname").Should().BeEmpty(); + tcpConfig.GetInt("port").Should().Be(0); + tcpConfig.GetString("public-hostname").Should().Be("localhost"); + tcpConfig.GetInt("public-port").Should().Be(12345); + } + + [Fact(DisplayName = "RemoteOptions should override remote settings that are overriden")] + public void WithRemotingOptionsOverrideTest() + { + // arrange + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(new RemoteOptions + { + HostName = "a", + PublicHostName = "b", + Port = 123, + PublicPort = 456, + EnableSsl = true, + SendBufferSize = 256, + ReceiveBufferSize = 512, + MaxFrameSize = 128, + Ssl = new SslOptions + { + SuppressValidation = true, + CertificateOptions = new SslCertificateOptions + { + Path = "c", + Password = "d", + UseThumbprintOverFile = true, + Thumbprint = "e", + StoreName = "f", + StoreLocation = "g", + } + }, + TransportFailureDetector = new DeadlineFailureDetectorOptions + { + HeartbeatInterval = 1.1.Seconds(), + AcceptableHeartbeatPause = 1.2.Seconds(), + }, + WatchFailureDetector = new PhiAccrualFailureDetectorOptions + { + HeartbeatInterval = 1.3.Seconds(), + AcceptableHeartbeatPause = 1.4.Seconds(), + Threshold = 1.5, + MaxSampleSize = 1, + MinStandardDeviation = 1.6.Seconds(), + UnreachableNodesReaperInterval = 1.7.Seconds(), + ExpectedResponseAfter = 1.8.Seconds() + } + }); + + // act + var config = builder.Configuration.Value; + var transportFailureDetector = config.GetConfig("akka.remote.transport-failure-detector"); + var watchFailureDetector = config.GetConfig("akka.remote.watch-failure-detector"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + tcpConfig.GetString("hostname").Should().Be("a"); + tcpConfig.GetInt("port").Should().Be(123); + tcpConfig.GetString("public-hostname").Should().Be("b"); + tcpConfig.GetInt("public-port").Should().Be(456); + tcpConfig.GetByteSize("send-buffer-size").Should().Be(256); + tcpConfig.GetByteSize("receive-buffer-size").Should().Be(512); + tcpConfig.GetByteSize("maximum-frame-size").Should().Be(128); + + var sslConfig = tcpConfig.GetConfig("ssl"); + sslConfig.GetBoolean("suppress-validation").Should().BeTrue(); + + var certConfig = sslConfig.GetConfig("certificate"); + certConfig.GetString("path").Should().Be("c"); + certConfig.GetString("password").Should().Be("d"); + certConfig.GetBoolean("use-thumbprint-over-file").Should().BeTrue(); + certConfig.GetString("thumbprint").Should().Be("e"); + certConfig.GetString("store-name").Should().Be("f"); + certConfig.GetString("store-location").Should().Be("g"); + + transportFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.1.Seconds()); + transportFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(1.2.Seconds()); + + watchFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.3.Seconds()); + watchFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(1.4.Seconds()); + watchFailureDetector.GetDouble("threshold").Should().Be(1.5); + watchFailureDetector.GetInt("max-sample-size").Should().Be(1); + watchFailureDetector.GetTimeSpan("min-std-deviation").Should().Be(1.6.Seconds()); + watchFailureDetector.GetTimeSpan("unreachable-nodes-reaper-interval").Should().Be(1.7.Seconds()); + watchFailureDetector.GetTimeSpan("expected-response-after").Should().Be(1.8.Seconds()); + } + + [Fact(DisplayName = "RemoteOptions using configurator should override remote settings that are overriden")] + public void WithRemotingOptionsConfiguratorOverrideTest() + { + // arrange + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(opt => + { + opt.HostName = "a"; + opt.PublicHostName = "b"; + opt.Port = 123; + opt.PublicPort = 456; + opt.EnableSsl = true; + opt.SendBufferSize = 256; + opt.ReceiveBufferSize = 512; + opt.MaxFrameSize = 128; + opt.Ssl.SuppressValidation = true; + opt.Ssl.CertificateOptions.Path = "c"; + opt.Ssl.CertificateOptions.Password = "d"; + opt.Ssl.CertificateOptions.UseThumbprintOverFile = true; + opt.Ssl.CertificateOptions.Thumbprint = "e"; + opt.Ssl.CertificateOptions.StoreName = "f"; + opt.Ssl.CertificateOptions.StoreLocation = "g"; + opt.TransportFailureDetector = new DeadlineFailureDetectorOptions + { + HeartbeatInterval = 1.1.Seconds(), + AcceptableHeartbeatPause = 1.2.Seconds(), + }; + opt.WatchFailureDetector = new PhiAccrualFailureDetectorOptions + { + HeartbeatInterval = 1.3.Seconds(), + AcceptableHeartbeatPause = 1.4.Seconds(), + Threshold = 1.5, + MaxSampleSize = 1, + MinStandardDeviation = 1.6.Seconds(), + UnreachableNodesReaperInterval = 1.7.Seconds(), + ExpectedResponseAfter = 1.8.Seconds() + }; + }); + + // act + var config = builder.Configuration.Value; + var transportFailureDetector = config.GetConfig("akka.remote.transport-failure-detector"); + var watchFailureDetector = config.GetConfig("akka.remote.watch-failure-detector"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + tcpConfig.GetString("hostname").Should().Be("a"); + tcpConfig.GetInt("port").Should().Be(123); + tcpConfig.GetString("public-hostname").Should().Be("b"); + tcpConfig.GetInt("public-port").Should().Be(456); + tcpConfig.GetByteSize("send-buffer-size").Should().Be(256); + tcpConfig.GetByteSize("receive-buffer-size").Should().Be(512); + tcpConfig.GetByteSize("maximum-frame-size").Should().Be(128); + + var sslConfig = tcpConfig.GetConfig("ssl"); + sslConfig.GetBoolean("suppress-validation").Should().BeTrue(); + + var certConfig = sslConfig.GetConfig("certificate"); + certConfig.GetString("path").Should().Be("c"); + certConfig.GetString("password").Should().Be("d"); + certConfig.GetBoolean("use-thumbprint-over-file").Should().BeTrue(); + certConfig.GetString("thumbprint").Should().Be("e"); + certConfig.GetString("store-name").Should().Be("f"); + certConfig.GetString("store-location").Should().Be("g"); + + transportFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.1.Seconds()); + transportFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(1.2.Seconds()); + + watchFailureDetector.GetTimeSpan("heartbeat-interval").Should().Be(1.3.Seconds()); + watchFailureDetector.GetTimeSpan("acceptable-heartbeat-pause").Should().Be(1.4.Seconds()); + watchFailureDetector.GetDouble("threshold").Should().Be(1.5); + watchFailureDetector.GetInt("max-sample-size").Should().Be(1); + watchFailureDetector.GetTimeSpan("min-std-deviation").Should().Be(1.6.Seconds()); + watchFailureDetector.GetTimeSpan("unreachable-nodes-reaper-interval").Should().Be(1.7.Seconds()); + watchFailureDetector.GetTimeSpan("expected-response-after").Should().Be(1.8.Seconds()); + } + + [Fact(DisplayName = "RemoteOptions with explicit certificate and ssl enabled should use provided certificate")] + public void WithRemotingOptionsSslEnabledCertificateTest() + { + // arrange + var certificate = X509CertificateLoader.LoadPkcs12FromFile("./Resources/akka-validcert.pfx", "password"); + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(new RemoteOptions + { + EnableSsl = true, + Ssl = new SslOptions + { + SuppressValidation = true, + X509Certificate = certificate + } + }); + + // act + var setup = (DotNettySslSetup) builder.Setups.First(s => s is DotNettySslSetup); + + // assert + setup.SuppressValidation.Should().BeTrue(); + setup.Certificate.Should().Be(certificate); + } + + [Fact(DisplayName = "RemoteOptions with explicit certificate and ssl disabled should ignore provided certificate")] + public void WithRemotingOptionsSslDisabledCertificateTest() + { + // arrange + var certificate = X509CertificateLoader.LoadPkcs12FromFile("./Resources/akka-validcert.pfx", "password"); + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(new RemoteOptions + { + EnableSsl = false, + Ssl = new SslOptions + { + SuppressValidation = true, + X509Certificate = certificate + } + }); + + // act + var setup = builder.Setups.FirstOrDefault(s => s is DotNettySslSetup); + + // assert + setup.Should().BeNull(); + } + + [Fact(DisplayName = "RemoteOptions with new SSL/TLS settings should generate correct HOCON configuration")] + public void WithRemotingNewSslSettingsHoconTest() + { + // arrange + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(new RemoteOptions + { + EnableSsl = true, + Ssl = new SslOptions + { + SuppressValidation = false, + RequireMutualAuthentication = false, // Explicitly set to false for testing + ValidateCertificateHostname = true, // Explicitly set to true for testing + // NOTE: Not providing X509Certificate so HOCON configuration will be generated + // When X509Certificate is provided, DotNettySslSetup is used instead of HOCON + CertificateOptions = new SslCertificateOptions + { + Path = "./Resources/akka-validcert.pfx", + Password = "password" + } + } + }); + + // act + var config = builder.Configuration.Value; + var sslConfig = config.GetConfig("akka.remote.dot-netty.tcp.ssl"); + + // assert + sslConfig.GetBoolean("suppress-validation").Should().BeFalse(); + sslConfig.GetBoolean("require-mutual-authentication").Should().BeFalse(); + sslConfig.GetBoolean("validate-certificate-hostname").Should().BeTrue(); + + var certConfig = sslConfig.GetConfig("certificate"); + certConfig.GetString("path").Should().Be("./Resources/akka-validcert.pfx"); + certConfig.GetString("password").Should().Be("password"); + } + + [Fact(DisplayName = "RemoteOptions with new SSL/TLS settings should properly configure DotNettySslSetup")] + public void WithRemotingNewSslSettingsDotNettySslSetupTest() + { + // arrange + var certificate = X509CertificateLoader.LoadPkcs12FromFile("./Resources/akka-validcert.pfx", "password"); + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(new RemoteOptions + { + EnableSsl = true, + Ssl = new SslOptions + { + SuppressValidation = false, + RequireMutualAuthentication = false, + ValidateCertificateHostname = true, + X509Certificate = certificate + } + }); + + // act + var setup = (DotNettySslSetup)builder.Setups.First(s => s is DotNettySslSetup); + + // assert + setup.SuppressValidation.Should().BeFalse(); + setup.Certificate.Should().Be(certificate); + // Note: The RequireMutualAuthentication and ValidateCertificateHostname properties + // are now passed to DotNettySslSetup via the 4-parameter constructor in Akka.NET v1.5.53 + setup.RequireMutualAuthentication.Should().BeFalse(); + setup.ValidateCertificateHostname.Should().BeTrue(); + } + + [Fact(DisplayName = "RemoteOptions with CustomValidator should properly configure DotNettySslSetup with custom validation")] + public void WithRemotingCustomValidatorDotNettySslSetupTest() + { + // arrange + var certificate = X509CertificateLoader.LoadPkcs12FromFile("./Resources/akka-validcert.pfx", "password"); + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + + // Create a simple custom validator for testing + Transport.DotNetty.CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + // This is just a test validator - in real usage, this would contain actual validation logic + return cert != null && cert.Thumbprint == certificate.Thumbprint; + }; + + builder.WithRemoting(new RemoteOptions + { + EnableSsl = true, + Ssl = new SslOptions + { + SuppressValidation = false, + RequireMutualAuthentication = true, + ValidateCertificateHostname = false, + X509Certificate = certificate, + CustomValidator = customValidator + } + }); + + // act + var setup = (DotNettySslSetup)builder.Setups.First(s => s is DotNettySslSetup); + + // assert + setup.Certificate.Should().Be(certificate); + setup.SuppressValidation.Should().BeFalse(); + setup.RequireMutualAuthentication.Should().BeTrue(); + setup.ValidateCertificateHostname.Should().BeFalse(); + setup.CustomValidator.Should().NotBeNull(); + setup.CustomValidator.Should().BeSameAs(customValidator); + } + + [Fact(DisplayName = "RemoteOptions without new SSL/TLS settings should use default values")] + public void WithRemotingDefaultSslSettingsTest() + { + // arrange + var certificate = X509CertificateLoader.LoadPkcs12FromFile("./Resources/akka-validcert.pfx", "password"); + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(new RemoteOptions + { + EnableSsl = true, + Ssl = new SslOptions + { + X509Certificate = certificate + // RequireMutualAuthentication and ValidateCertificateHostname not specified + } + }); + + // act + var setup = (DotNettySslSetup)builder.Setups.First(s => s is DotNettySslSetup); + + // assert + setup.Should().NotBeNull(); + setup.Certificate.Should().Be(certificate); + setup.RequireMutualAuthentication.Should().BeTrue(); + setup.ValidateCertificateHostname.Should().BeFalse(); + } + + [Fact(DisplayName = "RemoteOptions using configurator should set new SSL/TLS properties correctly")] + public void WithRemotingConfiguratorNewSslSettingsTest() + { + // arrange + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test"); + builder.WithRemoting(opt => + { + opt.EnableSsl = true; + opt.Ssl.RequireMutualAuthentication = true; + opt.Ssl.ValidateCertificateHostname = false; + // Use CertificateOptions instead of X509Certificate to test HOCON configuration + // When X509Certificate is provided, DotNettySslSetup takes precedence and HOCON is not emitted + opt.Ssl.CertificateOptions.Path = "./Resources/akka-validcert.pfx"; + opt.Ssl.CertificateOptions.Password = "password"; + }); + + // act + var config = builder.Configuration.Value; + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + var sslConfig = tcpConfig.GetConfig("ssl"); + + // assert + tcpConfig.GetBoolean("enable-ssl").Should().BeTrue(); + sslConfig.GetBoolean("require-mutual-authentication").Should().BeTrue(); + sslConfig.GetBoolean("validate-certificate-hostname").Should().BeFalse(); + } + + [Fact] + public async Task AkkaRemoteShouldUsePublicHostnameCorrectly() + { + // arrange + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting("0.0.0.0", 0, "localhost"); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + + // assert + actorSystem.Provider.DefaultAddress.Host.Should().Be("localhost"); + } + + [Fact(DisplayName = "RemoteOptions should be bindable using Microsoft.Extensions.Configuration")] + public async Task ClusterOptionsConfigurationTest() + { + const string json = @" +{ + ""Logging"": { + ""LogLevel"": { + ""Default"": ""Information"", + ""Microsoft.AspNetCore"": ""Warning"" + } + }, + ""ConnectionStrings"": { + ""sqlServerLocal"": ""Server=localhost,1533;Database=Akka;User Id=sa;Password=l0lTh1sIsOpenSource;"", + }, + ""Akka"": { + ""RemoteOptions"": { + ""HostName"": ""0.0.0.0"", + ""Port"" : 0, + ""PublicHostName"": ""localhost"", + ""PublicPort"": 12345 + } + } +}"; + + // arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var jsonConfig = new ConfigurationBuilder().AddJsonStream(stream).Build(); + var remoteOptions = jsonConfig.GetSection("Akka:RemoteOptions").Get()!; + + using var host = new HostBuilder().ConfigureServices(services => + { + services.AddAkka("RemoteSys", (builder, provider) => + { + builder.WithRemoting(remoteOptions); + }); + }).Build(); + + // act + await host.StartAsync(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var config = actorSystem.Settings.Config; + var adapters = config.GetStringList("akka.remote.enabled-transports"); + var tcpConfig = config.GetConfig("akka.remote.dot-netty.tcp"); + + // assert + adapters.Count.Should().Be(1); + adapters[0].Should().Be("akka.remote.dot-netty.tcp"); + + tcpConfig.GetString("hostname").Should().Be("0.0.0.0"); + tcpConfig.GetInt("port").Should().Be(0); + tcpConfig.GetString("public-hostname").Should().Be("localhost"); + tcpConfig.GetInt("public-port").Should().Be(12345); + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Remote.Hosting.Tests/Resources/akka-validcert.pfx b/src/contrib/hosting/Akka.Remote.Hosting.Tests/Resources/akka-validcert.pfx new file mode 100644 index 00000000000..0d2bff3786b Binary files /dev/null and b/src/contrib/hosting/Akka.Remote.Hosting.Tests/Resources/akka-validcert.pfx differ diff --git a/src/contrib/hosting/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj b/src/contrib/hosting/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj new file mode 100644 index 00000000000..e6344392871 --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj @@ -0,0 +1,17 @@ + + + + Akka.Remote Microsoft.Extensions.Hosting support. + $(NetStandardLibVersion) + enable + $(AkkaPackageTags);hosting;remoting + true + https://getakka.net/articles/hosting/index.html + + + + + + + + diff --git a/src/contrib/hosting/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs b/src/contrib/hosting/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs new file mode 100644 index 00000000000..4e60a0fc956 --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Actor; +using Akka.Hosting; + +namespace Akka.Remote.Hosting +{ + public static class AkkaRemoteHostingExtensions + { + /// + /// Adds Akka.Remote support to this . + /// + /// A configuration delegate. + /// Optional. The hostname to bind Akka.Remote upon. Default: "0.0.0.0" + /// Optional. The port to bind Akka.Remote upon. Default: 2552 + /// Optional. If using hostname aliasing, this is the host we will advertise. + /// Optional. If using port aliasing, this is the port we will advertise. + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithRemoting( + this AkkaConfigurationBuilder builder, + string? hostname = null, + int? port = null, + string? publicHostname = null, + int? publicPort = null) + => builder.WithRemoting(new RemoteOptions + { + HostName = hostname, + Port = port, + PublicHostName = publicHostname, + PublicPort = publicPort + }); + + /// + /// Adds Akka.Remote support to this . + /// + /// A configuration delegate. + /// + /// An delegate used to configure an + /// instance to configure Akka.Remote + /// + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithRemoting( + this AkkaConfigurationBuilder builder, + Action configure) + { + var options = new RemoteOptions(); + configure(options); + return builder.WithRemoting(options); + } + + /// + /// Adds Akka.Remote support to this . + /// + /// A configuration delegate. + /// + /// A instance to configure Akka.Remote + /// + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithRemoting( + this AkkaConfigurationBuilder builder, + RemoteOptions options) + { + options.Build(builder); + + if (builder.ActorRefProvider.HasValue) + { + switch (builder.ActorRefProvider.Value) + { + case ProviderSelection.Cluster _: + case ProviderSelection.Remote _: + case ProviderSelection.Custom _: + return builder; // no-op + } + } + + return builder.WithActorRefProvider(ProviderSelection.Remote.Instance); + } + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Remote.Hosting/FailureDetectorOptions.cs b/src/contrib/hosting/Akka.Remote.Hosting/FailureDetectorOptions.cs new file mode 100644 index 00000000000..e8f7932ec3d --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting/FailureDetectorOptions.cs @@ -0,0 +1,153 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Hosting; + +namespace Akka.Remote.Hosting; + +/// +/// This failure detector is usually used to detect TCP network connection failure. +/// For TCP it is not important to have fast failure detection, since +/// most connection failures are captured by TCP itself. +/// The default value will trigger if there are no heartbeats within +/// the duration heartbeat-interval + acceptable-heartbeat-pause, i.e. 124 seconds +/// +public class DeadlineFailureDetectorOptions +{ + /// + /// + /// The interval between each heartbeat messages that are sent to each connection. + /// + /// Default: 4 seconds + /// + public TimeSpan? HeartbeatInterval { get; set; } + + /// + /// + /// Number of potentially lost/delayed heartbeats that will be + /// accepted before considering it to be an anomaly. + /// This margin is important to be able to survive sudden, occasional, + /// pauses in heartbeat arrivals, due to for example garbage collect or + /// network drop. + /// + /// Default: 120 seconds + /// + public TimeSpan? AcceptableHeartbeatPause { get; set; } + + public StringBuilder ToHocon() + { + var sb = new StringBuilder(); + + if(HeartbeatInterval is not null) + sb.AppendLine($"heartbeat-interval = {HeartbeatInterval.ToHocon()}"); + if(AcceptableHeartbeatPause is not null) + sb.AppendLine($"acceptable-heartbeat-pause = {AcceptableHeartbeatPause.ToHocon()}"); + + return sb; + } +} + +/// +/// This failure detector is usually used for remote death watch. +/// It is based on Phi Accrual Failure Detector (http://ddg.jaist.ac.jp/pub/HDY+04.pdf +/// [Hayashibara et al]) +/// +public class PhiAccrualFailureDetectorOptions +{ + /// + /// + /// The interval between each heartbeat messages that are sent to each connection. + /// + /// Default: 1 seconds + /// + public TimeSpan? HeartbeatInterval { get; set; } + + /// + /// + /// Number of potentially lost/delayed heartbeats that will be + /// accepted before considering it to be an anomaly. + /// This margin is important to be able to survive sudden, occasional, + /// pauses in heartbeat arrivals, due to for example garbage collect or + /// network drop. + /// + /// Default: 10 seconds + /// + public TimeSpan? AcceptableHeartbeatPause { get; set; } + + /// + /// + /// Defines the failure detector threshold. + /// A low threshold is prone to generate many wrong suspicions but ensures + /// a quick detection in the event of a real crash. Conversely, a high + /// threshold generates fewer mistakes but needs more time to detect + /// actual crashes. + /// + /// Default: 10.0 + /// + public double? Threshold { get; set; } + + /// + /// + /// Number of the samples of inter-heartbeat arrival times to adaptively + /// calculate the failure timeout for connections. + /// + /// Default: 200 + /// + public int? MaxSampleSize { get; set; } + + /// + /// + /// Minimum standard deviation to use for the normal distribution in + /// . Too low standard deviation might result in + /// too much sensitivity for sudden, but normal, deviations in heartbeat + /// inter arrival times. + /// + /// Default: 100 milliseconds + /// + public TimeSpan? MinStandardDeviation { get; set; } + + /// + /// + /// Interval between unreachable node check by the failure detector + /// + /// Default: 1 second + /// + public TimeSpan? UnreachableNodesReaperInterval { get; set; } + + /// + /// + /// After the heartbeat request has been sent the first failure detection + /// will start after this period, even though no heartbeat message has + /// been received. + /// + /// Default: 1 second + /// + public TimeSpan? ExpectedResponseAfter { get; set; } + + public StringBuilder ToHocon() + { + var sb = new StringBuilder(); + + if(HeartbeatInterval is not null) + sb.AppendLine($"heartbeat-interval = {HeartbeatInterval.ToHocon()}"); + if(AcceptableHeartbeatPause is not null) + sb.AppendLine($"acceptable-heartbeat-pause = {AcceptableHeartbeatPause.ToHocon()}"); + if(Threshold is not null) + sb.AppendLine($"threshold = {Threshold.ToHocon()}"); + if(MaxSampleSize is not null) + sb.AppendLine($"max-sample-size = {MaxSampleSize.ToHocon()}"); + if(MinStandardDeviation is not null) + sb.AppendLine($"min-std-deviation = {MinStandardDeviation.ToHocon()}"); + if(UnreachableNodesReaperInterval is not null) + sb.AppendLine($"unreachable-nodes-reaper-interval = {UnreachableNodesReaperInterval.ToHocon()}"); + if(ExpectedResponseAfter is not null) + sb.AppendLine($"expected-response-after = {ExpectedResponseAfter.ToHocon()}"); + + return sb; + } +} \ No newline at end of file diff --git a/src/contrib/hosting/Akka.Remote.Hosting/README.md b/src/contrib/hosting/Akka.Remote.Hosting/README.md new file mode 100644 index 00000000000..c1b50761bbf --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting/README.md @@ -0,0 +1,90 @@ +# Akka Remoting Akka.Hosting Extensions + +## WithRemoting() Method + +An extension method to add [Akka.Remote](https://getakka.net/articles/remoting/index.html) support to the `ActorSystem`. + +```csharp +public static AkkaConfigurationBuilder WithRemoting( + this AkkaConfigurationBuilder builder, + string hostname = null, + int? port = null, + string publicHostname = null, + int? publicPort = null); +``` + +### Parameters +* `hostname` __string__ + + Optional. The hostname to bind Akka.Remote upon. + + __Default__: `IPAddress.Any` or "0.0.0.0" + +* `port` __int?__ + + Optional. The port to bind Akka.Remote upon. + + __Default__: 2552 + +* `publicHostname` __string__ + + Optional. If using hostname aliasing, this is the host we will advertise. + + __Default__: Fallback to `hostname` + +* `publicPort` __int?__ + + Optional. If using port aliasing, this is the port we will advertise. + + __Default__: Fallback to `port` + +### Example + +```csharp +using var host = new HostBuilder() + .ConfigureServices((context, services) => + { + services.AddAkka("remotingDemo", (builder, provider) => + { + builder.WithRemoting("127.0.0.1", 4053); + }); + }).Build(); + +await host.RunAsync(); +``` + +## SSL/TLS Configuration + +Akka.Remote supports SSL/TLS encryption for secure communication between actor systems. Starting with Akka.NET v1.5.55, you can provide custom certificate validation callbacks using the `CertificateValidation` helper class. + +```csharp +using System.Security.Cryptography.X509Certificates; +using Akka.Remote.Transport.DotNetty; + +var certificate = new X509Certificate2("/path/to/certificate.pfx", "certificate-password"); + +using var host = new HostBuilder() + .ConfigureServices((context, services) => + { + services.AddAkka("secureSystem", (builder, provider) => + { + builder.WithRemoting(options => + { + options.HostName = "127.0.0.1"; + options.Port = 4053; + options.EnableSsl = true; + options.Ssl.X509Certificate = certificate; + + // Use built-in validators for common scenarios + options.Ssl.CustomValidator = CertificateValidation.Combine( + CertificateValidation.ValidateChain(), + CertificateValidation.ValidateSubject("CN=*.mycompany.com") + ); + }); + }); + }).Build(); + +await host.RunAsync(); +``` + +Available `CertificateValidation` methods: `ValidateChain()`, `ValidateHostname()`, `PinnedCertificate()`, `ValidateSubject()`, `ValidateIssuer()`, and `Combine()`. diff --git a/src/contrib/hosting/Akka.Remote.Hosting/RemoteOptions.cs b/src/contrib/hosting/Akka.Remote.Hosting/RemoteOptions.cs new file mode 100644 index 00000000000..533eba8bfb9 --- /dev/null +++ b/src/contrib/hosting/Akka.Remote.Hosting/RemoteOptions.cs @@ -0,0 +1,353 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Akka.Configuration; +using Akka.Event; +using Akka.Hosting; +using Akka.Remote.Transport.DotNetty; + +namespace Akka.Remote.Hosting +{ + public class RemoteOptions + { + /// + /// The hostname or ip to bind akka remoting to, is used if empty + /// + public string? HostName { get; set; } + + /// + /// The default remote server port clients should connect to. + /// Default is 2552, use 0 if you want a random available port. + /// This port needs to be unique for each actor system on the same machine. + /// + public int? Port { get; set; } + + /// + /// If this value is set, this becomes the public address for the actor system on this + /// transport, which might be different than the physical ip address (hostname). + /// This is designed to make it easy to support private / public addressing schemes + /// + public string? PublicHostName { get; set; } + + /// + /// Similar to , this allows Akka.Remote users + /// to alias the port they're listening on. The socket will actually listen on the + /// property, but when connecting to other ActorSystems this node will advertise + /// itself as being connected to the "public-port". This is helpful when working with + /// hosting environments that rely on address translation and port-forwarding, such as Docker. + /// + public int? PublicPort { get; set; } + + /// + /// + /// Sets the send buffer size of the Sockets, set to 0 for platform default. + /// + /// Default: 256000 + /// + public long? SendBufferSize { get; set; } + + /// + /// + /// Sets the send buffer size of the Sockets, set to 0 for platform default. + /// + /// Default: 256000 + /// + public long? ReceiveBufferSize { get; set; } + + /// + /// + /// Maximum message size the transport will accept, but at least 32000 bytes. + /// Please note that UDP does not support arbitrary large datagrams, + /// so this setting has to be chosen carefully when using UDP. + /// Both and settings has to + /// be adjusted to be able to buffer messages of maximum size. + /// + /// Default: 128000 + /// + public long? MaxFrameSize { get; set; } + + /// + /// Flag to enable TLS/SSL support. If set to true, property need to be set. + /// + public bool? EnableSsl { get; set; } + + /// + /// The TLS/SSL option for the remote transport. + /// + public SslOptions Ssl { get; set; } = new (); + + /// + /// + /// Failure detection algorithm used to detect remote transport failure condition. + /// + /// + public DeadlineFailureDetectorOptions? TransportFailureDetector { get; set; } + + /// + /// + /// Failure detection algorithm used to detect remote death watch. + /// + /// + public PhiAccrualFailureDetectorOptions? WatchFailureDetector { get; set; } + + internal void Build(AkkaConfigurationBuilder builder) + { + var sb = new StringBuilder(); + Build(sb); + + if (sb.Length > 0) + builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); + + // SSL configuration strategy: + // 1. If X509Certificate object is provided -> Use DotNettySslSetup (takes precedence over HOCON) + // 2. If X509Certificate is null but SSL settings configured -> Use HOCON configuration only + // + // Important: DotNettySslSetup ALWAYS takes precedence when present, causing HOCON SSL settings + // to be completely ignored. We must not emit both to avoid confusion. + // See: https://github.com/akkadotnet/akka.net/blob/dev/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs#L163-L164 + + if (EnableSsl is false || Ssl.X509Certificate == null) + return; + + var suppressValidation = Ssl.SuppressValidation ?? false; + var requireMutualAuth = Ssl.RequireMutualAuthentication ?? true; // Default to true as per v1.5.52 + var validateHostname = Ssl.ValidateCertificateHostname ?? false; // Default to false as per v1.5.53 + + // Choose the appropriate constructor based on which settings are provided + if (Ssl.CustomValidator != null) + { + // Use the 5-parameter constructor with custom validator (v1.5.55+) + builder.AddSetup(new DotNettySslSetup(Ssl.X509Certificate, suppressValidation, requireMutualAuth, validateHostname, Ssl.CustomValidator)); + } + else if (Ssl.RequireMutualAuthentication.HasValue || Ssl.ValidateCertificateHostname.HasValue) + { + // Use the 4-parameter constructor (v1.5.52/v1.5.53+) + builder.AddSetup(new DotNettySslSetup(Ssl.X509Certificate, suppressValidation, requireMutualAuth, validateHostname)); + } + else + { + // Use legacy 2-parameter constructor for backward compatibility when new settings are not specified + builder.AddSetup(new DotNettySslSetup(Ssl.X509Certificate, suppressValidation)); + } + } + + private void Build(StringBuilder builder) + { + var sb = new StringBuilder(); + + if (TransportFailureDetector is not null) + { + var tsb = TransportFailureDetector.ToHocon(); + if (tsb.Length > 0) + { + sb.AppendLine("transport-failure-detector {\n"); + sb.Append(tsb); + sb.AppendLine("}"); + } + } + + if (WatchFailureDetector is not null) + { + var wsb = WatchFailureDetector.ToHocon(); + if (wsb.Length > 0) + { + sb.AppendLine("watch-failure-detector {\n"); + sb.Append(wsb); + sb.AppendLine("}"); + } + } + + var tcpSb = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(HostName)) + tcpSb.AppendLine($"hostname = {HostName.ToHocon()}"); + + if (Port is not null) + tcpSb.AppendLine($"port = {Port}"); + + if (!string.IsNullOrWhiteSpace(PublicHostName)) + tcpSb.AppendLine($"public-hostname = {PublicHostName.ToHocon()}"); + + if (PublicPort is not null) + tcpSb.AppendLine($"public-port = {PublicPort}"); + + if (SendBufferSize is not null) + tcpSb.AppendLine($"send-buffer-size = {SendBufferSize.ToHocon()}"); + + if (ReceiveBufferSize is not null) + tcpSb.AppendLine($"receive-buffer-size = {ReceiveBufferSize.ToHocon()}"); + + if (MaxFrameSize is not null) + tcpSb.AppendLine($"maximum-frame-size = {MaxFrameSize.ToHocon()}"); + + if (EnableSsl is not null) + { + tcpSb.AppendLine($"enable-ssl = {EnableSsl.ToHocon()}"); + if (EnableSsl.Value) + { + if(Ssl is null) + throw new ConfigurationException("Ssl property need to be populated when EnableSsl is set to true."); + + // Only emit HOCON SSL configuration if we're NOT going to create a DotNettySslSetup + // When DotNettySslSetup is present, it takes precedence and HOCON SSL settings are ignored + // See: https://github.com/akkadotnet/akka.net/issues/7914 and the warning at + // https://github.com/akkadotnet/akka.net/blob/dev/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs#L163-L164 + if (Ssl.X509Certificate == null) + { + Ssl.Build(tcpSb); + } + } + } + + if(tcpSb.Length > 0) + { + tcpSb.Insert(0, "dot-netty.tcp {\n"); + tcpSb.Append("}"); + sb.Append(tcpSb); + } + + if(sb.Length == 0) + return; + + sb.Insert(0, "akka.remote {\n"); + sb.Append("}"); + + builder.Append(sb); + } + + } + + public sealed class SslOptions + { + public bool? SuppressValidation { get; set; } + public X509Certificate2? X509Certificate { get; set; } + public SslCertificateOptions CertificateOptions { get; set; } = new (); + + /// + /// + /// When set to true, enables mutual TLS (mTLS) authentication where both client and server + /// must present valid certificates with accessible private keys during the TLS handshake. + /// + /// + /// This provides defense-in-depth security by ensuring bidirectional authentication and + /// preventing asymmetric connectivity issues in peer-to-peer Akka.Remote connections. + /// + /// Default: true (as of Akka.NET v1.5.52) + /// + public bool? RequireMutualAuthentication { get; set; } + + /// + /// + /// Controls whether certificate hostname validation is performed during TLS handshake. + /// + /// + /// When enabled (true): Traditional TLS hostname validation is performed - certificate CN/SAN must match the target hostname. + /// When disabled (false): Only validates certificate chain against CA, ignores hostname mismatches. + /// + /// + /// Disabling hostname validation may be necessary for: + /// - Mutual TLS with per-node certificates in P2P clusters + /// - IP-based connections where certificates use DNS names + /// - Service discovery with dynamic addresses + /// + /// Default: false (as of Akka.NET v1.5.53) + /// + public bool? ValidateCertificateHostname { get; set; } + + /// + /// + /// Custom certificate validation callback for advanced validation scenarios. + /// When provided, this callback takes precedence over config-based validation. + /// + /// + /// Use this to implement custom validation logic such as certificate pinning, + /// subject/issuer matching, or other business-specific validation rules. + /// + /// + /// The callback parameters are: + /// - X509Certificate2?: The peer certificate to validate + /// - X509Chain?: The X509 chain for validation + /// - string: The remote peer identifier + /// - SslPolicyErrors: SSL policy errors from standard validation + /// - ILoggingAdapter: Logger for diagnostics + /// + /// + /// Returns true to accept the certificate, false to reject it. + /// + /// Available since: Akka.NET v1.5.55 + /// + public Transport.DotNetty.CertificateValidationCallback? CustomValidator { get; set; } + + internal void Build(StringBuilder builder) + { + var sb = new StringBuilder(); + + if (SuppressValidation is not null) + sb.AppendLine($"suppress-validation = {SuppressValidation.ToHocon()}"); + + if (RequireMutualAuthentication is not null) + sb.AppendLine($"require-mutual-authentication = {RequireMutualAuthentication.ToHocon()}"); + + if (ValidateCertificateHostname is not null) + sb.AppendLine($"validate-certificate-hostname = {ValidateCertificateHostname.ToHocon()}"); + + CertificateOptions.Build(sb); + + if(sb.Length == 0) + return; + + sb.Insert(0, "ssl {"); + sb.AppendLine("}"); + builder.Append(sb); + } + } + + public sealed class SslCertificateOptions + { + public string? Path { get; set; } + public string? Password { get; set; } + public bool? UseThumbprintOverFile { get; set; } + public string? Thumbprint { get; set; } + public string? StoreName { get; set; } + public string? StoreLocation { get; set; } + + internal void Build(StringBuilder builder) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrEmpty(Path)) + sb.AppendLine($"path = {Path.ToHocon()}"); + + if (!string.IsNullOrEmpty(Password)) + sb.AppendLine($"password = {Password.ToHocon()}"); + + if (UseThumbprintOverFile is not null) + sb.AppendLine($"use-thumbprint-over-file = {UseThumbprintOverFile.ToHocon()}"); + + if (!string.IsNullOrEmpty(Thumbprint)) + sb.AppendLine($"thumbprint = {Thumbprint.ToHocon()}"); + + if (!string.IsNullOrEmpty(StoreName)) + sb.AppendLine($"store-name = {StoreName.ToHocon()}"); + + if (!string.IsNullOrEmpty(StoreLocation)) + sb.AppendLine($"store-location = {StoreLocation.ToHocon()}"); + + if (sb.Length == 0) + return; + + sb.Insert(0, "certificate {\n"); + sb.AppendLine("}"); + builder.Append(sb); + } + } +} \ No newline at end of file diff --git a/src/core/Akka.API.Tests/Akka.API.Tests.csproj b/src/core/Akka.API.Tests/Akka.API.Tests.csproj index c8beb79cde0..dc6c347fac4 100644 --- a/src/core/Akka.API.Tests/Akka.API.Tests.csproj +++ b/src/core/Akka.API.Tests/Akka.API.Tests.csproj @@ -21,11 +21,23 @@ + + + + + + + + diff --git a/src/core/Akka.API.Tests/CoreAPISpec.cs b/src/core/Akka.API.Tests/CoreAPISpec.cs index 65831580ef8..739e829ea01 100644 --- a/src/core/Akka.API.Tests/CoreAPISpec.cs +++ b/src/core/Akka.API.Tests/CoreAPISpec.cs @@ -133,5 +133,35 @@ public Task ApproveTestKitXunit2() { return VerifyAssembly(); } + + [Fact] + public Task ApproveHosting() + { + return VerifyAssembly(); + } + + [Fact] + public Task ApproveRemoteHosting() + { + return VerifyAssembly(); + } + + [Fact] + public Task ApproveClusterHosting() + { + return VerifyAssembly(); + } + + [Fact] + public Task ApprovePersistenceHosting() + { + return VerifyAssembly(); + } + + [Fact] + public Task ApproveHostingTestKit() + { + return VerifyAssembly(); + } } } diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveClusterHosting.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveClusterHosting.DotNet.verified.txt new file mode 100644 index 00000000000..5716d85c9cd --- /dev/null +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveClusterHosting.DotNet.verified.txt @@ -0,0 +1,196 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.Cluster.Hosting.Tests")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Akka.Cluster.Hosting +{ + public static class AkkaClusterHostingExtensions + { + public static Akka.Hosting.AkkaConfigurationBuilder WithAkkaClusterReadinessCheck(this Akka.Hosting.AkkaConfigurationBuilder builder, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClient(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Collections.Generic.IEnumerable initialContacts) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClient(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Collections.Generic.IList initialContacts) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClient(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Collections.Generic.IEnumerable initialContactAddresses, string receptionistActorName = "receptionist") { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClientDiscovery(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Cluster.Hosting.ClusterClientDiscoveryOptions options) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClientDiscovery(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action configure) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClientDiscovery(this Akka.Hosting.AkkaConfigurationBuilder builder, string serviceName, Akka.Hosting.IDiscoveryOptions discoveryOptions, string? portName = null, System.TimeSpan? retryInterval = default, System.TimeSpan? timeout = default, int? numberOfContacts = default, string? clientActorName = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterClientReceptionist(this Akka.Hosting.AkkaConfigurationBuilder builder, string name = "receptionist", string? role = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClustering(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Cluster.Hosting.ClusterOptions? options = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithDistributedData(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Cluster.Hosting.DDataOptions options) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithDistributedData(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action configurator) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithDistributedPubSub(this Akka.Hosting.AkkaConfigurationBuilder builder, string role) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegion(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, System.Func entityPropsFactory, Akka.Cluster.Sharding.IMessageExtractor messageExtractor, Akka.Cluster.Hosting.ShardOptions shardOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegion(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, System.Func> entityPropsFactory, Akka.Cluster.Sharding.IMessageExtractor messageExtractor, Akka.Cluster.Hosting.ShardOptions shardOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegion(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, System.Func> entityPropsFactory, Akka.Cluster.Sharding.IMessageExtractor messageExtractor, Akka.Cluster.Hosting.ShardOptions shardOptions) { } + [System.Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates" + + ".")] + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegion(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, System.Func entityPropsFactory, Akka.Cluster.Sharding.ExtractEntityId extractEntityId, Akka.Cluster.Sharding.ExtractShardId extractShardId, Akka.Cluster.Hosting.ShardOptions shardOptions) { } + [System.Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates" + + ".")] + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegion(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, System.Func> entityPropsFactory, Akka.Cluster.Sharding.ExtractEntityId extractEntityId, Akka.Cluster.Sharding.ExtractShardId extractShardId, Akka.Cluster.Hosting.ShardOptions shardOptions) { } + [System.Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates" + + ".")] + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegion(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, System.Func> entityPropsFactory, Akka.Cluster.Sharding.ExtractEntityId extractEntityId, Akka.Cluster.Sharding.ExtractShardId extractShardId, Akka.Cluster.Hosting.ShardOptions shardOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegionProxy(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, string roleName, Akka.Cluster.Sharding.IMessageExtractor messageExtractor) { } + [System.Obsolete("Use IMessageExtractor instead of the ExtractEntityId and ExtractShardId delegates" + + ".")] + public static Akka.Hosting.AkkaConfigurationBuilder WithShardRegionProxy(this Akka.Hosting.AkkaConfigurationBuilder builder, string typeName, string roleName, Akka.Cluster.Sharding.ExtractEntityId extractEntityId, Akka.Cluster.Sharding.ExtractShardId extractShardId) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardedDaemonProcess(this Akka.Hosting.AkkaConfigurationBuilder builder, string name, int numberOfInstances, System.Func> entityPropsFactory, Akka.Cluster.Hosting.ClusterDaemonOptions? options = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardedDaemonProcessProxy(this Akka.Hosting.AkkaConfigurationBuilder builder, string name, int numberOfInstances, string role) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardingDistributedData(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Cluster.Hosting.ShardingDDataOptions options) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithShardingDistributedData(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action configure) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSingleton(this Akka.Hosting.AkkaConfigurationBuilder builder, string singletonName, Akka.Actor.Props actorProps, Akka.Cluster.Hosting.ClusterSingletonOptions? options = null, bool createProxyToo = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSingleton(this Akka.Hosting.AkkaConfigurationBuilder builder, string singletonName, System.Func propsFactory, Akka.Cluster.Hosting.ClusterSingletonOptions? options = null, bool createProxyToo = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSingleton(this Akka.Hosting.AkkaConfigurationBuilder builder, string singletonManagerName, string singletonName, Akka.Actor.Props actorProps, Akka.Cluster.Hosting.ClusterSingletonOptions? options = null, bool createProxyToo = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSingleton(this Akka.Hosting.AkkaConfigurationBuilder builder, string singletonManagerName, string singletonName, System.Func propsFactory, Akka.Cluster.Hosting.ClusterSingletonOptions? options = null, bool createProxyToo = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSingletonProxy(this Akka.Hosting.AkkaConfigurationBuilder builder, string singletonManagerName, string singletonName, Akka.Cluster.Hosting.ClusterSingletonOptions? options = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSingletonProxy(this Akka.Hosting.AkkaConfigurationBuilder builder, string singletonName, Akka.Cluster.Hosting.ClusterSingletonOptions? options = null, string? singletonManagerPath = null) { } + } + public sealed class AkkaClusterReadinessCheck : Akka.Hosting.IAkkaHealthCheck + { + public AkkaClusterReadinessCheck() { } + public System.DateTime BeganJoining { get; } + public System.DateTime? FinishedJoining { get; } + public bool WeHaveJoined { get; } + public System.Threading.Tasks.Task CheckHealthAsync(Akka.Hosting.AkkaHealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult HealthyResult(System.DateTime finishedJoining) { } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult UnhealthyResult(System.DateTime now, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus failureStatus) { } + } + public sealed class ClusterClientDiscoveryOptions + { + public ClusterClientDiscoveryOptions() { } + public string? ClientActorName { get; set; } + public Akka.Hosting.IDiscoveryOptions DiscoveryOptions { get; set; } + public int? NumberOfContacts { get; set; } + public string? PortName { get; set; } + public System.TimeSpan? RetryInterval { get; set; } + public string ServiceName { get; set; } + public System.TimeSpan? Timeout { get; set; } + public override string ToString() { } + } + public sealed class ClusterDaemonOptions + { + public ClusterDaemonOptions() { } + public object? HandoffStopMessage { get; set; } + public System.TimeSpan? KeepAliveInterval { get; set; } + public string? Role { get; set; } + public Akka.Cluster.Sharding.ClusterShardingSettings? ShardingSettings { get; set; } + } + public sealed class ClusterOptions + { + public ClusterOptions() { } + public string? AppVersion { get; set; } + public bool ClusterReadyCheck { get; set; } + public Akka.Remote.Hosting.PhiAccrualFailureDetectorOptions? FailureDetector { get; set; } + public bool? LogInfo { get; set; } + public bool? LogInfoVerbose { get; set; } + public int? MinimumNumberOfMembers { get; set; } + public System.Collections.Generic.Dictionary? MinimumNumberOfMembersPerRole { get; set; } + public string[]? Roles { get; set; } + public string[]? SeedNodes { get; set; } + public Akka.Cluster.Hosting.SBR.SplitBrainResolverOption? SplitBrainResolver { get; set; } + } + public sealed class ClusterSingletonOptions + { + public ClusterSingletonOptions() { } + public int? BufferSize { get; set; } + public Akka.Hosting.Coordination.LeaseOptionBase? LeaseImplementation { get; set; } + public System.TimeSpan? LeaseRetryInterval { get; set; } + public bool? LogSingletonIdentificationFailure { get; set; } + public string? Role { get; set; } + public System.TimeSpan? SingletonIdentificationFailurePeriod { get; set; } + public System.TimeSpan? SingletonIdentificationInterval { get; set; } + public object? TerminationMessage { get; set; } + } + public class DDataOptions + { + public DDataOptions() { } + public Akka.Cluster.Hosting.DurableOptions Durable { get; set; } + public string? Name { get; set; } + public bool? PreferOldest { get; set; } + public bool? RecreateOnFailure { get; set; } + public string? Role { get; set; } + public bool? VerboseDebugLogging { get; set; } + } + public class DurableOptions + { + public DurableOptions() { } + public string[]? Keys { get; set; } + public Akka.Cluster.Hosting.LmdbOptions Lmdb { get; set; } + } + public class LmdbOptions + { + public LmdbOptions() { } + public string? Directory { get; set; } + public long? MapSize { get; set; } + public System.TimeSpan? WriteBehindInterval { get; set; } + } + public sealed class ShardOptions + { + public ShardOptions() { } + [System.Obsolete("This property is not being applied to the ActorSystem anymore. Use `WithShardingD" + + "istributedData()` extension method or set them using manual HOCON configuration " + + "to set \"akka.cluster.sharding.distributed-data\" values. Since v1.5.27")] + public Akka.Cluster.Hosting.ShardingDDataOptions DistributedData { get; } + public bool? FailOnInvalidEntityStateTransition { get; set; } + public object? HandOffStopMessage { get; set; } + public Akka.Persistence.Hosting.JournalOptions? JournalOptions { get; set; } + public string? JournalPluginId { get; set; } + public Akka.Hosting.Coordination.LeaseOptionBase? LeaseImplementation { get; set; } + public System.TimeSpan? LeaseRetryInterval { get; set; } + public System.TimeSpan? PassivateIdleEntityAfter { get; set; } + public bool? RememberEntities { get; set; } + public Akka.Cluster.Sharding.RememberEntitiesStore? RememberEntitiesStore { get; set; } + public string? Role { get; set; } + public System.TimeSpan? ShardRegionQueryTimeout { get; set; } + public bool? ShouldPassivateIdleEntities { get; set; } + public Akka.Persistence.Hosting.SnapshotOptions? SnapshotOptions { get; set; } + public string? SnapshotPluginId { get; set; } + public Akka.Cluster.Sharding.StateStoreMode? StateStoreMode { get; set; } + public override string ToString() { } + } + public sealed class ShardingDDataOptions : Akka.Cluster.Hosting.DDataOptions + { + public ShardingDDataOptions() { } + public int? MajorityMinimumCapacity { get; set; } + public int? MaxDeltaElements { get; set; } + } +} +namespace Akka.Cluster.Hosting.SBR +{ + public sealed class KeepMajorityOption : Akka.Cluster.Hosting.SBR.SplitBrainResolverOption + { + public KeepMajorityOption() { } + public override string ConfigPath { get; } + public override void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null) { } + } + public sealed class KeepOldestOption : Akka.Cluster.Hosting.SBR.SplitBrainResolverOption + { + public KeepOldestOption() { } + public override string ConfigPath { get; } + public bool? DownIfAlone { get; set; } + public override void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null) { } + } + public sealed class LeaseMajorityOption : Akka.Cluster.Hosting.SBR.SplitBrainResolverOption + { + public LeaseMajorityOption() { } + public override string ConfigPath { get; } + public Akka.Hosting.Coordination.LeaseOptionBase? LeaseImplementation { get; set; } + public string? LeaseName { get; set; } + public override void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null) { } + } + public abstract class SplitBrainResolverOption : Akka.Hosting.IHoconOption + { + public static readonly Akka.Cluster.Hosting.SBR.SplitBrainResolverOption Default; + protected SplitBrainResolverOption() { } + public System.Type Class { get; } + public abstract string ConfigPath { get; } + public string? Role { get; set; } + public abstract void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null); + } + public sealed class StaticQuorumOption : Akka.Cluster.Hosting.SBR.SplitBrainResolverOption + { + public StaticQuorumOption() { } + public override string ConfigPath { get; } + public int? QuorumSize { get; set; } + public override void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null) { } + } +} \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveHosting.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveHosting.DotNet.verified.txt new file mode 100644 index 00000000000..e18b60c8265 --- /dev/null +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveHosting.DotNet.verified.txt @@ -0,0 +1,281 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.Hosting.Maui")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.Hosting.Tests")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Akka.Hosting +{ + public class ActorRegistry : Akka.Actor.IExtension, Akka.Hosting.IActorRegistry, Akka.Hosting.IReadOnlyActorRegistry, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + { + public ActorRegistry() { } + public Akka.Actor.IActorRef Get() { } + public System.Threading.Tasks.Task GetAsync(System.Type key, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task GetAsync(System.Threading.CancellationToken ct = default) { } + public System.Collections.Generic.IEnumerator> GetEnumerator() { } + public void Register(Akka.Actor.IActorRef actor, bool overwrite = false) { } + public bool TryGet(System.Type key, out Akka.Actor.IActorRef actor) { } + public bool TryGet(out Akka.Actor.IActorRef actor) { } + public bool TryRegister(System.Type key, Akka.Actor.IActorRef actor, bool overwrite = false) { } + public bool TryRegister(Akka.Actor.IActorRef actor, bool overwrite = false) { } + public static Akka.Hosting.ActorRegistry For(Akka.Actor.ActorSystem actorSystem) { } + } + public class ActorRegistryException : System.Exception + { + public ActorRegistryException(string message) { } + public ActorRegistryException(string message, System.Exception innerException) { } + } + public sealed class ActorRegistryExtension : Akka.Actor.ExtensionIdProvider + { + public ActorRegistryExtension() { } + public override Akka.Hosting.ActorRegistry CreateExtension(Akka.Actor.ExtendedActorSystem system) { } + } + public delegate System.Threading.Tasks.Task ActorStarter(Akka.Actor.ActorSystem system, Akka.Hosting.IActorRegistry registry); + public delegate System.Threading.Tasks.Task ActorStarterWithResolver(Akka.Actor.ActorSystem system, Akka.Hosting.IActorRegistry registry, Akka.DependencyInjection.IDependencyResolver resolver); + public sealed class AkkaConfigurationBuilder + { + [Akka.Annotations.InternalApi] + public readonly System.Collections.Generic.HashSet Setups; + public AkkaConfigurationBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, string actorSystemName) { } + public Akka.Util.Option ActorRefProvider { get; } + public Akka.Util.Option Configuration { get; } + public Akka.Hosting.AkkaConfigurationBuilder AddStartup(Akka.Hosting.StartupTask startupTask) { } + public Akka.Hosting.AkkaConfigurationBuilder AddStartup(System.Action startupTask) { } + public Akka.Hosting.AkkaConfigurationBuilder StartActors(Akka.Hosting.ActorStarter starter) { } + public Akka.Hosting.AkkaConfigurationBuilder StartActors(Akka.Hosting.ActorStarterWithResolver starter) { } + public Akka.Hosting.AkkaConfigurationBuilder StartActors(System.Action starter) { } + public Akka.Hosting.AkkaConfigurationBuilder StartActors(System.Action starter) { } + public Akka.Hosting.AkkaConfigurationBuilder WithCustomSerializer(string serializerIdentifier, System.Collections.Generic.IEnumerable boundTypes, System.Func serializerFactory) { } + public Akka.Hosting.AkkaConfigurationBuilder WithExtension() + where T : Akka.Actor.IExtensionId { } + public Akka.Hosting.AkkaConfigurationBuilder WithExtensions(params System.Type[] extensions) { } + public Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(Akka.Hosting.AkkaHealthCheckRegistration registration) { } + public Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) + where T : class, Akka.Hosting.IAkkaHealthCheck { } + } + public sealed class AkkaHealthCheckContext + { + public AkkaHealthCheckContext(Akka.Actor.ActorSystem actorSystem) { } + public Akka.Hosting.ActorRegistry ActorRegistry { get; } + public Akka.Actor.ActorSystem ActorSystem { get; } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration Registration { get; set; } + } + public sealed class AkkaHealthCheckRegistration + { + public AkkaHealthCheckRegistration(string name, Akka.Hosting.IAkkaHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable? tags) { } + public AkkaHealthCheckRegistration(string name, Akka.Hosting.IAkkaHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable? tags, System.TimeSpan? timeout) { } + public System.Func Factory { get; set; } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus FailureStatus { get; set; } + public string Name { get; set; } + public System.Collections.Generic.ISet Tags { get; } + public System.TimeSpan Timeout { get; set; } + } + [Akka.Annotations.InternalApi] + public class AkkaHostedService : Microsoft.Extensions.Hosting.IHostedService + { + protected Akka.Actor.ActorSystem? ActorSystem; + protected readonly Akka.Hosting.AkkaConfigurationBuilder ConfigurationBuilder; + protected Akka.Actor.CoordinatedShutdown? CoordinatedShutdown; + protected readonly Microsoft.Extensions.Hosting.IHostApplicationLifetime? HostApplicationLifetime; + protected readonly Microsoft.Extensions.Logging.ILogger Logger; + protected readonly System.IServiceProvider ServiceProvider; + public AkkaHostedService(Akka.Hosting.AkkaConfigurationBuilder configurationBuilder, System.IServiceProvider serviceProvider, Microsoft.Extensions.Logging.ILogger logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime? applicationLifetime) { } + public virtual System.Threading.Tasks.Task StartAsync(System.Threading.CancellationToken cancellationToken) { } + public virtual System.Threading.Tasks.Task StopAsync(System.Threading.CancellationToken cancellationToken) { } + } + public static class AkkaHostingExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddAkka(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string actorSystemName, System.Action builder) { } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddAkka(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string actorSystemName, System.Action builder) { } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddAkka(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string actorSystemName, System.Action builder) + where T : Akka.Hosting.AkkaHostedService { } + public static Akka.Hosting.AkkaConfigurationBuilder AddHocon(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Configuration.Config hocon, Akka.Hosting.HoconAddMode addMode) { } + public static Akka.Hosting.AkkaConfigurationBuilder AddHocon(this Akka.Hosting.AkkaConfigurationBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration, Akka.Hosting.HoconAddMode addMode, bool normalizeKeys = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder AddHoconFile(this Akka.Hosting.AkkaConfigurationBuilder builder, string hoconFilePath, Akka.Hosting.HoconAddMode addMode) { } + public static Akka.Hosting.AkkaConfigurationBuilder AddSetup(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup setup) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActorAskTimeout(this Akka.Hosting.AkkaConfigurationBuilder builder, System.TimeSpan timeout) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActorRefProvider(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.ProviderSelection providerSelection) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActorSystemLivenessCheck(this Akka.Hosting.AkkaConfigurationBuilder builder, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActors(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Hosting.ActorStarter actorStarter) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActors(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Hosting.ActorStarterWithResolver actorStarter) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActors(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action actorStarter) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithActors(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action actorStarter) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(this Akka.Hosting.AkkaConfigurationBuilder builder, string name, Akka.Hosting.IAkkaHealthCheck healthCheck, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(this Akka.Hosting.AkkaConfigurationBuilder builder, string name, System.Func> healthCheck, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithStrictSerialization(this Akka.Hosting.AkkaConfigurationBuilder builder, bool enabled = true) { } + } + public static class AkkaOpenTelemetryExtensions + { + public static OpenTelemetry.Logs.OpenTelemetryLoggerOptions AddAkkaTraceCorrelation(this OpenTelemetry.Logs.OpenTelemetryLoggerOptions options) { } + } + public class DeadLetterOptions + { + public DeadLetterOptions() { } + public int? LogCount { get; set; } + public bool? LogDuringShutdown { get; set; } + public System.TimeSpan? LogSuspendDuration { get; set; } + public Akka.Hosting.TriStateValue ShouldLog { get; set; } + public override string ToString() { } + } + public class DebugOptions + { + public DebugOptions() { } + public bool? AutoReceive { get; set; } + public bool? EventStream { get; set; } + public bool? FiniteStateMachine { get; set; } + public bool? LifeCycle { get; set; } + public bool? Receive { get; set; } + public bool? RouterMisconfiguration { get; set; } + public bool? Unhandled { get; set; } + public override string ToString() { } + } + public sealed class DuplicateActorRegistryException : Akka.Hosting.ActorRegistryException + { + public DuplicateActorRegistryException(string message) { } + public DuplicateActorRegistryException(string message, System.Exception innerException) { } + } + public enum HoconAddMode + { + Append = 0, + Prepend = 1, + Replace = 2, + } + public delegate Akka.Configuration.Config HoconConfigurator(Akka.Configuration.Config currentConfig, Akka.Configuration.Config configToAdd); + public static class HoconExtensions + { + public static string ToHocon(this bool value) { } + public static string ToHocon(this bool? value) { } + public static string ToHocon(this double value) { } + public static string ToHocon(this double? value) { } + public static string ToHocon(this float value) { } + public static string ToHocon(this float? value) { } + public static string ToHocon(this int value) { } + public static string ToHocon(this int? value) { } + public static string ToHocon(this long value) { } + public static string ToHocon(this long? value) { } + public static string ToHocon(this string? text) { } + public static string ToHocon(this System.TimeSpan value, bool allowInfinite = false, bool zeroIsInfinite = false) { } + public static string ToHocon(this System.TimeSpan? value, bool allowInfinite = false, bool zeroIsInfinite = false) { } + } + public interface IActorRegistry : Akka.Hosting.IReadOnlyActorRegistry, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + { + void Register(Akka.Actor.IActorRef actor, bool overwrite = false); + bool TryRegister(System.Type key, Akka.Actor.IActorRef actor, bool overwrite = false); + bool TryRegister(Akka.Actor.IActorRef actor, bool overwrite = false); + } + public interface IAkkaHealthCheck + { + System.Threading.Tasks.Task CheckHealthAsync(Akka.Hosting.AkkaHealthCheckContext context, System.Threading.CancellationToken cancellationToken = default); + } + public interface IDiscoveryOptions : Akka.Hosting.IHoconOption { } + public interface IHoconOption + { + System.Type Class { get; } + string ConfigPath { get; } + void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null); + } + public interface IReadOnlyActorRegistry : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + { + Akka.Actor.IActorRef Get(); + System.Threading.Tasks.Task GetAsync(System.Type key, System.Threading.CancellationToken ct = default); + System.Threading.Tasks.Task GetAsync(System.Threading.CancellationToken ct = default); + bool TryGet(System.Type key, out Akka.Actor.IActorRef actor); + bool TryGet(out Akka.Actor.IActorRef actor); + } + public interface IRequiredActor + { + Akka.Actor.IActorRef ActorRef { get; } + System.Threading.Tasks.Task GetAsync(System.Threading.CancellationToken cancellationToken = default); + } + public sealed class LoggerConfigBuilder + { + public Akka.Hosting.DeadLetterOptions? DeadLetterOptions { get; set; } + public Akka.Hosting.DebugOptions? DebugOptions { get; set; } + public bool? LogConfigOnStart { get; set; } + public Akka.Event.LogFilterBuilder? LogFilterBuilder { get; set; } + public Akka.Event.LogLevel? LogLevel { get; set; } + [System.Obsolete("Use the WithDefaultLogMessageFormatter method instead")] + public System.Type LogMessageFormatter { get; set; } + public Akka.Hosting.LoggerConfigBuilder AddLogger() + where T : Akka.Dispatch.IRequiresMessageQueue { } + public Akka.Hosting.LoggerConfigBuilder ClearLoggers() { } + [System.Obsolete("SemanticLogMessageFormatter is now the default. Only use this method if you have " + + "a custom ILogMessageFormatter implementation.")] + public Akka.Hosting.LoggerConfigBuilder WithDefaultLogMessageFormatter() + where T : Akka.Event.ILogMessageFormatter { } + public Akka.Hosting.LoggerConfigBuilder WithLogFilter(System.Action filterBuilder) { } + } + public static class LoggingExtensions + { + public static Akka.Hosting.LoggerConfigBuilder AddDefaultLogger(this Akka.Hosting.LoggerConfigBuilder configBuilder) { } + public static Akka.Hosting.LoggerConfigBuilder AddLoggerFactory(this Akka.Hosting.LoggerConfigBuilder configBuilder) { } + public static Akka.Hosting.LoggerConfigBuilder AddLoggerFactory(this Akka.Hosting.LoggerConfigBuilder configBuilder, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + public static Akka.Hosting.AkkaConfigurationBuilder ConfigureLoggers(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action configurator) { } + } + public sealed class MissingActorRegistryEntryException : Akka.Hosting.ActorRegistryException + { + public MissingActorRegistryEntryException(string message) { } + public MissingActorRegistryEntryException(string message, System.Exception innerException) { } + } + public sealed class RequiredActor : Akka.Hosting.IRequiredActor + { + public RequiredActor(Akka.Hosting.IReadOnlyActorRegistry registry) { } + public Akka.Actor.IActorRef ActorRef { get; } + public System.Threading.Tasks.Task GetAsync(System.Threading.CancellationToken cancellationToken = default) { } + } + public delegate System.Threading.Tasks.Task StartupTask(Akka.Actor.ActorSystem system, Akka.Hosting.IActorRegistry registry); + public enum TriStateValue + { + None = 0, + All = 1, + Some = 2, + } + public static class Util + { + public static Akka.Configuration.Config MoveTo(this Akka.Configuration.Config config, string path) { } + } +} +namespace Akka.Hosting.Configuration +{ + public static class ConfigurationHoconAdapter + { + public static Akka.Configuration.Config ToHocon(this Microsoft.Extensions.Configuration.IConfiguration config, bool normalizeKeys = true) { } + } +} +namespace Akka.Hosting.Coordination +{ + public abstract class LeaseOptionBase : Akka.Hosting.IHoconOption + { + protected LeaseOptionBase() { } + public abstract System.Type Class { get; } + public abstract string ConfigPath { get; } + public abstract void Apply(Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Actor.Setup.Setup? setup = null); + } +} +namespace Akka.Hosting.HealthChecks +{ + public sealed class ActorSystemLivenessCheck : Akka.Hosting.IAkkaHealthCheck + { + public ActorSystemLivenessCheck() { } + public System.Threading.Tasks.Task CheckHealthAsync(Akka.Hosting.AkkaHealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { } + } +} +namespace Akka.Hosting.Logging +{ + public sealed class AkkaTraceContextProcessor : OpenTelemetry.BaseProcessor + { + public AkkaTraceContextProcessor() { } + public override void OnEnd(OpenTelemetry.Logs.LogRecord data) { } + } + public class LoggerFactoryLogger : Akka.Actor.ActorBase, Akka.Dispatch.IRequiresMessageQueue + { + protected readonly Akka.Event.ILoggingAdapter InternalLogger; + public LoggerFactoryLogger() { } + protected virtual void Log(Akka.Event.LogEvent log, Akka.Actor.ActorPath path) { } + protected override void PostStop() { } + protected override bool Receive(object message) { } + } + public class LoggerFactorySetup : Akka.Actor.Setup.Setup + { + public LoggerFactorySetup(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + public Microsoft.Extensions.Logging.ILoggerFactory LoggerFactory { get; } + } +} \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveHostingTestKit.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveHostingTestKit.DotNet.verified.txt new file mode 100644 index 00000000000..5e4f7c9060f --- /dev/null +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveHostingTestKit.DotNet.verified.txt @@ -0,0 +1,74 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.Hosting.TestKit.Tests")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Akka.Hosting.TestKit.Internals +{ + public class TestKitLoggerFactoryLogger : Akka.Hosting.Logging.LoggerFactoryLogger + { + public TestKitLoggerFactoryLogger() { } + protected override bool Receive(object message) { } + } + public class XUnitLogger : Microsoft.Extensions.Logging.ILogger + { + public XUnitLogger(string category, Xunit.ITestOutputHelper helper, Microsoft.Extensions.Logging.LogLevel logLevel) { } + public System.IDisposable? BeginScope(TState state) + where TState : notnull { } + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) { } + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, System.Exception? exception, System.Func formatter) { } + } + public class XUnitLoggerProvider : Microsoft.Extensions.Logging.ILoggerProvider, System.IDisposable + { + public XUnitLoggerProvider(Xunit.ITestOutputHelper helper, Microsoft.Extensions.Logging.LogLevel logLevel) { } + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) { } + public void Dispose() { } + } +} +namespace Akka.Hosting.TestKit +{ + public abstract class PersistenceTestKit : Akka.Hosting.TestKit.TestKit + { + public static readonly Akka.Configuration.Config DefaultConfiguration; + public PersistenceTestKit(string? actorSystemName = null, Xunit.ITestOutputHelper? output = null, System.TimeSpan? startupTimeout = default, Microsoft.Extensions.Logging.LogLevel logLevel = 2) { } + public Akka.Persistence.TestKit.ITestJournal Journal { get; } + public Akka.Actor.IActorRef JournalActorRef { get; } + public Akka.Persistence.TestKit.ITestSnapshotStore Snapshots { get; } + public Akka.Actor.IActorRef SnapshotsActorRef { get; } + protected override void ConfigureAkka(Akka.Hosting.AkkaConfigurationBuilder builder, System.IServiceProvider provider) { } + public System.Threading.Tasks.Task WithJournalRecovery(System.Func behaviorSelector, System.Action execution) { } + public System.Threading.Tasks.Task WithJournalRecovery(System.Func behaviorSelector, System.Func execution) { } + public System.Threading.Tasks.Task WithJournalWrite(System.Func behaviorSelector, System.Action execution) { } + public System.Threading.Tasks.Task WithJournalWrite(System.Func behaviorSelector, System.Func execution) { } + public System.Threading.Tasks.Task WithSnapshotDelete(System.Func behaviorSelector, System.Action execution) { } + public System.Threading.Tasks.Task WithSnapshotDelete(System.Func behaviorSelector, System.Func execution) { } + public System.Threading.Tasks.Task WithSnapshotLoad(System.Func behaviorSelector, System.Action execution) { } + public System.Threading.Tasks.Task WithSnapshotLoad(System.Func behaviorSelector, System.Func execution) { } + public System.Threading.Tasks.Task WithSnapshotSave(System.Func behaviorSelector, System.Action execution) { } + public System.Threading.Tasks.Task WithSnapshotSave(System.Func behaviorSelector, System.Func execution) { } + } + [Akka.TestKit.Xunit.Attributes.AkkaCleanAmbientContext] + public abstract class TestKit : Akka.TestKit.TestKitBase, System.IAsyncDisposable, Xunit.IAsyncLifetime + { + protected TestKit(string? actorSystemName = null, Xunit.ITestOutputHelper? output = null, System.TimeSpan? startupTimeout = default, Microsoft.Extensions.Logging.LogLevel logLevel = 2) { } + public Akka.Hosting.ActorRegistry ActorRegistry { get; } + public string ActorSystemName { get; } + protected virtual Akka.Configuration.Config? Config { get; } + public Microsoft.Extensions.Hosting.IHost Host { get; } + public Microsoft.Extensions.Logging.LogLevel LogLevel { get; } + public Xunit.ITestOutputHelper? Output { get; } + public System.TimeSpan StartupTimeout { get; } + public new Akka.Actor.ActorSystem Sys { get; } + protected static Akka.TestKit.Xunit.XunitAssertions Assertions { get; } + protected virtual System.Threading.Tasks.Task AfterAllAsync() { } + protected virtual System.Threading.Tasks.Task BeforeTestStart() { } + protected abstract void ConfigureAkka(Akka.Hosting.AkkaConfigurationBuilder builder, System.IServiceProvider provider); + protected virtual void ConfigureAppConfiguration(Microsoft.Extensions.Hosting.HostBuilderContext context, Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { } + protected virtual void ConfigureHostBuilder(Microsoft.Extensions.Hosting.IHostBuilder builder) { } + protected virtual void ConfigureHostConfiguration(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { } + protected virtual void ConfigureLogging(Microsoft.Extensions.Logging.ILoggingBuilder builder) { } + protected virtual void ConfigureServices(Microsoft.Extensions.Hosting.HostBuilderContext context, Microsoft.Extensions.DependencyInjection.IServiceCollection services) { } + public System.Threading.Tasks.ValueTask DisposeAsync() { } + [Akka.Annotations.InternalApi] + public System.Threading.Tasks.ValueTask InitializeAsync() { } + protected override sealed void InitializeTest(Akka.Actor.ActorSystem system, Akka.Actor.Setup.ActorSystemSetup config, string actorSystemName, string testActorName) { } + } +} \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistenceHosting.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistenceHosting.DotNet.verified.txt new file mode 100644 index 00000000000..e3fb8346ff4 --- /dev/null +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistenceHosting.DotNet.verified.txt @@ -0,0 +1,114 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.Persistence.Hosting.Tests")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Akka.Persistence.Hosting +{ + public static class AkkaPersistenceHostingExtensions + { + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterShardingJournalMigrationAdapter(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions journalOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithClusterShardingJournalMigrationAdapter(this Akka.Hosting.AkkaConfigurationBuilder builder, string journalId) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithInMemoryJournal(this Akka.Hosting.AkkaConfigurationBuilder builder) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithInMemoryJournal(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action journalBuilder, string journalId = "inmem", bool isDefaultPlugin = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithInMemorySnapshotStore(this Akka.Hosting.AkkaConfigurationBuilder builder, string snapshotStoreId = "inmem", bool isDefaultPlugin = true) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithJournal(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions journalOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithJournal(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions journalOptions, System.Action? configureBuilder) { } + [System.Obsolete("Use WithJournal(journalOptions, configureBuilder) instead to combine options conf" + + "iguration with event adapters and health checks. This method will be removed in " + + "v1.6.")] + public static Akka.Hosting.AkkaConfigurationBuilder WithJournal(this Akka.Hosting.AkkaConfigurationBuilder builder, string journalId, System.Action journalBuilder) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithJournalAndSnapshot(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions journalOptions, Akka.Persistence.Hosting.SnapshotOptions snapshotOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithJournalAndSnapshot(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions journalOptions, Akka.Persistence.Hosting.SnapshotOptions snapshotOptions, System.Action? configureJournal, System.Action? configureSnapshot) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSnapshot(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.SnapshotOptions snapshotOptions) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithSnapshot(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.SnapshotOptions snapshotOptions, System.Action? configureBuilder) { } + } + public sealed class AkkaPersistenceJournalBuilder + { + public AkkaPersistenceJournalBuilder(string journalId, Akka.Hosting.AkkaConfigurationBuilder builder) { } + public AkkaPersistenceJournalBuilder(string journalId, Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.JournalOptions options) { } + public Akka.Persistence.Hosting.JournalOptions? Options { get; } + public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder AddEventAdapter(string eventAdapterName, System.Collections.Generic.IEnumerable boundTypes) + where TAdapter : Akka.Persistence.Journal.IEventAdapter { } + public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder AddReadEventAdapter(string eventAdapterName, System.Collections.Generic.IEnumerable boundTypes) + where TAdapter : Akka.Persistence.Journal.IReadEventAdapter { } + public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder AddWriteEventAdapter(string eventAdapterName, System.Collections.Generic.IEnumerable boundTypes) + where TAdapter : Akka.Persistence.Journal.IWriteEventAdapter { } + public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder WithCustomHealthCheck(Akka.Hosting.AkkaHealthCheckRegistration registration) { } + public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder WithHealthCheck(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus unHealthyStatus = 1, string? name = null, System.Collections.Generic.IEnumerable? tags = null) { } + } + public sealed class AkkaPersistenceSnapshotBuilder + { + public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, Akka.Hosting.AkkaConfigurationBuilder builder) { } + public AkkaPersistenceSnapshotBuilder(string snapshotStoreId, Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Persistence.Hosting.SnapshotOptions options) { } + public Akka.Persistence.Hosting.SnapshotOptions? Options { get; } + public Akka.Persistence.Hosting.AkkaPersistenceSnapshotBuilder WithCustomHealthCheck(Akka.Hosting.AkkaHealthCheckRegistration registration) { } + public Akka.Persistence.Hosting.AkkaPersistenceSnapshotBuilder WithHealthCheck(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus unHealthyStatus = 1, string? name = null, System.Collections.Generic.IEnumerable? tags = null) { } + } + public static class Extensions + { + public static string ToHocon(this System.Data.IsolationLevel level) { } + public static string ToHocon(this System.Data.IsolationLevel? level) { } + } + public abstract class JournalOptions + { + protected JournalOptions(bool isDefault) { } + [System.Obsolete("Use the configureBuilder callback parameter in WithJournal() instead. This proper" + + "ty will be removed in v1.6.0. See https://github.com/akkadotnet/Akka.Hosting/iss" + + "ues/665")] + public Akka.Persistence.Hosting.AkkaPersistenceJournalBuilder Adapters { get; set; } + public bool AutoInitialize { get; set; } + public Akka.Configuration.Config DefaultConfig { get; } + public abstract string Identifier { get; set; } + protected abstract Akka.Configuration.Config InternalDefaultConfig { get; } + public bool IsDefaultPlugin { get; set; } + public string PluginId { get; } + public string? Serializer { get; set; } + protected virtual System.Text.StringBuilder Build(System.Text.StringBuilder sb) { } + public Akka.Configuration.Config ToConfig() { } + public override sealed string ToString() { } + } + public enum PersistenceMode + { + Both = 0, + Journal = 1, + SnapshotStore = 2, + } + public abstract class SnapshotOptions + { + protected SnapshotOptions(bool isDefault) { } + public bool AutoInitialize { get; set; } + public Akka.Configuration.Config DefaultConfig { get; } + public abstract string Identifier { get; set; } + protected abstract Akka.Configuration.Config InternalDefaultConfig { get; } + public bool IsDefaultPlugin { get; set; } + public string PluginId { get; } + public string? Serializer { get; set; } + protected virtual System.Text.StringBuilder Build(System.Text.StringBuilder sb) { } + public Akka.Configuration.Config ToConfig() { } + public override sealed string ToString() { } + } + public abstract class SqlJournalOptions : Akka.Persistence.Hosting.JournalOptions + { + protected SqlJournalOptions(bool isDefault) { } + public string ConnectionString { get; set; } + public System.TimeSpan ConnectionTimeout { get; set; } + public abstract string MetadataTableName { get; set; } + public abstract System.Data.IsolationLevel ReadIsolationLevel { get; set; } + public abstract string SchemaName { get; set; } + public abstract bool SequentialAccess { get; set; } + public abstract string TableName { get; set; } + public abstract System.Data.IsolationLevel WriteIsolationLevel { get; set; } + protected override System.Text.StringBuilder Build(System.Text.StringBuilder sb) { } + } + public abstract class SqlSnapshotOptions : Akka.Persistence.Hosting.SnapshotOptions + { + protected SqlSnapshotOptions(bool isDefault) { } + public string ConnectionString { get; set; } + public System.TimeSpan ConnectionTimeout { get; set; } + public abstract System.Data.IsolationLevel ReadIsolationLevel { get; set; } + public abstract string SchemaName { get; set; } + public abstract bool SequentialAccess { get; set; } + public abstract string TableName { get; set; } + public abstract System.Data.IsolationLevel WriteIsolationLevel { get; set; } + protected override System.Text.StringBuilder Build(System.Text.StringBuilder sb) { } + } +} \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemoteHosting.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemoteHosting.DotNet.verified.txt new file mode 100644 index 00000000000..ac76ac4ed61 --- /dev/null +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemoteHosting.DotNet.verified.txt @@ -0,0 +1,65 @@ +[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")] +[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace Akka.Remote.Hosting +{ + public static class AkkaRemoteHostingExtensions + { + public static Akka.Hosting.AkkaConfigurationBuilder WithRemoting(this Akka.Hosting.AkkaConfigurationBuilder builder, Akka.Remote.Hosting.RemoteOptions options) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithRemoting(this Akka.Hosting.AkkaConfigurationBuilder builder, System.Action configure) { } + public static Akka.Hosting.AkkaConfigurationBuilder WithRemoting(this Akka.Hosting.AkkaConfigurationBuilder builder, string? hostname = null, int? port = default, string? publicHostname = null, int? publicPort = default) { } + } + public class DeadlineFailureDetectorOptions + { + public DeadlineFailureDetectorOptions() { } + public System.TimeSpan? AcceptableHeartbeatPause { get; set; } + public System.TimeSpan? HeartbeatInterval { get; set; } + public System.Text.StringBuilder ToHocon() { } + } + public class PhiAccrualFailureDetectorOptions + { + public PhiAccrualFailureDetectorOptions() { } + public System.TimeSpan? AcceptableHeartbeatPause { get; set; } + public System.TimeSpan? ExpectedResponseAfter { get; set; } + public System.TimeSpan? HeartbeatInterval { get; set; } + public int? MaxSampleSize { get; set; } + public System.TimeSpan? MinStandardDeviation { get; set; } + public double? Threshold { get; set; } + public System.TimeSpan? UnreachableNodesReaperInterval { get; set; } + public System.Text.StringBuilder ToHocon() { } + } + public class RemoteOptions + { + public RemoteOptions() { } + public bool? EnableSsl { get; set; } + public string? HostName { get; set; } + public long? MaxFrameSize { get; set; } + public int? Port { get; set; } + public string? PublicHostName { get; set; } + public int? PublicPort { get; set; } + public long? ReceiveBufferSize { get; set; } + public long? SendBufferSize { get; set; } + public Akka.Remote.Hosting.SslOptions Ssl { get; set; } + public Akka.Remote.Hosting.DeadlineFailureDetectorOptions? TransportFailureDetector { get; set; } + public Akka.Remote.Hosting.PhiAccrualFailureDetectorOptions? WatchFailureDetector { get; set; } + } + public sealed class SslCertificateOptions + { + public SslCertificateOptions() { } + public string? Password { get; set; } + public string? Path { get; set; } + public string? StoreLocation { get; set; } + public string? StoreName { get; set; } + public string? Thumbprint { get; set; } + public bool? UseThumbprintOverFile { get; set; } + } + public sealed class SslOptions + { + public SslOptions() { } + public Akka.Remote.Hosting.SslCertificateOptions CertificateOptions { get; set; } + public Akka.Remote.Transport.DotNetty.CertificateValidationCallback? CustomValidator { get; set; } + public bool? RequireMutualAuthentication { get; set; } + public bool? SuppressValidation { get; set; } + public bool? ValidateCertificateHostname { get; set; } + public System.Security.Cryptography.X509Certificates.X509Certificate2? X509Certificate { get; set; } + } +} \ No newline at end of file diff --git a/src/core/Akka.Persistence.FSharp/Akka.Persistence.FSharp.fsproj b/src/core/Akka.Persistence.FSharp/Akka.Persistence.FSharp.fsproj index b47606e5f45..c49e5231a66 100644 --- a/src/core/Akka.Persistence.FSharp/Akka.Persistence.FSharp.fsproj +++ b/src/core/Akka.Persistence.FSharp/Akka.Persistence.FSharp.fsproj @@ -17,4 +17,12 @@ + + + + + \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Akka.Hosting.Asp.LoggingDemo.csproj b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Akka.Hosting.Asp.LoggingDemo.csproj new file mode 100644 index 00000000000..6d93f50a8e8 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Akka.Hosting.Asp.LoggingDemo.csproj @@ -0,0 +1,23 @@ + + + + $(NetTestVersion) + enable + enable + false + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Echo.cs b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Echo.cs new file mode 100644 index 00000000000..4f696534686 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Echo.cs @@ -0,0 +1,3 @@ +namespace Akka.Hosting.Asp.LoggingDemo; + +public struct Echo{} \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Program.cs b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Program.cs new file mode 100644 index 00000000000..7d06c2e823d --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/Program.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Akka.Hosting; +using Akka.Actor; +using Akka.Actor.Dsl; +using Akka.Cluster.Hosting; +using Akka.Event; +using Akka.Hosting.Asp.LoggingDemo; +using Akka.Remote.Hosting; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using LogLevel = Akka.Event.LogLevel; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddHealthChecks(); + +builder.Services.AddAkka("MyActorSystem", (configurationBuilder, serviceProvider) => +{ + configurationBuilder + .ConfigureLoggers(setup => + { + // This sets the minimum log level + setup.LogLevel = LogLevel.DebugLevel; + + // Clear all loggers (remove the default console logger) + setup.ClearLoggers(); + + // 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(); + }) + .WithRemoting("localhost", 8110) + .WithClustering(new ClusterOptions { + Roles = ["myRole"], + SeedNodes = ["akka.tcp://MyActorSystem@localhost:8110"] + }) + .WithAkkaClusterReadinessCheck() + .WithActorSystemLivenessCheck() + .WithHealthCheck("di-test", HealthStatus.Unhealthy, new[] { "test", "custom" }) + .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 + }); +}); + +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.MapHealthChecks("/healthz", new HealthCheckOptions +{ + Predicate = _ => true, // include all checks + ResponseWriter = async (ctx, report) => + { + ctx.Response.ContentType = "application/json; charset=utf-8"; + + var payload = new + { + status = report.Status.ToString(), + totalDuration = report.TotalDuration, + checks = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + duration = e.Value.Duration, + description = e.Value.Description, + tags = e.Value.Tags, + data = e.Value.Data // anything you added via context.Registration + }) + }; + + await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + // or in .NET 8+: await ctx.Response.WriteAsJsonAsync(payload); + } +}); + +app.Run(); \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/TestHealthCheck.cs b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/TestHealthCheck.cs new file mode 100644 index 00000000000..29c01d22c25 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/TestHealthCheck.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Akka.Hosting.Asp.LoggingDemo +{ + public class TestHealth : IAkkaHealthCheck + { + private readonly ILogger _logger; + + public TestHealth(ILogger logger) + { + _logger = logger; + } + + public Task CheckHealthAsync(AkkaHealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("DI healthcheck is running"); + return Task.FromResult(HealthCheckResult.Healthy("Test is healthy")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Health check failed for test"); + return Task.FromResult(HealthCheckResult.Unhealthy($"Test health check failed: {ex.Message}")); + } + } + } +} diff --git a/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/appsettings.Development.json b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/appsettings.Development.json new file mode 100644 index 00000000000..8983e0fc1c5 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/appsettings.json b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/appsettings.json new file mode 100644 index 00000000000..d60ba0df02a --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.Asp.LoggingDemo/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Akka": "Debug" + } + }, + "AllowedHosts": "*" +} diff --git a/src/examples/Hosting/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj b/src/examples/Hosting/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj new file mode 100644 index 00000000000..6a6741afa43 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj @@ -0,0 +1,28 @@ + + + + Exe + $(NetTestVersion) + enable + enable + false + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/examples/Hosting/Akka.Hosting.LoggingDemo/Echo.cs b/src/examples/Hosting/Akka.Hosting.LoggingDemo/Echo.cs new file mode 100644 index 00000000000..9065b98cddf --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.LoggingDemo/Echo.cs @@ -0,0 +1,3 @@ +namespace Akka.Hosting.LoggingDemo; + +public struct Echo{} \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.LoggingDemo/Program.cs b/src/examples/Hosting/Akka.Hosting.LoggingDemo/Program.cs new file mode 100644 index 00000000000..f5b002b7b5e --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.LoggingDemo/Program.cs @@ -0,0 +1,57 @@ +using Akka.Hosting; +using Akka.Actor; +using Akka.Actor.Dsl; +using Akka.Cluster.Hosting; +using Akka.Event; +using Akka.Hosting.LoggingDemo; +using Akka.Remote.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using LogLevel = Akka.Event.LogLevel; + +var builder = Host.CreateDefaultBuilder(args); + +await builder + .ConfigureServices(services => + { + services + .AddSingleton() + .AddHostedService() + .AddAkka("MyActorSystem", (configurationBuilder, serviceProvider) => + { + configurationBuilder + .ConfigureLoggers(setup => + { + // This sets the minimum log level + setup.LogLevel = LogLevel.DebugLevel; + + // Clear all loggers (remove the default console logger) + setup.ClearLoggers(); + + // 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(); + }) + .WithRemoting("localhost", 8110) + .WithClustering(new ClusterOptions { + Roles = ["myRole"], + SeedNodes = ["akka.tcp://MyActorSystem@localhost:8110"] + }) + .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 + }); + }); + }) + .UseConsoleLifetime() + .RunConsoleAsync(); \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.LoggingDemo/WorkerService.cs b/src/examples/Hosting/Akka.Hosting.LoggingDemo/WorkerService.cs new file mode 100644 index 00000000000..b73aa952e4e --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.LoggingDemo/WorkerService.cs @@ -0,0 +1,60 @@ +using Akka.Actor; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting.LoggingDemo; + +public class WorkerService: IHostedService +{ + private readonly ILogger _logger; + private readonly IRequiredActor _reqEcho; + private readonly CancellationTokenSource _cancellationTokenSource; + private Task? _timerTask; + + public WorkerService(IRequiredActor reqActor, ILogger logger) + { + _reqEcho = reqActor; + _logger = logger; + _cancellationTokenSource = new CancellationTokenSource(); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _timerTask = StartTimerTask(); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _cancellationTokenSource.Cancel(); + if(_timerTask != null) + await _timerTask; + } + + private async Task StartTimerTask() + { + var echoActor = await _reqEcho.GetAsync(); + var periodicTimer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + + try + { + while (true) + { + if (await periodicTimer.WaitForNextTickAsync(_cancellationTokenSource.Token)) + { + var response = await echoActor.Ask(Guid.NewGuid().ToString(), _cancellationTokenSource.Token); + _logger.LogInformation(response); + } + } + } + catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested) + { + // StopAsync cancelled the token: this is the normal way out of the loop. + _logger.LogInformation("Echo loop stopped because the service is shutting down"); + } + finally + { + periodicTimer.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.LoggingDemo/appsettings.Development.json b/src/examples/Hosting/Akka.Hosting.LoggingDemo/appsettings.Development.json new file mode 100644 index 00000000000..8983e0fc1c5 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.LoggingDemo/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/examples/Hosting/Akka.Hosting.LoggingDemo/appsettings.json b/src/examples/Hosting/Akka.Hosting.LoggingDemo/appsettings.json new file mode 100644 index 00000000000..d60ba0df02a --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.LoggingDemo/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Akka": "Debug" + } + }, + "AllowedHosts": "*" +} diff --git a/src/examples/Hosting/Akka.Hosting.SimpleDemo/Akka.Hosting.SimpleDemo.csproj b/src/examples/Hosting/Akka.Hosting.SimpleDemo/Akka.Hosting.SimpleDemo.csproj new file mode 100644 index 00000000000..501128f0f57 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.SimpleDemo/Akka.Hosting.SimpleDemo.csproj @@ -0,0 +1,14 @@ + + + + $(NetTestVersion) + enable + enable + false + + + + + + + diff --git a/src/examples/Hosting/Akka.Hosting.SimpleDemo/Echo.cs b/src/examples/Hosting/Akka.Hosting.SimpleDemo/Echo.cs new file mode 100644 index 00000000000..c5e8fdefb94 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.SimpleDemo/Echo.cs @@ -0,0 +1 @@ +public struct Echo{} \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.SimpleDemo/Program.cs b/src/examples/Hosting/Akka.Hosting.SimpleDemo/Program.cs new file mode 100644 index 00000000000..6dca789891c --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.SimpleDemo/Program.cs @@ -0,0 +1,81 @@ +using Akka.Actor; +using Akka.Cluster.Hosting; +using Akka.Cluster.Sharding; +using Akka.Remote.Hosting; +using Akka.Util; + +namespace Akka.Hosting.SimpleDemo; + +public interface IReplyGenerator +{ + string Reply(object input); +} + +public class DefaultReplyGenerator : IReplyGenerator +{ + public string Reply(object input) + { + return input.ToString()!; + } +} + +public class EchoActor : ReceiveActor +{ + private readonly string _entityId; + private readonly IReplyGenerator _replyGenerator; + public EchoActor(string entityId, IReplyGenerator replyGenerator) + { + _entityId = entityId; + _replyGenerator = replyGenerator; + ReceiveAny(message => { + Sender.Tell($"{Self} rcv {_replyGenerator.Reply(message)}"); + }); + } +} + +public class Program +{ + private const int NumberOfShards = 5; + + private static IMessageExtractor Extractor { get; } = HashCodeMessageExtractor.Create(NumberOfShards, msg => + { + if (msg is string id) + return id; + return string.Empty; + }); + + public static void Main(params string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddTransient(); + 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) => + { + return s => resolver.Props(s); + }, + messageExtractor: Extractor, + shardOptions: new ShardOptions()); + }); + + var app = builder.Build(); + + app.MapGet("/", async (HttpContext context, IRequiredActor echoActor) => + { + var echo = echoActor.ActorRef; + var body = await echo.Ask( + message: context.TraceIdentifier, + cancellationToken: context.RequestAborted) + .ConfigureAwait(false); + await context.Response.WriteAsync(body); + }); + + app.Run(); + } +} \ No newline at end of file diff --git a/src/examples/Hosting/Akka.Hosting.SimpleDemo/appsettings.Development.json b/src/examples/Hosting/Akka.Hosting.SimpleDemo/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.SimpleDemo/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/examples/Hosting/Akka.Hosting.SimpleDemo/appsettings.json b/src/examples/Hosting/Akka.Hosting.SimpleDemo/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/src/examples/Hosting/Akka.Hosting.SimpleDemo/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}