From 9c2385a831c2e6f550863fad50ed4211748bb575 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 16 Jun 2022 10:57:55 -0500 Subject: [PATCH 01/46] Adding Akka.Persistence.Hosting module (#67) * [WIP] Adding Akka.Persistence.Hosting module working on #64 * fixed up Akka.Persistence.Hosting * added reference to Akka.Persistence.Hosting from Akka.Cluster.Hosting To make it easier to configure where it's likely to be used. * stubbing out unit tests * fixed type binding issue with `AkkaPersistenceJournalBuilder` * completed unit tests * added journal configurator to both Postgres and SQL Server Also made `PersistenceMode` part of Akka.Persistence.Hosting, rather than lib-specific. --- Akka.Hosting.sln | 12 ++ .../Akka.Cluster.Hosting.csproj | 1 + .../Akka.Persistence.Hosting.Tests.csproj | 18 +++ .../EventAdapterSpecs.cs | 114 +++++++++++++++ .../Akka.Persistence.Hosting.csproj | 21 +++ .../AkkaPersistenceHostingExtensions.cs | 136 ++++++++++++++++++ ...Akka.Persistence.PostgreSql.Hosting.csproj | 1 + ...aPersistencePostgreSqlHostingExtensions.cs | 37 ++--- .../Akka.Persistence.SqlServer.Hosting.csproj | 1 + ...kaPersistenceSqlServerHostingExtensions.cs | 45 +++--- 10 files changed, 337 insertions(+), 49 deletions(-) create mode 100644 src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj create mode 100644 src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs create mode 100644 src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj create mode 100644 src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs diff --git a/Akka.Hosting.sln b/Akka.Hosting.sln index c34d12d5..21e96435 100644 --- a/Akka.Hosting.sln +++ b/Akka.Hosting.sln @@ -35,6 +35,10 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -87,6 +91,14 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj b/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj index 96ec5581..0d445c2f 100644 --- a/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj +++ b/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj @@ -11,6 +11,7 @@ + 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..532b0bd0 --- /dev/null +++ b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -0,0 +1,18 @@ + + + + $(TestsNetCoreFramework) + + + + + + + + + + + + + + diff --git a/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs b/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs new file mode 100644 index 00000000..6ed7a5ee --- /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 +{ + 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); + } + } + + [Fact] + public async Task Should_use_correct_EventAdapter_bindings() + { + // arrange + using var host = await StartHost(collection => collection.AddAkka("MySys", builder => + { + 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) }); + }); + })); + + // act + var sys = host.Services.GetRequiredService(); + 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/Akka.Persistence.Hosting.csproj b/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj new file mode 100644 index 00000000..85fbdf94 --- /dev/null +++ b/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj @@ -0,0 +1,21 @@ + + + $(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..df7238e5 --- /dev/null +++ b/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs @@ -0,0 +1,136 @@ +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; + } + } +} \ No newline at end of file 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..2b123a4e 100644 --- a/src/Akka.Persistence.PostgreSql.Hosting/Akka.Persistence.PostgreSql.Hosting.csproj +++ b/src/Akka.Persistence.PostgreSql.Hosting/Akka.Persistence.PostgreSql.Hosting.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs b/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs index 00812042..afff5f36 100644 --- a/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs +++ b/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs @@ -1,29 +1,11 @@ using System; 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 /// @@ -32,12 +14,12 @@ public static class AkkaPersistencePostgreSqlHostingExtensions 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 +70,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.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj b/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj index 05998808..e47e22e0 100644 --- a/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj +++ b/src/Akka.Persistence.SqlServer.Hosting/Akka.Persistence.SqlServer.Hosting.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs b/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs index c7dd3340..fe7dbea7 100644 --- a/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs +++ b/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs @@ -1,38 +1,30 @@ using System; +using Akka.Actor; using Akka.Configuration; using Akka.Hosting; +using Akka.Persistence.Hosting; using Akka.Persistence.Query.Sql; 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 to this . + /// + /// + /// + /// + /// + /// + /// public static AkkaConfigurationBuilder WithSqlServerPersistence( this AkkaConfigurationBuilder builder, string connectionString, - SqlPersistenceMode mode = SqlPersistenceMode.Both) + PersistenceMode mode = PersistenceMode.Both, Action configurator = null) { Config journalConfiguration = @$" akka.persistence {{ @@ -65,19 +57,24 @@ class = ""Akka.Persistence.SqlServer.Snapshot.SqlServerSnapshotStore, Akka.Persi 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.") }; + if (configurator != null) // configure event adapters + { + builder.WithJournal("sql-server", configurator); + } + return builder.AddHocon(finalConfig.WithFallback(SqlServerPersistence.DefaultConfiguration())); } } -} +} \ No newline at end of file From 5bd0aacb6eb161fde5b4f094b435b56fa1e78012 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 17 Jun 2022 00:32:25 +0700 Subject: [PATCH 02/46] Add Akka.Cluster.Tools.Client support (#66) * Add Akka.Cluster.Tools.Client support * Add settings unit tests * Simplify ClusterClient setup * Add some WithClusterClient overloads to give user options --- .../ClusterClientSpecs.cs | 39 ++++++ .../AkkaClusterHostingExtensions.cs | 130 ++++++++++++++++++ .../Properties/FriendsOf.cs | 3 + .../RemoteConfigurationSpecs.cs | 2 + 4 files changed, 174 insertions(+) create mode 100644 src/Akka.Cluster.Hosting.Tests/ClusterClientSpecs.cs create mode 100644 src/Akka.Cluster.Hosting/Properties/FriendsOf.cs 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/AkkaClusterHostingExtensions.cs b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs index c6181ee9..0e2bef80 100644 --- a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs +++ b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs @@ -1,12 +1,18 @@ 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.Sharding; using Akka.Cluster.Tools.Client; using Akka.Cluster.Tools.PublishSubscribe; using Akka.Cluster.Tools.Singleton; +using Akka.Configuration; using Akka.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Akka.Cluster.Hosting { @@ -367,5 +373,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.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs index b8fe0641..51e4f63a 100644 --- a/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs +++ b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs @@ -29,4 +29,6 @@ public async Task AkkaRemoteShouldUsePublicHostnameCorrectly() // assert actorSystem.Provider.DefaultAddress.Host.Should().Be("localhost"); } + + } \ No newline at end of file From 67776ce358293eb7f156bd87e6ee5625e0372cc0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 16 Jun 2022 12:40:18 -0500 Subject: [PATCH 03/46] Added v0.3.3 Release Notes (#68) ## [0.3.3] / 16 June 2022 - [Added common `Akka.Persistence.Hosting` package to make it easier to add `IEventAdapter`s to journals](https://github.com/akkadotnet/Akka.Hosting/issues/64). - [Made Akka.Persistence.SqlServer.Hosting and Akka.Persistence.PostgreSql.Hosting both take a shared overload / dependency on Akka.Persistence.Hosting](https://github.com/akkadotnet/Akka.Hosting/pull/67) - did this to make it easier to add `IEventAdapter`s to each of those. - [Add Akka.Cluster.Tools.Client support](https://github.com/akkadotnet/Akka.Hosting/pull/66) - now possible to start `ClusterClient` and `ClusterClientReceptionist`s easily from Akka.Hosting. --- RELEASE_NOTES.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8c453bb8..233cc94a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,7 +1,4 @@ -## [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.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) +## [0.3.3] / 16 June 2022 +- [Added common `Akka.Persistence.Hosting` package to make it easier to add `IEventAdapter`s to journals](https://github.com/akkadotnet/Akka.Hosting/issues/64). +- [Made Akka.Persistence.SqlServer.Hosting and Akka.Persistence.PostgreSql.Hosting both take a shared overload / dependency on Akka.Persistence.Hosting](https://github.com/akkadotnet/Akka.Hosting/pull/67) - did this to make it easier to add `IEventAdapter`s to each of those. +- [Add Akka.Cluster.Tools.Client support](https://github.com/akkadotnet/Akka.Hosting/pull/66) - now possible to start `ClusterClient` and `ClusterClientReceptionist`s easily from Akka.Hosting. From 639b90568222bf67931d9995b810d34539017a11 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 23 Jun 2022 14:33:39 -0400 Subject: [PATCH 04/46] Sharding add actor registry overloads (#70) * Added `ActorRegistry` overloads for Akka.Cluster.Sharding * separated `TestHelper` from Akka.Cluster.Hosting.Tests * successfully tested new ActorRegistry-enabled methods --- .../ClusterShardingSpecs.cs | 113 ++++++++++++++++++ .../ClusterSingletonSpecs.cs | 71 ++--------- src/Akka.Cluster.Hosting.Tests/TestHelper.cs | 58 +++++++++ .../AkkaClusterHostingExtensions.cs | 59 ++++++--- 4 files changed, 227 insertions(+), 74 deletions(-) create mode 100644 src/Akka.Cluster.Hosting.Tests/ClusterShardingSpecs.cs create mode 100644 src/Akka.Cluster.Hosting.Tests/TestHelper.cs 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/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/AkkaClusterHostingExtensions.cs b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs index 0e2bef80..0ad5cef3 100644 --- a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs +++ b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs @@ -138,10 +138,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); }); } @@ -179,9 +177,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.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.TryRegister(shardRegion); + registry.Register(shardRegion); }); } @@ -210,10 +243,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); }); } @@ -237,10 +268,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); }); } @@ -267,7 +296,7 @@ public static AkkaConfigurationBuilder WithDistributedPubSub(this AkkaConfigurat { // force the initialization var mediator = DistributedPubSub.Get(system).Mediator; - registry.TryRegister(mediator); + registry.Register(mediator); }); } From 658c49155e6ac2b0af0e57c9a66454073ea8ffc9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 23 Jun 2022 14:38:47 -0400 Subject: [PATCH 05/46] added v0.3.4 release notes (#71) ## [0.3.4] / 23 June 2022 - [Adds `ActorRegistry`-capable overloads to the `WithShardRegion` methods](https://github.com/akkadotnet/Akka.Hosting/pull/70) --- RELEASE_NOTES.md | 6 ++---- src/Directory.Build.props | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 233cc94a..fbf28f6d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,2 @@ -## [0.3.3] / 16 June 2022 -- [Added common `Akka.Persistence.Hosting` package to make it easier to add `IEventAdapter`s to journals](https://github.com/akkadotnet/Akka.Hosting/issues/64). -- [Made Akka.Persistence.SqlServer.Hosting and Akka.Persistence.PostgreSql.Hosting both take a shared overload / dependency on Akka.Persistence.Hosting](https://github.com/akkadotnet/Akka.Hosting/pull/67) - did this to make it easier to add `IEventAdapter`s to each of those. -- [Add Akka.Cluster.Tools.Client support](https://github.com/akkadotnet/Akka.Hosting/pull/66) - now possible to start `ClusterClient` and `ClusterClientReceptionist`s easily from Akka.Hosting. +## [0.3.4] / 23 June 2022 +- [Adds `ActorRegistry`-capable overloads to the `WithShardRegion` methods](https://github.com/akkadotnet/Akka.Hosting/pull/70) \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a8ed25a1..0b8c5315 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.3.4 + • [Adds ActorRegistry-capable overloads to the WithShardRegion<TKey> methods](https://github.com/akkadotnet/Akka.Hosting/pull/70) akkalogo.png https://github.com/akkadotnet/Akka.Hosting From db3d82780fc29445cbfbf826e029012bfb314057 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Jul 2022 10:30:43 -0500 Subject: [PATCH 06/46] Bump docfx.console from 2.59.2 to 2.59.3 (#76) Bumps [docfx.console](https://github.com/dotnet/docfx) from 2.59.2 to 2.59.3. - [Release notes](https://github.com/dotnet/docfx/releases) - [Changelog](https://github.com/dotnet/docfx/blob/dev/RELEASENOTE.md) - [Commits](https://github.com/dotnet/docfx/compare/v2.59.2...v2.59.3) --- updated-dependencies: - dependency-name: docfx.console dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/_build.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/_build.csproj b/build/_build.csproj index 7a92ccae..8efed254 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,7 +12,7 @@ - + From 1352d420f0cf37222b4583a11e19893c2b886a8d Mon Sep 17 00:00:00 2001 From: Ebere Abanonu Date: Fri, 8 Jul 2022 17:39:49 +0100 Subject: [PATCH 07/46] Nuke.Common (#74) * Bump Nuke.Common from 6.0.1 to 6.1.2 * Xunit Runne Visualstudio * Information VersionSuffix * Build Number * EnvironmentInfo.GetVariable("GITHUB_CONTEXT") * GitHubActions * GitHub Actions * Information * breaking changes --- .github/workflows/Windows_release.yml | 3 +- .github/workflows/pr_validation.yml | 8 ++--- .nuke/build.schema.json | 11 +++--- build/Build.CI.GitHubActions.cs | 7 ++-- build/Build.cs | 35 ++++++++++--------- build/_build.csproj | 2 +- .../Akka.Cluster.Hosting.Tests.csproj | 5 ++- .../Akka.Hosting.Tests.csproj | 5 ++- .../Akka.Persistence.Hosting.Tests.csproj | 5 ++- .../Akka.Remote.Hosting.Tests.csproj | 5 ++- src/Directory.Build.props | 1 + 11 files changed, 47 insertions(+), 40 deletions(-) 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/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 8efed254..884157c7 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -11,7 +11,7 @@ - + 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.Hosting.Tests/Akka.Hosting.Tests.csproj b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj index 4a5d79a6..c6ec4e45 100644 --- a/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj +++ b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj @@ -7,7 +7,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj index 532b0bd0..7153572b 100644 --- a/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj +++ b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -8,7 +8,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + 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/Directory.Build.props b/src/Directory.Build.props index 0b8c5315..024f7bac 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -220,6 +220,7 @@ net6.0 2.4.1 17.2.0 + 2.4.5 1.4.39 [3.0.0,) From c969ceb27d846eae3d49cec40e173fb49802c338 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Mon, 18 Jul 2022 19:17:59 +0700 Subject: [PATCH 08/46] Add ILoggerFactory logger support (#72) * Add ILoggerFactory logger support * Rename WithLoggerFactorLogger to WithLoggerFactory * Add ILoggerFactory sample project * Make `Akka.Hosting.LoggingDemo.csproj` not packable Co-authored-by: Aaron Stannard --- Akka.Hosting.sln | 7 + .../Logging/AkkaLoggerFactoryExtensions.cs | 38 +++++ .../Logging/LoggerFactoryLogger.cs | 135 ++++++++++++++++++ .../Akka.Hosting.LoggingDemo.csproj | 12 ++ src/Examples/Akka.Hosting.LoggingDemo/Echo.cs | 3 + .../Akka.Hosting.LoggingDemo/Program.cs | 43 ++++++ .../appsettings.Development.json | 9 ++ .../Akka.Hosting.LoggingDemo/appsettings.json | 11 ++ 8 files changed, 258 insertions(+) create mode 100644 src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs create mode 100644 src/Akka.Hosting/Logging/LoggerFactoryLogger.cs create mode 100644 src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj create mode 100644 src/Examples/Akka.Hosting.LoggingDemo/Echo.cs create mode 100644 src/Examples/Akka.Hosting.LoggingDemo/Program.cs create mode 100644 src/Examples/Akka.Hosting.LoggingDemo/appsettings.Development.json create mode 100644 src/Examples/Akka.Hosting.LoggingDemo/appsettings.json diff --git a/Akka.Hosting.sln b/Akka.Hosting.sln index 21e96435..ce8668b3 100644 --- a/Akka.Hosting.sln +++ b/Akka.Hosting.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Persistence.Hosting", 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -99,6 +101,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -109,5 +115,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/src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs b/src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs new file mode 100644 index 00000000..12721d30 --- /dev/null +++ b/src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using Microsoft.Extensions.Logging; + +namespace Akka.Hosting.Logging +{ + public static class AkkaLoggerFactoryExtensions + { + public static AkkaConfigurationBuilder WithLoggerFactory(this AkkaConfigurationBuilder builder) + { + return builder.AddHocon("akka.loggers = [\"Akka.Hosting.Logging.LoggerFactoryLogger, Akka.Hosting\"]"); + } + + public static AkkaConfigurationBuilder AddLoggerFactory(this AkkaConfigurationBuilder builder) + { + var loggers = builder.Configuration.HasValue + ? builder.Configuration.Value.GetStringList("akka.loggers") + : new List(); + + if(loggers.Count == 0) + loggers.Add("Akka.Event.DefaultLogger"); + + loggers.Add("Akka.Hosting.Logging.LoggerFactoryLogger, Akka.Hosting"); + return builder.AddHocon($"akka.loggers = [{string.Join(", ", loggers.Select(s => $"\"{s}\""))}]"); + } + + } +} \ 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..3251400c --- /dev/null +++ b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs @@ -0,0 +1,135 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text; +using Akka.Actor; +using Akka.Configuration; +using Akka.DependencyInjection; +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}}}][{{SourceContext}}][{{LogSource}}][{{ActorPath}}][{{Thread:0000}}]: {{Message}}"; + private static readonly Event.LogLevel[] AllLogLevels = Enum.GetValues(typeof(Event.LogLevel)).Cast().ToArray(); + + private readonly ConcurrentDictionary _loggerCache = new ConcurrentDictionary(); + private readonly ILoggingAdapter _log = Akka.Event.Logging.GetLogger(Context.System.EventStream, nameof(LoggerFactoryLogger)); + private ILoggerFactory _loggerFactory; + private readonly string _messageFormat; + + public LoggerFactoryLogger() + { + _messageFormat = string.Format(DefaultMessageFormat, DefaultTimeStampFormat); + } + + protected override void PostStop() + { + _log.Info($"{nameof(LoggerFactoryLogger)} stopped"); + } + + protected override bool Receive(object message) + { + switch (message) + { + case InitializeLogger _: + var resolver = DependencyResolver.For(Context.System); + _loggerFactory = resolver.Resolver.GetService(); + if (_loggerFactory == null) + throw new ConfigurationException("Could not find any ILoggerFactory service inside ServiceProvider"); + + _log.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 logger = _loggerCache.GetOrAdd(log.LogClass, type => _loggerFactory.CreateLogger(type)); + var message = GetMessage(log.Message); + logger.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.LogClass.FullName, 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.Warning; + default: + // Should never reach this code path + return LogLevel.Error; + } + } + } +} \ No newline at end of file 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..036ab85a --- /dev/null +++ b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj @@ -0,0 +1,12 @@ + + + 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..80c0f1b4 --- /dev/null +++ b/src/Examples/Akka.Hosting.LoggingDemo/Program.cs @@ -0,0 +1,43 @@ +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.Remote.Hosting; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAkka("MyActorSystem", (configurationBuilder, serviceProvider) => +{ + configurationBuilder + .AddHocon("akka.loglevel = DEBUG") + .WithLoggerFactory() + .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": "*" +} From 2f1683dd42e237badd823bb2ff4739d5efe51020 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Tue, 19 Jul 2022 02:26:26 +0700 Subject: [PATCH 09/46] Update RELEASE_NOTES.md for v0.4.0 release (#77) * Update RELEASE_NOTES.md for v0.4.0 release * Add documentation to RELEASE_NOTES.md * Update RELEASE_NOTES.md Co-authored-by: Aaron Stannard --- RELEASE_NOTES.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fbf28f6d..6339ce7e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,2 +1,49 @@ -## [0.3.4] / 23 June 2022 -- [Adds `ActorRegistry`-capable overloads to the `WithShardRegion` methods](https://github.com/akkadotnet/Akka.Hosting/pull/70) \ No newline at end of file +## [0.4.0] / 18 July 2022 +- [Add `Microsoft.Extensions.Logging.ILoggerFactory` logging support](https://github.com/akkadotnet/Akka.Hosting/pull/72) + +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. + +Example: +``` +builder.Services.AddAkka("MyActorSystem", (configurationBuilder, serviceProvider) => +{ + configurationBuilder + .AddHocon("akka.loglevel = DEBUG") + .WithLoggerFactory() + .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 + }); +}); +``` + +There are two `Akka.Hosting` extension methods provided: +- `.WithLoggerFactory()`: Replaces all Akka.NET loggers with the new `ILoggerFactory` logger. +- `.AddLoggerFactory()`: Inserts the new `ILoggerFactory` logger into the Akka.NET logger list. + +__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" + } + } +} +``` From ee706f077ec5f9d8f52bb3f821a1f6da89e017c6 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Thu, 21 Jul 2022 22:22:42 +0700 Subject: [PATCH 10/46] Fix logger factory logger (#81) * Fix LoggerFactoryLogger * Add setup validation * Make LoggerFactorySetup class public * Redo the logging configuration API * Cleanup using * Make AddLogger generic constraint more restrictive * Add serilog to the example, to prove that this system is compatible with other loggers * Mark LoggerSetup class as sealed * Add unit tests * cleaned up logger implementations * renamed `LoggerSetup` to `LoggerConfigBuilder` to avoid confusion with `Setup` types from Akka.NET * removed reflection magic * added End2End coverage for `ILoggerFactory` support * Change ConcurrentDictionary to Dictionary * simplified logging internals Co-authored-by: Aaron Stannard --- .../Akka.Hosting.Tests.csproj | 1 + .../Logging/LoggerConfigBuilderSpecs.cs | 50 ++++ .../Logging/LoggerConfigEnd2EndSpecs.cs | 88 +++++++ .../Logging/LoggerFactoryLoggerSpec.cs | 234 ++++++++++++++++++ src/Akka.Hosting.Tests/Logging/TestLogger.cs | 96 +++++++ src/Akka.Hosting.Tests/TestHelpers.cs | 17 ++ src/Akka.Hosting/Akka.Hosting.csproj | 1 + src/Akka.Hosting/AkkaConfigurationBuilder.cs | 18 ++ src/Akka.Hosting/AkkaHostingExtensions.cs | 2 +- src/Akka.Hosting/LoggerConfigBuilder.cs | 96 +++++++ .../Logging/AkkaLoggerFactoryExtensions.cs | 38 --- .../Logging/LoggerFactoryLogger.cs | 41 +-- .../Logging/LoggerFactorySetup.cs | 22 ++ src/Akka.Hosting/LoggingExtensions.cs | 68 +++++ src/Directory.Build.props | 4 +- .../Akka.Hosting.LoggingDemo.csproj | 5 + .../Akka.Hosting.LoggingDemo/Program.cs | 31 ++- 17 files changed, 750 insertions(+), 62 deletions(-) create mode 100644 src/Akka.Hosting.Tests/Logging/LoggerConfigBuilderSpecs.cs create mode 100644 src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs create mode 100644 src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs create mode 100644 src/Akka.Hosting.Tests/Logging/TestLogger.cs create mode 100644 src/Akka.Hosting/LoggerConfigBuilder.cs delete mode 100644 src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs create mode 100644 src/Akka.Hosting/Logging/LoggerFactorySetup.cs create mode 100644 src/Akka.Hosting/LoggingExtensions.cs diff --git a/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj index c6ec4e45..7c82edea 100644 --- a/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj +++ b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj @@ -4,6 +4,7 @@ + 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..b4aa6af4 --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs @@ -0,0 +1,88 @@ +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 CustomLoggingProvider(TestLogger logger) + { + _logger = logger; + } + + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + { + 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() + { + // arrange + using var host = await StartHost(collection => + { + collection.AddLogging(builder => { builder.AddProvider(new CustomLoggingProvider(_logger)); }); + + collection.AddAkka("MySys", (builder, provider) => + { + builder.ConfigureLoggers(configBuilder => { configBuilder.AddLogger(); }); + builder.AddTestOutputLogger(_output); + }); + }); + 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..745cfafb --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs @@ -0,0 +1,234 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.Hosting.Logging; +using FluentAssertions; +using FluentAssertions.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit; +using 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 async Task DisposeAsync() + { + await _host.StopAsync(); + } + + [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/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..6acf97c0 100644 --- a/src/Akka.Hosting/AkkaConfigurationBuilder.cs +++ b/src/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -8,9 +8,11 @@ using Akka.Actor.Setup; 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 { @@ -231,6 +233,22 @@ private static Func ActorSystemFactory() /* * Build setups */ + + // 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 diff --git a/src/Akka.Hosting/AkkaHostingExtensions.cs b/src/Akka.Hosting/AkkaHostingExtensions.cs index fdcb9bd8..bb9cea81 100644 --- a/src/Akka.Hosting/AkkaHostingExtensions.cs +++ b/src/Akka.Hosting/AkkaHostingExtensions.cs @@ -3,6 +3,7 @@ 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 +54,6 @@ public static IServiceCollection AddAkka(this IServiceCollection services, strin var b = new AkkaConfigurationBuilder(services, actorSystemName); services.AddSingleton(sp => { - builder(b, sp); return b; }); 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/AkkaLoggerFactoryExtensions.cs b/src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs deleted file mode 100644 index 12721d30..00000000 --- a/src/Akka.Hosting/Logging/AkkaLoggerFactoryExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2013-2022 .NET Foundation -// -// ----------------------------------------------------------------------- - -using System; -using System.Collections.Generic; -using System.Linq; -using Akka.Actor; -using Akka.Configuration; -using Akka.Event; -using Microsoft.Extensions.Logging; - -namespace Akka.Hosting.Logging -{ - public static class AkkaLoggerFactoryExtensions - { - public static AkkaConfigurationBuilder WithLoggerFactory(this AkkaConfigurationBuilder builder) - { - return builder.AddHocon("akka.loggers = [\"Akka.Hosting.Logging.LoggerFactoryLogger, Akka.Hosting\"]"); - } - - public static AkkaConfigurationBuilder AddLoggerFactory(this AkkaConfigurationBuilder builder) - { - var loggers = builder.Configuration.HasValue - ? builder.Configuration.Value.GetStringList("akka.loggers") - : new List(); - - if(loggers.Count == 0) - loggers.Add("Akka.Event.DefaultLogger"); - - loggers.Add("Akka.Hosting.Logging.LoggerFactoryLogger, Akka.Hosting"); - return builder.AddHocon($"akka.loggers = [{string.Join(", ", loggers.Select(s => $"\"{s}\""))}]"); - } - - } -} \ No newline at end of file diff --git a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs index 3251400c..05c0c882 100644 --- a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs +++ b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs @@ -5,12 +5,11 @@ // ----------------------------------------------------------------------- using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Text; using Akka.Actor; using Akka.Configuration; -using Akka.DependencyInjection; using Akka.Dispatch; using Akka.Event; using Microsoft.Extensions.Logging; @@ -21,35 +20,40 @@ 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}}}][{{SourceContext}}][{{LogSource}}][{{ActorPath}}][{{Thread:0000}}]: {{Message}}"; + private const string DefaultMessageFormat = "[{{Timestamp:{0}}}][{{LogSource}}][{{ActorPath}}][{{Thread:0000}}]: {{Message}}"; private static readonly Event.LogLevel[] AllLogLevels = Enum.GetValues(typeof(Event.LogLevel)).Cast().ToArray(); - - private readonly ConcurrentDictionary _loggerCache = new ConcurrentDictionary(); - private readonly ILoggingAdapter _log = Akka.Event.Logging.GetLogger(Context.System.EventStream, nameof(LoggerFactoryLogger)); - private ILoggerFactory _loggerFactory; + + /// + /// 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() { - _log.Info($"{nameof(LoggerFactoryLogger)} stopped"); + _internalLogger.Info($"{nameof(LoggerFactoryLogger)} stopped"); } protected override bool Receive(object message) { switch (message) - { + { case InitializeLogger _: - var resolver = DependencyResolver.For(Context.System); - _loggerFactory = resolver.Resolver.GetService(); - if (_loggerFactory == null) - throw new ConfigurationException("Could not find any ILoggerFactory service inside ServiceProvider"); - - _log.Info($"{nameof(LoggerFactoryLogger)} started"); + _internalLogger.Info($"{nameof(LoggerFactoryLogger)} started"); Sender.Tell(new LoggerInitialized()); return true; @@ -64,13 +68,12 @@ protected override bool Receive(object message) private void Log(LogEvent log, ActorPath path) { - var logger = _loggerCache.GetOrAdd(log.LogClass, type => _loggerFactory.CreateLogger(type)); var message = GetMessage(log.Message); - logger.Log(GetLogLevel(log.LogLevel()), log.Cause, _messageFormat, GetArgs(log, path, 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.LogClass.FullName, log.LogSource, path, log.Thread.ManagedThreadId, message }; + => new []{ log.Timestamp, log.LogSource, path, log.Thread.ManagedThreadId, message }; private static object GetMessage(object obj) { @@ -125,7 +128,7 @@ private static LogLevel GetLogLevel(Event.LogLevel level) case Event.LogLevel.WarningLevel: return LogLevel.Warning; case Event.LogLevel.ErrorLevel: - return LogLevel.Warning; + return LogLevel.Error; default: // Should never reach this code path return LogLevel.Error; 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/Directory.Build.props b/src/Directory.Build.props index 024f7bac..1f003aab 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,8 +2,8 @@ Copyright © 2013-2022 Akka.NET Team Akka.NET Team - 0.3.4 - • [Adds ActorRegistry-capable overloads to the WithShardRegion<TKey> methods](https://github.com/akkadotnet/Akka.Hosting/pull/70) + 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 diff --git a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj index 036ab85a..727557bc 100644 --- a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj +++ b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj @@ -9,4 +9,9 @@ + + + + + diff --git a/src/Examples/Akka.Hosting.LoggingDemo/Program.cs b/src/Examples/Akka.Hosting.LoggingDemo/Program.cs index 80c0f1b4..6de4cfd1 100644 --- a/src/Examples/Akka.Hosting.LoggingDemo/Program.cs +++ b/src/Examples/Akka.Hosting.LoggingDemo/Program.cs @@ -5,15 +5,42 @@ 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 - .AddHocon("akka.loglevel = DEBUG") - .WithLoggerFactory() + .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")}}) From f079ded575064c7799d99476224a3a6d5dd434bb Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 21 Jul 2022 10:35:26 -0500 Subject: [PATCH 11/46] fixed hanging ILogger test (#83) --- src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs b/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs index 745cfafb..e17a3733 100644 --- a/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs +++ b/src/Akka.Hosting.Tests/Logging/LoggerFactoryLoggerSpec.cs @@ -41,9 +41,10 @@ public async Task InitializeAsync() _echo = registry.Get(); } - public async Task DisposeAsync() + public Task DisposeAsync() { - await _host.StopAsync(); + _host.Dispose(); + return Task.CompletedTask; } [Fact(DisplayName = "LoggerFactoryLogger should log events")] From 173e9445e657300ac0ce63484e8a5a790fac423a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 21 Jul 2022 11:05:16 -0500 Subject: [PATCH 12/46] added `InMemory` snapshot store and journal configuration settings (#84) * added `InMemory` snapshot store and journal configuration settings * added specs to validate * Update Akka.Persistence.Hosting.Tests.csproj --- .../Akka.Persistence.Hosting.Tests.csproj | 1 + .../InMemoryPersistenceSpecs.cs | 125 ++++++++++++++++++ .../AkkaPersistenceHostingExtensions.cs | 39 ++++++ 3 files changed, 165 insertions(+) create mode 100644 src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs diff --git a/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj index 7153572b..469bf721 100644 --- a/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj +++ b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs b/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs new file mode 100644 index 00000000..c20dac63 --- /dev/null +++ b/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs @@ -0,0 +1,125 @@ +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 + { + + 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; + } + + [Fact] + public async Task Should_Start_ActorSystem_wth_InMemory_Persistence() + { + // arrange + using var host = await StartHost(collection => collection.AddAkka("MySys", builder => + { + builder.WithInMemoryJournal().WithInMemorySnapshotStore() + .StartActors((system, registry) => + { + var myActor = system.ActorOf(Props.Create(() => new MyPersistenceActor("ac1")), "actor1"); + registry.Register(myActor); + }) + .WithActors((system, registry) => + { + var extSystem = (ExtendedActorSystem)system; + var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(_output)), "log-test"); + logger.Tell(new InitializeLogger(system.EventStream)); + });; + })); + + var actorSystem = host.Services.GetRequiredService(); + var actorRegistry = host.Services.GetRequiredService(); + 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 = actorSystem.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 = actorSystem.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/AkkaPersistenceHostingExtensions.cs b/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs index df7238e5..cfb21c59 100644 --- a/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs +++ b/src/Akka.Persistence.Hosting/AkkaPersistenceHostingExtensions.cs @@ -132,5 +132,44 @@ public static AkkaConfigurationBuilder WithJournal(this AkkaConfigurationBuilder 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 From a52690c120708ddbf6dd77eaeb1f8a6ebbf2d107 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 22 Jul 2022 00:09:31 +0700 Subject: [PATCH 13/46] Update RELEASE_NOTES.md for v0.4.1 release (#85) --- RELEASE_NOTES.md | 64 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6339ce7e..3a51725e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,15 +1,39 @@ -## [0.4.0] / 18 July 2022 -- [Add `Microsoft.Extensions.Logging.ILoggerFactory` logging support](https://github.com/akkadotnet/Akka.Hosting/pull/72) +## [0.4.1] / 21 July 2022 +- [Fix `Microsoft.Extensions.Logging.ILoggerFactory` logging support](https://github.com/akkadotnet/Akka.Hosting/pull/81) +- [Add `InMemory` snapshot store and journal persistence support](https://github.com/akkadotnet/Akka.Hosting/pull/84) -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. +Due to a bad API design, we're rolling back the `Microsoft.Extensions.Logging.ILoggerFactory` logger support introduced in version 0.4.0, the 0.4.0 NuGet version is now considered as deprecated in support of the new API design introduced in version 0.4.1. + +__Logger Configuration Support__ + +You can now use the new `AkkaConfigurationBuilder` extension method called `ConfigureLoggers(Action)` to configure how Akka.NET logger behave. Example: -``` -builder.Services.AddAkka("MyActorSystem", (configurationBuilder, serviceProvider) => +```csharp +builder.Services.AddAkka("MyActorSystem", configurationBuilder => { configurationBuilder - .AddHocon("akka.loglevel = DEBUG") - .WithLoggerFactory() + .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 => @@ -25,11 +49,23 @@ builder.Services.AddAkka("MyActorSystem", (configurationBuilder, serviceProvider }); ``` -There are two `Akka.Hosting` extension methods provided: -- `.WithLoggerFactory()`: Replaces all Akka.NET loggers with the new `ILoggerFactory` logger. -- `.AddLoggerFactory()`: Inserts the new `ILoggerFactory` logger into the Akka.NET logger list. +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. -__Log Event Filtering__ +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. @@ -47,3 +83,9 @@ To set up the `Microsoft.Extensions.Logging` log filtering, you will need to edi } } ``` + +__InMemory Snapshot Store And Journal Support__ + +You can now use these `AkkaConfigurationBuilder` extension methods to enable `InMemory` persistence back end: +- `WithInMemoryJournal()`: Sets the `InMemory` journal as the default journal persistence plugin +- `WithInMemorySnapshotStore()`: Sets the `InMemory` snapshot store as the default snapshot-store persistence plugin. \ No newline at end of file From 82cb91f9c25d42a18a58c5f6d67d406bc5fab929 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Jul 2022 07:55:10 -0500 Subject: [PATCH 14/46] Bump AkkaVersion from 1.4.39 to 1.4.40 (#86) Bumps `AkkaVersion` from 1.4.39 to 1.4.40. Updates `Akka.DependencyInjection` from 1.4.39 to 1.4.40 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.40/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.39...1.4.40) Updates `Akka.TestKit.Xunit2` from 1.4.39 to 1.4.40 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.40/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.39...1.4.40) Updates `Akka.Remote` from 1.4.39 to 1.4.40 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.40/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.39...1.4.40) Updates `Akka.Cluster.Sharding` from 1.4.39 to 1.4.40 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.40/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.39...1.4.40) Updates `Akka.Persistence.Query.Sql` from 1.4.39 to 1.4.40 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.40/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.39...1.4.40) Updates `Akka.Persistence` from 1.4.39 to 1.4.40 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.40/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.39...1.4.40) --- updated-dependencies: - dependency-name: Akka.DependencyInjection dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.TestKit.Xunit2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Remote dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Cluster.Sharding dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Persistence.Query.Sql dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Persistence dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1f003aab..27fd1d60 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -221,7 +221,7 @@ 2.4.1 17.2.0 2.4.5 - 1.4.39 + 1.4.40 [3.0.0,) From 36be64a574df486cd9e7a4eea9ff56154d6744e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Aug 2022 08:20:36 -0500 Subject: [PATCH 15/46] Bump xunit from 2.4.1 to 2.4.2 (#87) Bumps [xunit](https://github.com/xunit/xunit) from 2.4.1 to 2.4.2. - [Release notes](https://github.com/xunit/xunit/releases) - [Commits](https://github.com/xunit/xunit/compare/2.4.1...2.4.2) --- updated-dependencies: - dependency-name: xunit dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 27fd1d60..d4c7e406 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -218,7 +218,7 @@ netstandard2.0 net6.0 - 2.4.1 + 2.4.2 17.2.0 2.4.5 1.4.40 From b81628538b4a358b645f13ca4925eb6d41f86b00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Aug 2022 09:41:33 -0500 Subject: [PATCH 16/46] Bump Microsoft.NET.Test.Sdk from 17.2.0 to 17.3.0 (#89) Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 17.2.0 to 17.3.0. - [Release notes](https://github.com/microsoft/vstest/releases) - [Commits](https://github.com/microsoft/vstest/compare/v17.2.0...v17.3.0) --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d4c7e406..71a77036 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -219,7 +219,7 @@ netstandard2.0 net6.0 2.4.2 - 17.2.0 + 17.3.0 2.4.5 1.4.40 [3.0.0,) From fd5fa2dce78b2f57e22db1c4b790468a52a56710 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Thu, 11 Aug 2022 21:09:52 +0700 Subject: [PATCH 17/46] Add `WithExtensions` support (#91) --- src/Akka.Hosting/AkkaHostingExtensions.cs | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Akka.Hosting/AkkaHostingExtensions.cs b/src/Akka.Hosting/AkkaHostingExtensions.cs index bb9cea81..148c4907 100644 --- a/src/Akka.Hosting/AkkaHostingExtensions.cs +++ b/src/Akka.Hosting/AkkaHostingExtensions.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using Akka.Actor; using Akka.Actor.Setup; using Akka.Configuration; @@ -145,5 +146,31 @@ public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder return builder.StartActors(actorStarter); } + /// + /// 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)); + /// + /// + /// The builder instance being configured. + /// A list of extension providers that will be automatically started + /// when the starts + /// The same instance originally passed in. + public static AkkaConfigurationBuilder WithExtensions( + this AkkaConfigurationBuilder builder, + params Type[] extensions) + { + builder.AddHocon( + $"akka.extensions=[{string.Join(", ", extensions.Select(s => $"\"{s.AssemblyQualifiedName}\""))}]", + HoconAddMode.Prepend); + return builder; + } } } From d27f0e62c3fbf46dc12dd56e42ffc7278be84ce0 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 12 Aug 2022 02:50:38 +0700 Subject: [PATCH 18/46] Improve WithExtensions so it can be used universally (#92) --- src/Akka.Hosting.Tests/ExtensionsSpecs.cs | 99 +++++++++++++++++++ src/Akka.Hosting.Tests/XUnitLogger.cs | 84 ++++++++++++++++ src/Akka.Hosting.Tests/XUnitLoggerProvider.cs | 26 +++++ src/Akka.Hosting/AkkaConfigurationBuilder.cs | 62 ++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/Akka.Hosting.Tests/ExtensionsSpecs.cs create mode 100644 src/Akka.Hosting.Tests/XUnitLogger.cs create mode 100644 src/Akka.Hosting.Tests/XUnitLoggerProvider.cs diff --git a/src/Akka.Hosting.Tests/ExtensionsSpecs.cs b/src/Akka.Hosting.Tests/ExtensionsSpecs.cs new file mode 100644 index 00000000..35d0b935 --- /dev/null +++ b/src/Akka.Hosting.Tests/ExtensionsSpecs.cs @@ -0,0 +1,99 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +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; + +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(); + } + + 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/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/AkkaConfigurationBuilder.cs b/src/Akka.Hosting/AkkaConfigurationBuilder.cs index 6acf97c0..83adb37b 100644 --- a/src/Akka.Hosting/AkkaConfigurationBuilder.cs +++ b/src/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -79,6 +79,7 @@ public sealed class AkkaConfigurationBuilder internal readonly IServiceCollection ServiceCollection; internal readonly HashSet Serializers = new HashSet(); internal readonly HashSet Setups = new HashSet(); + internal readonly HashSet Extensions = new HashSet(); /// /// The currently configured . @@ -203,6 +204,33 @@ 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 (Extensions.Contains(extension)) + continue; + Extensions.Add(extension); + } + return this; + } + internal void Bind() { // register as singleton - not interested in supporting multi-Sys use cases @@ -224,6 +252,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 => @@ -234,6 +293,9 @@ 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) From e377bffbdd6567308953354fd8fd8fa48d98d33b Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 12 Aug 2022 02:56:11 +0700 Subject: [PATCH 19/46] Add StartupTask feature (#90) * Add StartupTask feature * Roll the extension methods into the config builder class to reduce confusion Co-authored-by: Aaron Stannard --- src/Akka.Hosting/AkkaConfigurationBuilder.cs | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/Akka.Hosting/AkkaConfigurationBuilder.cs b/src/Akka.Hosting/AkkaConfigurationBuilder.cs index 83adb37b..0baaab5f 100644 --- a/src/Akka.Hosting/AkkaConfigurationBuilder.cs +++ b/src/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -52,6 +52,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; @@ -99,6 +101,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) @@ -179,6 +182,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) { @@ -194,6 +208,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) @@ -366,6 +408,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; } } From 293e5a9ddf7e183c45961430aaaa4699e63fbbf9 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 12 Aug 2022 07:28:46 +0700 Subject: [PATCH 20/46] Update RELEASE_NOTES.md for v0.4.2 release (#93) --- RELEASE_NOTES.md | 98 ++++------------------- src/Akka.Hosting/AkkaHostingExtensions.cs | 27 ------- 2 files changed, 17 insertions(+), 108 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3a51725e..7b98cd59 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,91 +1,27 @@ -## [0.4.1] / 21 July 2022 -- [Fix `Microsoft.Extensions.Logging.ILoggerFactory` logging support](https://github.com/akkadotnet/Akka.Hosting/pull/81) -- [Add `InMemory` snapshot store and journal persistence support](https://github.com/akkadotnet/Akka.Hosting/pull/84) +## [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) -Due to a bad API design, we're rolling back the `Microsoft.Extensions.Logging.ILoggerFactory` logger support introduced in version 0.4.0, the 0.4.0 NuGet version is now considered as deprecated in support of the new API design introduced in version 0.4.1. +__WithExtensions()__ -__Logger Configuration Support__ - -You can now use the new `AkkaConfigurationBuilder` extension method called `ConfigureLoggers(Action)` to configure how Akka.NET logger behave. +`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 -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 - }); -}); +// Starts distributed pub-sub, cluster metrics, and cluster bootstrap extensions at start-up +builder.WithExtensions( + typeof(DistributedPubSubExtensionProvider), + typeof(ClusterMetricsExtensionProvider), + typeof(ClusterBootstrapProvider)); ``` -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. +__AddStartup()__ -__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" - } - } -} -``` +`AddStartup()` method adds `StartupTask` delegate to the configuration builder. -__InMemory Snapshot Store And Journal Support__ +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. -You can now use these `AkkaConfigurationBuilder` extension methods to enable `InMemory` persistence back end: -- `WithInMemoryJournal()`: Sets the `InMemory` journal as the default journal persistence plugin -- `WithInMemorySnapshotStore()`: Sets the `InMemory` snapshot store as the default snapshot-store persistence plugin. \ No newline at end of file +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/src/Akka.Hosting/AkkaHostingExtensions.cs b/src/Akka.Hosting/AkkaHostingExtensions.cs index 148c4907..4cc15140 100644 --- a/src/Akka.Hosting/AkkaHostingExtensions.cs +++ b/src/Akka.Hosting/AkkaHostingExtensions.cs @@ -145,32 +145,5 @@ public static AkkaConfigurationBuilder WithActors(this AkkaConfigurationBuilder { return builder.StartActors(actorStarter); } - - /// - /// 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)); - /// - /// - /// The builder instance being configured. - /// A list of extension providers that will be automatically started - /// when the starts - /// The same instance originally passed in. - public static AkkaConfigurationBuilder WithExtensions( - this AkkaConfigurationBuilder builder, - params Type[] extensions) - { - builder.AddHocon( - $"akka.extensions=[{string.Join(", ", extensions.Select(s => $"\"{s.AssemblyQualifiedName}\""))}]", - HoconAddMode.Prepend); - return builder; - } } } From e93c38d4878860b5425ccb03bf1ec9f0bdab8c41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Aug 2022 09:52:05 -0500 Subject: [PATCH 21/46] Bump Nuke.Common from 6.1.2 to 6.2.1 (#94) Bumps [Nuke.Common](https://github.com/nuke-build/nuke) from 6.1.2 to 6.2.1. - [Release notes](https://github.com/nuke-build/nuke/releases) - [Changelog](https://github.com/nuke-build/nuke/blob/develop/CHANGELOG.md) - [Commits](https://github.com/nuke-build/nuke/compare/6.1.2...6.2.1) --- updated-dependencies: - dependency-name: Nuke.Common dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/_build.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/_build.csproj b/build/_build.csproj index 884157c7..0f2366de 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -11,7 +11,7 @@ - + From c88a8fa4fe9f8968b66bf560db1c4f9102855949 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 23 Aug 2022 19:48:21 -0500 Subject: [PATCH 22/46] Update README.md --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/README.md b/README.md index 77a6ea8a..9ea3e52e 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,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" + } + } +} +``` From db99763e707512496d76ef6cb32f2a4c4612818f Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Mon, 29 Aug 2022 21:30:21 +0700 Subject: [PATCH 23/46] Change Setups field to public for testing purposes (#96) --- src/Akka.Hosting/AkkaConfigurationBuilder.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Akka.Hosting/AkkaConfigurationBuilder.cs b/src/Akka.Hosting/AkkaConfigurationBuilder.cs index 0baaab5f..36ade78a 100644 --- a/src/Akka.Hosting/AkkaConfigurationBuilder.cs +++ b/src/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -6,6 +6,7 @@ using Akka.Actor; using Akka.Actor.Dsl; using Akka.Actor.Setup; +using Akka.Annotations; using Akka.Configuration; using Akka.DependencyInjection; using Akka.Hosting.Logging; @@ -80,9 +81,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 . /// From 45a5c67119e4b41728e65d1b39b5c2eb275a3a28 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Mon, 29 Aug 2022 23:18:52 +0700 Subject: [PATCH 24/46] Add statically typed `WithExtension` method (#97) --- src/Akka.Hosting.Tests/ExtensionsSpecs.cs | 40 ++++++++++++++++++++ src/Akka.Hosting/AkkaConfigurationBuilder.cs | 23 +++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/Akka.Hosting.Tests/ExtensionsSpecs.cs b/src/Akka.Hosting.Tests/ExtensionsSpecs.cs index 35d0b935..63495d8a 100644 --- a/src/Akka.Hosting.Tests/ExtensionsSpecs.cs +++ b/src/Akka.Hosting.Tests/ExtensionsSpecs.cs @@ -7,6 +7,7 @@ using System; using System.Threading.Tasks; using Akka.Actor; +using Akka.Configuration; using Akka.Event; using Akka.TestKit.Xunit2.Internals; using FluentAssertions; @@ -16,6 +17,7 @@ using Xunit; using Xunit.Abstractions; using LogLevel = Microsoft.Extensions.Logging.LogLevel; +using static FluentAssertions.FluentActions; namespace Akka.Hosting.Tests; @@ -72,6 +74,44 @@ public async Task CanBeCalledMultipleTimes() 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 { } diff --git a/src/Akka.Hosting/AkkaConfigurationBuilder.cs b/src/Akka.Hosting/AkkaConfigurationBuilder.cs index 36ade78a..119de2b2 100644 --- a/src/Akka.Hosting/AkkaConfigurationBuilder.cs +++ b/src/Akka.Hosting/AkkaConfigurationBuilder.cs @@ -2,6 +2,7 @@ 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; @@ -277,12 +278,34 @@ 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() { From 3ca967f1d196b3ffb55411b514abdaa6af37c2b4 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Mon, 29 Aug 2022 23:27:00 +0700 Subject: [PATCH 25/46] Add split brain resolver configuration support to Akka.Cluster.Hosting (#95) * Add split brain resolver configuration support to Akka.Cluster.Hosting * Add unit tests Co-authored-by: Aaron Stannard --- .../Lease/TestLease.cs | 157 +++++++++++ .../Lease/TestLeaseActor.cs | 253 ++++++++++++++++++ .../SplitBrainResolverSpecs.cs | 178 ++++++++++++ .../AkkaClusterHostingExtensions.cs | 92 ++++++- .../SBR/SplitBrainResolverOption.cs | 100 +++++++ 5 files changed, 768 insertions(+), 12 deletions(-) create mode 100644 src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs create mode 100644 src/Akka.Cluster.Hosting.Tests/Lease/TestLeaseActor.cs create mode 100644 src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs create mode 100644 src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs 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..25fb3543 --- /dev/null +++ b/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs @@ -0,0 +1,157 @@ +//----------------------------------------------------------------------- +// +// 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.Configuration; +using Akka.Coordination; +using Akka.Event; +using Akka.TestKit; +using Akka.TestKit.Xunit2; +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 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..437b290b --- /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 = "test-lease", + 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/AkkaClusterHostingExtensions.cs b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs index 0ad5cef3..9df8b9f4 100644 --- a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs +++ b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs @@ -5,14 +5,14 @@ using System.Text; using System.Text.RegularExpressions; using Akka.Actor; +using Akka.Cluster.Hosting.SBR; +using Akka.Cluster.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; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace Akka.Cluster.Hosting { @@ -68,17 +68,72 @@ 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 (sbrOptions != null) + { + var cfgBuilder = new StringBuilder() + .AppendFormat("akka.cluster.downing-provider-class = \"{0}\"\n", typeof(SplitBrainResolverProvider).AssemblyQualifiedName); + + switch (sbrOptions) + { + case StaticQuorumOption opt: + cfgBuilder.AppendLine(@$" +akka.cluster.split-brain-resolver.active-strategy = static-quorum +akka.cluster.split-brain-resolver.static-quorum {{ + quorum-size = {opt.QuorumSize} + role = ""{opt.Role}"" +}}"); + break; + + case KeepMajorityOption opt: + cfgBuilder.AppendLine(@$" +akka.cluster.split-brain-resolver.active-strategy = keep-majority +akka.cluster.split-brain-resolver.keep-majority {{ + role = ""{opt.Role}"" +}}"); + break; + + case KeepOldestOption opt: + cfgBuilder.AppendLine(@$" +akka.cluster.split-brain-resolver.active-strategy = keep-oldest +akka.cluster.split-brain-resolver.keep-oldest {{ + down-if-alone = {(opt.DownIfAlone ? "on" : "off")} + role = ""{opt.Role}"" +}}"); + break; + + case LeaseMajorityOption opt: + cfgBuilder.AppendLine(@$" +akka.cluster.split-brain-resolver.active-strategy = lease-majority +akka.cluster.split-brain-resolver.lease-majority {{ + lease-implementation = ""{opt.LeaseImplementation}"" + lease-name = ""{opt.LeaseName}"" + role = ""{opt.Role}"" +}}"); + break; + + default: + throw new ConfigurationException($"Unknown {nameof(SplitBrainResolverOption)} type: {sbrOptions.GetType()}"); + } - if (options.SeedNodes is { Length: > 0 }) - builder = builder.BuildClusterSeedsHocon(options.SeedNodes); + builder.AddHocon(cfgBuilder.ToString(), HoconAddMode.Prepend); + } // populate all of the possible Clustering default HOCON configurations here return builder.AddHocon(ClusterSharding.DefaultConfig() @@ -92,11 +147,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) { diff --git a/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs new file mode 100644 index 00000000..1e3b16de --- /dev/null +++ b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs @@ -0,0 +1,100 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2022 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using Akka.Annotations; +using Akka.Coordination; + +namespace Akka.Cluster.Hosting.SBR +{ + public abstract class SplitBrainResolverOption + { + 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; } + } + + /// + /// + /// 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 + { + /// + /// Minimum number of nodes that the cluster must have + /// + public int QuorumSize { get; set; } = 0; + } + + /// + /// 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 + { + } + + /// + /// + /// 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 + { + /// + /// Enable downing of the oldest node when it is partitioned from all other nodes + /// + public bool DownIfAlone { get; set; } = true; + } + + /// + /// 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 + { + /// + /// A class type that extends the abstract class + /// + public string 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; } + } +} \ No newline at end of file From cad2cf71b24f0e280e56a03a6c8ebafb6a096a71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Aug 2022 08:33:45 -0500 Subject: [PATCH 26/46] Bump Microsoft.NET.Test.Sdk from 17.3.0 to 17.3.1 (#98) Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 17.3.0 to 17.3.1. - [Release notes](https://github.com/microsoft/vstest/releases) - [Commits](https://github.com/microsoft/vstest/compare/v17.3.0...v17.3.1) --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 71a77036..d25098ee 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -219,7 +219,7 @@ netstandard2.0 net6.0 2.4.2 - 17.3.0 + 17.3.1 2.4.5 1.4.40 [3.0.0,) From 8834538c0ae6c13324f91c1fb37a0bd80efe7c0c Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Wed, 31 Aug 2022 02:00:15 +0700 Subject: [PATCH 27/46] Add Akka.Cluster.Hosting documentation (#99) --- README.md | 14 +- .../Akka.Cluster.Hosting.csproj | 4 - src/Akka.Cluster.Hosting/README.md | 443 ++++++++++++++++++ .../SBR/SplitBrainResolverOption.cs | 2 +- .../Akka.Hosting.SimpleDemo/Program.cs | 89 ++-- 5 files changed, 516 insertions(+), 36 deletions(-) create mode 100644 src/Akka.Cluster.Hosting/README.md diff --git a/README.md b/README.md index 9ea3e52e..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. @@ -156,11 +164,11 @@ Currently supported logger methods: - `AddDefaultLogger()`: Add the default Akka.NET console logger. - `AddLoggerFactory()`: Add the new `ILoggerFactory` logger. -__Microsoft.Extensions.Logging.ILoggerFactory Logging Support__ +### 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__ +### 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. diff --git a/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj b/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj index 0d445c2f..04cedf65 100644 --- a/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj +++ b/src/Akka.Cluster.Hosting/Akka.Cluster.Hosting.csproj @@ -6,10 +6,6 @@ 9 - - - - diff --git a/src/Akka.Cluster.Hosting/README.md b/src/Akka.Cluster.Hosting/README.md new file mode 100644 index 00000000..7c2b8cc7 --- /dev/null +++ b/src/Akka.Cluster.Hosting/README.md @@ -0,0 +1,443 @@ +# 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`. + +# 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 + +## 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 index 1e3b16de..08a52b57 100644 --- a/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs +++ b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs @@ -85,7 +85,7 @@ public sealed class KeepOldestOption : SplitBrainResolverOption public sealed class LeaseMajorityOption : SplitBrainResolverOption { /// - /// A class type that extends the abstract class + /// The absolute HOCON path to the implementation HOCON configuration block /// public string LeaseImplementation { get; set; } 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 From c8f4781aac68425532ac07f1992a08bcc66500c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Sep 2022 08:43:21 -0500 Subject: [PATCH 28/46] Bump AkkaVersion from 1.4.40 to 1.4.41 (#100) Bumps `AkkaVersion` from 1.4.40 to 1.4.41. Updates `Akka.DependencyInjection` from 1.4.40 to 1.4.41 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.41/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.40...1.4.41) Updates `Akka.TestKit.Xunit2` from 1.4.40 to 1.4.41 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.41/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.40...1.4.41) Updates `Akka.Remote` from 1.4.40 to 1.4.41 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.41/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.40...1.4.41) Updates `Akka.Cluster.Sharding` from 1.4.40 to 1.4.41 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.41/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.40...1.4.41) Updates `Akka.Persistence.Query.Sql` from 1.4.40 to 1.4.41 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.41/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.40...1.4.41) Updates `Akka.Persistence` from 1.4.40 to 1.4.41 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.41/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.40...1.4.41) --- updated-dependencies: - dependency-name: Akka.DependencyInjection dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.TestKit.Xunit2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Remote dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Cluster.Sharding dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Persistence.Query.Sql dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Persistence dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d25098ee..3ec9fcfc 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -221,7 +221,7 @@ 2.4.2 17.3.1 2.4.5 - 1.4.40 + 1.4.41 [3.0.0,) From 60b4ffea5d7252ff60c1d3557741969cafa0dc5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Sep 2022 13:48:24 +0000 Subject: [PATCH 29/46] Bump Serilog.Sinks.Console from 4.0.1 to 4.1.0 (#101) Bumps [Serilog.Sinks.Console](https://github.com/serilog/serilog-sinks-console) from 4.0.1 to 4.1.0. - [Release notes](https://github.com/serilog/serilog-sinks-console/releases) - [Commits](https://github.com/serilog/serilog-sinks-console/compare/v4.0.1...v4.1.0) --- updated-dependencies: - dependency-name: Serilog.Sinks.Console dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj index 727557bc..b61583da 100644 --- a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj +++ b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj @@ -12,6 +12,6 @@ - + From 6c23b04a90ed0db1e319b830a6e724ba4e1dbef2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Sep 2022 13:55:55 +0000 Subject: [PATCH 30/46] Bump docfx.console from 2.59.3 to 2.59.4 (#103) Bumps [docfx.console](https://github.com/dotnet/docfx) from 2.59.3 to 2.59.4. - [Release notes](https://github.com/dotnet/docfx/releases) - [Changelog](https://github.com/dotnet/docfx/blob/dev/RELEASENOTE.md) - [Commits](https://github.com/dotnet/docfx/compare/v2.59.3...v2.59.4) --- updated-dependencies: - dependency-name: docfx.console dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/_build.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/_build.csproj b/build/_build.csproj index 0f2366de..963a5d43 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,7 +12,7 @@ - + From 5ebc39b2bbcaa4906b1f5f634104b9e9c5b2f998 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Sat, 10 Sep 2022 22:17:35 +0700 Subject: [PATCH 31/46] Update RELEASE_NOTES.md for v0.4.3 release (#104) --- RELEASE_NOTES.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7b98cd59..10ef8446 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,27 @@ +## [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) From a6af1b0782dd3c905adce3c52a63e49e8cc1408b Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Wed, 14 Sep 2022 21:04:14 +0700 Subject: [PATCH 32/46] Extend Persistence.SqlServer support with full fledged option configuration (#107) * Extend Persistence.SqlServer support with full fledged option configuration * Add unit test --- src/Akka.Hosting/HoconExtensions.cs | 49 ++++ .../Akka.Persistence.Hosting.Tests.csproj | 1 + .../SqlServer/SqlServerOptionsSpec.cs | 158 +++++++++++++ ...kaPersistenceSqlServerHostingExtensions.cs | 159 +++++++++++-- .../Properties/FriendsOf.cs | 3 + .../SqlServerOptions.cs | 219 ++++++++++++++++++ 6 files changed, 564 insertions(+), 25 deletions(-) create mode 100644 src/Akka.Hosting/HoconExtensions.cs create mode 100644 src/Akka.Persistence.Hosting.Tests/SqlServer/SqlServerOptionsSpec.cs create mode 100644 src/Akka.Persistence.SqlServer.Hosting/Properties/FriendsOf.cs create mode 100644 src/Akka.Persistence.SqlServer.Hosting/SqlServerOptions.cs 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.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj index 469bf721..b06789e6 100644 --- a/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj +++ b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -17,6 +17,7 @@ + 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.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs b/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs index fe7dbea7..9e20f28f 100644 --- a/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs +++ b/src/Akka.Persistence.SqlServer.Hosting/AkkaPersistenceSqlServerHostingExtensions.cs @@ -5,6 +5,7 @@ using Akka.Persistence.Hosting; using Akka.Persistence.Query.Sql; +#nullable enable namespace Akka.Persistence.SqlServer.Hosting { /// @@ -13,32 +14,42 @@ namespace Akka.Persistence.SqlServer.Hosting public static class AkkaPersistenceSqlServerHostingExtensions { /// - /// Adds Akka.Persistence.SqlServer to this . + /// 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, - PersistenceMode mode = PersistenceMode.Both, Action configurator = null) + 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 = @$" @@ -46,35 +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 - { - PersistenceMode.Both => journalConfiguration - .WithFallback(snapshotStoreConfig) - .WithFallback(SqlReadJournal.DefaultConfiguration()), + return builder.WithSqlServerPersistence(journalConfiguration, snapshotStoreConfig, mode, configurator); + } - PersistenceMode.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); - PersistenceMode.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/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 From cf7c95d6c6813c0af4a07da350f2bb354a368e6b Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 16 Sep 2022 21:17:06 +0700 Subject: [PATCH 33/46] WithRemoting should only override declared configurations (#108) --- .../RemoteConfigurationSpecs.cs | 89 ++++++++++++++++++- .../AkkaRemoteHostingExtensions.cs | 47 ++++++---- 2 files changed, 119 insertions(+), 17 deletions(-) diff --git a/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs index 51e4f63a..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,7 +111,7 @@ 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"); diff --git a/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs b/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs index 05ed8d57..13c03591 100644 --- a/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs +++ b/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs @@ -1,27 +1,37 @@ -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); } /// @@ -33,7 +43,12 @@ private static AkkaConfigurationBuilder BuildRemoteHocon(this AkkaConfigurationB /// 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); From 8c7fa89200dd7e5b7fc2bbc5684b524565a663d0 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Thu, 22 Sep 2022 21:29:35 +0700 Subject: [PATCH 34/46] Add TOC to Cluster.Hosting docs for easier navigation (#111) --- src/Akka.Cluster.Hosting/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Akka.Cluster.Hosting/README.md b/src/Akka.Cluster.Hosting/README.md index 7c2b8cc7..a043c88c 100644 --- a/src/Akka.Cluster.Hosting/README.md +++ b/src/Akka.Cluster.Hosting/README.md @@ -2,6 +2,23 @@ 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 @@ -277,6 +294,8 @@ public static AkkaConfigurationBuilder WithShardRegionProxy( 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. From f42d2dd2dce04d6e4d2c51f9b7740f9516eb0b55 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 23 Sep 2022 21:25:51 +0700 Subject: [PATCH 35/46] Add Akka.Persistence.SqlServer.Hosting documentation (#115) --- .../Akka.Persistence.SqlServer.Hosting.csproj | 4 - .../README.md | 84 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/Akka.Persistence.SqlServer.Hosting/README.md 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 e47e22e0..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,10 +6,6 @@ 9 - - - - 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 From c10d15c8b728274cddd621ea24a2cb13e63c23de Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 23 Sep 2022 21:31:07 +0700 Subject: [PATCH 36/46] Add Akka.Persistence.PostgreSql.Hosting documentation (#116) Co-authored-by: Aaron Stannard --- ...Akka.Persistence.PostgreSql.Hosting.csproj | 4 -- ...aPersistencePostgreSqlHostingExtensions.cs | 36 ++++++++++ .../README.md | 71 +++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/Akka.Persistence.PostgreSql.Hosting/README.md 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 2b123a4e..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,10 +6,6 @@ 9 - - - - diff --git a/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs b/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs index afff5f36..a27abbaa 100644 --- a/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs +++ b/src/Akka.Persistence.PostgreSql.Hosting/AkkaPersistencePostgreSqlHostingExtensions.cs @@ -1,4 +1,5 @@ using System; +using Akka.Actor; using Akka.Configuration; using Akka.Hosting; using Akka.Persistence.Hosting; @@ -11,6 +12,41 @@ namespace Akka.Persistence.PostgreSql.Hosting /// 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, 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) + From 25ec6185840cbf64941ae9cbfe34ccdc27fff8e0 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 23 Sep 2022 22:01:32 +0700 Subject: [PATCH 37/46] Add documentation to Akka.Persistence.Hosting (#114) --- .../Akka.Persistence.Hosting.csproj | 4 -- src/Akka.Persistence.Hosting/README.md | 54 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 src/Akka.Persistence.Hosting/README.md diff --git a/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj b/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj index 85fbdf94..aecfabcd 100644 --- a/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj +++ b/src/Akka.Persistence.Hosting/Akka.Persistence.Hosting.csproj @@ -6,10 +6,6 @@ 9 - - - - 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); +``` From c9da7e07baa0e47e15177880808e81c41399c7a2 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 23 Sep 2022 22:49:19 +0700 Subject: [PATCH 38/46] Add documentation to Akka.Remote.Hosting (#113) * [REMOTE] Add documentation Co-authored-by: Aaron Stannard --- .../Akka.Remote.Hosting.csproj | 4 -- .../AkkaRemoteHostingExtensions.cs | 4 +- src/Akka.Remote.Hosting/README.md | 54 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 src/Akka.Remote.Hosting/README.md 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 13c03591..69ab6ba7 100644 --- a/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs +++ b/src/Akka.Remote.Hosting/AkkaRemoteHostingExtensions.cs @@ -38,8 +38,8 @@ private static AkkaConfigurationBuilder BuildRemoteHocon( /// 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. 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(); +``` From 203f1f3260706cc79d44f1f6708f14c6e77c4fc9 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Fri, 23 Sep 2022 23:29:33 +0700 Subject: [PATCH 39/46] Fix racy LoggerConfigEnd2EndSpecs unit test (#117) Co-authored-by: Aaron Stannard --- .../Logging/LoggerConfigEnd2EndSpecs.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs b/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs index b4aa6af4..8166b6c5 100644 --- a/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/LoggerConfigEnd2EndSpecs.cs @@ -18,6 +18,8 @@ private class CustomLoggingProvider : ILoggerProvider { private readonly TestLogger _logger; + public bool Created { get; private set; } + public CustomLoggingProvider(TestLogger logger) { _logger = logger; @@ -29,6 +31,11 @@ public void Dispose() public ILogger CreateLogger(string categoryName) { + if (categoryName.Contains(nameof(ActorSystem))) + { + Created = true; + _logger.LogInformation("ActorSystem logger created."); + } return _logger; } } @@ -45,10 +52,12 @@ public LoggerConfigEnd2EndSpecs(ITestOutputHelper 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(new CustomLoggingProvider(_logger)); }); + collection.AddLogging(builder => { builder.AddProvider(loggingProvider); }); collection.AddAkka("MySys", (builder, provider) => { @@ -56,6 +65,9 @@ public async Task Should_configure_LoggerFactoryLogger() builder.AddTestOutputLogger(_output); }); }); + + // Make sure that the logger has already been created + await AwaitConditionAsync(() => loggingProvider.Created); var actorSystem = host.Services.GetRequiredService(); // act From 1518fadd097c16f5c4868296546ed1b29a5e07f6 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Sat, 24 Sep 2022 00:16:38 +0700 Subject: [PATCH 40/46] Bump Akka.NET from v1.4.41 to v1.4.42 (#118) --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3ec9fcfc..3cb1cece 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -221,7 +221,7 @@ 2.4.2 17.3.1 2.4.5 - 1.4.41 + 1.4.42 [3.0.0,) From 849044ba9d8fccf5e1cfaa20b19d98ee32b5a607 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Sep 2022 03:39:40 -0500 Subject: [PATCH 41/46] Bump Akka.Logger.Serilog from 1.4.26 to 1.4.42 (#120) Bumps [Akka.Logger.Serilog](https://github.com/akkadotnet/Akka.Logger.Serilog) from 1.4.26 to 1.4.42. - [Release notes](https://github.com/akkadotnet/Akka.Logger.Serilog/releases) - [Changelog](https://github.com/akkadotnet/Akka.Logger.Serilog/blob/dev/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/Akka.Logger.Serilog/compare/1.4.26...1.4.42) --- updated-dependencies: - dependency-name: Akka.Logger.Serilog dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj index b61583da..2e9fc935 100644 --- a/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj +++ b/src/Examples/Akka.Hosting.LoggingDemo/Akka.Hosting.LoggingDemo.csproj @@ -11,7 +11,7 @@ - + From d08d0d0e81aaff65dd6cf859e1e3bf30bfd51840 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Sep 2022 08:46:20 +0000 Subject: [PATCH 42/46] Bump Microsoft.NET.Test.Sdk from 17.3.1 to 17.3.2 (#119) Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 17.3.1 to 17.3.2. - [Release notes](https://github.com/microsoft/vstest/releases) - [Commits](https://github.com/microsoft/vstest/compare/v17.3.1...v17.3.2) --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3cb1cece..d5e405a1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -219,7 +219,7 @@ netstandard2.0 net6.0 2.4.2 - 17.3.1 + 17.3.2 2.4.5 1.4.42 [3.0.0,) From 826b375fea5ea39e8f0c200af05337df84663836 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Mon, 3 Oct 2022 21:35:07 +0700 Subject: [PATCH 43/46] Spec Proposal to standardize HOCON path based configuration pattern (#110) * Standardize HOCON path based configuration pattern * Add IOption implementation example * Refactor IOption to IHoconOption Co-authored-by: Aaron Stannard --- .../Lease/TestLease.cs | 15 ++- .../SplitBrainResolverSpecs.cs | 2 +- .../AkkaClusterHostingExtensions.cs | 51 +------ .../SBR/SplitBrainResolverOption.cs | 125 +++++++++++++++++- src/Akka.Hosting/IHoconOption.cs | 89 +++++++++++++ 5 files changed, 222 insertions(+), 60 deletions(-) create mode 100644 src/Akka.Hosting/IHoconOption.cs diff --git a/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs b/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs index 25fb3543..8258c022 100644 --- a/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs +++ b/src/Akka.Cluster.Hosting.Tests/Lease/TestLease.cs @@ -9,11 +9,12 @@ 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.TestKit; -using Akka.TestKit.Xunit2; +using Akka.Hosting; using Akka.Util; namespace Akka.Cluster.Hosting.Tests.Lease @@ -58,6 +59,16 @@ public void SetTestLease(string name, TestLease 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 diff --git a/src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs b/src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs index 437b290b..aae1dacc 100644 --- a/src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs +++ b/src/Akka.Cluster.Hosting.Tests/SplitBrainResolverSpecs.cs @@ -159,7 +159,7 @@ public async Task LeaseMajorityTest() builder.AddHocon(TestLease.Configuration, HoconAddMode.Prepend); builder.WithClustering(sbrOptions: new LeaseMajorityOption { - LeaseImplementation = "test-lease", + LeaseImplementation = new TestLeaseOption(), LeaseName = "myService-akka-sbr", Role = "myRole" }); diff --git a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs index 9df8b9f4..d54c454a 100644 --- a/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs +++ b/src/Akka.Cluster.Hosting/AkkaClusterHostingExtensions.cs @@ -6,7 +6,6 @@ using System.Text.RegularExpressions; using Akka.Actor; using Akka.Cluster.Hosting.SBR; -using Akka.Cluster.SBR; using Akka.Cluster.Sharding; using Akka.Cluster.Tools.Client; using Akka.Cluster.Tools.PublishSubscribe; @@ -85,55 +84,7 @@ private static AkkaConfigurationBuilder BuildClusterHocon( builder = builder.BuildClusterSeedsHocon(options.SeedNodes); } - if (sbrOptions != null) - { - var cfgBuilder = new StringBuilder() - .AppendFormat("akka.cluster.downing-provider-class = \"{0}\"\n", typeof(SplitBrainResolverProvider).AssemblyQualifiedName); - - switch (sbrOptions) - { - case StaticQuorumOption opt: - cfgBuilder.AppendLine(@$" -akka.cluster.split-brain-resolver.active-strategy = static-quorum -akka.cluster.split-brain-resolver.static-quorum {{ - quorum-size = {opt.QuorumSize} - role = ""{opt.Role}"" -}}"); - break; - - case KeepMajorityOption opt: - cfgBuilder.AppendLine(@$" -akka.cluster.split-brain-resolver.active-strategy = keep-majority -akka.cluster.split-brain-resolver.keep-majority {{ - role = ""{opt.Role}"" -}}"); - break; - - case KeepOldestOption opt: - cfgBuilder.AppendLine(@$" -akka.cluster.split-brain-resolver.active-strategy = keep-oldest -akka.cluster.split-brain-resolver.keep-oldest {{ - down-if-alone = {(opt.DownIfAlone ? "on" : "off")} - role = ""{opt.Role}"" -}}"); - break; - - case LeaseMajorityOption opt: - cfgBuilder.AppendLine(@$" -akka.cluster.split-brain-resolver.active-strategy = lease-majority -akka.cluster.split-brain-resolver.lease-majority {{ - lease-implementation = ""{opt.LeaseImplementation}"" - lease-name = ""{opt.LeaseName}"" - role = ""{opt.Role}"" -}}"); - break; - - default: - throw new ConfigurationException($"Unknown {nameof(SplitBrainResolverOption)} type: {sbrOptions.GetType()}"); - } - - builder.AddHocon(cfgBuilder.ToString(), HoconAddMode.Prepend); - } + sbrOptions?.Apply(builder); // populate all of the possible Clustering default HOCON configurations here return builder.AddHocon(ClusterSharding.DefaultConfig() diff --git a/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs index 08a52b57..f84c93e2 100644 --- a/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs +++ b/src/Akka.Cluster.Hosting/SBR/SplitBrainResolverOption.cs @@ -5,12 +5,14 @@ // ----------------------------------------------------------------------- using System; -using Akka.Annotations; -using Akka.Coordination; +using System.Text; +using Akka.Actor.Setup; +using Akka.Cluster.SBR; +using Akka.Hosting; namespace Akka.Cluster.Hosting.SBR { - public abstract class SplitBrainResolverOption + public abstract class SplitBrainResolverOption: IHoconOption { public static readonly SplitBrainResolverOption Default = new KeepMajorityOption(); @@ -18,6 +20,12 @@ public abstract class SplitBrainResolverOption /// 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); } /// @@ -38,10 +46,37 @@ public abstract class SplitBrainResolverOption /// 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 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); + } } /// @@ -53,6 +88,22 @@ public sealed class StaticQuorumOption : SplitBrainResolverOption /// 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); + } } /// @@ -70,12 +121,46 @@ public sealed class KeepMajorityOption : SplitBrainResolverOption /// 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 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. @@ -84,10 +169,13 @@ public sealed class KeepOldestOption : SplitBrainResolverOption /// public sealed class LeaseMajorityOption : SplitBrainResolverOption { + public override string ConfigPath => SplitBrainResolverSettings.LeaseMajorityName; + /// - /// The absolute HOCON path to the implementation HOCON configuration block + /// An class instance that extends , used to configure the lease provider used in this + /// strategy. /// - public string LeaseImplementation { get; set; } + public LeaseOptionBase LeaseImplementation { get; set; } /// /// The name of the lease. @@ -96,5 +184,28 @@ public sealed class LeaseMajorityOption : SplitBrainResolverOption /// 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/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 From 89b70c465447e6284a0ddd577ecb861ad8ec406f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Oct 2022 14:40:10 +0000 Subject: [PATCH 44/46] Bump AkkaVersion from 1.4.42 to 1.4.43 (#121) Bumps `AkkaVersion` from 1.4.42 to 1.4.43. Updates `Akka.DependencyInjection` from 1.4.42 to 1.4.43 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.43/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.42...1.4.43) Updates `Akka.TestKit.Xunit2` from 1.4.42 to 1.4.43 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.43/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.42...1.4.43) Updates `Akka.Remote` from 1.4.42 to 1.4.43 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.43/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.42...1.4.43) Updates `Akka.Cluster.Sharding` from 1.4.42 to 1.4.43 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.43/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.42...1.4.43) Updates `Akka.Persistence.Query.Sql` from 1.4.42 to 1.4.43 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.43/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.42...1.4.43) Updates `Akka.Persistence` from 1.4.42 to 1.4.43 - [Release notes](https://github.com/akkadotnet/akka.net/releases) - [Changelog](https://github.com/akkadotnet/akka.net/blob/1.4.43/RELEASE_NOTES.md) - [Commits](https://github.com/akkadotnet/akka.net/compare/1.4.42...1.4.43) --- updated-dependencies: - dependency-name: Akka.DependencyInjection dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.TestKit.Xunit2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Remote dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Cluster.Sharding dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Persistence.Query.Sql dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Akka.Persistence dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d5e405a1..40cfdc5b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -221,7 +221,7 @@ 2.4.2 17.3.2 2.4.5 - 1.4.42 + 1.4.43 [3.0.0,) From cf87206ab641e4eee35cc70947514c67e746dfa0 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Tue, 4 Oct 2022 21:10:38 +0700 Subject: [PATCH 45/46] Add Akka.Hosting.TestKit module (#102) * Add Akka.Hosting.TestKit * Add xunit.runner.json file * Fix ImplicitSender * Fix spec .csproj file * Base the TestKit off TestKitBase instead * Fix TestKit, test should start only after TestKit is initialized * Fix TestEventListenerTest * Change `ConfigureAkka` from virtual to abstract * Refactor Akka.Persistence.Hosting.Tests to use Akka.Hosting.TestKit Co-authored-by: Aaron Stannard --- Akka.Hosting.sln | 12 + .../Akka.Hosting.TestKit.Tests.csproj | 28 ++ .../HostingSpecSpec.cs | 64 ++++ .../NoImplicitSenderSpec.cs | 75 +++++ .../Properties/AssemblyInfo.cs | 35 +++ .../TestActorRefTests/BossActor.cs | 55 ++++ .../TestActorRefTests/FsmActor.cs | 56 ++++ .../TestActorRefTests/Logger.cs | 28 ++ .../TestActorRefTests/NestingActor.cs | 35 +++ .../TestActorRefTests/ReplyActor.cs | 40 +++ .../TestActorRefTests/SenderActor.cs | 44 +++ .../TestActorRefTests/TActorBase.cs | 30 ++ .../TestActorRefTests/TestActorRefSpec.cs | 235 ++++++++++++++ .../TestActorRefTests/TestProbeSpec.cs | 120 +++++++ .../TestActorRefTests/WatchAndForwardActor.cs | 31 ++ .../TestActorRefTests/WorkerActor.cs | 31 ++ .../TestActorRefTests/WrappedTerminated.cs | 22 ++ .../AllTestForEventFilterBase.cs | 293 ++++++++++++++++++ .../AllTestForEventFilterBase_Instances.cs | 131 ++++++++ .../TestEventListenerTests/ConfigTests.cs | 32 ++ .../CustomEventFilterTests.cs | 62 ++++ .../DeadLettersEventFilterTests.cs | 70 +++++ .../EventFilterTestBase.cs | 77 +++++ .../ExceptionEventFilterTests.cs | 174 +++++++++++ .../ForwardAllEventsTestEventListener.cs | 46 +++ .../TestFSMRefTests/TestFSMRefSpec.cs | 91 ++++++ .../TestKitBaseTests/AwaitAssertTests.cs | 40 +++ .../TestKitBaseTests/DilatedTests.cs | 85 +++++ .../TestKitBaseTests/ExpectTests.cs | 62 ++++ .../TestKitBaseTests/IgnoreMessagesTests.cs | 68 ++++ .../TestKitBaseTests/ReceiveTests.cs | 283 +++++++++++++++++ .../TestKitBaseTests/RemainingTests.cs | 26 ++ .../TestKitBaseTests/WithinTests.cs | 26 ++ .../TestKit_Config_Tests.cs | 38 +++ .../TestSchedulerTests.cs | 203 ++++++++++++ .../xunit.runner.json | 6 + .../ActorCellKeepingSynchronizationContext.cs | 83 +++++ .../Akka.Hosting.TestKit.csproj | 19 ++ .../Akka.Hosting.TestKit.csproj.DotSettings | 2 + .../Internals/XUnitLogger.cs | 86 +++++ .../Internals/XUnitLoggerProvider.cs | 28 ++ src/Akka.Hosting.TestKit/TestKit.cs | 211 +++++++++++++ .../Akka.Persistence.Hosting.Tests.csproj | 1 + .../EventAdapterSpecs.cs | 34 +- .../InMemoryPersistenceSpecs.cs | 40 ++- 45 files changed, 3219 insertions(+), 39 deletions(-) create mode 100644 src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj create mode 100644 src/Akka.Hosting.TestKit.Tests/HostingSpecSpec.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/NoImplicitSenderSpec.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/BossActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/FsmActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/Logger.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/NestingActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/ReplyActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/SenderActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TActorBase.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestActorRefSpec.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/TestProbeSpec.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WatchAndForwardActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WorkerActor.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestActorRefTests/WrappedTerminated.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/AllTestForEventFilterBase_Instances.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ConfigTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/CustomEventFilterTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/DeadLettersEventFilterTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/EventFilterTestBase.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ExceptionEventFilterTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestEventListenerTests/ForwardAllEventsTestEventListener.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestFSMRefTests/TestFSMRefSpec.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/AwaitAssertTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/DilatedTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ExpectTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/IgnoreMessagesTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/ReceiveTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/RemainingTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKitBaseTests/WithinTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestKit_Config_Tests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/TestSchedulerTests.cs create mode 100644 src/Akka.Hosting.TestKit.Tests/xunit.runner.json create mode 100644 src/Akka.Hosting.TestKit/ActorCellKeepingSynchronizationContext.cs create mode 100644 src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj create mode 100644 src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj.DotSettings create mode 100644 src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs create mode 100644 src/Akka.Hosting.TestKit/Internals/XUnitLoggerProvider.cs create mode 100644 src/Akka.Hosting.TestKit/TestKit.cs diff --git a/Akka.Hosting.sln b/Akka.Hosting.sln index ce8668b3..28ff68a4 100644 --- a/Akka.Hosting.sln +++ b/Akka.Hosting.sln @@ -41,6 +41,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Persistence.Hosting.Te 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 @@ -105,6 +109,14 @@ Global {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 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.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj index b06789e6..d503b8db 100644 --- a/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj +++ b/src/Akka.Persistence.Hosting.Tests/Akka.Persistence.Hosting.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs b/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs index 6ed7a5ee..4dee92a4 100644 --- a/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs +++ b/src/Akka.Persistence.Hosting.Tests/EventAdapterSpecs.cs @@ -11,7 +11,7 @@ namespace Akka.Persistence.Hosting.Tests; -public class EventAdapterSpecs +public class EventAdapterSpecs: Akka.Hosting.TestKit.TestKit { public static async Task StartHost(Action testSetup) { @@ -79,25 +79,25 @@ public IEventSequence FromJournal(object evt, string manifest) } } - [Fact] - public async Task Should_use_correct_EventAdapter_bindings() + protected override Task ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) { - // arrange - using var host = await StartHost(collection => collection.AddAkka("MySys", builder => + builder.WithJournal("sql-server", journalBuilder => { - 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) }); - }); - })); - + 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 sys = host.Services.GetRequiredService(); - var config = sys.Settings.Config; + var config = Sys.Settings.Config; var sqlPersistenceJournal = config.GetConfig("akka.persistence.journal.sql-server"); // assert diff --git a/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs b/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs index c20dac63..f770da67 100644 --- a/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs +++ b/src/Akka.Persistence.Hosting.Tests/InMemoryPersistenceSpecs.cs @@ -13,7 +13,7 @@ namespace Akka.Persistence.Hosting.Tests { - public class InMemoryPersistenceSpecs + public class InMemoryPersistenceSpecs: Akka.Hosting.TestKit.TestKit { private readonly ITestOutputHelper _output; @@ -76,30 +76,26 @@ public static async Task StartHost(Action testSetup) 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 - using var host = await StartHost(collection => collection.AddAkka("MySys", builder => - { - builder.WithInMemoryJournal().WithInMemorySnapshotStore() - .StartActors((system, registry) => - { - var myActor = system.ActorOf(Props.Create(() => new MyPersistenceActor("ac1")), "actor1"); - registry.Register(myActor); - }) - .WithActors((system, registry) => - { - var extSystem = (ExtendedActorSystem)system; - var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(_output)), "log-test"); - logger.Tell(new InitializeLogger(system.EventStream)); - });; - })); - - var actorSystem = host.Services.GetRequiredService(); - var actorRegistry = host.Services.GetRequiredService(); - var myPersistentActor = actorRegistry.Get(); + var myPersistentActor = ActorRegistry.Get(); // act var resp1 = await myPersistentActor.Ask(1, TimeSpan.FromSeconds(3)); @@ -111,13 +107,13 @@ public async Task Should_Start_ActorSystem_wth_InMemory_Persistence() // kill + recreate actor with same PersistentId await myPersistentActor.GracefulStop(TimeSpan.FromSeconds(3)); - var myPersistentActor2 = actorSystem.ActorOf(Props.Create(() => new MyPersistenceActor("ac1")), "actor1a"); + 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 = actorSystem.Settings.Config; + 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"); } From 3f89a3a4501242959ed4f5644437c45a93ec8ec3 Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Wed, 5 Oct 2022 00:18:52 +0700 Subject: [PATCH 46/46] Update RELEASE_NOTES.md for 0.5.0 release (#122) * Update RELEASE_NOTES.md for 0.5.0 release * Remove trailing whitespaces * Fix RELEASE_NOTES --- RELEASE_NOTES.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 10ef8446..88d91ca5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,101 @@ +## [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) + +**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)