diff --git a/.github/workflows/Windows_release.yml b/.github/workflows/Windows_release.yml index 17581bf3..1db22911 100644 --- a/.github/workflows/Windows_release.yml +++ b/.github/workflows/Windows_release.yml @@ -26,7 +26,7 @@ jobs: name: windows-latest runs-on: windows-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Make build.sh executable run: chmod +x ./build.sh - name: Make build.cmd executable @@ -49,4 +49,3 @@ jobs: env: Nuget_Key: ${{ secrets.NUGET_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_CONTEXT: ${{ toJSON(github) }} diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 0cd782e1..8f002a22 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -31,7 +31,7 @@ jobs: name: windows-latest runs-on: windows-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Make build.sh executable run: chmod +x ./build.sh - name: Make build.cmd executable @@ -51,13 +51,11 @@ jobs: key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj') }} - name: Run './build.cmd All' run: ./build.cmd All - env: - GITHUB_CONTEXT: ${{ toJSON(github) }} ubuntu-latest: name: ubuntu-latest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Make build.sh executable run: chmod +x ./build.sh - name: Make build.cmd executable @@ -77,5 +75,3 @@ jobs: key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj') }} - name: Run './build.cmd All' run: ./build.cmd All - env: - GITHUB_CONTEXT: ${{ toJSON(github) }} diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 83216e1d..47e3224f 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -18,10 +18,6 @@ "type": "boolean", "description": "Indicates to continue a previously failed build attempt" }, - "GitHubToken": { - "type": "string", - "default": "Secrets must be entered via 'nuke :secret [profile]'" - }, "Help": { "type": "boolean", "description": "Shows the help text for this build assembly" @@ -33,6 +29,7 @@ "AppVeyor", "AzurePipelines", "Bamboo", + "Bitbucket", "Bitrise", "GitHubActions", "GitLab", @@ -52,7 +49,7 @@ }, "NugetKey": { "type": "string", - "default": "Secrets must be entered via 'nuke :secret [profile]'" + "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, "NugetPrerelease": { "type": "string" @@ -84,11 +81,11 @@ }, "SignClientSecret": { "type": "string", - "default": "Secrets must be entered via 'nuke :secret [profile]'" + "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, "SignClientUser": { "type": "string", - "default": "Secrets must be entered via 'nuke :secret [profile]'" + "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, "SigningDescription": { "type": "string" diff --git a/Akka.Hosting.sln b/Akka.Hosting.sln index c34d12d5..28ff68a4 100644 --- a/Akka.Hosting.sln +++ b/Akka.Hosting.sln @@ -35,6 +35,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Remote.Hosting.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Cluster.Hosting.Tests", "src\Akka.Cluster.Hosting.Tests\Akka.Cluster.Hosting.Tests.csproj", "{EEFCC5A9-94BB-41DA-A9D3-12ACB889FE42}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Persistence.Hosting", "src\Akka.Persistence.Hosting\Akka.Persistence.Hosting.csproj", "{424A63E4-2B7A-45B9-9E69-185277EBE507}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Persistence.Hosting.Tests", "src\Akka.Persistence.Hosting.Tests\Akka.Persistence.Hosting.Tests.csproj", "{876DE0B6-5FA8-4F79-876E-92EF5E9E7011}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Hosting.LoggingDemo", "src\Examples\Akka.Hosting.LoggingDemo\Akka.Hosting.LoggingDemo.csproj", "{4F79325B-9EE7-4501-800F-7A1F8DFBCC80}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Hosting.TestKit", "src\Akka.Hosting.TestKit\Akka.Hosting.TestKit.csproj", "{E28D4F3C-6C34-497B-BDC8-F2B3EA8BA309}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Hosting.TestKit.Tests", "src\Akka.Hosting.TestKit.Tests\Akka.Hosting.TestKit.Tests.csproj", "{3883AD08-B981-4943-8153-1E7FFD2C3127}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -87,6 +97,26 @@ Global {EEFCC5A9-94BB-41DA-A9D3-12ACB889FE42}.Debug|Any CPU.Build.0 = Debug|Any CPU {EEFCC5A9-94BB-41DA-A9D3-12ACB889FE42}.Release|Any CPU.ActiveCfg = Release|Any CPU {EEFCC5A9-94BB-41DA-A9D3-12ACB889FE42}.Release|Any CPU.Build.0 = Release|Any CPU + {424A63E4-2B7A-45B9-9E69-185277EBE507}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {424A63E4-2B7A-45B9-9E69-185277EBE507}.Debug|Any CPU.Build.0 = Debug|Any CPU + {424A63E4-2B7A-45B9-9E69-185277EBE507}.Release|Any CPU.ActiveCfg = Release|Any CPU + {424A63E4-2B7A-45B9-9E69-185277EBE507}.Release|Any CPU.Build.0 = Release|Any CPU + {876DE0B6-5FA8-4F79-876E-92EF5E9E7011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {876DE0B6-5FA8-4F79-876E-92EF5E9E7011}.Debug|Any CPU.Build.0 = Debug|Any CPU + {876DE0B6-5FA8-4F79-876E-92EF5E9E7011}.Release|Any CPU.ActiveCfg = Release|Any CPU + {876DE0B6-5FA8-4F79-876E-92EF5E9E7011}.Release|Any CPU.Build.0 = Release|Any CPU + {4F79325B-9EE7-4501-800F-7A1F8DFBCC80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F79325B-9EE7-4501-800F-7A1F8DFBCC80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F79325B-9EE7-4501-800F-7A1F8DFBCC80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F79325B-9EE7-4501-800F-7A1F8DFBCC80}.Release|Any CPU.Build.0 = Release|Any CPU + {E28D4F3C-6C34-497B-BDC8-F2B3EA8BA309}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E28D4F3C-6C34-497B-BDC8-F2B3EA8BA309}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E28D4F3C-6C34-497B-BDC8-F2B3EA8BA309}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E28D4F3C-6C34-497B-BDC8-F2B3EA8BA309}.Release|Any CPU.Build.0 = Release|Any CPU + {3883AD08-B981-4943-8153-1E7FFD2C3127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3883AD08-B981-4943-8153-1E7FFD2C3127}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3883AD08-B981-4943-8153-1E7FFD2C3127}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3883AD08-B981-4943-8153-1E7FFD2C3127}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -97,5 +127,6 @@ Global GlobalSection(NestedProjects) = preSolution {5F6A7BE8-6906-46CE-BA1C-72EA11EFA33B} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} {2C2C2DE2-5A79-4689-9D1A-D70CCF17545B} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} + {4F79325B-9EE7-4501-800F-7A1F8DFBCC80} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 77a6ea8a..fad824d8 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,17 @@ Consists of the following packages: 1. `Akka.Hosting` - core, needed for everything 2. `Akka.Remote.Hosting` - enables Akka.Remote configuration -3. `Akka.Cluster.Hosting` - used for Akka.Cluster, Akka.Cluster.Sharding +3. [`Akka.Cluster.Hosting`](src/Akka.Cluster.Hosting/README.md) - used for Akka.Cluster, Akka.Cluster.Sharding, and Akka.Cluster.Tools 4. `Akka.Persistence.SqlServer.Hosting` - used for Akka.Persistence.SqlServer support. 5. `Akka.Persistence.PostgreSql.Hosting` - used for Akka.Persistence.PostgreSql support. +6. [`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) +7. [The Akka.Management Project Repository](https://github.com/akkadotnet/Akka.Management) - useful tools for managing Akka.NET clusters running inside containerized or cloud based environment. `Akka.Hosting` is embedded in each of its packages: + * [`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. + * [`Akka.Management.Cluster.Bootstrap`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/cluster.bootstrap/Akka.Management.Cluster.Bootstrap) - used to bootstrap a cluster formation inside dynamic deployment environments, relies on `Akka.Discovery` to function. + * [`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. + * [`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. + * [`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. + * [`Akka.Coordination.KubernetesApi`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/kubernetes/Akka.Coordination.KubernetesApi) - provides a lease-based distributed lock mechanism for Akka Split Brain Resolver, Akka.Cluster.Sharding, and Akka.Cluster.Singleton See the ["Introduction to Akka.Hosting - HOCONless, "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. @@ -96,3 +104,85 @@ var registry = ActorRegistry.For(myActorSystem); // fetch from ActorSystem registry.TryRegister(indexer); // register for DI registry.Get(); // use in DI ``` + +## Microsoft.Extensions.Logging Integration + +__Logger Configuration Support__ + +You can now use the new `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.Hosting/tree/dev/src/Examples/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. + +### 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" + } + } +} +``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8c453bb8..88d91ca5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,7 +1,149 @@ -## [0.3.2] / 13 June 2022 -- [Fixed: WithDistributedPubSub role HOCON settings not inserted in proper order](https://github.com/akkadotnet/Akka.Hosting/issues/60) +## [0.5.0] / 9 October 2022 +* [Update Akka.NET from 1.4.41 to 1.4.43](https://github.com/akkadotnet/akka.net/releases/tag/1.4.43) +* [Add full options support to Akka.Persistence.SqlServer.Hosting](https://github.com/akkadotnet/Akka.Hosting/pull/107) +* [Improved Akka.Remote.Hosting implementation](https://github.com/akkadotnet/Akka.Hosting/pull/108) +* [Add a standardized option code pattern for Akka.Hosting developer](https://github.com/akkadotnet/Akka.Hosting/pull/110) +* [Add Akka.Hosting.TestKit module for unit testing projects using Akka.Hosting](https://github.com/akkadotnet/Akka.Hosting/pull/102) -## [0.3.1] / 09 June 2022 -- [Fixed: WithDistributedPubSub throws NullReferenceException](https://github.com/akkadotnet/Akka.Hosting/issues/55) -- [Introduced `AddHoconFile` method](https://github.com/akkadotnet/Akka.Hosting/pull/58) -- [Upgraded to Akka.NET 1.4.39](https://github.com/akkadotnet/akka.net/releases/tag/1.4.39) +**Add full options support to Akka.Persistence.SqlServer.Hosting** + +You can now use an option class in Akka.Persistence.SqlServer.Hosting to replace HOCON configuration fully. + +**Add Akka.Hosting.TestKit module** + +The biggest difference between _Akka.Hosting.TestKit_ and _Akka.TestKit_ is that, since the test is started asynchronously, the _TestKit_ properties and methods would not be available in the unit test class constructor anymore. Since the spec depends on Microsoft `HostBuilder`, configuration has to be broken down into steps. There are overridable methods that user can use to override virtually all of the setup process. + +These are steps of what overridable methods gets called. Not all of the methods needs to be overriden, at the minimum, if you do not need a custom hosting environment, you need to override the `ConfigureAkka` method. + +1. `ConfigureLogging(ILoggingBuilder)` + + Add custom logger and filtering rules on the `HostBuilder` level. +2. `ConfigureHostConfiguration(IConfigurationBuilder)` + + Inject any additional hosting environment configuration here, such as faking environment variables, in the `HostBuilder` level. +3. `ConfigureAppConfiguration(HostBuilderContext, IConfigurationBuilder)` + + Inject the application configuration. +4. `ConfigureServices(HostBuilderContext, IServiceCollection)` + + Add additional services needed by the test, such as mocked up services used inside the dependency injection. +5. User defined HOCON configuration is injected by overriding the `Config` property, it is not passed as part of the constructor anymore. +6. `ConfigureAkka(AkkaConfigurationBuilder, IServiceProvider)` + + This is called inside `AddAkka`, use this to configure the `AkkaConfigurationBuilder` +7. `BeforeTestStart()` + + This method is called after the TestKit is initialized. Move all of the codes that used to belong in the constructor here. + +`Akka.Hosting.TestKit` extends `Akka.TestKit.TestKitBase` directly, all testing methods are available out of the box. +All of the properties, such as `Sys` and `TestActor` will be available once the unit test class is invoked. + +**Add a standardized option code pattern for Akka.Hosting developer** + +This new feature is intended for Akka.Hosting module developer only, it is used to standardize how Akka.Hosting addresses a very common HOCON configuration pattern. This allows for a HOCON-less programmatic setup replacement for the HOCON path used to configure the HOCON property. + +The pattern: + +```text +# 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: +```csharp +// The base class for the option, needs to implement the IHoconOption template interface +public abstract class DiscoveryOptionBase : IHoconOption +{ } + +// The actual option implementation +public class ConfigOption : DiscoveryOptionBase +{ + // The path value in the akka.discovery.method property above + public string ConfigPath => "akka.discovery.config"; + + // The FQCN value in the akka.discovery.config.class property above + public Type Class => typeof(ConfigServiceDiscovery); + + // Generate the same HOCON config as above + public void Apply(AkkaConfigurationBuilder builder, Setup setup = null) + { + // Modifies Akka.NET configuration either via HOCON or setup class + builder.AddHocon( + $"akka.discovery.method = {ConfigPath.ToHocon()}", + HoconAddMode.Prepend); + builder.AddHocon($"akka.discovery.config.class = { + Class.AssemblyQualifiedName.ToHocon()}", + 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); +} +``` + +## [0.4.3] / 9 September 2022 +- [Update Akka.NET from 1.4.40 to 1.4.41](https://github.com/akkadotnet/akka.net/releases/tag/1.4.41) +- [Cluster.Hosting: Add split-brain resolver support](https://github.com/akkadotnet/Akka.Hosting/pull/95) +- [Hosting: Add `WithExtension()` extension method](https://github.com/akkadotnet/Akka.Hosting/pull/97) + +__WithExtension()__ + +`AkkaConfigurationBuilder.WithExtension()` works similarly to `AkkaConfigurationBuilder.WithExtensions()` and is used to configure the `akka.extensions` HOCON settings. The difference is that it is statically typed to only accept classes that extends the `IExtensionId` interface. + +This pull request also adds a validation code to the `AkkaConfigurationBuilder.WithExtensions()` method to make sure that all the types passed in actually extends the `IExtensionId` interface. The method will throw a `ConfigurationException` exception if one of the types did not extend `IExtensionId` or if they are abstract or static class types. + +Example: +```csharp +// Starts distributed pub-sub, cluster metrics, and cluster bootstrap extensions at start-up +builder + .WithExtension() + .WithExtension() + .WithExtension(); +``` + +__Clustering split-brain resolver support__ + +The split-brain resolver can now be set using the second parameter named `sbrOption` in the `.WithClustering()` extension method. You can read more about this in the [documentation](https://github.com/akkadotnet/Akka.Hosting/tree/dev/src/Akka.Cluster.Hosting#configure-a-cluster-with-split-brain-resolver-sbr). + +## [0.4.2] / 11 August 2022 +- [Update Akka.NET from 1.4.39 to 1.4.40](https://github.com/akkadotnet/akka.net/releases/tag/1.4.40) +- [Add `WithExtensions()` method](https://github.com/akkadotnet/Akka.Hosting/pull/92) +- [Add `AddStartup` method](https://github.com/akkadotnet/Akka.Hosting/pull/90) + +__WithExtensions()__ + +`AkkaConfigurationBuilder.WithExtensions()` is used to configure the `akka.extensions` HOCON settings. It is used to set an Akka.NET extension provider to start-up automatically during `ActorSystem` start-up. + +Example: +```csharp +// Starts distributed pub-sub, cluster metrics, and cluster bootstrap extensions at start-up +builder.WithExtensions( + typeof(DistributedPubSubExtensionProvider), + typeof(ClusterMetricsExtensionProvider), + typeof(ClusterBootstrapProvider)); +``` + +__AddStartup()__ + +`AddStartup()` method adds `StartupTask` delegate to the configuration builder. + +This feature is useful when a user need to run a specific initialization code if anf only if the `ActorSystem` and all of the actors have been started. Although it is semantically the same as `AddActors` and `WithActors`, it disambiguate the use-case with a guarantee that it will only be executed after everything is ready. + +For example, this feature is useful for: +- kicking off actor initializations by using Tell()s once all of the actor infrastructure are in place, or +- pre-populating certain persistence or database data after everything is set up and running, useful for unit testing or adding fake data for local development. \ No newline at end of file diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs index 2a2aa9da..74b1ea4d 100644 --- a/build/Build.CI.GitHubActions.cs +++ b/build/Build.CI.GitHubActions.cs @@ -17,8 +17,7 @@ OnPullRequestBranches = new[] { "master", "dev" }, InvokedTargets = new[] { nameof(All) }, PublishArtifacts = true, - EnableGitHubContext = true) -] + EnableGitHubToken = true)] [CustomGitHubActions("Windows_release", GitHubActionsImage.WindowsLatest, @@ -26,8 +25,8 @@ AutoGenerate = false, InvokedTargets = new[] { nameof(NuGet) }, ImportSecrets = new[] { "Nuget_Key", "GITHUB_TOKEN" }, - EnableGitHubContext = true, - PublishArtifacts = true) + PublishArtifacts = true, + EnableGitHubToken = true) ] partial class Build diff --git a/build/Build.cs b/build/Build.cs index 171ba12c..1c03bcf2 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -25,9 +25,11 @@ using static Nuke.Common.Tools.Git.GitTasks; using Octokit; using Nuke.Common.Utilities; +using Nuke.Common.CI.GitHubActions; -[CheckBuildProjectConfigurations] [ShutdownDotNetAfterServerBuild] +[DotNetVerbosityMapping] +[UnsetVisualStudioEnvironmentVariables] partial class Build : NukeBuild { /// Support plugins are available for: @@ -45,7 +47,6 @@ partial class Build : NukeBuild [Parameter] string NugetPublishUrl = "https://api.nuget.org/v3/index.json"; - [Parameter][Secret] string GitHubToken; [Parameter][Secret] string NugetKey; [Parameter] int Port = 8090; @@ -74,23 +75,22 @@ partial class Build : NukeBuild public string ChangelogFile => RootDirectory / "RELEASE_NOTES.md"; public AbsolutePath DocFxDir => RootDirectory / "docs"; public AbsolutePath DocFxDirJson => DocFxDir / "docfx.json"; - readonly Solution Solution = ProjectModelTasks.ParseSolution(RootDirectory.GlobFiles("*.sln").FirstOrDefault()); - - static readonly JsonElement? _githubContext = string.IsNullOrWhiteSpace(EnvironmentInfo.GetVariable("GITHUB_CONTEXT")) ? - null - : JsonSerializer.Deserialize(EnvironmentInfo.GetVariable("GITHUB_CONTEXT")); - - //let hasTeamCity = (not (buildNumber = "0")) // check if we have the TeamCity environment variable for build # set - static readonly int BuildNumber = _githubContext.HasValue ? int.Parse(_githubContext.Value.GetProperty("run_number").GetString()) : 0; - - static readonly string PreReleaseVersionSuffix = "beta" + (BuildNumber > 0 ? BuildNumber : DateTime.UtcNow.Ticks.ToString()); + GitHubActions GitHubActions => GitHubActions.Instance; + private long BuildNumber() + { + return GitHubActions.RunNumber; + } + private string PreReleaseVersionSuffix() + { + return "beta" + (BuildNumber() > 0 ? BuildNumber() : DateTime.UtcNow.Ticks.ToString()); + } public ChangeLog Changelog => ReadChangelog(ChangelogFile); public ReleaseNotes ReleaseNotes => Changelog.ReleaseNotes.OrderByDescending(s => s.Version).FirstOrDefault() ?? throw new ArgumentException("Bad Changelog File. Version Should Exist"); private string VersionFromReleaseNotes => ReleaseNotes.Version.IsPrerelease ? ReleaseNotes.Version.OriginalVersion : ""; - private string VersionSuffix => NugetPrerelease == "dev" ? PreReleaseVersionSuffix : NugetPrerelease == "" ? VersionFromReleaseNotes : NugetPrerelease; + private string VersionSuffix => NugetPrerelease == "dev" ? PreReleaseVersionSuffix() : NugetPrerelease == "" ? VersionFromReleaseNotes : NugetPrerelease; public string ReleaseVersion => ReleaseNotes.Version?.ToString() ?? throw new ArgumentException("Bad Changelog File. Define at least one version"); GitHubClient GitHubClient; Target Clean => _ => _ @@ -175,18 +175,18 @@ partial class Build : NukeBuild }); Target AuthenticatedGitHubClient => _ => _ .Unlisted() - .OnlyWhenDynamic(() => !string.IsNullOrWhiteSpace(GitHubToken)) + .OnlyWhenDynamic(() => !string.IsNullOrWhiteSpace(GitHubActions.Token)) .Executes(() => { GitHubClient = new GitHubClient(new ProductHeaderValue("nuke-build")) { - Credentials = new Credentials(GitHubToken, AuthenticationType.Bearer) + Credentials = new Credentials(GitHubActions.Token, AuthenticationType.Bearer) }; }); Target GitHubRelease => _ => _ .Unlisted() .Description("Creates a GitHub release (or amends existing) and uploads the artifact") - .OnlyWhenDynamic(() => !string.IsNullOrWhiteSpace(GitHubToken)) + .OnlyWhenDynamic(() => !string.IsNullOrWhiteSpace(GitHubActions.Token)) .DependsOn(AuthenticatedGitHubClient) .Executes(async () => { @@ -194,9 +194,12 @@ partial class Build : NukeBuild var releaseNotes = GetNuGetReleaseNotes(ChangelogFile); Release release; var releaseName = $"{version}"; + if (!VersionSuffix.IsNullOrWhiteSpace()) releaseName = $"{version}-{VersionSuffix}"; + var identifier = GitRepository.Identifier.Split("/"); + var (gitHubOwner, repoName) = (identifier[0], identifier[1]); try { diff --git a/build/_build.csproj b/build/_build.csproj index 7a92ccae..963a5d43 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/src/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj b/src/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj index a263cac3..66c4cf19 100644 --- a/src/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj +++ b/src/Akka.Cluster.Hosting.Tests/Akka.Cluster.Hosting.Tests.csproj @@ -8,7 +8,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs b/src/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs new file mode 100644 index 00000000..acc8fee2 --- /dev/null +++ b/src/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Akka.Actor; +using Akka.Cluster.Tools.Client; +using FluentAssertions; +using FluentAssertions.Extensions; +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); + + config.GetString("name").Should().Be("customName"); + settings.Role.Should().Be("customRole"); + } + + [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); + + settings.InitialContacts.Should().BeEquivalentTo(contacts); + } +} \ No newline at end of file diff --git a/src/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs b/src/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs new file mode 100644 index 00000000..dc211abc --- /dev/null +++ b/src/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Cluster.Sharding; +using Akka.Hosting; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +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 + var id = await shardRegion.Ask(new MyEntityActor.GetId("foo"), TimeSpan.FromSeconds(3)); + var sourceRef = + await shardRegion.Ask(new MyEntityActor.GetSourceRef("foo"), TimeSpan.FromSeconds(3)); + + // assert + id.Should().Be("foo"); + sourceRef.Should().Be(actorRegistry.Get()); + } +} \ No newline at end of file diff --git a/src/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs b/src/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs index 60ea05b3..a8f878e5 100644 --- a/src/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs +++ b/src/Akka.Cluster.Hosting.Tests/ClusterSingletonSpecs.cs @@ -1,14 +1,9 @@ using System; -using System.Threading; using System.Threading.Tasks; using Akka.Actor; -using Akka.Event; using Akka.Hosting; -using Akka.Remote.Hosting; -using Akka.TestKit.Xunit2.Internals; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Xunit; using Xunit.Abstractions; @@ -25,7 +20,7 @@ public ClusterSingletonSpecs(ITestOutputHelper output) private class MySingletonActor : ReceiveActor { - public static Props MyProps => Props.Create(() => new MySingletonActor()); + public static Props MyProps => Props.Create(() => new ClusterSingletonSpecs.MySingletonActor()); public MySingletonActor() { @@ -33,58 +28,16 @@ public MySingletonActor() } } - private async Task CreateHost(Action specBuilder, ClusterOptions options) - { - var tcs = new TaskCompletionSource(); - using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - - var host = new HostBuilder() - .ConfigureServices(collection => - { - collection.AddAkka("TestSys", (configurationBuilder, provider) => - { - configurationBuilder - .WithRemoting("localhost", 0) - .WithClustering(options) - .WithActors((system, registry) => - { - var extSystem = (ExtendedActorSystem)system; - var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(Output)), "log-test"); - logger.Tell(new InitializeLogger(system.EventStream)); - }) - .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); // force system to wait until we're up - } - }); - specBuilder(configurationBuilder); - }); - }).Build(); - - await host.StartAsync(cancellationTokenSource.Token); - await (tcs.Task.WaitAsync(cancellationTokenSource.Token)); - - return host; - } - [Fact] public async Task Should_launch_ClusterSingletonAndProxy() { // arrange - using var host = await CreateHost( - builder => { builder.WithSingleton("my-singleton", MySingletonActor.MyProps); }, - new ClusterOptions(){ Roles = new[] { "my-host"}}); + 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(); + var singletonProxy = registry.Get(); // act @@ -103,19 +56,19 @@ public async Task Should_launch_ClusterSingleton_and_Proxy_separately() // arrange var singletonOptions = new ClusterSingletonOptions() { Role = "my-host" }; - using var singletonHost = await CreateHost( - builder => { builder.WithSingleton("my-singleton", MySingletonActor.MyProps, singletonOptions, createProxyToo:false); }, - new ClusterOptions(){ Roles = new[] { "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 CreateHost( - builder => { builder.WithSingletonProxy("my-singleton", singletonOptions); }, - new ClusterOptions(){ Roles = new[] { "proxy" }, SeedNodes = new Address[]{ address } }); + using var singletonProxyHost = await TestHelper.CreateHost( + builder => { builder.WithSingletonProxy("my-singleton", singletonOptions); }, + new ClusterOptions(){ Roles = new[] { "proxy" }, SeedNodes = new Address[]{ address } }, Output); var registry = singletonProxyHost.Services.GetRequiredService(); - var singletonProxy = registry.Get(); + var singletonProxy = registry.Get(); // act diff --git a/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs b/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs new file mode 100644 index 00000000..8258c022 --- /dev/null +++ b/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs @@ -0,0 +1,168 @@ +//----------------------------------------------------------------------- +// +// 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.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 ConcurrentDictionary(); + + 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/Akka.Cluster.Hosting.Tests/Lease/TestLeaseActor.cs b/src/Akka.Cluster.Hosting.Tests/Lease/TestLeaseActor.cs new file mode 100644 index 00000000..455cbdd5 --- /dev/null +++ b/src/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/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs b/src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs new file mode 100644 index 00000000..aae1dacc --- /dev/null +++ b/src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs @@ -0,0 +1,178 @@ +// ----------------------------------------------------------------------- +// +// 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 FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +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(sbrOptions: SplitBrainResolverOption.Default); + }); + + var system = host.Services.GetRequiredService(); + + Cluster.Get(system).DowningProvider.Should().BeOfType(); + var settings = new SplitBrainResolverSettings(system.Settings.Config); + settings.DowningStrategy.Should().Be(SplitBrainResolverSettings.KeepMajorityName); + settings.KeepMajorityRole.Should().BeNull(); + } + + [Fact(DisplayName = "Static quorum SBR set from Akka.Hosting should load")] + public async Task StaticQuorumTest() + { + var host = await StartHost(builder => + { + builder.WithClustering(sbrOptions: new StaticQuorumOption + { + QuorumSize = 1, + Role = "myRole" + }); + }); + + var system = host.Services.GetRequiredService(); + Cluster.Get(system).DowningProvider.Should().BeOfType(); + + var settings = new SplitBrainResolverSettings(system.Settings.Config); + settings.DowningStrategy.Should().Be(SplitBrainResolverSettings.StaticQuorumName); + settings.StaticQuorumSettings.Size.Should().Be(1); + settings.StaticQuorumSettings.Role.Should().Be("myRole"); + } + + [Fact(DisplayName = "Keep majority SBR set from Akka.Hosting should load")] + public async Task KeepMajorityTest() + { + var host = await StartHost(builder => + { + builder.WithClustering(sbrOptions: new KeepMajorityOption + { + Role = "myRole" + }); + }); + + var system = host.Services.GetRequiredService(); + Cluster.Get(system).DowningProvider.Should().BeOfType(); + + var settings = new SplitBrainResolverSettings(system.Settings.Config); + settings.DowningStrategy.Should().Be(SplitBrainResolverSettings.KeepMajorityName); + settings.KeepMajorityRole.Should().Be("myRole"); + } + + [Fact(DisplayName = "Keep oldest SBR set from Akka.Hosting should load")] + public async Task KeepOldestTest() + { + var host = await StartHost(builder => + { + builder.WithClustering(sbrOptions: new KeepOldestOption + { + DownIfAlone = false, + Role = "myRole" + }); + }); + + var system = host.Services.GetRequiredService(); + Cluster.Get(system).DowningProvider.Should().BeOfType(); + + var settings = new SplitBrainResolverSettings(system.Settings.Config); + settings.DowningStrategy.Should().Be(SplitBrainResolverSettings.KeepOldestName); + settings.KeepOldestSettings.DownIfAlone.Should().BeFalse(); + settings.KeepOldestSettings.Role.Should().Be("myRole"); + } + + [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(sbrOptions: new LeaseMajorityOption + { + LeaseImplementation = new TestLeaseOption(), + LeaseName = "myService-akka-sbr", + Role = "myRole" + }); + }); + + var system = host.Services.GetRequiredService(); + Cluster.Get(system).DowningProvider.Should().BeOfType(); + + var settings = new SplitBrainResolverSettings(system.Settings.Config); + settings.DowningStrategy.Should().Be(SplitBrainResolverSettings.LeaseMajorityName); + settings.LeaseMajoritySettings.LeaseImplementation.Should().Be("test-lease"); + settings.LeaseMajoritySettings.LeaseName.Should().Be("myService-akka-sbr"); + settings.LeaseMajoritySettings.Role.Should().Be("myRole"); + } + +} \ No newline at end of file diff --git a/src/Akka.Cluster.Hosting.Tests/TestHelper.cs b/src/Akka.Cluster.Hosting.Tests/TestHelper.cs new file mode 100644 index 00000000..cd12ce0e --- /dev/null +++ b/src/Akka.Cluster.Hosting.Tests/TestHelper.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting; +using Akka.Remote.Hosting; +using Akka.TestKit.Xunit2.Internals; +using Microsoft.Extensions.Hosting; +using Xunit.Abstractions; + +namespace Akka.Cluster.Hosting.Tests; + +public static class TestHelper +{ + + public static async Task CreateHost(Action specBuilder, ClusterOptions options, ITestOutputHelper output) + { + var tcs = new TaskCompletionSource(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var host = new HostBuilder() + .ConfigureServices(collection => + { + collection.AddAkka("TestSys", (configurationBuilder, provider) => + { + configurationBuilder + .WithRemoting("localhost", 0) + .WithClustering(options) + .WithActors((system, registry) => + { + var extSystem = (ExtendedActorSystem)system; + var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(output)), "log-test"); + logger.Tell(new InitializeLogger(system.EventStream)); + }) + .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); // force system to wait until we're up + } + }); + specBuilder(configurationBuilder); + }); + }).Build(); + + await host.StartAsync(cancellationTokenSource.Token); + await (tcs.Task.WaitAsync(cancellationTokenSource.Token)); + + return host; + } +} \ No newline at end of file diff --git a/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj b/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj index 96ec5581..04cedf65 100644 --- a/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj +++ b/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj @@ -7,10 +7,7 @@ - - - - + diff --git a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs index c6181ee9..d54c454a 100644 --- a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs +++ b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs @@ -1,11 +1,16 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; 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.Hosting; namespace Akka.Cluster.Hosting @@ -62,17 +67,24 @@ private static AkkaConfigurationBuilder BuildClusterSeedsHocon(this AkkaConfigur return builder.AddHocon(config, HoconAddMode.Prepend); } - private static AkkaConfigurationBuilder BuildClusterHocon(this AkkaConfigurationBuilder builder, - ClusterOptions options) + private static AkkaConfigurationBuilder BuildClusterHocon( + this AkkaConfigurationBuilder builder, + ClusterOptions options, + SplitBrainResolverOption sbrOptions) { - if (options == null) + if (options == null && sbrOptions == null) return builder; - if (options.Roles is { Length: > 0 }) - builder = builder.BuildClusterRolesHocon(options.Roles); + if (options != null) + { + if (options.Roles is { Length: > 0 }) + builder = builder.BuildClusterRolesHocon(options.Roles); + + if (options.SeedNodes is { Length: > 0 }) + builder = builder.BuildClusterSeedsHocon(options.SeedNodes); + } - if (options.SeedNodes is { Length: > 0 }) - builder = builder.BuildClusterSeedsHocon(options.SeedNodes); + sbrOptions?.Apply(builder); // populate all of the possible Clustering default HOCON configurations here return builder.AddHocon(ClusterSharding.DefaultConfig() @@ -86,11 +98,24 @@ private static AkkaConfigurationBuilder BuildClusterHocon(this AkkaConfiguration /// /// The builder instance being configured. /// Optional. Akka.Cluster configuration parameters. + /// + /// Optional. 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. + /// /// The same instance originally passed in. - public static AkkaConfigurationBuilder WithClustering(this AkkaConfigurationBuilder builder, - ClusterOptions options = null) + public static AkkaConfigurationBuilder WithClustering( + this AkkaConfigurationBuilder builder, + ClusterOptions options = null, + SplitBrainResolverOption sbrOptions = null) { - var hoconBuilder = BuildClusterHocon(builder, options); + var hoconBuilder = BuildClusterHocon(builder, options, sbrOptions); if (builder.ActorRefProvider.HasValue) { @@ -132,10 +157,8 @@ public static AkkaConfigurationBuilder WithShardRegion(this AkkaConfigurat .WithRole(shardOptions.Role) .WithRememberEntities(shardOptions.RememberEntities) .WithStateStoreMode(shardOptions.StateStoreMode), messageExtractor); - - // TODO: should throw here if duplicate key used - - registry.TryRegister(shardRegion); + + registry.Register(shardRegion); }); } @@ -173,9 +196,44 @@ public static AkkaConfigurationBuilder WithShardRegion(this AkkaConfigurat .WithRememberEntities(shardOptions.RememberEntities) .WithStateStoreMode(shardOptions.StateStoreMode), extractEntityId, extractShardId); - // TODO: should throw here if duplicate key used + registry.Register(shardRegion); + }); + } + + public static AkkaConfigurationBuilder WithShardRegion(this AkkaConfigurationBuilder builder, + string typeName, + Func> compositePropsFactory, IMessageExtractor messageExtractor, ShardOptions shardOptions) + { + return builder.WithActors(async (system, registry) => + { + var entityPropsFactory = compositePropsFactory(system, registry); + + var shardRegion = await ClusterSharding.Get(system).StartAsync(typeName, entityPropsFactory, + ClusterShardingSettings.Create(system) + .WithRole(shardOptions.Role) + .WithRememberEntities(shardOptions.RememberEntities) + .WithStateStoreMode(shardOptions.StateStoreMode), messageExtractor); - registry.TryRegister(shardRegion); + registry.Register(shardRegion); + }); + } + + public static AkkaConfigurationBuilder WithShardRegion(this AkkaConfigurationBuilder builder, + string typeName, + Func> compositePropsFactory, ExtractEntityId extractEntityId, + ExtractShardId extractShardId, ShardOptions shardOptions) + { + return builder.WithActors(async (system, registry) => + { + var entityPropsFactory = compositePropsFactory(system, registry); + + var shardRegion = await ClusterSharding.Get(system).StartAsync(typeName, entityPropsFactory, + ClusterShardingSettings.Create(system) + .WithRole(shardOptions.Role) + .WithRememberEntities(shardOptions.RememberEntities) + .WithStateStoreMode(shardOptions.StateStoreMode), extractEntityId, extractShardId); + + registry.Register(shardRegion); }); } @@ -204,10 +262,8 @@ public static AkkaConfigurationBuilder WithShardRegionProxy(this AkkaConfi { var shardRegionProxy = await ClusterSharding.Get(system) .StartProxyAsync(typeName, roleName, extractEntityId, extractShardId); - - // TODO: should throw here if duplicate key used - - registry.TryRegister(shardRegionProxy); + + registry.Register(shardRegionProxy); }); } @@ -231,10 +287,8 @@ public static AkkaConfigurationBuilder WithShardRegionProxy(this AkkaConfi { var shardRegionProxy = await ClusterSharding.Get(system) .StartProxyAsync(typeName, roleName, messageExtractor); - - // TODO: should throw here if duplicate key used - - registry.TryRegister(shardRegionProxy); + + registry.Register(shardRegionProxy); }); } @@ -261,7 +315,7 @@ public static AkkaConfigurationBuilder WithDistributedPubSub(this AkkaConfigurat { // force the initialization var mediator = DistributedPubSub.Get(system).Mediator; - registry.TryRegister(mediator); + registry.Register(mediator); }); } @@ -367,5 +421,129 @@ public static AkkaConfigurationBuilder WithSingletonProxy(this AkkaConfigu CreateAndRegisterSingletonProxy(singletonName, singletonManagerPath, singletonProxySettings, system, 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(QuoteIfNeeded(name)); + + if(!string.IsNullOrEmpty(role)) + sb.Append(root).Append("role:").AppendLine(QuoteIfNeeded(role)); + + 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.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()); + } + + #region Helper functions + + private static readonly Regex EscapeRegex = new Regex("[ \t:]{1}", RegexOptions.Compiled); + + private static string QuoteIfNeeded(string text) + { + return text == null + ? "" : EscapeRegex.IsMatch(text) + ? $"\"{text}\"" : text; + } + + #endregion } } diff --git a/src/Akka.Cluster.Hosting/Properties/FriendsOf.cs b/src/Akka.Cluster.Hosting/Properties/FriendsOf.cs new file mode 100644 index 00000000..0511f30e --- /dev/null +++ b/src/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/Akka.Cluster.Hosting/README.md b/src/Akka.Cluster.Hosting/README.md new file mode 100644 index 00000000..a043c88c --- /dev/null +++ b/src/Akka.Cluster.Hosting/README.md @@ -0,0 +1,462 @@ +# 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-resolverhttpsgetakkanetarticlesclusteringsplit-brain-resolverhtml-sbr) +- [Akka.Cluster.Sharding](https://getakka.net/articles/clustering/cluster-sharding.html) + - [WithShardRegion()](#withshardregion-method) + - [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) + - [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, + SplitBrainResolverOption sbrOptions = null); +``` + +### Parameters +* `options` __ClusterOptions__ + + Optional. Akka.Cluster configuration parameters. + +* `sbrOptions` __SplitBrainResolverOption__ + + Optional. Split brain resolver configuration parameters. This can be an instance of one of these classes: + - `KeepMajorityOption` + - `StaticQuorumOption` + - `KeepOldestOption` + - `LeaseMajorityOption` + +### Example +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAkka("MyActorSystem", configurationBuilder => +{ + configurationBuilder + .WithRemoting("localhost", 8110) + .WithClustering( + options: new ClusterOptions { + Roles = new[] { "myRole" }, + SeedNodes = new[] { Address.Parse("akka.tcp://MyActorSystem@localhost:8110")}} + sbrOptions: 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](https://getakka.net/articles/clustering/split-brain-resolver.html) (SBR) + +The __sbrOptions__ parameter 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(sbrOption: new KeepMajorityOption{ Role = "myRole" }); +}); +``` + +__NOTE__: Currently, in order to use `LeaseMajorityOption` you will need to provide the absolute HOCON path to 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 `akka.coordination.lease.kubernetes` + +# 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(); + } +} +``` + +## 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. + +## 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/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs new file mode 100644 index 00000000..f84c93e2 --- /dev/null +++ b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs @@ -0,0 +1,211 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Actor.Setup; +using Akka.Cluster.SBR; +using Akka.Hosting; + +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); + } + } + + public abstract class LeaseOptionBase : IHoconOption + { + public abstract string ConfigPath { get; } + public abstract Type Class { get; } + public abstract void Apply(AkkaConfigurationBuilder builder, Setup setup = null); + } + + /// + /// 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/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj b/src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj new file mode 100644 index 00000000..920e8b5e --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj @@ -0,0 +1,28 @@ + + + + $(TestsNetCoreFramework) + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + Always + + + + diff --git a/src/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs b/src/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs new file mode 100644 index 00000000..46248177 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs @@ -0,0 +1,64 @@ +// ----------------------------------------------------------------------- +// +// 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 Xunit.Abstractions; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Akka.Hosting.TestKit.Tests +{ + public class HostingSpecSpec: TestKit + { + private enum Echo + { } + + public HostingSpecSpec(ITestOutputHelper output) + : base(nameof(HostingSpecSpec), output, logLevel: LogLevel.Debug) + { + } + + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithActors((system, registry) => + { + var echo = system.ActorOf(Props.Create(() => new SimpleEchoActor())); + registry.Register(echo); + }); + return Task.CompletedTask; + } + + [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); + }); + } + } + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/NoImplicitSenderSpec.cs b/src/Akka.Hosting.TestKit.Tests/NoImplicitSenderSpec.cs new file mode 100644 index 00000000..23192787 --- /dev/null +++ b/src/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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs b/src/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e2336be8 --- /dev/null +++ b/src/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.CollectionPerAssembly, DisableTestParallelization = true)] diff --git a/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/BossActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/BossActor.cs new file mode 100644 index 00000000..5c5e9f1d --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestActorRefTests/FsmActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/FsmActor.cs new file mode 100644 index 00000000..297f82ec --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs new file mode 100644 index 00000000..33eb78e2 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// 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) + { + var warning = message as Warning; + if(warning != null && warning.Message is string) + { + _count++; + _msg = (string)warning.Message; + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/NestingActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/NestingActor.cs new file mode 100644 index 00000000..64558707 --- /dev/null +++ b/src/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.System.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/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs new file mode 100644 index 00000000..e2c111f9 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// 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 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": + _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/Akka.Hosting.TestKit.Tests/TestActorRefTests/SenderActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/SenderActor.cs new file mode 100644 index 00000000..420ec2d0 --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestActorRefTests/TActorBase.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TActorBase.cs new file mode 100644 index 00000000..4bf03ac4 --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs new file mode 100644 index 00000000..2be76e17 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs @@ -0,0 +1,235 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + 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 void 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 = a.Ask("any", DefaultTimeout).Result; + Assert.NotNull(nested); + Assert.NotSame(a, nested); + } + + [Fact] + public void 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 = a.Ask("any", DefaultTimeout).Result; + 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 a = new TestActorRef(Sys, Props.Create(), null, "will-be-killed"); + Sys.ActorOf(Props.Create(() => new WatchAndForwardActor(a, TestActor)), "forwarder"); + a.Tell(PoisonPill.Instance); + ExpectMsg(w => w.Terminated.ActorRef == a, TimeSpan.FromSeconds(10), string.Format("that the terminated actor was the one killed, i.e. {0}", a.Path)); + var actorRef = (InternalTestActorRef)a.Ref; + actorRef.IsTerminated.Should().Be(true); + 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 void TestActorRef_must_support_futures() + { + var worker = new TestActorRef(Sys, Props.Create()); + var task = worker.Ask("work"); + Assert.True(task.IsCompleted, "Task should be completed"); + if(!task.Wait(DefaultTimeout)) throw new TimeoutException("Timed out"); //Using a timeout to stop the test if there is something wrong with the code + Assert.Equal("workDone", task.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()); + a.Receive("work"); + var actorRef = (InternalTestActorRef)a.Ref; + Assert.True(actorRef.IsTerminated); + } + + [Fact] + public void TestActorRef_must_proxy_receive_for_the_underlying_actor_with_sender() + { + var a = new TestActorRef(Sys, Props.Create()); + a.Receive("work", TestActor); //This will stop the actor + var actorRef = (InternalTestActorRef)a.Ref; + Assert.True(actorRef.IsTerminated); + ExpectMsg("workDone"); + } + + [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/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs new file mode 100644 index 00000000..ce64f64a --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs @@ -0,0 +1,120 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + /// + /// 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 void TestProbe_restart_a_failing_child_if_the_given_supervisor_says_so() + { + var restarts = new AtomicCounter(0); + var probe = CreateTestProbe(); + var child = probe.ChildActorOf(Props.Create(() => new FailingActor(restarts)), SupervisorStrategy.DefaultStrategy); + AwaitAssert(() => + { + child.Tell("hello"); + restarts.Current.Should().BeGreaterThan(1); + }); + } + + class FailingActor : ActorBase + { + private AtomicCounter Restarts { get; } + + public FailingActor(AtomicCounter restarts) + { + Restarts = restarts; + } + + protected override bool Receive(object message) + { + throw new Exception("Simulated failure"); + } + + protected override void PostRestart(Exception reason) + { + Restarts.IncrementAndGet(); + } + } + } +} diff --git a/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WatchAndForwardActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WatchAndForwardActor.cs new file mode 100644 index 00000000..0306bc9e --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestActorRefTests/WorkerActor.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WorkerActor.cs new file mode 100644 index 00000000..832849b2 --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestActorRefTests/WrappedTerminated.cs b/src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WrappedTerminated.cs new file mode 100644 index 00000000..b940dedd --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs new file mode 100644 index 00000000..dd4878c4 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs @@ -0,0 +1,293 @@ +//----------------------------------------------------------------------- +// +// 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.Abstractions; +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 VirtualMemberCallInContructor + _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() + { + _testingEventFilter.ForLogLevel(LogLevel).ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + + [Fact] + public void Can_intercept_messages_when_start_is_specified() + { + _testingEventFilter.ForLogLevel(LogLevel, start: "what").ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + [Fact] + public void Do_not_intercept_messages_when_start_does_not_match() + { + _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() + { + _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() + { + _testingEventFilter.ForLogLevel(LogLevel, contains: "ate").ExpectOne(() => LogMessage("whatever")); + TestSuccessful = true; + } + + [Fact] + public void Do_not_intercept_messages_when_contains_does_not_match() + { + _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() + { + _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() + { + _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_messagesan_be_intercepted() + { + _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() + { + 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() + { + 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() + { + _testingEventFilter.ForLogLevel(LogLevel).Mute(() => + { + LogMessage("whatever"); + LogMessage("whatever"); + }); + TestSuccessful = true; + } + + + [Fact] + public void Messages_can_be_muted_from_now_on() + { + 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() + { + using(_testingEventFilter.ForLogLevel(LogLevel).Mute()) + { + LogMessage("whatever"); + LogMessage("whatever"); + } + TestSuccessful = true; + } + + + [Fact] + public void Make_sure_async_works() + { + _testingEventFilter.ForLogLevel(LogLevel).Expect(1, TimeSpan.FromSeconds(2), () => + { + Task.Delay(TimeSpan.FromMilliseconds(10)).ContinueWith(t => { LogMessage("whatever"); }); + }); + } + + [Fact] + public void Chain_many_filters() + { + _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() + { + 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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase_Instances.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase_Instances.cs new file mode 100644 index 00000000..0aa33b9e --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs new file mode 100644 index 00000000..6d5bfef0 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/CustomEventFilterTests.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/CustomEventFilterTests.cs new file mode 100644 index 00000000..0e1a1f63 --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/DeadLettersEventFilterTests.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/DeadLettersEventFilterTests.cs new file mode 100644 index 00000000..86f0416a --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs new file mode 100644 index 00000000..ce5fdac8 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs @@ -0,0 +1,77 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Event; +using Xunit.Abstractions; + +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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.ConfigureLoggers(logger => + { + logger.LogLevel = _logLevel; + logger.ClearLoggers(); + logger.AddLogger(); + }); + + return Task.CompletedTask; + } + + 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); + ExpectMsg("OK"); + //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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ExceptionEventFilterTests.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ExceptionEventFilterTests.cs new file mode 100644 index 00000000..b4d2b12f --- /dev/null +++ b/src/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/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs new file mode 100644 index 00000000..30644a63 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs @@ -0,0 +1,46 @@ +//----------------------------------------------------------------------- +// +// 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) + { + _forwarder = ((ForwardAllEventsTo)m.Message).Forwarder; + _forwarder.Tell("OK"); + } + else if(_forwarder != null) + { + _forwarder.Forward(m); + } + else + { + base.Print(m); + } + } + + public class ForwardAllEventsTo + { + private readonly IActorRef _forwarder; + + public ForwardAllEventsTo(IActorRef forwarder) + { + _forwarder = forwarder; + } + + public IActorRef Forwarder { get { return _forwarder; } } + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/TestFSMRefTests/TestFSMRefSpec.cs b/src/Akka.Hosting.TestKit.Tests/TestFSMRefTests/TestFSMRefSpec.cs new file mode 100644 index 00000000..8f099a43 --- /dev/null +++ b/src/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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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.StateName == 2 && fsm.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, "go"); + if(fsmEvent is StateTimeout) + return GoTo(2, "timeout"); + return null; + }); + When(2, e => + { + var fsmEvent = e.FsmEvent; + if(Equals(fsmEvent, "back")) + return GoTo(1, "back"); + return null; + }); + } + } + private class TimerTestFsm : FSM + { + public TimerTestFsm() + { + StartWith(1, null); + When(1, e => Stay()); + } + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs new file mode 100644 index 00000000..32b1a12b --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs new file mode 100644 index 00000000..54ec0a3c --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs @@ -0,0 +1,85 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Diagnostics; +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; + private const int Margin = 1000; // margin for GC + private const int DiffDelta = 100; + + protected override Config Config { get; } = $"akka.test.timefactor={TimeFactor}"; + + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [Fact] + public void Dilates_correctly_using_timeFactor() + { + Assert.Equal(Dilated(TimeSpan.FromMilliseconds(Timeout)), TimeSpan.FromMilliseconds(ExpectedTimeout)); + } + + [Fact] + public void AwaitCondition_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + Invoking(() => AwaitCondition(() => false, TimeSpan.FromMilliseconds(Timeout))) + .Should().Throw(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + [Fact] + public void ReceiveN_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + Invoking(() => ReceiveN(42, TimeSpan.FromMilliseconds(Timeout))) + .Should().Throw(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + [Fact] + public void ExpectMsgAllOf_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + Invoking(() => ExpectMsgAllOf(TimeSpan.FromMilliseconds(Timeout), "1", "2")) + .Should().Throw(); + stopwatch.Stop(); + AssertDilated(stopwatch.ElapsedMilliseconds, $"Expected the timeout to be {ExpectedTimeout} but in fact it was {stopwatch.ElapsedMilliseconds}."); + } + + [Fact] + public void FishForMessage_should_dilate_timeout() + { + var stopwatch = Stopwatch.StartNew(); + Invoking(() => FishForMessage(_=>false, TimeSpan.FromMilliseconds(Timeout))) + .Should().Throw(); + 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/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs new file mode 100644 index 00000000..0ca717ac --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs @@ -0,0 +1,62 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [Fact] + public void ExpectMsgAllOf_should_receive_correct_messages() + { + TestActor.Tell("1"); + TestActor.Tell("2"); + TestActor.Tell("3"); + TestActor.Tell("4"); + ExpectMsgAllOf("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("3", "1", "2")) + .Should().Throw(); + } + + [Fact] + public void ExpectMsgAllOf_should_timeout_when_not_receiving_any_messages() + { + Invoking(() => ExpectMsgAllOf(TimeSpan.FromMilliseconds(100), "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), "3", "1", "2")) + .Should().Throw(); + } + +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs new file mode 100644 index 00000000..ad22590d --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs @@ -0,0 +1,68 @@ +//----------------------------------------------------------------------- +// +// 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.TestKitBaseTests; + +public class IgnoreMessagesTests : TestKit +{ + public class IgnoredMessage + { + public IgnoredMessage(string ignoreMe = null) + { + IgnoreMe = ignoreMe; + } + + public string IgnoreMe { get; } + } + + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [Fact] + public void IgnoreMessages_should_ignore_messages() + { + IgnoreMessages(o => o is int && (int)o == 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/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs new file mode 100644 index 00000000..2bd7c342 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs @@ -0,0 +1,283 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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); + try + { + await probe.WaitForRadioSilenceAsync(max: TimeSpan.FromMilliseconds(0), maxMessages: 0); + Assert.True(false, "we should never get here"); + } + catch (XunitException) { } + } + + [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/Akka.Hosting.TestKit.Tests/TestKitBaseTests/RemainingTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/RemainingTests.cs new file mode 100644 index 00000000..3efd2ff4 --- /dev/null +++ b/src/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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [Fact] + public void Throw_if_remaining_is_called_outside_Within() + { + Assert.Throws(() => Remaining); + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.cs b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.cs new file mode 100644 index 00000000..2a927f0a --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.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 WithinTests : TestKit +{ + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [Fact] + public void Within_should_increase_max_timeout_by_the_provided_epsilon_value() + { + Within(TimeSpan.FromSeconds(1), () => ExpectNoMsg(), TimeSpan.FromMilliseconds(50)); + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs b/src/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs new file mode 100644 index 00000000..c0f4cf74 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs @@ -0,0 +1,38 @@ +//----------------------------------------------------------------------- +// +// 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + [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/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs b/src/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs new file mode 100644 index 00000000..973bd119 --- /dev/null +++ b/src/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs @@ -0,0 +1,203 @@ +//----------------------------------------------------------------------- +// +// 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.TestKit; +using Akka.TestKit.Configs; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.TestKit.Tests; + +public class TestSchedulerTests : TestKit +{ + private IActorRef _testReceiveActor; + + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + return Task.CompletedTask; + } + + 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 void Delivers_message_when_scheduled_time_reached() + { + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1))); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + ExpectMsg(); + } + + [Fact] + public void Does_not_deliver_message_prematurely() + { + _testReceiveActor.Tell(new ScheduleOnceMessage(TimeSpan.FromSeconds(1))); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromMilliseconds(999)); + ExpectNoMsg(TimeSpan.FromMilliseconds(20)); + } + + [Fact] + public void 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)); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + var firstId = ExpectMsg().Id; + var secondId = ExpectMsg().Id; + Assert.Equal(1, firstId); + Assert.Equal(2, secondId); + } + + [Fact] + public void Keeps_delivering_rescheduled_message() + { + _testReceiveActor.Tell(new RescheduleMessage(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5))); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + for (int i = 0; i < 500; i ++) + { + Scheduler.Advance(TimeSpan.FromSeconds(5)); + ExpectMsg(); + } + } + + [Fact] + public void Uses_initial_delay_to_schedule_first_rescheduled_message() + { + _testReceiveActor.Tell(new RescheduleMessage(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5))); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + ExpectMsg(); + } + + [Fact] + public void Doesnt_reschedule_cancelled() + { + _testReceiveActor.Tell(new CancelableMessage(TimeSpan.FromSeconds(1))); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + Scheduler.Advance(TimeSpan.FromSeconds(1)); + ExpectMsg(); + _testReceiveActor.Tell(new CancelMessage()); + Scheduler.Advance(TimeSpan.FromSeconds(1)); + ExpectNoMsg(TimeSpan.FromMilliseconds(20)); + } + + + [Fact] + public void 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)); + _testReceiveActor.Ask(new Identify(null), RemainingOrDefault) + .Wait(RemainingOrDefault).Should().BeTrue(); // verify that the ActorCell has started + + Scheduler.AdvanceTo(Scheduler.Now.AddSeconds(2)); + var firstId = ExpectMsg().Id; + var secondId = ExpectMsg().Id; + ExpectNoMsg(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(_ => + { + _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/Akka.Hosting.TestKit.Tests/xunit.runner.json b/src/Akka.Hosting.TestKit.Tests/xunit.runner.json new file mode 100644 index 00000000..4a73b1e5 --- /dev/null +++ b/src/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": false, + "parallelizeTestCollections": false +} \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit/ActorCellKeepingSynchronizationContext.cs b/src/Akka.Hosting.TestKit/ActorCellKeepingSynchronizationContext.cs new file mode 100644 index 00000000..3af04257 --- /dev/null +++ b/src/Akka.Hosting.TestKit/ActorCellKeepingSynchronizationContext.cs @@ -0,0 +1,83 @@ +//----------------------------------------------------------------------- +// +// 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.Actor.Internal; + +namespace Akka.Hosting.TestKit +{ + /// + /// TBD + /// + class ActorCellKeepingSynchronizationContext : SynchronizationContext + { + private readonly ActorCell _cell; + + internal static ActorCell AsyncCache { get; set; } + + /// + /// TBD + /// + /// TBD + public ActorCellKeepingSynchronizationContext(ActorCell cell) + { + _cell = cell; + } + + /// + /// TBD + /// + /// TBD + /// TBD + public override void Post(SendOrPostCallback d, object state) + { + ThreadPool.QueueUserWorkItem(_ => + { + var oldCell = InternalCurrentActorCellKeeper.Current; + var oldContext = Current; + SetSynchronizationContext(this); + InternalCurrentActorCellKeeper.Current = AsyncCache ?? _cell; + + try + { + d(state); + } + finally + { + InternalCurrentActorCellKeeper.Current = oldCell; + SetSynchronizationContext(oldContext); + } + }, state); + } + + /// + /// TBD + /// + /// TBD + /// TBD + public override void Send(SendOrPostCallback d, object state) + { + var tcs = new TaskCompletionSource(); + Post(_ => + { + try + { + d(state); + tcs.SetResult(0); + } + catch (Exception e) + { + tcs.TrySetException(e); + } + }, state); + tcs.Task.Wait(); + } + } +} diff --git a/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj b/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj new file mode 100644 index 00000000..ab6944ed --- /dev/null +++ b/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj @@ -0,0 +1,19 @@ + + + + TestKit for writing tests for Akka.NET using Akka.Hosting and xUnit. + $(LibraryFramework) + true + 8.0 + + + + + + + + + + + + diff --git a/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings b/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings new file mode 100644 index 00000000..00152058 --- /dev/null +++ b/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs b/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs new file mode 100644 index 00000000..f822c5dd --- /dev/null +++ b/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Akka.Hosting.TestKit.Internals +{ + 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) + { + 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; + } + } +} + diff --git a/src/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs b/src/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs new file mode 100644 index 00000000..024d312c --- /dev/null +++ b/src/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Akka.Hosting.TestKit.Internals +{ + 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); + } + } +} + diff --git a/src/Akka.Hosting.TestKit/TestKit.cs b/src/Akka.Hosting.TestKit/TestKit.cs new file mode 100644 index 00000000..15515ccc --- /dev/null +++ b/src/Akka.Hosting.TestKit/TestKit.cs @@ -0,0 +1,211 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +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 Akka.TestKit.Xunit2; +using Akka.TestKit.Xunit2.Internals; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +#nullable enable +namespace Akka.Hosting.TestKit +{ + public abstract class TestKit: TestKitBase, IAsyncLifetime + { + /// + /// Commonly used assertions used throughout the testkit. + /// + protected static XunitAssertions Assertions { get; } = new XunitAssertions(); + + private IHost? _host; + public IHost Host + { + get + { + if(_host is null) + throw new XunitException("Test has not been initialized yet"); + return _host; + } + } + + public ActorRegistry ActorRegistry => Host.Services.GetRequiredService(); + + public TimeSpan StartupTimeout { get; } + public string ActorSystemName { get; } + public ITestOutputHelper? Output { get; } + public LogLevel LogLevel { get; } + + private TaskCompletionSource _initialized = new TaskCompletionSource(); + + protected TestKit(string? actorSystemName = null, ITestOutputHelper? output = null, TimeSpan? startupTimeout = null, LogLevel logLevel = LogLevel.Information) + : base(Assertions) + { + ActorSystemName = actorSystemName ?? "test"; + Output = output; + LogLevel = logLevel; + StartupTimeout = startupTimeout ?? TimeSpan.FromSeconds(10); + } + + protected virtual void ConfigureHostConfiguration(IConfigurationBuilder builder) + { } + + protected virtual void ConfigureAppConfiguration(HostBuilderContext context, IConfigurationBuilder builder) + { } + + protected virtual void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { } + + private void InternalConfigureServices(HostBuilderContext context, IServiceCollection services) + { + ConfigureServices(context, services); + + services.AddAkka(ActorSystemName, async (builder, provider) => + { + builder.AddHocon(DefaultConfig, HoconAddMode.Prepend); + if (Config is { }) + builder.AddHocon(Config, HoconAddMode.Prepend); + + builder.ConfigureLoggers(logger => + { + logger.LogLevel = ToAkkaLogLevel(LogLevel); + logger.ClearLoggers(); + logger.AddLogger(); + }); + + if (Output is { }) + { + builder.StartActors(async (system, registry) => + { + var extSystem = (ExtendedActorSystem)system; + var logger = extSystem.SystemActorOf(Props.Create(() => new LoggerFactoryLogger()), "log-test"); + await logger.Ask(new InitializeLogger(system.EventStream)); + }); + } + + await ConfigureAkka(builder, provider); + + builder.AddStartup((system, registry) => + { + base.InitializeTest(system, (ActorSystemSetup)null!, null, null); + _initialized.SetResult(Done.Instance); + }); + }); + } + + protected virtual Config? Config { get; } = null; + + protected virtual void ConfigureLogging(ILoggingBuilder builder) + { } + + protected abstract Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider); + + [InternalApi] + public async Task InitializeAsync() + { + 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) + .ConfigureServices(InternalConfigureServices); + + _host = hostBuilder.Build(); + + var cts = new CancellationTokenSource(StartupTimeout); + cts.Token.Register(() => + throw new TimeoutException($"Host failed to start within {StartupTimeout.Seconds} seconds")); + try + { + await _host.StartAsync(cts.Token); + } + finally + { + cts.Dispose(); + } + + await _initialized.Task; + await BeforeTestStart(); + } + + protected sealed override void InitializeTest(ActorSystem system, ActorSystemSetup config, string actorSystemName, string testActorName) + { + // no-op, deferring InitializeTest after Host have ran + } + + 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() + { + Shutdown(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await AfterAllAsync(); + if(_host != null) + { + await _host.StopAsync(); + if (_host is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else + { + _host.Dispose(); + } + } + } + + 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/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj index 4a5d79a6..7c82edea 100644 --- a/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj +++ b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj @@ -4,10 +4,14 @@ + - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Akka.Hosting.Tests/ExtensionsSpecs.cs b/src/Akka.Hosting.Tests/ExtensionsSpecs.cs new file mode 100644 index 00000000..63495d8a --- /dev/null +++ b/src/Akka.Hosting.Tests/ExtensionsSpecs.cs @@ -0,0 +1,139 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using Akka.TestKit.Xunit2.Internals; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +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\"]"); + 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\"]"); + 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/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs b/src/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs new file mode 100644 index 00000000..1f4b0bb5 --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs @@ -0,0 +1,50 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using Akka.Hosting.Logging; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using LogLevel = Akka.Event.LogLevel; + +namespace Akka.Hosting.Tests.Logging; + +public class LoggerConfigBuilderSpecs +{ + [Fact(DisplayName = "LoggerConfigBuilder should contain proper default configuration")] + public void LoggerSetupDefaultValues() + { + var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "test") + .ConfigureLoggers(_ => { }); + + builder.Configuration.HasValue.Should().BeTrue(); + var config = builder.Configuration.Value; + config.GetString("akka.loglevel").Should().Be("Info"); + config.GetString("akka.log-config-on-start").Should().Be("false"); + var loggers = config.GetStringList("akka.loggers"); + loggers.Count.Should().Be(1); + loggers[0].Should().Contain("Akka.Event.DefaultLogger"); + } + + [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(); + }); + + builder.Configuration.HasValue.Should().BeTrue(); + var config = builder.Configuration.Value; + config.GetString("akka.loglevel").Should().Be("Warning"); + config.GetString("akka.log-config-on-start").Should().Be("true"); + var loggers = config.GetStringList("akka.loggers"); + loggers.Count.Should().Be(0); + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs b/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs new file mode 100644 index 00000000..8166b6c5 --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Hosting.Logging; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using static Akka.Hosting.Tests.TestHelpers; + +namespace Akka.Hosting.Tests.Logging; + +public class LoggerConfigEnd2EndSpecs : TestKit.Xunit2.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(() => 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/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs b/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs new file mode 100644 index 00000000..e17a3733 --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs @@ -0,0 +1,235 @@ +// ----------------------------------------------------------------------- +// +// 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 Xunit.Abstractions; +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 Task InitializeAsync() + { + _host = await SetupHost(_logger); + var registry = _host.Services.GetRequiredService(); + _echo = registry.Get(); + } + + public Task DisposeAsync() + { + _host.Dispose(); + return Task.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/Akka.Hosting.Tests/Logging/TestLogger.cs b/src/Akka.Hosting.Tests/Logging/TestLogger.cs new file mode 100644 index 00000000..4da1b10d --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/TestLogger.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +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) + { + return EmptyDisposable.Instance; + } +} + +public class EmptyDisposable : IDisposable +{ + public static readonly EmptyDisposable Instance = new EmptyDisposable(); + + private EmptyDisposable() + { + } + + public void Dispose() + { + } +} \ No newline at end of file diff --git a/src/Akka.Hosting.Tests/TestHelpers.cs b/src/Akka.Hosting.Tests/TestHelpers.cs index 4c58183f..1cca12da 100644 --- a/src/Akka.Hosting.Tests/TestHelpers.cs +++ b/src/Akka.Hosting.Tests/TestHelpers.cs @@ -1,7 +1,11 @@ using System; using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.TestKit.Xunit2.Internals; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Xunit.Abstractions; namespace Akka.Hosting.Tests; @@ -19,4 +23,17 @@ public static async Task StartHost(Action testSetup) 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/Akka.Hosting.Tests/XUnitLogger.cs b/src/Akka.Hosting.Tests/XUnitLogger.cs new file mode 100644 index 00000000..eaf73ccc --- /dev/null +++ b/src/Akka.Hosting.Tests/XUnitLogger.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +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) + { + 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/Akka.Hosting.Tests/XUnitLoggerProvider.cs b/src/Akka.Hosting.Tests/XUnitLoggerProvider.cs new file mode 100644 index 00000000..c5f8d18c --- /dev/null +++ b/src/Akka.Hosting.Tests/XUnitLoggerProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +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/Akka.Hosting/Akka.Hosting.csproj b/src/Akka.Hosting/Akka.Hosting.csproj index 83a3590d..b7834b0d 100644 --- a/src/Akka.Hosting/Akka.Hosting.csproj +++ b/src/Akka.Hosting/Akka.Hosting.csproj @@ -3,6 +3,7 @@ $(LibraryFramework) README.md Akka.NET Microsoft.Extensions.Hosting support. + 8.0 diff --git a/src/Akka.Hosting/AkkaConfigurationBuilder.cs b/src/Akka.Hosting/AkkaConfigurationBuilder.cs index e585f6c9..119de2b2 100644 --- a/src/Akka.Hosting/AkkaConfigurationBuilder.cs +++ b/src/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -2,15 +2,19 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Akka.Actor; using Akka.Actor.Dsl; using Akka.Actor.Setup; +using Akka.Annotations; using Akka.Configuration; using Akka.DependencyInjection; +using Akka.Hosting.Logging; using Akka.Serialization; using Akka.Util; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Akka.Hosting { @@ -50,6 +54,8 @@ public enum HoconAddMode /// public delegate Task ActorStarter(ActorSystem system, IActorRegistry registry); + public delegate Task StartupTask(ActorSystem system, IActorRegistry registry); + /// /// Used to help populate a upon starting the , /// if any are added to the builder; @@ -76,8 +82,19 @@ public sealed class AkkaConfigurationBuilder internal readonly string ActorSystemName; internal readonly IServiceCollection ServiceCollection; internal readonly HashSet Serializers = new HashSet(); - internal readonly HashSet Setups = new HashSet(); + internal readonly HashSet Extensions = new HashSet(); + /// + /// 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 HashSet(); + /// /// The currently configured . /// @@ -96,6 +113,7 @@ public sealed class AkkaConfigurationBuilder internal Option Sys { get; set; } = Option.None; private readonly HashSet _actorStarters = new HashSet(); + private readonly HashSet _startupTasks = new HashSet(); private bool _complete = false; public AkkaConfigurationBuilder(IServiceCollection serviceCollection, string actorSystemName) @@ -176,6 +194,17 @@ Task Starter(ActorSystem f, IActorRegistry registry) 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) { @@ -191,6 +220,34 @@ public AkkaConfigurationBuilder StartActors(ActorStarter starter) 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) @@ -201,6 +258,55 @@ public AkkaConfigurationBuilder WithCustomSerializer( 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; + } + internal void Bind() { // register as singleton - not interested in supporting multi-Sys use cases @@ -222,6 +328,37 @@ internal void Bind() }); } + /// + /// 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 => @@ -231,6 +368,25 @@ private static Func ActorSystemFactory() /* * 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 @@ -286,6 +442,11 @@ internal async Task StartAsync(ActorSystem sys) await starter(sys, registry).ConfigureAwait(false); } + foreach (var startupTask in _startupTasks) + { + await startupTask(sys, registry).ConfigureAwait(false); + } + return sys; } } diff --git a/src/Akka.Hosting/AkkaHostingExtensions.cs b/src/Akka.Hosting/AkkaHostingExtensions.cs index fdcb9bd8..4cc15140 100644 --- a/src/Akka.Hosting/AkkaHostingExtensions.cs +++ b/src/Akka.Hosting/AkkaHostingExtensions.cs @@ -1,8 +1,10 @@ using System; using System.IO; +using System.Linq; using Akka.Actor; using Akka.Actor.Setup; using Akka.Configuration; +using Akka.Util; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ServiceProvider = Microsoft.Extensions.DependencyInjection.ServiceProvider; @@ -53,7 +55,6 @@ public static IServiceCollection AddAkka(this IServiceCollection services, strin var b = new AkkaConfigurationBuilder(services, actorSystemName); services.AddSingleton(sp => { - builder(b, sp); return b; }); @@ -144,6 +145,5 @@ public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder { return builder.StartActors(actorStarter); } - } } diff --git a/src/Akka.Hosting/HoconExtensions.cs b/src/Akka.Hosting/HoconExtensions.cs new file mode 100644 index 00000000..687646bc --- /dev/null +++ b/src/Akka.Hosting/HoconExtensions.cs @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Globalization; +using System.Text.RegularExpressions; +using Akka.Configuration; + +namespace Akka.Hosting +{ + public static class HoconExtensions + { + private static readonly Regex EscapeRegex = new Regex("[ \t:]{1}", RegexOptions.Compiled); + + public static string ToHocon(this string text) + { + if (text is null) + throw new ConfigurationException("Value can not be null"); + + return EscapeRegex.IsMatch(text) ? $"\"{text}\"" : text; + } + + 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) + { + if (value is null) + throw new ConfigurationException("Value can not be null"); + + return value.Value.TotalMilliseconds.ToString(CultureInfo.InvariantCulture); + } + + public static string ToHocon(this TimeSpan value) + => value.TotalMilliseconds.ToString(CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/src/Akka.Hosting/IHoconOption.cs b/src/Akka.Hosting/IHoconOption.cs new file mode 100644 index 00000000..7e9ea4c8 --- /dev/null +++ b/src/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/Akka.Hosting/LoggerConfigBuilder.cs b/src/Akka.Hosting/LoggerConfigBuilder.cs new file mode 100644 index 00000000..dd91a15e --- /dev/null +++ b/src/Akka.Hosting/LoggerConfigBuilder.cs @@ -0,0 +1,96 @@ +// ----------------------------------------------------------------------- +// +// 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 = new List { typeof(DefaultLogger) }; + 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; } = LogLevel.InfoLevel; + + /// + /// + /// 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; } = false; + + /// + /// 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; + } + + /// + /// 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); + } + + internal Config ToConfig() + { + var sb = new StringBuilder() + .Append("akka.loglevel=").AppendLine(ParseLogLevel(LogLevel)) + .Append("akka.loggers=[").Append(string.Join(",", _loggers.Select(t => $"\"{t.AssemblyQualifiedName}\""))).AppendLine("]") + .Append("akka.log-config-on-start=").AppendLine(LogConfigOnStart ? "true" : "false"); + return ConfigurationFactory.ParseString(sb.ToString()); + } + + private string ParseLogLevel(LogLevel logLevel) + => logLevel switch + { + LogLevel.DebugLevel => "Debug", + LogLevel.InfoLevel => "Info", + LogLevel.WarningLevel => "Warning", + LogLevel.ErrorLevel => "Error", + _ => throw new ConfigurationException($"Unknown {nameof(LogLevel)} enum value: {logLevel}") + }; + } +} \ No newline at end of file diff --git a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs new file mode 100644 index 00000000..05c0c882 --- /dev/null +++ b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs @@ -0,0 +1,138 @@ +// ----------------------------------------------------------------------- +// +// 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 + { + public const string DefaultTimeStampFormat = "yy/MM/dd-HH:mm:ss.ffff"; + private const string DefaultMessageFormat = "[{{Timestamp:{0}}}][{{LogSource}}][{{ActorPath}}][{{Thread:0000}}]: {{Message}}"; + private static readonly Event.LogLevel[] AllLogLevels = Enum.GetValues(typeof(Event.LogLevel)).Cast().ToArray(); + + /// + /// only used when we're shutting down / spinning up + /// + private readonly ILoggingAdapter _internalLogger = Akka.Event.Logging.GetLogger(Context.System.EventStream, nameof(LoggerFactoryLogger)); + private readonly ILoggerFactory _loggerFactory; + private ILogger _akkaLogger; + private readonly string _messageFormat; + + public LoggerFactoryLogger() + { + _messageFormat = string.Format(DefaultMessageFormat, DefaultTimeStampFormat); + 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; + } + } + + private void Log(LogEvent log, ActorPath path) + { + var message = GetMessage(log.Message); + _akkaLogger.Log(GetLogLevel(log.LogLevel()), log.Cause, _messageFormat, GetArgs(log, path, message)); + } + + private static object[] GetArgs(LogEvent log, ActorPath path, object message) + => new []{ log.Timestamp, log.LogSource, path, log.Thread.ManagedThreadId, message }; + + private static object GetMessage(object obj) + { + try + { + return obj is LogMessage m ? string.Format(m.Format, m.Args) : obj; + } + catch (Exception ex) + { + // Formatting/ToString error handling + var sb = new StringBuilder("Exception while recording log: ") + .Append(ex.Message) + .Append(' '); + switch (obj) + { + case LogMessage msg: + var args = msg.Args.Select(o => + { + try + { + return o.ToString(); + } + catch(Exception e) + { + return $"{o.GetType()}.ToString() throws {e.GetType()}: {e.Message}"; + } + }); + sb.Append($"Format: [{msg.Format}], Args: [{string.Join(",", args)}]."); + break; + case string str: + sb.Append($"Message: [{str}]."); + break; + default: + sb.Append($"Failed to invoke {obj.GetType()}.ToString()."); + break; + } + + sb.AppendLine(" Please take a look at the logging call where this occurred and fix your format string."); + sb.Append(ex); + return sb.ToString(); + } + } + + private static LogLevel GetLogLevel(Event.LogLevel level) + { + switch (level) + { + case Event.LogLevel.DebugLevel: + return LogLevel.Debug; + case Event.LogLevel.InfoLevel: + return LogLevel.Information; + case Event.LogLevel.WarningLevel: + return LogLevel.Warning; + case Event.LogLevel.ErrorLevel: + return LogLevel.Error; + default: + // Should never reach this code path + return LogLevel.Error; + } + } + } +} \ No newline at end of file diff --git a/src/Akka.Hosting/Logging/LoggerFactorySetup.cs b/src/Akka.Hosting/Logging/LoggerFactorySetup.cs new file mode 100644 index 00000000..40a549a3 --- /dev/null +++ b/src/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/Akka.Hosting/LoggingExtensions.cs b/src/Akka.Hosting/LoggingExtensions.cs new file mode 100644 index 00000000..e792c966 --- /dev/null +++ b/src/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 builder.AddHoconConfiguration(setup.ToConfig(), HoconAddMode.Prepend); + } + + /// + /// 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/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj new file mode 100644 index 00000000..d503b8db --- /dev/null +++ b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -0,0 +1,24 @@ + + + + $(TestsNetCoreFramework) + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs b/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs new file mode 100644 index 00000000..4dee92a4 --- /dev/null +++ b/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs @@ -0,0 +1,114 @@ +using System; +using System.Threading.Tasks; +using Akka.Actor; +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; + } + + 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 Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithJournal("sql-server", 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) }); + }); + + return Task.CompletedTask; + } + + [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()); + } +} \ No newline at end of file diff --git a/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs b/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs new file mode 100644 index 00000000..f770da67 --- /dev/null +++ b/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting; +using Akka.TestKit.Xunit2.Internals; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; +using Xunit.Abstractions; + +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; } + } + + public static async Task StartHost(Action testSetup) + { + var host = new HostBuilder() + .ConfigureServices(testSetup).Build(); + + await host.StartAsync(); + return host; + } + + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder + .WithInMemoryJournal() + .WithInMemorySnapshotStore() + .StartActors((system, registry) => + { + var myActor = system.ActorOf(Props.Create(() => new MyPersistenceActor("ac1")), "actor1"); + registry.Register(myActor); + }); + + return Task.CompletedTask; + } + + [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/Akka.Persistence.Hosting.Tests/SqlServer/SqlServerOptionsSpec.cs b/src/Akka.Persistence.Hosting.Tests/SqlServer/SqlServerOptionsSpec.cs new file mode 100644 index 00000000..3d54d85c --- /dev/null +++ b/src/Akka.Persistence.Hosting.Tests/SqlServer/SqlServerOptionsSpec.cs @@ -0,0 +1,158 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Persistence.Query.Sql; +using Akka.Persistence.SqlServer; +using Akka.Persistence.SqlServer.Hosting; +using FluentAssertions; +using FluentAssertions.Extensions; +using Xunit; + +namespace Akka.Persistence.Hosting.Tests.SqlServer; + +public class SqlServerOptionsSpec +{ + #region Journal unit tests + + [Fact(DisplayName = "Empty SqlServerJournalOptions should generate empty config")] + public void EmptyJournalOptionsTest() + { + var options = new SqlServerJournalOptions(); + var config = options.ToConfig(); + + config.GetString("akka.persistence.journal.plugin").Should().Be("akka.persistence.journal.sql-server"); + config.HasPath("akka.persistence.query.journal.sql.refresh-interval").Should().BeFalse(); + config.HasPath("akka.persistence.journal.sql-server").Should().BeFalse(); + } + + [Fact(DisplayName = "Empty SqlServerJournalOptions with default fallback should return default config")] + public void DefaultJournalOptionsTest() + { + var options = new SqlServerJournalOptions(); + var baseConfig = options.ToConfig() + .WithFallback(SqlServerPersistence.DefaultConfiguration()) + .WithFallback(SqlReadJournal.DefaultConfiguration()); + + baseConfig.GetString("akka.persistence.journal.plugin").Should().Be("akka.persistence.journal.sql-server"); + + baseConfig.GetTimeSpan("akka.persistence.query.journal.sql.refresh-interval").Should() + .Be(3.Seconds()); + + var config = baseConfig.GetConfig("akka.persistence.journal.sql-server"); + config.Should().NotBeNull(); + config.GetString("connection-string").Should().BeEmpty(); + config.GetTimeSpan("connection-timeout").Should().Be(30.Seconds()); + config.GetString("schema-name").Should().Be("dbo"); + config.GetString("table-name").Should().Be("EventJournal"); + config.GetBoolean("auto-initialize").Should().BeFalse(); + config.GetString("metadata-table-name").Should().Be("Metadata"); + config.GetBoolean("sequential-access").Should().BeTrue(); + config.GetBoolean("use-constant-parameter-size").Should().BeFalse(); + } + + [Fact(DisplayName = "SqlServerJournalOptions should generate proper config")] + public void JournalOptionsTest() + { + var options = new SqlServerJournalOptions + { + AutoInitialize = true, + ConnectionString = "testConnection", + ConnectionTimeout = 1.Seconds(), + MetadataTableName = "testMetadata", + QueryRefreshInterval = 2.Seconds(), + SchemaName = "testSchema", + SequentialAccess = false, + TableName = "testTable", + UseConstantParameterSize = true + }; + var baseConfig = options.ToConfig() + .WithFallback(SqlServerPersistence.DefaultConfiguration()) + .WithFallback(SqlReadJournal.DefaultConfiguration()); + + baseConfig.GetString("akka.persistence.journal.plugin").Should().Be("akka.persistence.journal.sql-server"); + + baseConfig.GetTimeSpan("akka.persistence.query.journal.sql.refresh-interval").Should() + .Be(options.QueryRefreshInterval.Value); + + var config = baseConfig.GetConfig("akka.persistence.journal.sql-server"); + config.Should().NotBeNull(); + config.GetString("connection-string").Should().Be(options.ConnectionString); + config.GetTimeSpan("connection-timeout").Should().Be(options.ConnectionTimeout.Value); + config.GetString("schema-name").Should().Be(options.SchemaName); + config.GetString("table-name").Should().Be(options.TableName); + config.GetBoolean("auto-initialize").Should().Be(options.AutoInitialize.Value); + config.GetString("metadata-table-name").Should().Be(options.MetadataTableName); + config.GetBoolean("sequential-access").Should().Be(options.SequentialAccess.Value); + config.GetBoolean("use-constant-parameter-size").Should().Be(options.UseConstantParameterSize.Value); + } + + #endregion + + #region Snapshot unit tests + + [Fact(DisplayName = "Empty SqlServerSnapshotOptions should generate empty config")] + public void EmptySnapshotOptionsTest() + { + var options = new SqlServerSnapshotOptions(); + var config = options.ToConfig(); + + config.GetString("akka.persistence.snapshot-store.plugin").Should().Be("akka.persistence.snapshot-store.sql-server"); + config.HasPath("akka.persistence.snapshot-store.sql-server").Should().BeFalse(); + } + + [Fact(DisplayName = "Empty SqlServerSnapshotOptions with default fallback should return default config")] + public void DefaultSnapshotOptionsTest() + { + var options = new SqlServerSnapshotOptions(); + var baseConfig = options.ToConfig() + .WithFallback(SqlServerPersistence.DefaultConfiguration()); + + baseConfig.GetString("akka.persistence.snapshot-store.plugin").Should().Be("akka.persistence.snapshot-store.sql-server"); + + var config = baseConfig.GetConfig("akka.persistence.snapshot-store.sql-server"); + config.Should().NotBeNull(); + config.GetString("connection-string").Should().BeEmpty(); + config.GetTimeSpan("connection-timeout").Should().Be(30.Seconds()); + config.GetString("schema-name").Should().Be("dbo"); + config.GetString("table-name").Should().Be("SnapshotStore"); + config.GetBoolean("auto-initialize").Should().BeFalse(); + config.GetBoolean("sequential-access").Should().BeTrue(); + config.GetBoolean("use-constant-parameter-size").Should().BeFalse(); + } + + [Fact(DisplayName = "SqlServerSnapshotOptions should generate proper config")] + public void JournalSnapshotTest() + { + var options = new SqlServerSnapshotOptions + { + AutoInitialize = true, + ConnectionString = "testConnection", + ConnectionTimeout = 1.Seconds(), + SchemaName = "testSchema", + SequentialAccess = false, + TableName = "testTable", + UseConstantParameterSize = true + }; + var baseConfig = options.ToConfig() + .WithFallback(SqlServerPersistence.DefaultConfiguration()); + + baseConfig.GetString("akka.persistence.snapshot-store.plugin").Should().Be("akka.persistence.snapshot-store.sql-server"); + + var config = baseConfig.GetConfig("akka.persistence.snapshot-store.sql-server"); + config.Should().NotBeNull(); + config.GetString("connection-string").Should().Be(options.ConnectionString); + config.GetTimeSpan("connection-timeout").Should().Be(options.ConnectionTimeout.Value); + config.GetString("schema-name").Should().Be(options.SchemaName); + config.GetString("table-name").Should().Be(options.TableName); + config.GetBoolean("auto-initialize").Should().Be(options.AutoInitialize.Value); + config.GetBoolean("sequential-access").Should().Be(options.SequentialAccess.Value); + config.GetBoolean("use-constant-parameter-size").Should().Be(options.UseConstantParameterSize.Value); + } + + #endregion +} \ No newline at end of file diff --git a/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj b/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj new file mode 100644 index 00000000..aecfabcd --- /dev/null +++ b/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj @@ -0,0 +1,17 @@ + + + $(LibraryFramework) + README.md + Akka.Persistence Microsoft.Extensions.Hosting support. + 9 + + + + + + + + + + + diff --git a/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs b/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs new file mode 100644 index 00000000..cfb21c59 --- /dev/null +++ b/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Persistence.Journal; +using Akka.Util; + +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, + } + + /// + /// 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(); + + public AkkaPersistenceJournalBuilder(string journalId, AkkaConfigurationBuilder builder) + { + JournalId = journalId; + Builder = builder; + } + + 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); + } + } + + /// + /// INTERNAL API - Builds the HOCON and then injects it. + /// + internal void Build() + { + // useless configuration - don't bother. + if (Adapters.Count == 0 || Bindings.Count == 0) + return; + + var adapters = new StringBuilder() + .Append($"akka.persistence.journal.{JournalId}").Append("{") + .AppendLine("event-adapters {"); + foreach (var kv in Adapters) + { + adapters.AppendLine($"{kv.Key} = \"{kv.Value.TypeQualifiedName()}\""); + } + + adapters.AppendLine("}").AppendLine("event-adapter-bindings {"); + foreach (var kv in Bindings) + { + adapters.AppendLine($"\"{kv.Key.TypeQualifiedName()}\" = [{string.Join(",", kv.Value)}]"); + } + + adapters.AppendLine("}").AppendLine("}"); + + var finalHocon = ConfigurationFactory.ParseString(adapters.ToString()); + Builder.AddHocon(finalHocon, HoconAddMode.Prepend); + } + } + + /// + /// The set of options for generic Akka.Persistence. + /// + public static class AkkaPersistenceHostingExtensions + { + /// + /// 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` journal, just type `sql`. + /// Configuration method for configuring the journal. + /// The same instance originally passed in. + /// + /// This method can be called multiple times for different s. + /// + 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, journalBuilder => { }); + } + + public static AkkaConfigurationBuilder WithInMemoryJournal(this AkkaConfigurationBuilder builder, + Action journalBuilder) + { + builder.WithJournal("inmem", journalBuilder); + + const string liveConfig = @"akka.persistence.journal.plugin = akka.persistence.journal.inmem + akka.persistence.journal.inmem { + # Class name of the plugin. + class = ""Akka.Persistence.Journal.MemoryJournal, Akka.Persistence"" + # Dispatcher for the plugin actor. + plugin-dispatcher = ""akka.actor.default-dispatcher"" + }"; + + builder.AddHocon(liveConfig, HoconAddMode.Prepend); + + return builder; + } + + public static AkkaConfigurationBuilder WithInMemorySnapshotStore(this AkkaConfigurationBuilder builder) + { + const string liveConfig = @"akka.persistence.snapshot-store.plugin = akka.persistence.snapshot-store.inmem + # In-memory snapshot store plugin. + akka.persistence.snapshot-store.inmem { + # Class name of the plugin. + class = ""Akka.Persistence.Snapshot.MemorySnapshotStore, Akka.Persistence"" + # Dispatcher for the plugin actor. + plugin-dispatcher = ""akka.actor.default-dispatcher"" + }"; + + builder.AddHocon(liveConfig, HoconAddMode.Prepend); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Akka.Persistence.Hosting/README.md b/src/Akka.Persistence.Hosting/README.md new file mode 100644 index 00000000..aa4c2310 --- /dev/null +++ b/src/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/Akka.Persistence.PostgreSql.Hosting/Akka.Persistence.PostgreSql.Hosting.csproj b/src/Akka.Persistence.PostgreSql.Hosting/Akka.Persistence.PostgreSql.Hosting.csproj index 86de9550..6af370fd 100644 --- a/src/Akka.Persistence.PostgreSql.Hosting/Akka.Persistence.PostgreSql.Hosting.csproj +++ b/src/Akka.Persistence.PostgreSql.Hosting/Akka.Persistence.PostgreSql.Hosting.csproj @@ -6,12 +6,9 @@ 9 - - - - + diff --git a/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs b/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs index 00812042..a27abbaa 100644 --- a/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs +++ b/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs @@ -1,43 +1,61 @@ using System; +using Akka.Actor; using Akka.Configuration; using Akka.Hosting; +using Akka.Persistence.Hosting; using Akka.Persistence.Query.Sql; namespace Akka.Persistence.PostgreSql.Hosting { - public enum SqlPersistenceMode - { - /// - /// Sets both the akka.persistence.journal and the akka.persistence.snapshot-store to use - /// Akka.Persistence.PostgreSql. - /// - Both, - - /// - /// Sets ONLY the akka.persistence.journal to use Akka.Persistence.PostgreSql. - /// - Journal, - - /// - /// Sets ONLY the akka.persistence.snapshot-store to use Akka.Persistence.PostgreSql. - /// - SnapshotStore, - } - /// /// Extension methods for Akka.Persistence.PostgreSql /// public static class AkkaPersistencePostgreSqlHostingExtensions { + /// + /// Add Akka.Persistence.PostgreSql support to the + /// + /// + /// The builder instance being configured. + /// + /// + /// Connection string used for database access. + /// + /// + /// Determines which settings should be added by this method call. + /// + /// + /// The schema name for the journal and snapshot store table. + /// + /// + /// Should the SQL store table be initialized automatically. + /// + /// + /// Determines how data are being de/serialized into the table. + /// + /// + /// Uses the `CommandBehavior.SequentialAccess` when creating SQL commands, providing a performance + /// improvement for reading large BLOBS. + /// + /// + /// When set to true, persistence will use `BIGINT` and `GENERATED ALWAYS AS IDENTITY` for journal table + /// schema creation. + /// + /// + /// An Action delegate used to configure an instance. + /// + /// + /// The same instance originally passed in. + /// public static AkkaConfigurationBuilder WithPostgreSqlPersistence( this AkkaConfigurationBuilder builder, string connectionString, - SqlPersistenceMode mode = SqlPersistenceMode.Both, + PersistenceMode mode = PersistenceMode.Both, string schemaName = "public", bool autoInitialize = false, StoredAsType storedAsType = StoredAsType.ByteA, bool sequentialAccess = false, - bool useBigintIdentityForOrderingColumn = false) + bool useBigintIdentityForOrderingColumn = false, Action configurator = null) { var storedAs = storedAsType switch { @@ -88,17 +106,22 @@ class = ""Akka.Persistence.PostgreSql.Snapshot.PostgreSqlSnapshotStore, Akka.Per var finalConfig = mode switch { - SqlPersistenceMode.Both => journalConfiguration + PersistenceMode.Both => journalConfiguration .WithFallback(snapshotStoreConfig) .WithFallback(SqlReadJournal.DefaultConfiguration()), - SqlPersistenceMode.Journal => journalConfiguration + PersistenceMode.Journal => journalConfiguration .WithFallback(SqlReadJournal.DefaultConfiguration()), - SqlPersistenceMode.SnapshotStore => snapshotStoreConfig, + PersistenceMode.SnapshotStore => snapshotStoreConfig, - _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid SqlPersistenceMode defined.") + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid PersistenceMode defined.") }; + + if (configurator != null) // configure event adapters + { + builder.WithJournal("postgresql", configurator); + } return builder.AddHocon(finalConfig.WithFallback(PostgreSqlPersistence.DefaultConfiguration())); } diff --git a/src/Akka.Persistence.PostgreSql.Hosting/README.md b/src/Akka.Persistence.PostgreSql.Hosting/README.md new file mode 100644 index 00000000..06dc7e3d --- /dev/null +++ b/src/Akka.Persistence.PostgreSql.Hosting/README.md @@ -0,0 +1,71 @@ +# Akka.Persistence.PostgreSql.Hosting + +Akka.Hosting extension methods to add Akka.Persistence.PostgreSql to an ActorSystem + +# Akka.Persistence.PostgreSql Extension Methods + +## WithPostgreSqlPersistence() Method + +```csharp +public static AkkaConfigurationBuilder WithPostgreSqlPersistence( + this AkkaConfigurationBuilder builder, + string connectionString, + PersistenceMode mode = PersistenceMode.Both, + string schemaName = "public", + bool autoInitialize = false, + StoredAsType storedAsType = StoredAsType.ByteA, + bool sequentialAccess = false, + bool useBigintIdentityForOrderingColumn = false, + Action configurator = null); +``` + +### Parameters + +* `connectionString` __string__ + + Connection string used for database access. + +* `mode` __PersistenceMode__ + + Determines which settings should be added by this method call. __Default__: `PersistenceMode.Both` + + * `PersistenceMode.Journal`: Only add the journal settings + * `PersistenceMode.SnapshotStore`: Only add the snapshot store settings + * `PersistenceMode.Both`: Add both journal and snapshot store settings + +* `schemaName` __string__ + + The schema name for the journal and snapshot store table. __Default__: `"public"` + +* `autoInitialize` __bool__ + + Should the SQL store table be initialized automatically. __Default__: `false` + +* `storedAsType` __StoredAsType__ + + Determines how data are being de/serialized into the table. __Default__: `StoredAsType.ByteA` + + * `StoredAsType.ByteA`: Byte array + * `StoredAsType.Json`: JSON + * `StoredAsType.JsonB`: Binary JSON + +* `sequentialAccess` __bool__ + + Uses the `CommandBehavior.SequentialAccess` when creating SQL commands, providing a performance improvement for reading large BLOBS. __Default__: `false` + +* `useBigintIdentityForOrderingColumn` __bool__ + + When set to true, persistence will use `BIGINT` and `GENERATED ALWAYS AS IDENTITY` for journal table schema creation. __Default__: false + + > __NOTE__ + > + > This only affects newly created tables, as such, it should not affect any existing database. + + > __WARNING__ + > + > To use this feature, you have to have PorsgreSql version 10 or above + +* `configurator` __Action\__ + + An Action delegate used to configure an `AkkaPersistenceJournalBuilder` instance. Used to configure [Event Adapters](https://getakka.net/articles/persistence/event-adapters.html) + diff --git a/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj b/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj index 05998808..bbe88e19 100644 --- a/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj +++ b/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj @@ -6,12 +6,9 @@ 9 - - - - + diff --git a/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs b/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs index c7dd3340..9e20f28f 100644 --- a/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs +++ b/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs @@ -1,52 +1,55 @@ using System; +using Akka.Actor; using Akka.Configuration; using Akka.Hosting; +using Akka.Persistence.Hosting; using Akka.Persistence.Query.Sql; +#nullable enable namespace Akka.Persistence.SqlServer.Hosting { - public enum SqlPersistenceMode - { - /// - /// Sets both the akka.persistence.journal and the akka.persistence.snapshot-store to use - /// Akka.Persistence.SqlServer. - /// - Both, - - /// - /// Sets ONLY the akka.persistence.journal to use Akka.Persistence.SqlServer. - /// - Journal, - - /// - /// Sets ONLY the akka.persistence.snapshot-store to use Akka.Persistence.SqlServer. - /// - SnapshotStore, - } - /// /// Extension methods for Akka.Persistence.SqlServer /// public static class AkkaPersistenceSqlServerHostingExtensions { + /// + /// Adds Akka.Persistence.SqlServer support to this . + /// + /// + /// The builder instance being configured. + /// + /// + /// Connection string used for database access. + /// + /// + /// Should the SQL store table be initialized automatically. + /// + /// + /// + /// An Action delegate used to configure an instance. + /// + /// + /// The same instance originally passed in. + /// + /// public static AkkaConfigurationBuilder WithSqlServerPersistence( this AkkaConfigurationBuilder builder, string connectionString, - SqlPersistenceMode mode = SqlPersistenceMode.Both) + PersistenceMode mode = PersistenceMode.Both, + Action? configurator = null, + bool autoInitialize = true) { Config journalConfiguration = @$" akka.persistence {{ journal {{ plugin = ""akka.persistence.journal.sql-server"" sql-server {{ - class = ""Akka.Persistence.SqlServer.Journal.SqlServerJournal, Akka.Persistence.SqlServer"" connection-string = ""{connectionString}"" - table-name = EventJournal - schema-name = dbo - auto-initialize = on - refresh-interval = 1s + auto-initialize = {autoInitialize.ToHocon()} }} }} + query.journal.sql.refresh-interval = 1s }}"; Config snapshotStoreConfig = @$" @@ -54,30 +57,133 @@ class = ""Akka.Persistence.SqlServer.Journal.SqlServerJournal, Akka.Persistence. snapshot-store {{ plugin = ""akka.persistence.snapshot-store.sql-server"" sql-server {{ - class = ""Akka.Persistence.SqlServer.Snapshot.SqlServerSnapshotStore, Akka.Persistence.SqlServer"" - schema-name = dbo - table-name = SnapshotStore - auto-initialize = on connection-string = ""{connectionString}"" + auto-initialize = {autoInitialize.ToHocon()} }} }} }}"; - var finalConfig = mode switch - { - SqlPersistenceMode.Both => journalConfiguration - .WithFallback(snapshotStoreConfig) - .WithFallback(SqlReadJournal.DefaultConfiguration()), + return builder.WithSqlServerPersistence(journalConfiguration, snapshotStoreConfig, mode, configurator); + } - SqlPersistenceMode.Journal => journalConfiguration - .WithFallback(SqlReadJournal.DefaultConfiguration()), + /// + /// Adds Akka.Persistence.SqlServer support to this . + /// + /// + /// The builder instance being configured. + /// + /// + /// An Action delegate to configure a instance. + /// + /// + /// An Action delegate to configure a instance. + /// + /// + /// An Action delegate used to configure an instance. + /// + /// + /// The same instance originally passed in. + /// + /// + /// Thrown when both and are null. + /// + public static AkkaConfigurationBuilder WithSqlServerPersistence( + this AkkaConfigurationBuilder builder, + Action? journalConfigurator = null, + Action? snapshotConfigurator = null, + Action? configurator = null) + { + var journalOptions = new SqlServerJournalOptions(); + journalConfigurator?.Invoke(journalOptions); - SqlPersistenceMode.SnapshotStore => snapshotStoreConfig, + var snapshotOptions = new SqlServerSnapshotOptions(); + snapshotConfigurator?.Invoke(snapshotOptions); - _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid SqlPersistenceMode defined.") + return (journalConfigurator, snapshotConfigurator) switch + { + (null, null) => throw new ArgumentException($"{nameof(journalConfigurator)} and {nameof(snapshotConfigurator)} could not both be null"), + (null, _) => builder.WithSqlServerPersistence(Config.Empty, snapshotOptions.ToConfig(), PersistenceMode.SnapshotStore, configurator), + (_, null) => builder.WithSqlServerPersistence(journalOptions.ToConfig(), Config.Empty, PersistenceMode.Journal, configurator), + (_, _) => builder.WithSqlServerPersistence(journalOptions.ToConfig(), snapshotOptions.ToConfig(), PersistenceMode.Both, configurator), }; + } + + /// + /// Adds Akka.Persistence.SqlServer support to this . + /// + /// + /// The builder instance being configured. + /// + /// + /// An instance to configure the SqlServer journal. + /// + /// + /// An instance to configure the SqlServer snapshot store. + /// + /// + /// An Action delegate used to configure a instance. + /// + /// + /// The same instance originally passed in. + /// + /// + /// Thrown when both and are null. + /// + public static AkkaConfigurationBuilder WithSqlServerPersistence( + this AkkaConfigurationBuilder builder, + SqlServerJournalOptions? journalOptions = null, + SqlServerSnapshotOptions? snapshotOptions = null, + Action? configurator = null) + { + var mode = (journalOptions, snapshotOptions) switch + { + (null, null) => throw new ArgumentException($"{nameof(journalOptions)} and {nameof(snapshotOptions)} could not both be null"), + (null, _) => PersistenceMode.SnapshotStore, + (_, null) => PersistenceMode.Journal, + (_, _) => PersistenceMode.Both + }; + + return builder.WithSqlServerPersistence( + journalConfiguration: journalOptions?.ToConfig() ?? Config.Empty, + snapshotStoreConfig: snapshotOptions?.ToConfig() ?? Config.Empty, + mode: mode, + configurator: configurator); + } + + private static AkkaConfigurationBuilder WithSqlServerPersistence( + this AkkaConfigurationBuilder builder, + Config journalConfiguration, + Config snapshotStoreConfig, + PersistenceMode mode = PersistenceMode.Both, + Action? configurator = null) + { + switch (mode) + { + case PersistenceMode.Both: + builder.AddHocon(journalConfiguration, HoconAddMode.Prepend); + builder.AddHocon(snapshotStoreConfig, HoconAddMode.Prepend); + builder.AddHocon(SqlReadJournal.DefaultConfiguration()); + break; + + case PersistenceMode.Journal: + builder.AddHocon(journalConfiguration, HoconAddMode.Prepend); + builder.AddHocon(SqlReadJournal.DefaultConfiguration()); + break; + + case PersistenceMode.SnapshotStore: + builder.AddHocon(snapshotStoreConfig, HoconAddMode.Prepend); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Invalid SqlPersistenceMode defined."); + } + + if (configurator != null) // configure event adapters + { + builder.WithJournal("sql-server", configurator); + } - return builder.AddHocon(finalConfig.WithFallback(SqlServerPersistence.DefaultConfiguration())); + return builder.AddHocon(SqlServerPersistence.DefaultConfiguration()); } } -} +} \ No newline at end of file diff --git a/src/Akka.Persistence.SqlServer.Hosting/Properties/FriendsOf.cs b/src/Akka.Persistence.SqlServer.Hosting/Properties/FriendsOf.cs new file mode 100644 index 00000000..f454ac80 --- /dev/null +++ b/src/Akka.Persistence.SqlServer.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/Akka.Persistence.SqlServer.Hosting/README.md b/src/Akka.Persistence.SqlServer.Hosting/README.md new file mode 100644 index 00000000..70f8991f --- /dev/null +++ b/src/Akka.Persistence.SqlServer.Hosting/README.md @@ -0,0 +1,84 @@ +# Akka.Persistence.SqlServer.Hosting + +Akka.Hosting extension methods to add Akka.Persistence.SqlServer to an ActorSystem + +# Akka.Persistence.SqlServer Extension Methods + +## WithSqlServerPersistence() Method + +```csharp +public static AkkaConfigurationBuilder WithSqlServerPersistence( + this AkkaConfigurationBuilder builder, + string connectionString, + PersistenceMode mode = PersistenceMode.Both, + Action? configurator = null, + bool autoInitialize = true); +``` + +```csharp +public static AkkaConfigurationBuilder WithSqlServerPersistence( + this AkkaConfigurationBuilder builder, + Action? journalConfigurator = null, + Action? snapshotConfigurator = null, + Action? configurator = null); +``` + +```csharp +public static AkkaConfigurationBuilder WithSqlServerPersistence( + this AkkaConfigurationBuilder builder, + SqlServerJournalOptions? journalOptions = null, + SqlServerSnapshotOptions? snapshotOptions = null, + Action? configurator = null); +``` + +### Parameters + +* `connectionString` __string__ + + Connection string used for database access. + +* `mode` __PersistenceMode__ + + Determines which settings should be added by this method call. + + * `PersistenceMode.Journal`: Only add the journal settings + * `PersistenceMode.SnapshotStore`: Only add the snapshot store settings + * `PersistenceMode.Both`: Add both journal and snapshot store settings + +* `configurator` __Action\__ + + An Action delegate used to configure an `AkkaPersistenceJournalBuilder` instance. Used to configure [Event Adapters](https://getakka.net/articles/persistence/event-adapters.html) + +* `journalConfigurator` __Action\__ + + An Action delegate to configure a `SqlServerJournalOptions` instance. + +* `snapshotConfigurator` __Action\__ + + An Action delegate to configure a `SqlServerSnapshotOptions` instance. + +* `journalOptions` __SqlServerJournalOptions__ + + An `SqlServerJournalOptions` instance to configure the SqlServer journal. + +* `snapshotOptions` __SqlServerSnapshotOptions__ + + An `SqlServerSnapshotOptions` instance to configure the SqlServer snapshot store. + +## Example + +```csharp +using var host = new HostBuilder() + .ConfigureServices((context, services) => + { + services.AddAkka("ecsBootstrapDemo", (builder, provider) => + { + builder + .WithRemoting("localhost", 8110) + .WithClustering() + .WithSqlServerPersistence("your-sqlserver-connection-string"); + }); + }).Build(); + +await host.RunAsync(); +``` \ No newline at end of file diff --git a/src/Akka.Persistence.SqlServer.Hosting/SqlServerOptions.cs b/src/Akka.Persistence.SqlServer.Hosting/SqlServerOptions.cs new file mode 100644 index 00000000..d6182737 --- /dev/null +++ b/src/Akka.Persistence.SqlServer.Hosting/SqlServerOptions.cs @@ -0,0 +1,219 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Text; +using Akka.Configuration; +using Akka.Hosting; + +namespace Akka.Persistence.SqlServer.Hosting +{ + public sealed class SqlServerJournalOptions + { + /// + /// Connection string used for database access + /// + public string ConnectionString { get; set; } + + /// + /// + /// SQL commands timeout. + /// + /// Default: 30 seconds + /// + public TimeSpan? ConnectionTimeout { get; set; } + + /// + /// + /// SQL server schema name to table corresponding with persistent journal. + /// + /// Default: "dbo" + /// + public string SchemaName { get; set; } + + /// + /// + /// SQL server table corresponding with persistent journal. + /// + /// Default: "EventJournal" + /// + public string TableName { get; set; } + + /// + /// + /// Should corresponding journal table be initialized automatically. + /// + /// Default: false + /// + public bool? AutoInitialize { get; set; } + + /// + /// + /// SQL server table corresponding with persistent journal metadata. + /// + /// Default: "Metadata" + /// + public string MetadataTableName { get; set; } + + /// + /// + /// Uses the CommandBehavior.SequentialAccess when creating the command, providing a performance + /// improvement for reading large BLOBS. + /// + /// Default: true + /// + public bool? SequentialAccess { get; set; } + + /// + /// + /// By default, string parameter size in ADO.NET queries are set dynamically based on current parameter + /// value size. + /// If this parameter set to true, column sizes are loaded on journal startup from database schema, and + /// string parameters have constant size which equals to corresponding column size. + /// + /// Default: false + /// + public bool? UseConstantParameterSize { get; set; } + + /// + /// + /// The SQL write journal is notifying the query side as soon as things + /// are persisted, but for efficiency reasons the query side retrieves the events + /// in batches that sometimes can be delayed up to the configured . + /// + /// Default: 3 seconds + /// + public TimeSpan? QueryRefreshInterval { get; set; } + + internal Config ToConfig() + { + var sb = new StringBuilder() + .AppendLine("akka.persistence.journal.plugin = \"akka.persistence.journal.sql-server\""); + + if (QueryRefreshInterval != null) + sb.AppendFormat("akka.persistence.query.journal.sql.refresh-interval = {0}\n", QueryRefreshInterval.ToHocon()); + + var innerSb = new StringBuilder(); + if (ConnectionString != null) + innerSb.AppendFormat("connection-string = {0}\n", ConnectionString.ToHocon()); + + if (ConnectionTimeout != null) + innerSb.AppendFormat("connection-timeout = {0}\n", ConnectionTimeout.ToHocon()); + + if (SchemaName != null) + innerSb.AppendFormat("schema-name = {0}\n", SchemaName.ToHocon()); + + if (TableName != null) + innerSb.AppendFormat("table-name = {0}\n", TableName.ToHocon()); + + if (AutoInitialize != null) + innerSb.AppendFormat("auto-initialize = {0}\n", AutoInitialize.ToHocon()); + + if (MetadataTableName != null) + innerSb.AppendFormat("metadata-table-name = {0}\n", MetadataTableName.ToHocon()); + + if (SequentialAccess != null) + innerSb.AppendFormat("sequential-access = {0}\n", SequentialAccess.ToHocon()); + + if(UseConstantParameterSize != null) + innerSb.AppendFormat("use-constant-parameter-size = {0}\n", UseConstantParameterSize.ToHocon()); + + if (innerSb.Length > 0) + { + sb.AppendLine("akka.persistence.journal.sql-server {") + .Append(innerSb) + .AppendLine("}"); + } + + return sb.ToString(); + } + } + + public sealed class SqlServerSnapshotOptions + { + /// + /// Connection string used for database access. + /// + public string ConnectionString { get; set; } + + /// + /// SQL commands timeout. + /// Default: 30 seconds + /// + public TimeSpan? ConnectionTimeout { get; set; } + + /// + /// SQL server schema name to table corresponding with persistent snapshot store. + /// Default: "dbo" + /// + public string SchemaName { get; set; } + + /// + /// SQL server table corresponding with persistent snapshot store. + /// Default: "EventJournal" + /// + public string TableName { get; set; } + + /// + /// Should corresponding snapshot store table be initialized automatically. + /// Default: false + /// + public bool? AutoInitialize { get; set; } + + /// + /// Uses the CommandBehavior.SequentialAccess when creating the command, providing a performance + /// improvement for reading large BLOBS. + /// Default: true + /// + public bool? SequentialAccess { get; set; } + + /// + /// By default, string parameter size in ADO.NET queries are set dynamically based on current parameter + /// value size. + /// If this parameter set to true, column sizes are loaded on journal startup from database schema, and + /// string parameters have constant size which equals to corresponding column size. + /// Default: false + /// + public bool? UseConstantParameterSize { get; set; } + + internal Config ToConfig() + { + var sb = new StringBuilder() + .AppendLine("akka.persistence.snapshot-store.plugin = \"akka.persistence.snapshot-store.sql-server\""); + + var innerSb = new StringBuilder(); + if (ConnectionString != null) + innerSb.AppendFormat("connection-string = {0}\n", ConnectionString.ToHocon()); + + if (ConnectionTimeout != null) + innerSb.AppendFormat("connection-timeout = {0}\n", ConnectionTimeout.ToHocon()); + + if (SchemaName != null) + innerSb.AppendFormat("schema-name = {0}\n", SchemaName.ToHocon()); + + if (TableName != null) + innerSb.AppendFormat("table-name = {0}\n", TableName.ToHocon()); + + if (AutoInitialize != null) + innerSb.AppendFormat("auto-initialize = {0}\n", AutoInitialize.ToHocon()); + + if (SequentialAccess != null) + innerSb.AppendFormat("sequential-access = {0}\n", SequentialAccess.ToHocon()); + + if(UseConstantParameterSize != null) + innerSb.AppendFormat("use-constant-parameter-size = {0}\n", UseConstantParameterSize.ToHocon()); + + if (innerSb.Length > 0) + { + sb.AppendLine("akka.persistence.snapshot-store.sql-server {") + .Append(innerSb) + .AppendLine("}"); + } + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj b/src/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj index 8fc01403..36feed94 100644 --- a/src/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj +++ b/src/Akka.Remote.Hosting.Tests/Akka.Remote.Hosting.Tests.csproj @@ -7,7 +7,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs index b8fe0641..bf4ec5a3 100644 --- a/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs +++ b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs @@ -10,6 +10,93 @@ 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(); + }); + }).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(2552); + tcpConfig.GetString("public-hostname").Should().BeEmpty(); + tcpConfig.GetInt("public-port").Should().Be(0); + } + + [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 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); + }); + }).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(2552); + tcpConfig.GetString("public-hostname").Should().Be("localhost"); + tcpConfig.GetInt("public-port").Should().Be(12345); + } + [Fact] public async Task AkkaRemoteShouldUsePublicHostnameCorrectly() { @@ -24,9 +111,11 @@ public async Task AkkaRemoteShouldUsePublicHostnameCorrectly() // act await host.StartAsync(); - ExtendedActorSystem actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); + var actorSystem = (ExtendedActorSystem)host.Services.GetRequiredService(); // assert actorSystem.Provider.DefaultAddress.Host.Should().Be("localhost"); } + + } \ No newline at end of file diff --git a/src/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj b/src/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj index 449b376e..89f60bd4 100644 --- a/src/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj +++ b/src/Akka.Remote.Hosting/Akka.Remote.Hosting.csproj @@ -5,10 +5,6 @@ Akka.Remote Microsoft.Extensions.Hosting support. - - - - diff --git a/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs b/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs index 05ed8d57..69ab6ba7 100644 --- a/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs +++ b/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs @@ -1,39 +1,54 @@ -using Akka.Actor; +using System.Text; +using Akka.Actor; using Akka.Hosting; -using Akka.Util; namespace Akka.Remote.Hosting { public static class AkkaRemoteHostingExtensions { - private static AkkaConfigurationBuilder BuildRemoteHocon(this AkkaConfigurationBuilder builder, string hostname, int port, string publicHostname = null, int? publicPort = null) + private static AkkaConfigurationBuilder BuildRemoteHocon( + this AkkaConfigurationBuilder builder, + string hostname = null, + int? port = null, + string publicHostname = null, + int? publicPort = null) { - if (string.IsNullOrEmpty(publicHostname)) - { - publicHostname = hostname; - hostname = "0.0.0.0"; // bind to all addresses by default - } - var config = $@" - akka.remote.dot-netty.tcp.hostname = ""{hostname}"" - akka.remote.dot-netty.tcp.public-hostname = ""{publicHostname ?? hostname}"" - akka.remote.dot-netty.tcp.port = {port} - akka.remote.dot-netty.tcp.public-port = {publicPort ?? port} - "; + var sb = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(hostname)) + sb.AppendFormat("hostname = {0}\n", hostname); + if (port != null) + sb.AppendFormat("port = {0}\n", port); + if(!string.IsNullOrWhiteSpace(publicHostname)) + sb.AppendFormat("public-hostname = {0}\n", publicHostname); + if(publicPort != null) + sb.AppendFormat("public-port = {0}\n", publicPort); + + if (sb.Length == 0) + return builder; + + sb.Insert(0, "akka.remote.dot-netty.tcp {\n"); + sb.Append("}"); // prepend the remoting configuration to the front - return builder.AddHocon(config, HoconAddMode.Prepend); + return builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); } /// /// Adds Akka.Remote support to this . /// /// A configuration delegate. - /// The hostname to bind Akka.Remote upon. - /// The port to bind Akka.Remote upon. + /// 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, int port, string publicHostname = null, int? publicPort = null) + public static AkkaConfigurationBuilder WithRemoting( + this AkkaConfigurationBuilder builder, + string hostname = null, + int? port = null, + string publicHostname = null, + int? publicPort = null) { var hoconBuilder = BuildRemoteHocon(builder, hostname, port, publicHostname, publicPort); diff --git a/src/Akka.Remote.Hosting/README.md b/src/Akka.Remote.Hosting/README.md new file mode 100644 index 00000000..732530fb --- /dev/null +++ b/src/Akka.Remote.Hosting/README.md @@ -0,0 +1,54 @@ +# 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(); +``` diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a8ed25a1..40cfdc5b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,10 +2,8 @@ Copyright © 2013-2022 Akka.NET Team Akka.NET Team - 0.3.1 - • [Fixed: WithDistributedPubSub throws NullReferenceException](https://github.com/akkadotnet/Akka.Hosting/issues/55) -• [Introduced AddHoconFile method](https://github.com/akkadotnet/Akka.Hosting/pull/58) -• [Upgraded to Akka.NET 1.4.39](https://github.com/akkadotnet/akka.net/releases/tag/1.4.39) + 0.4.0 + • [Add Microsoft.Extensions.Logging.ILoggerFactory logging support](https://github.com/akkadotnet/Akka.Hosting/pull/72) akkalogo.png https://github.com/akkadotnet/Akka.Hosting @@ -220,9 +218,10 @@ netstandard2.0 net6.0 - 2.4.1 - 17.2.0 - 1.4.39 + 2.4.2 + 17.3.2 + 2.4.5 + 1.4.43 [3.0.0,) diff --git a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj new file mode 100644 index 00000000..2e9fc935 --- /dev/null +++ b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj @@ -0,0 +1,17 @@ + + + net6.0 + enable + enable + false + + + + + + + + + + + diff --git a/src/Examples/Akka.Hosting.LoggingDemo/Echo.cs b/src/Examples/Akka.Hosting.LoggingDemo/Echo.cs new file mode 100644 index 00000000..9065b98c --- /dev/null +++ b/src/Examples/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/Akka.Hosting.LoggingDemo/Program.cs b/src/Examples/Akka.Hosting.LoggingDemo/Program.cs new file mode 100644 index 00000000..6de4cfd1 --- /dev/null +++ b/src/Examples/Akka.Hosting.LoggingDemo/Program.cs @@ -0,0 +1,70 @@ +using Akka.Hosting; +using Akka.Actor; +using Akka.Actor.Dsl; +using Akka.Cluster.Hosting; +using Akka.Event; +using Akka.Hosting.Logging; +using Akka.Hosting.LoggingDemo; +using Akka.Logger.Serilog; +using Akka.Remote.Hosting; +using Serilog; +using LogLevel = Akka.Event.LogLevel; + +Serilog.Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .MinimumLevel.Debug() + .CreateLogger(); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAkka("MyActorSystem", (configurationBuilder, serviceProvider) => +{ + 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(); + }) + .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) => + { + 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.Run(); \ No newline at end of file diff --git a/src/Examples/Akka.Hosting.LoggingDemo/appsettings.Development.json b/src/Examples/Akka.Hosting.LoggingDemo/appsettings.Development.json new file mode 100644 index 00000000..8983e0fc --- /dev/null +++ b/src/Examples/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/Akka.Hosting.LoggingDemo/appsettings.json b/src/Examples/Akka.Hosting.LoggingDemo/appsettings.json new file mode 100644 index 00000000..d60ba0df --- /dev/null +++ b/src/Examples/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/Akka.Hosting.SimpleDemo/Program.cs b/src/Examples/Akka.Hosting.SimpleDemo/Program.cs index ec44b274..377a3ffa 100644 --- a/src/Examples/Akka.Hosting.SimpleDemo/Program.cs +++ b/src/Examples/Akka.Hosting.SimpleDemo/Program.cs @@ -1,37 +1,70 @@ -using Akka.Hosting; using Akka.Actor; -using Akka.Actor.Dsl; using Akka.Cluster.Hosting; using Akka.Remote.Hosting; +using Akka.Util; -var builder = WebApplication.CreateBuilder(args); +namespace Akka.Hosting.SimpleDemo; -builder.Services.AddAkka("MyActorSystem", configurationBuilder => +public class EchoActor : ReceiveActor { - configurationBuilder - .WithRemoting("localhost", 8110) - .WithClustering(new ClusterOptions(){ Roles = new[]{ "myRole" }, - SeedNodes = new[]{ Address.Parse("akka.tcp://MyActorSystem@localhost:8110")}}) - .WithActors((system, registry) => + private readonly string _entityId; + public EchoActor(string entityId) { - 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) => + _entityId = entityId; + ReceiveAny(message => { + Sender.Tell($"{Self} rcv {message}"); + }); + } +} + +public class Program { - var echo = context.RequestServices.GetRequiredService().Get(); - var body = await echo.Ask(context.TraceIdentifier, context.RequestAborted).ConfigureAwait(false); - await context.Response.WriteAsync(body); -}); + 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(); \ No newline at end of file + app.Run(); + } +} \ No newline at end of file