From a66c6b1f8b9cb171e170fc4aadf92c1d51b69e88 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 30 Sep 2026 22:09:56 +0000 Subject: [PATCH 1/6] Serialization: module tables carry aliases and bindings (#8676, part 2) Replace ModuleSerializer with SerializerRegistration(Alias, Type, Create, Bindings) in all 8 first-party module tables (Remote, Streams, Cluster, Cluster.Tools, Cluster.Sharding, DistributedData, Cluster.Metrics, Persistence), filled in exactly from each module's reference.conf. The tables now hold everything HOCON holds for these modules; HOCON still drives Serialization - no behavior change. BoundTypes is gone; LoadedModule derives its bound-type index from the union of every registration's Bindings, with identical lookup behavior. Extend ModuleSerializerSpecs.AssertTableMatchesConfig with an overload that checks alias-to-type and binding-to-alias parity in both directions, and update all 8 module specs to use it. --- .../Serialization/MetricsSerializersSpec.cs | 2 +- .../Serialization/MetricsSerializers.cs | 21 +++-- .../Serialization/ShardingSerializersSpec.cs | 2 +- .../Serialization/ShardingSerializers.cs | 10 +-- .../Serialization/ToolsSerializersSpec.cs | 2 +- .../Serialization/ToolsSerializers.cs | 20 ++--- .../DistributedDataSerializersSpec.cs | 2 +- .../DistributedDataSerializers.cs | 14 ++-- .../Serialization/ClusterSerializersSpec.cs | 10 +-- .../Serialization/ClusterSerializers.cs | 15 ++-- .../PersistenceSerializersSpec.cs | 10 +-- .../Serialization/PersistenceSerializers.cs | 14 ++-- .../Serialization/RemoteSerializersSpec.cs | 12 ++- .../Serialization/RemoteSerializers.cs | 79 +++++++++---------- .../Serialization/StreamsSerializersSpec.cs | 10 +-- .../Serialization/StreamsSerializers.cs | 12 +-- .../ModuleSerializerSpecs.cs | 31 ++++++++ .../Serialization/ModuleSerializersSpec.cs | 18 ++--- .../Akka/Serialization/ModuleSerializers.cs | 29 ++++--- 19 files changed, 150 insertions(+), 163 deletions(-) diff --git a/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs index b52ed3579c1..e0250b20720 100644 --- a/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs @@ -50,7 +50,7 @@ public MetricsSerializersSpec(ITestOutputHelper output) : base(MetricsRows, outp public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new MetricsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(MetricsRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(MetricsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs b/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs index ebdcc1238f6..faf737c896d 100644 --- a/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs @@ -18,18 +18,17 @@ namespace Akka.Cluster.Metrics.Serialization internal sealed class MetricsSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; ClusterMetricsMessageSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(ClusterMetricsMessageSerializer), (system, _) => new ClusterMetricsMessageSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(MetricsGossipEnvelope), - typeof(AdaptiveLoadBalancingPool), - typeof(MixMetricsSelector), - typeof(CpuMetricsSelector), - typeof(MemoryMetricsSelector), + new SerializerRegistration("akka-cluster-metrics", typeof(ClusterMetricsMessageSerializer), (system, _) => new ClusterMetricsMessageSerializer(system), + new[] + { + typeof(MetricsGossipEnvelope), + typeof(AdaptiveLoadBalancingPool), + typeof(MixMetricsSelector), + typeof(CpuMetricsSelector), + typeof(MemoryMetricsSelector), + }), }; } } diff --git a/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs index 4013b6f33a7..2e4faa985ab 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs @@ -45,7 +45,7 @@ public ShardingSerializersSpec(ITestOutputHelper output) : base(ClusterSharding. public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new ShardingSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ShardingRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(ShardingRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs b/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs index 4f3415f4c09..829b0d56fb1 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs @@ -18,14 +18,10 @@ namespace Akka.Cluster.Sharding.Serialization internal sealed class ShardingSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; ClusterShardingMessageSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(ClusterShardingMessageSerializer), (system, _) => new ClusterShardingMessageSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(IClusterShardingSerializable), + new SerializerRegistration("akka-sharding", typeof(ClusterShardingMessageSerializer), (system, _) => new ClusterShardingMessageSerializer(system), + new[] { typeof(IClusterShardingSerializable) }), }; } } diff --git a/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs index 1b0e177b012..c5983aa4fc8 100644 --- a/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs @@ -51,7 +51,7 @@ public ToolsSerializersSpec(ITestOutputHelper output) : base(ToolsRows, output) public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new ToolsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ToolsRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(ToolsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs b/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs index 22cb4aa2cad..d77051645f5 100644 --- a/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs @@ -26,20 +26,14 @@ namespace Akka.Cluster.Tools internal sealed class ToolsSerializers : ModuleSerializers { // the constructor reflection picks for each feature's reference.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(ClusterClientMessageSerializer), (system, _) => new ClusterClientMessageSerializer(system)), - new ModuleSerializer(typeof(DistributedPubSubMessageSerializer), (system, _) => new DistributedPubSubMessageSerializer(system)), - new ModuleSerializer(typeof(ClusterSingletonMessageSerializer), (system, _) => new ClusterSingletonMessageSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(IClusterClientMessage), - typeof(IClusterClientProtocolMessage), - typeof(IDistributedPubSubMessage), - typeof(SendToOneSubscriber), - typeof(IClusterSingletonMessage), + new SerializerRegistration("akka-cluster-client", typeof(ClusterClientMessageSerializer), (system, _) => new ClusterClientMessageSerializer(system), + new[] { typeof(IClusterClientMessage), typeof(IClusterClientProtocolMessage) }), + new SerializerRegistration("akka-pubsub", typeof(DistributedPubSubMessageSerializer), (system, _) => new DistributedPubSubMessageSerializer(system), + new[] { typeof(IDistributedPubSubMessage), typeof(SendToOneSubscriber) }), + new SerializerRegistration("akka-singleton", typeof(ClusterSingletonMessageSerializer), (system, _) => new ClusterSingletonMessageSerializer(system), + new[] { typeof(IClusterSingletonMessage) }), }; } } diff --git a/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs b/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs index 6e4ea5ee978..dd521971bb9 100644 --- a/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs +++ b/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs @@ -43,7 +43,7 @@ public DistributedDataSerializersSpec(ITestOutputHelper output) : base(DDataRows public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new DistributedDataSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(DDataRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(DDataRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs b/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs index 936536322b1..e19af4ce3bc 100644 --- a/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs +++ b/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs @@ -18,16 +18,12 @@ namespace Akka.DistributedData.Serialization internal sealed class DistributedDataSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(ReplicatedDataSerializer), (system, _) => new ReplicatedDataSerializer(system)), - new ModuleSerializer(typeof(ReplicatorMessageSerializer), (system, _) => new ReplicatorMessageSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(IReplicatedDataSerialization), - typeof(IReplicatorMessage), + new SerializerRegistration("akka-replicated-data", typeof(ReplicatedDataSerializer), (system, _) => new ReplicatedDataSerializer(system), + new[] { typeof(IReplicatedDataSerialization) }), + new SerializerRegistration("akka-data-replication", typeof(ReplicatorMessageSerializer), (system, _) => new ReplicatorMessageSerializer(system), + new[] { typeof(IReplicatorMessage) }), }; } } diff --git a/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs b/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs index 570fa808ede..03f60b50651 100644 --- a/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs +++ b/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs @@ -76,7 +76,7 @@ private static AkkaSerialization Build(ActorSystem system, ModuleSerializerTable public void Should_match_reflection_When_resolving_every_Cluster_conf_row() { var table = new ClusterSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); // core's module map names Akka.Cluster, and ClusterSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Cluster").Should().NotBeNull(); @@ -84,12 +84,10 @@ public void Should_match_reflection_When_resolving_every_Cluster_conf_row() var reflected = Build(Sys, NoModules, dynamicTypeLoading: true); var fromTable = Build(Sys, ModuleSerializerTable.Default, dynamicTypeLoading: true); var settings = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); - var aliasByType = ModuleSerializerSpecs.SerializerRows(ClusterRows) - .ToDictionary(r => Type.GetType(r.TypeName, throwOnError: true)!, r => r.Alias); - foreach (var (type, create) in table.Serializers.Select(s => (s.Type, s.Create))) + foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) { - var built = create((ExtendedActorSystem)Sys, settings.GetConfig(aliasByType[type])); + var built = create((ExtendedActorSystem)Sys, settings.GetConfig(alias)); built.Should().BeOfType(reflected.GetSerializerById(built.Identifier).GetType(), type.Name); fromTable.GetSerializerById(built.Identifier).Should().BeOfType(type, type.Name); } @@ -102,7 +100,7 @@ public void Should_match_reflection_When_resolving_every_Cluster_conf_row() public void Should_have_a_Cluster_conf_row_When_the_table_lists_a_type() { var table = new ClusterSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve Cluster.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs b/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs index 846bfc40ed7..21733de3098 100644 --- a/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs +++ b/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs @@ -20,17 +20,12 @@ namespace Akka.Cluster.Serialization internal sealed class ClusterSerializers : ModuleSerializers { // the constructor reflection picks for Cluster.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(ClusterMessageSerializer), (system, _) => new ClusterMessageSerializer(system)), - new ModuleSerializer(typeof(ReliableDeliverySerializer), (system, _) => new ReliableDeliverySerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(IClusterMessage), - typeof(ClusterRouterPool), - typeof(IDeliverySerializable), + new SerializerRegistration("akka-cluster", typeof(ClusterMessageSerializer), (system, _) => new ClusterMessageSerializer(system), + new[] { typeof(IClusterMessage), typeof(ClusterRouterPool) }), + new SerializerRegistration("reliable-delivery", typeof(ReliableDeliverySerializer), (system, _) => new ReliableDeliverySerializer(system), + new[] { typeof(IDeliverySerializable) }), }; } } diff --git a/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs b/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs index 7234e281681..cb7a0ee2ae8 100644 --- a/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs +++ b/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs @@ -68,7 +68,7 @@ private static AkkaSerialization Build(ActorSystem system, ModuleSerializerTable public void Should_match_reflection_When_resolving_every_persistence_conf_row() { var table = new PersistenceSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); // core's module map names Akka.Persistence, and PersistenceSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Persistence").Should().NotBeNull(); @@ -76,12 +76,10 @@ public void Should_match_reflection_When_resolving_every_persistence_conf_row() var reflected = Build(Sys, NoModules, dynamicTypeLoading: true); var fromTable = Build(Sys, ModuleSerializerTable.Default, dynamicTypeLoading: true); var settings = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); - var aliasByType = ModuleSerializerSpecs.SerializerRows(PersistenceRows) - .ToDictionary(r => Type.GetType(r.TypeName, throwOnError: true)!, r => r.Alias); - foreach (var (type, create) in table.Serializers.Select(s => (s.Type, s.Create))) + foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) { - var built = create((ExtendedActorSystem)Sys, settings.GetConfig(aliasByType[type])); + var built = create((ExtendedActorSystem)Sys, settings.GetConfig(alias)); built.Should().BeOfType(reflected.GetSerializerById(built.Identifier).GetType(), type.Name); fromTable.GetSerializerById(built.Identifier).Should().BeOfType(type, type.Name); } @@ -94,7 +92,7 @@ public void Should_match_reflection_When_resolving_every_persistence_conf_row() public void Should_have_a_persistence_conf_row_When_the_table_lists_a_type() { var table = new PersistenceSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve persistence.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs b/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs index b6cfc8c4bf5..edf48ce3205 100644 --- a/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs +++ b/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs @@ -18,16 +18,12 @@ namespace Akka.Persistence.Serialization internal sealed class PersistenceSerializers : ModuleSerializers { // the constructor reflection picks for persistence.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(PersistenceMessageSerializer), (system, _) => new PersistenceMessageSerializer(system)), - new ModuleSerializer(typeof(PersistenceSnapshotSerializer), (system, _) => new PersistenceSnapshotSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(IMessage), - typeof(Snapshot), + new SerializerRegistration("akka-persistence-message", typeof(PersistenceMessageSerializer), (system, _) => new PersistenceMessageSerializer(system), + new[] { typeof(IMessage) }), + new SerializerRegistration("akka-persistence-snapshot", typeof(PersistenceSnapshotSerializer), (system, _) => new PersistenceSnapshotSerializer(system), + new[] { typeof(Snapshot) }), }; } } diff --git a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs index 260675cf830..7efd11b099e 100644 --- a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs +++ b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs @@ -72,7 +72,7 @@ private static AkkaSerialization Build(ActorSystem system, ModuleSerializerTable public void Should_match_reflection_When_resolving_every_Remote_conf_row() { var table = new RemoteSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); // core's module map names Akka.Remote, and RemoteSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Remote").Should().NotBeNull(); @@ -80,18 +80,16 @@ public void Should_match_reflection_When_resolving_every_Remote_conf_row() var reflected = Build(Sys, NoModules, dynamicTypeLoading: true); var fromTable = Build(Sys, ModuleSerializerTable.Default, dynamicTypeLoading: true); var settings = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); - var aliasByType = ModuleSerializerSpecs.SerializerRows(RemoteRows) - .ToDictionary(r => Type.GetType(r.TypeName, throwOnError: true)!, r => r.Alias); - foreach (var (type, create) in table.Serializers.Select(s => (s.Type, s.Create))) + foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) { // the factory builds what reflection builds, under the same id - var built = create((ExtendedActorSystem)Sys, settings.GetConfig(aliasByType[type])); + var built = create((ExtendedActorSystem)Sys, settings.GetConfig(alias)); built.Should().BeOfType(reflected.GetSerializerById(built.Identifier).GetType(), type.Name); fromTable.GetSerializerById(built.Identifier).Should().BeOfType(type, type.Name); } - foreach (var type in table.BoundTypes) + foreach (var type in table.Serializers.SelectMany(s => s.Bindings)) { var expected = reflected.FindSerializerForType(type); var actual = fromTable.FindSerializerForType(type); @@ -108,7 +106,7 @@ public void Should_match_reflection_When_resolving_every_Remote_conf_row() public void Should_have_a_Remote_conf_row_When_the_table_lists_a_type() { var table = new RemoteSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve Remote.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs index 09f158312e5..43bd8d3966c 100644 --- a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs +++ b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs @@ -24,48 +24,45 @@ namespace Akka.Remote.Serialization internal sealed class RemoteSerializers : ModuleSerializers { // the constructor reflection picks for Remote.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(MessageContainerSerializer), (system, _) => new MessageContainerSerializer(system)), - new ModuleSerializer(typeof(MiscMessageSerializer), (system, _) => new MiscMessageSerializer(system)), - new ModuleSerializer(typeof(PrimitiveSerializers), (system, config) => new PrimitiveSerializers(system, config)), - new ModuleSerializer(typeof(ProtobufSerializer), (system, _) => new ProtobufSerializer(system)), - new ModuleSerializer(typeof(DaemonMsgCreateSerializer), (system, _) => new DaemonMsgCreateSerializer(system)), - new ModuleSerializer(typeof(SystemMessageSerializer), (system, _) => new SystemMessageSerializer(system)), - new ModuleSerializer(typeof(ArteryControlMessageSerializer), (system, _) => new ArteryControlMessageSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(ActorSelectionMessage), - typeof(DaemonMsgCreate), - typeof(Google.Protobuf.IMessage), - typeof(Identify), - typeof(ActorIdentity), - typeof(IActorRef), - typeof(PoisonPill), - typeof(IntentionalRestart), - typeof(Kill), - typeof(Status.Failure), - typeof(Status.Success), - typeof(RemoteScope), - typeof(FromConfig), - typeof(DefaultResizer), - typeof(RoundRobinPool), - typeof(BroadcastPool), - typeof(RandomPool), - typeof(ScatterGatherFirstCompletedPool), - typeof(TailChoppingPool), - typeof(ConsistentHashingPool), - typeof(Config), - typeof(RemoteWatcher.Heartbeat), - typeof(RemoteWatcher.HeartbeatRsp), - typeof(IArteryControlMessage), - typeof(RemoteRouterConfig), - typeof(SystemMessage), - typeof(string), - typeof(int), - typeof(long), + new SerializerRegistration("akka-containers", typeof(MessageContainerSerializer), (system, _) => new MessageContainerSerializer(system), + new[] { typeof(ActorSelectionMessage) }), + new SerializerRegistration("akka-misc", typeof(MiscMessageSerializer), (system, _) => new MiscMessageSerializer(system), + new[] + { + typeof(Identify), + typeof(ActorIdentity), + typeof(IActorRef), + typeof(PoisonPill), + typeof(IntentionalRestart), + typeof(Kill), + typeof(Status.Failure), + typeof(Status.Success), + typeof(RemoteScope), + typeof(FromConfig), + typeof(DefaultResizer), + typeof(RoundRobinPool), + typeof(BroadcastPool), + typeof(RandomPool), + typeof(ScatterGatherFirstCompletedPool), + typeof(TailChoppingPool), + typeof(ConsistentHashingPool), + typeof(Config), + typeof(RemoteWatcher.Heartbeat), + typeof(RemoteWatcher.HeartbeatRsp), + typeof(RemoteRouterConfig), + }), + new SerializerRegistration("primitive", typeof(PrimitiveSerializers), (system, config) => new PrimitiveSerializers(system, config), + new[] { typeof(string), typeof(int), typeof(long) }), + new SerializerRegistration("proto", typeof(ProtobufSerializer), (system, _) => new ProtobufSerializer(system), + new[] { typeof(Google.Protobuf.IMessage) }), + new SerializerRegistration("daemon-create", typeof(DaemonMsgCreateSerializer), (system, _) => new DaemonMsgCreateSerializer(system), + new[] { typeof(DaemonMsgCreate) }), + new SerializerRegistration("akka-system-msg", typeof(SystemMessageSerializer), (system, _) => new SystemMessageSerializer(system), + new[] { typeof(SystemMessage) }), + new SerializerRegistration("artery-control", typeof(ArteryControlMessageSerializer), (system, _) => new ArteryControlMessageSerializer(system), + new[] { typeof(IArteryControlMessage) }), }; } } diff --git a/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs b/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs index ffd85a055c7..a51a0c673f7 100644 --- a/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs +++ b/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs @@ -72,7 +72,7 @@ public async Task Should_match_reflection_When_resolving_every_reference_conf_ro await ModuleSerializerSpecs.WithSystem("streams-parity", Config.Empty, StreamsRows, system => { var table = new StreamsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); // core's module map names Akka.Streams, and StreamsSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Streams").Should().NotBeNull(); @@ -80,12 +80,10 @@ await ModuleSerializerSpecs.WithSystem("streams-parity", Config.Empty, StreamsRo var reflected = Build(system, NoModules, dynamicTypeLoading: true); var fromTable = Build(system, ModuleSerializerTable.Default, dynamicTypeLoading: true); var settings = system.Settings.Config.GetConfig("akka.actor.serialization-settings"); - var aliasByType = ModuleSerializerSpecs.SerializerRows(StreamsRows) - .ToDictionary(r => Type.GetType(r.TypeName, throwOnError: true)!, r => r.Alias); - foreach (var (type, create) in table.Serializers.Select(s => (s.Type, s.Create))) + foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) { - var built = create((ExtendedActorSystem)system, settings.GetConfig(aliasByType[type])); + var built = create((ExtendedActorSystem)system, settings.GetConfig(alias)); built.Identifier.Should().Be(30); built.Should().BeOfType(reflected.GetSerializerById(30).GetType(), type.Name); fromTable.GetSerializerById(30).Should().BeOfType(type, type.Name); @@ -100,7 +98,7 @@ await ModuleSerializerSpecs.WithSystem("streams-parity", Config.Empty, StreamsRo public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new StreamsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Serializers.Select(s => s.Type), table.BoundTypes); + ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Streams/Serialization/StreamsSerializers.cs b/src/core/Akka.Streams/Serialization/StreamsSerializers.cs index 52ef1b31b93..3fff634a293 100644 --- a/src/core/Akka.Streams/Serialization/StreamsSerializers.cs +++ b/src/core/Akka.Streams/Serialization/StreamsSerializers.cs @@ -19,16 +19,10 @@ namespace Akka.Streams.Serialization internal sealed class StreamsSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; StreamRefSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(StreamRefSerializer), (system, _) => new StreamRefSerializer(system)), - }; - - public override IReadOnlyList BoundTypes { get; } = new[] - { - typeof(SinkRefImpl), - typeof(SourceRefImpl), - typeof(IStreamRefsProtocol), + new SerializerRegistration("akka-stream-ref", typeof(StreamRefSerializer), (system, _) => new StreamRefSerializer(system), + new[] { typeof(SinkRefImpl), typeof(SourceRefImpl), typeof(IStreamRefsProtocol) }), }; } } diff --git a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs index b24f561f135..e7e1e02be16 100644 --- a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs +++ b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs @@ -49,6 +49,37 @@ public static void AssertTableMatchesConfig(Config moduleConfig, IEnumerable + /// Asserts a module's table is a complete, alias-accurate mirror of its config: every registration's alias + /// names that registration's type in `akka.actor.serializers` (and vice versa - no extra alias), and every + /// `akka.actor.serialization-bindings` row matches exactly one registration whose Bindings contains + /// that row's type under that row's alias (and vice versa - no extra binding in the table). + /// + /// The module's own reference.conf (or the combined config of its files). + /// Each registration's alias, serializer type, and the types it binds. + public static void AssertTableMatchesConfig( + Config moduleConfig, IEnumerable<(string Alias, Type Type, IReadOnlyList Bindings)> registrations) + { + var table = registrations.ToList(); + + var configuredTypeByAlias = SerializerRows(moduleConfig) + .ToDictionary(r => r.Alias, r => Type.GetType(r.TypeName, throwOnError: true)!); + + // aliases match in both directions: every table alias is a config row for the same type, and every + // config row has a table entry + table.Select(r => (r.Alias, r.Type)).Should().BeEquivalentTo( + configuredTypeByAlias.Select(kv => (Alias: kv.Key, Type: kv.Value))); + + var configuredBindings = BindingRows(moduleConfig) + .Select(r => (Type: Type.GetType(r.TypeName, throwOnError: true)!, r.Alias)); + + var tableBindings = table.SelectMany(r => r.Bindings.Select(t => (Type: t, r.Alias))); + + // bindings match in both directions: every table binding is a config row under the same alias, and + // every config row is bound by exactly one registration under that alias + tableBindings.Should().BeEquivalentTo(configuredBindings); + } + /// Builds a over the default module table, holding the switch at for the call. public static Serialization BuildDefault(ActorSystem system, bool dynamicTypeLoading) { diff --git a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs index 9b0f143ed19..901cb062c17 100644 --- a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs +++ b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs @@ -67,14 +67,12 @@ public SetupSerializer(ExtendedActorSystem system) : base(system) private sealed class FakeModule : ModuleSerializers { - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new ModuleSerializer(typeof(FakeSerializer), - (system, config) => config.IsNullOrEmpty() ? new FakeSerializer(system) : new FakeSerializer(system, config)) + new SerializerRegistration("fake-module", typeof(FakeSerializer), + (system, config) => config.IsNullOrEmpty() ? new FakeSerializer(system) : new FakeSerializer(system, config), + new[] { typeof(ModuleMessage), typeof(string), typeof(Identify), typeof(PoisonPill) }) }; - - public override IReadOnlyList BoundTypes { get; } = - new[] { typeof(ModuleMessage), typeof(string), typeof(Identify), typeof(PoisonPill) }; } /// Stands in for a module built against a different Akka: its table's constructor hits a missing member. @@ -82,9 +80,7 @@ internal sealed class SkewedModule : ModuleSerializers { public SkewedModule() => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); - public override IReadOnlyList Serializers => throw new NotSupportedException(); - - public override IReadOnlyList BoundTypes => throw new NotSupportedException(); + public override IReadOnlyList Serializers => throw new NotSupportedException(); } /// The same skew hit in a static initializer, which arrives wrapped in TypeInitializationException. @@ -92,9 +88,7 @@ internal sealed class StaticSkewedModule : ModuleSerializers { static StaticSkewedModule() => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); - public override IReadOnlyList Serializers => throw new NotSupportedException(); - - public override IReadOnlyList BoundTypes => throw new NotSupportedException(); + public override IReadOnlyList Serializers => throw new NotSupportedException(); } private const string ModuleMessageName = "Akka.Tests.Serialization.ModuleSerializersSpec+ModuleMessage"; diff --git a/src/core/Akka/Serialization/ModuleSerializers.cs b/src/core/Akka/Serialization/ModuleSerializers.cs index 2217cf77cf3..4b5cba74165 100644 --- a/src/core/Akka/Serialization/ModuleSerializers.cs +++ b/src/core/Akka/Serialization/ModuleSerializers.cs @@ -18,21 +18,22 @@ namespace Akka.Serialization { /// - /// INTERNAL API. A serializer type a module's reference.conf names, and its factory. The factory calls the constructor - /// reflection picks for the module's shipped config; a serializer with one constructor always gets that one. - /// Return a serializer; null skips the alias (a safety net, not a feature). + /// INTERNAL API. A serializer row a module's reference.conf names: its alias, its type and factory, and the + /// types its serialization-bindings rows bind to that alias (empty when the alias has none, e.g. a + /// V2 serializer that is registered for reads only). The factory calls the constructor reflection picks for + /// the module's shipped config; a serializer with one constructor always gets that one. Return a serializer; + /// null skips the alias (a safety net, not a feature). /// - internal sealed record ModuleSerializer(Type Type, Func Create); + internal sealed record SerializerRegistration( + string Alias, Type Type, Func Create, IReadOnlyList Bindings); /// - /// INTERNAL API. The serializer and bound types a first-party module's reference.conf names, so - /// can resolve those rows without . + /// INTERNAL API. The serializer rows a first-party module's reference.conf names, so + /// can resolve those rows without . /// internal abstract class ModuleSerializers { - public abstract IReadOnlyList Serializers { get; } - - public abstract IReadOnlyList BoundTypes { get; } + public abstract IReadOnlyList Serializers { get; } } /// @@ -40,18 +41,20 @@ internal abstract class ModuleSerializers /// internal sealed class LoadedModule { - private readonly Dictionary _serializers = new(StringComparer.Ordinal); + private readonly Dictionary _serializers = new(StringComparer.Ordinal); private readonly Dictionary _boundTypes = new(StringComparer.Ordinal); internal LoadedModule(ModuleSerializers module) { foreach (var entry in module.Serializers) + { _serializers[KeyOf(entry.Type)] = (entry, entry.Type.Assembly.GetName().Name, IsAkka(entry.Type)); - foreach (var type in module.BoundTypes) - _boundTypes[KeyOf(type)] = (type, type.Assembly.GetName().Name, IsAkka(type)); + foreach (var type in entry.Bindings) + _boundTypes[KeyOf(type)] = (type, type.Assembly.GetName().Name, IsAkka(type)); + } } - internal ModuleSerializer? FindSerializer(string name, string? assembly) + internal SerializerRegistration? FindSerializer(string name, string? assembly) => _serializers.TryGetValue(name, out var s) && Accepts(s.Assembly, s.IsAkka, assembly) ? s.Entry : null; internal Type? FindBoundType(string name, string? assembly) From ecb07c563c775f123a84ce2052ba70f8a9561a09 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 30 Sep 2026 22:22:31 +0000 Subject: [PATCH 2/6] Remove the unused table-parity overload; fix a stale comment --- .../ModuleSerializerSpecs.cs | 13 ------------- src/core/Akka/Serialization/ModuleSerializers.cs | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs index e7e1e02be16..ead71a7f282 100644 --- a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs +++ b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs @@ -36,19 +36,6 @@ public static class ModuleSerializerSpecs public static IEnumerable<(string TypeName, string Alias)> BindingRows(Config moduleConfig) => moduleConfig.GetConfig("akka.actor.serialization-bindings").AsEnumerable().Select(kv => (kv.Key, kv.Value.GetString())); - /// - /// Asserts a module's table names exactly the types its config rows name, in both directions: every row - /// resolves to a type the table has, and the table has no type without a row. - /// - public static void AssertTableMatchesConfig(Config moduleConfig, IEnumerable serializerTypes, IEnumerable boundTypes) - { - var configuredSerializerTypes = SerializerRows(moduleConfig).Select(r => Type.GetType(r.TypeName, throwOnError: true)); - var configuredBoundTypes = BindingRows(moduleConfig).Select(r => Type.GetType(r.TypeName, throwOnError: true)); - - serializerTypes.Should().BeEquivalentTo(configuredSerializerTypes); - boundTypes.Should().BeEquivalentTo(configuredBoundTypes); - } - /// /// Asserts a module's table is a complete, alias-accurate mirror of its config: every registration's alias /// names that registration's type in `akka.actor.serializers` (and vice versa - no extra alias), and every diff --git a/src/core/Akka/Serialization/ModuleSerializers.cs b/src/core/Akka/Serialization/ModuleSerializers.cs index 4b5cba74165..db7d2b1ade7 100644 --- a/src/core/Akka/Serialization/ModuleSerializers.cs +++ b/src/core/Akka/Serialization/ModuleSerializers.cs @@ -118,7 +118,7 @@ internal ModuleSerializerTable(IDictionary> mod { try { - // reads both lists here, so a member missing from either also lands in the catch + // reads the list here, so a missing member also lands in the catch return load() is { } module ? new LoadedModule(module) : null; } catch (Exception e) when (IsVersionSkew(e)) From fa58ac0d2bf4d3a4b4d594e87680526a277d2155 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 30 Sep 2026 22:56:14 +0000 Subject: [PATCH 3/6] Rename the table row type to BuiltInSerializer Akka.Serialization.V2 already exposes a public SerializerRegistration. Any assembly that sees Akka's internals and imports both namespaces hit CS0104, which broke the Akka.Benchmarks build. --- .../Serialization/MetricsSerializers.cs | 4 ++-- .../Serialization/ShardingSerializers.cs | 4 ++-- .../Serialization/ToolsSerializers.cs | 8 ++++---- .../Serialization/DistributedDataSerializers.cs | 6 +++--- .../Serialization/ClusterSerializers.cs | 6 +++--- .../Serialization/PersistenceSerializers.cs | 6 +++--- .../Serialization/RemoteSerializers.cs | 16 ++++++++-------- .../Serialization/StreamsSerializers.cs | 4 ++-- .../Serialization/ModuleSerializersSpec.cs | 8 ++++---- src/core/Akka/Serialization/ModuleSerializers.cs | 8 ++++---- 10 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs b/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs index faf737c896d..2faa03814b7 100644 --- a/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs @@ -18,9 +18,9 @@ namespace Akka.Cluster.Metrics.Serialization internal sealed class MetricsSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; ClusterMetricsMessageSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-cluster-metrics", typeof(ClusterMetricsMessageSerializer), (system, _) => new ClusterMetricsMessageSerializer(system), + new BuiltInSerializer("akka-cluster-metrics", typeof(ClusterMetricsMessageSerializer), (system, _) => new ClusterMetricsMessageSerializer(system), new[] { typeof(MetricsGossipEnvelope), diff --git a/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs b/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs index 829b0d56fb1..1518be8cfbd 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs @@ -18,9 +18,9 @@ namespace Akka.Cluster.Sharding.Serialization internal sealed class ShardingSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; ClusterShardingMessageSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-sharding", typeof(ClusterShardingMessageSerializer), (system, _) => new ClusterShardingMessageSerializer(system), + new BuiltInSerializer("akka-sharding", typeof(ClusterShardingMessageSerializer), (system, _) => new ClusterShardingMessageSerializer(system), new[] { typeof(IClusterShardingSerializable) }), }; } diff --git a/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs b/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs index d77051645f5..4c2ebf765b8 100644 --- a/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs @@ -26,13 +26,13 @@ namespace Akka.Cluster.Tools internal sealed class ToolsSerializers : ModuleSerializers { // the constructor reflection picks for each feature's reference.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-cluster-client", typeof(ClusterClientMessageSerializer), (system, _) => new ClusterClientMessageSerializer(system), + new BuiltInSerializer("akka-cluster-client", typeof(ClusterClientMessageSerializer), (system, _) => new ClusterClientMessageSerializer(system), new[] { typeof(IClusterClientMessage), typeof(IClusterClientProtocolMessage) }), - new SerializerRegistration("akka-pubsub", typeof(DistributedPubSubMessageSerializer), (system, _) => new DistributedPubSubMessageSerializer(system), + new BuiltInSerializer("akka-pubsub", typeof(DistributedPubSubMessageSerializer), (system, _) => new DistributedPubSubMessageSerializer(system), new[] { typeof(IDistributedPubSubMessage), typeof(SendToOneSubscriber) }), - new SerializerRegistration("akka-singleton", typeof(ClusterSingletonMessageSerializer), (system, _) => new ClusterSingletonMessageSerializer(system), + new BuiltInSerializer("akka-singleton", typeof(ClusterSingletonMessageSerializer), (system, _) => new ClusterSingletonMessageSerializer(system), new[] { typeof(IClusterSingletonMessage) }), }; } diff --git a/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs b/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs index e19af4ce3bc..748e9efaa65 100644 --- a/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs +++ b/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs @@ -18,11 +18,11 @@ namespace Akka.DistributedData.Serialization internal sealed class DistributedDataSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-replicated-data", typeof(ReplicatedDataSerializer), (system, _) => new ReplicatedDataSerializer(system), + new BuiltInSerializer("akka-replicated-data", typeof(ReplicatedDataSerializer), (system, _) => new ReplicatedDataSerializer(system), new[] { typeof(IReplicatedDataSerialization) }), - new SerializerRegistration("akka-data-replication", typeof(ReplicatorMessageSerializer), (system, _) => new ReplicatorMessageSerializer(system), + new BuiltInSerializer("akka-data-replication", typeof(ReplicatorMessageSerializer), (system, _) => new ReplicatorMessageSerializer(system), new[] { typeof(IReplicatorMessage) }), }; } diff --git a/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs b/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs index 21733de3098..2dd9f916c7e 100644 --- a/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs +++ b/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs @@ -20,11 +20,11 @@ namespace Akka.Cluster.Serialization internal sealed class ClusterSerializers : ModuleSerializers { // the constructor reflection picks for Cluster.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-cluster", typeof(ClusterMessageSerializer), (system, _) => new ClusterMessageSerializer(system), + new BuiltInSerializer("akka-cluster", typeof(ClusterMessageSerializer), (system, _) => new ClusterMessageSerializer(system), new[] { typeof(IClusterMessage), typeof(ClusterRouterPool) }), - new SerializerRegistration("reliable-delivery", typeof(ReliableDeliverySerializer), (system, _) => new ReliableDeliverySerializer(system), + new BuiltInSerializer("reliable-delivery", typeof(ReliableDeliverySerializer), (system, _) => new ReliableDeliverySerializer(system), new[] { typeof(IDeliverySerializable) }), }; } diff --git a/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs b/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs index edf48ce3205..29d50da1ce4 100644 --- a/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs +++ b/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs @@ -18,11 +18,11 @@ namespace Akka.Persistence.Serialization internal sealed class PersistenceSerializers : ModuleSerializers { // the constructor reflection picks for persistence.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-persistence-message", typeof(PersistenceMessageSerializer), (system, _) => new PersistenceMessageSerializer(system), + new BuiltInSerializer("akka-persistence-message", typeof(PersistenceMessageSerializer), (system, _) => new PersistenceMessageSerializer(system), new[] { typeof(IMessage) }), - new SerializerRegistration("akka-persistence-snapshot", typeof(PersistenceSnapshotSerializer), (system, _) => new PersistenceSnapshotSerializer(system), + new BuiltInSerializer("akka-persistence-snapshot", typeof(PersistenceSnapshotSerializer), (system, _) => new PersistenceSnapshotSerializer(system), new[] { typeof(Snapshot) }), }; } diff --git a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs index 43bd8d3966c..4034842cc80 100644 --- a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs +++ b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs @@ -24,11 +24,11 @@ namespace Akka.Remote.Serialization internal sealed class RemoteSerializers : ModuleSerializers { // the constructor reflection picks for Remote.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-containers", typeof(MessageContainerSerializer), (system, _) => new MessageContainerSerializer(system), + new BuiltInSerializer("akka-containers", typeof(MessageContainerSerializer), (system, _) => new MessageContainerSerializer(system), new[] { typeof(ActorSelectionMessage) }), - new SerializerRegistration("akka-misc", typeof(MiscMessageSerializer), (system, _) => new MiscMessageSerializer(system), + new BuiltInSerializer("akka-misc", typeof(MiscMessageSerializer), (system, _) => new MiscMessageSerializer(system), new[] { typeof(Identify), @@ -53,15 +53,15 @@ internal sealed class RemoteSerializers : ModuleSerializers typeof(RemoteWatcher.HeartbeatRsp), typeof(RemoteRouterConfig), }), - new SerializerRegistration("primitive", typeof(PrimitiveSerializers), (system, config) => new PrimitiveSerializers(system, config), + new BuiltInSerializer("primitive", typeof(PrimitiveSerializers), (system, config) => new PrimitiveSerializers(system, config), new[] { typeof(string), typeof(int), typeof(long) }), - new SerializerRegistration("proto", typeof(ProtobufSerializer), (system, _) => new ProtobufSerializer(system), + new BuiltInSerializer("proto", typeof(ProtobufSerializer), (system, _) => new ProtobufSerializer(system), new[] { typeof(Google.Protobuf.IMessage) }), - new SerializerRegistration("daemon-create", typeof(DaemonMsgCreateSerializer), (system, _) => new DaemonMsgCreateSerializer(system), + new BuiltInSerializer("daemon-create", typeof(DaemonMsgCreateSerializer), (system, _) => new DaemonMsgCreateSerializer(system), new[] { typeof(DaemonMsgCreate) }), - new SerializerRegistration("akka-system-msg", typeof(SystemMessageSerializer), (system, _) => new SystemMessageSerializer(system), + new BuiltInSerializer("akka-system-msg", typeof(SystemMessageSerializer), (system, _) => new SystemMessageSerializer(system), new[] { typeof(SystemMessage) }), - new SerializerRegistration("artery-control", typeof(ArteryControlMessageSerializer), (system, _) => new ArteryControlMessageSerializer(system), + new BuiltInSerializer("artery-control", typeof(ArteryControlMessageSerializer), (system, _) => new ArteryControlMessageSerializer(system), new[] { typeof(IArteryControlMessage) }), }; } diff --git a/src/core/Akka.Streams/Serialization/StreamsSerializers.cs b/src/core/Akka.Streams/Serialization/StreamsSerializers.cs index 3fff634a293..c6da1160d15 100644 --- a/src/core/Akka.Streams/Serialization/StreamsSerializers.cs +++ b/src/core/Akka.Streams/Serialization/StreamsSerializers.cs @@ -19,9 +19,9 @@ namespace Akka.Streams.Serialization internal sealed class StreamsSerializers : ModuleSerializers { // the constructor reflection picks for reference.conf; StreamRefSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("akka-stream-ref", typeof(StreamRefSerializer), (system, _) => new StreamRefSerializer(system), + new BuiltInSerializer("akka-stream-ref", typeof(StreamRefSerializer), (system, _) => new StreamRefSerializer(system), new[] { typeof(SinkRefImpl), typeof(SourceRefImpl), typeof(IStreamRefsProtocol) }), }; } diff --git a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs index 901cb062c17..1d0078bb80a 100644 --- a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs +++ b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs @@ -67,9 +67,9 @@ public SetupSerializer(ExtendedActorSystem system) : base(system) private sealed class FakeModule : ModuleSerializers { - public override IReadOnlyList Serializers { get; } = new[] + public override IReadOnlyList Serializers { get; } = new[] { - new SerializerRegistration("fake-module", typeof(FakeSerializer), + new BuiltInSerializer("fake-module", typeof(FakeSerializer), (system, config) => config.IsNullOrEmpty() ? new FakeSerializer(system) : new FakeSerializer(system, config), new[] { typeof(ModuleMessage), typeof(string), typeof(Identify), typeof(PoisonPill) }) }; @@ -80,7 +80,7 @@ internal sealed class SkewedModule : ModuleSerializers { public SkewedModule() => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); - public override IReadOnlyList Serializers => throw new NotSupportedException(); + public override IReadOnlyList Serializers => throw new NotSupportedException(); } /// The same skew hit in a static initializer, which arrives wrapped in TypeInitializationException. @@ -88,7 +88,7 @@ internal sealed class StaticSkewedModule : ModuleSerializers { static StaticSkewedModule() => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); - public override IReadOnlyList Serializers => throw new NotSupportedException(); + public override IReadOnlyList Serializers => throw new NotSupportedException(); } private const string ModuleMessageName = "Akka.Tests.Serialization.ModuleSerializersSpec+ModuleMessage"; diff --git a/src/core/Akka/Serialization/ModuleSerializers.cs b/src/core/Akka/Serialization/ModuleSerializers.cs index db7d2b1ade7..2e1c5b81ed7 100644 --- a/src/core/Akka/Serialization/ModuleSerializers.cs +++ b/src/core/Akka/Serialization/ModuleSerializers.cs @@ -24,7 +24,7 @@ namespace Akka.Serialization /// the module's shipped config; a serializer with one constructor always gets that one. Return a serializer; /// null skips the alias (a safety net, not a feature). /// - internal sealed record SerializerRegistration( + internal sealed record BuiltInSerializer( string Alias, Type Type, Func Create, IReadOnlyList Bindings); /// @@ -33,7 +33,7 @@ internal sealed record SerializerRegistration( /// internal abstract class ModuleSerializers { - public abstract IReadOnlyList Serializers { get; } + public abstract IReadOnlyList Serializers { get; } } /// @@ -41,7 +41,7 @@ internal abstract class ModuleSerializers /// internal sealed class LoadedModule { - private readonly Dictionary _serializers = new(StringComparer.Ordinal); + private readonly Dictionary _serializers = new(StringComparer.Ordinal); private readonly Dictionary _boundTypes = new(StringComparer.Ordinal); internal LoadedModule(ModuleSerializers module) @@ -54,7 +54,7 @@ internal LoadedModule(ModuleSerializers module) } } - internal SerializerRegistration? FindSerializer(string name, string? assembly) + internal BuiltInSerializer? FindSerializer(string name, string? assembly) => _serializers.TryGetValue(name, out var s) && Accepts(s.Assembly, s.IsAkka, assembly) ? s.Entry : null; internal Type? FindBoundType(string name, string? assembly) From e95eebf342975aa4fd5af4b006d03f8be6c1a578 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 1 Oct 2026 00:51:17 +0000 Subject: [PATCH 4/6] Rework module serializer tables to reuse SerializerDetails Delete the bespoke BuiltInSerializer record and have each module's ModuleSerializers.Create(system) build an ImmutableHashSet directly, the same shape SerializationSetup already uses. Serialization now builds each module's serializers at most once per instance (a new ModuleResolver), indexing by the built serializer's own type instead of a separate type+factory pair. PrimitiveSerializers is the only table that reads a settings block, and it now reads akka.actor.serialization-settings.primitive itself instead of being fed a per-alias Config. ModuleSerializerTable keeps caching the loaded ModuleSerializers object process-wide; building its serializers needs the ActorSystem, so that part is cached per Serialization instance instead. A missing member thrown from Create is caught the same way a missing member from the module's constructor already was, so the module still counts as absent under version skew. Update every module's table (Remote, Streams, Cluster, Cluster.Tools, Cluster.Sharding, DistributedData, Cluster.Metrics, Persistence), the shared parity helper, and the module and loader test specs to match. --- .../Serialization/MetricsSerializersSpec.cs | 4 +- .../Serialization/MetricsSerializers.cs | 18 +-- .../Serialization/ShardingSerializersSpec.cs | 4 +- .../Serialization/ShardingSerializers.cs | 14 +- .../Serialization/ToolsSerializersSpec.cs | 4 +- .../Serialization/ToolsSerializers.cs | 22 ++- .../DistributedDataSerializersSpec.cs | 4 +- .../DistributedDataSerializers.cs | 18 +-- .../Serialization/ClusterSerializersSpec.cs | 14 +- .../Serialization/ClusterSerializers.cs | 18 +-- .../PersistenceSerializersSpec.cs | 14 +- .../Serialization/PersistenceSerializers.cs | 18 +-- .../Serialization/RemoteSerializersSpec.cs | 19 ++- .../Serialization/RemoteSerializers.cs | 43 +++--- .../Serialization/StreamsSerializersSpec.cs | 16 +- .../Serialization/StreamsSerializers.cs | 14 +- .../ModuleSerializerSpecs.cs | 17 +- .../Serialization/ModuleSerializersSpec.cs | 27 +++- .../Akka/Serialization/ModuleSerializers.cs | 145 ++++++++++++++---- src/core/Akka/Serialization/Serialization.cs | 80 +++------- 20 files changed, 270 insertions(+), 243 deletions(-) diff --git a/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs index e0250b20720..1298f8d0e73 100644 --- a/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs @@ -7,8 +7,8 @@ #nullable enable using System; -using System.Linq; using System.Threading.Tasks; +using Akka.Actor; using Akka.Cluster.Metrics.Serialization; using Akka.Configuration; using Akka.Serialization; @@ -50,7 +50,7 @@ public MetricsSerializersSpec(ITestOutputHelper output) : base(MetricsRows, outp public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new MetricsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(MetricsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(MetricsRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs b/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs index 2faa03814b7..128eb830436 100644 --- a/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Metrics/Serialization/MetricsSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Serialization; namespace Akka.Cluster.Metrics.Serialization @@ -17,18 +17,14 @@ namespace Akka.Cluster.Metrics.Serialization /// internal sealed class MetricsSerializers : ModuleSerializers { - // the constructor reflection picks for reference.conf; ClusterMetricsMessageSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-cluster-metrics", typeof(ClusterMetricsMessageSerializer), (system, _) => new ClusterMetricsMessageSerializer(system), - new[] - { + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-cluster-metrics", new ClusterMetricsMessageSerializer(system), + ImmutableHashSet.Create( typeof(MetricsGossipEnvelope), typeof(AdaptiveLoadBalancingPool), typeof(MixMetricsSelector), typeof(CpuMetricsSelector), - typeof(MemoryMetricsSelector), - }), - }; + typeof(MemoryMetricsSelector))) + ); } } diff --git a/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs index 2e4faa985ab..64ac3a02610 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System.Linq; using System.Threading.Tasks; +using Akka.Actor; using Akka.Cluster.Sharding.Serialization; using Akka.Configuration; using Akka.Serialization; @@ -45,7 +45,7 @@ public ShardingSerializersSpec(ITestOutputHelper output) : base(ClusterSharding. public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new ShardingSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ShardingRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(ShardingRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs b/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs index 1518be8cfbd..f002517d56c 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding/Serialization/ShardingSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Serialization; namespace Akka.Cluster.Sharding.Serialization @@ -17,11 +17,9 @@ namespace Akka.Cluster.Sharding.Serialization /// internal sealed class ShardingSerializers : ModuleSerializers { - // the constructor reflection picks for reference.conf; ClusterShardingMessageSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-sharding", typeof(ClusterShardingMessageSerializer), (system, _) => new ClusterShardingMessageSerializer(system), - new[] { typeof(IClusterShardingSerializable) }), - }; + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-sharding", new ClusterShardingMessageSerializer(system), + ImmutableHashSet.Create(typeof(IClusterShardingSerializable))) + ); } } diff --git a/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs index c5983aa4fc8..84e189e77e7 100644 --- a/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System.Linq; using System.Threading.Tasks; +using Akka.Actor; using Akka.Cluster.Tools.Client; using Akka.Cluster.Tools.Client.Serialization; using Akka.Cluster.Tools.PublishSubscribe; @@ -51,7 +51,7 @@ public ToolsSerializersSpec(ITestOutputHelper output) : base(ToolsRows, output) public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new ToolsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ToolsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(ToolsRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs b/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs index 4c2ebf765b8..18ad60f549d 100644 --- a/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs +++ b/src/contrib/cluster/Akka.Cluster.Tools/Serialization/ToolsSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Cluster.Tools.Client; using Akka.Cluster.Tools.Client.Serialization; using Akka.Cluster.Tools.PublishSubscribe; @@ -25,15 +25,13 @@ namespace Akka.Cluster.Tools /// internal sealed class ToolsSerializers : ModuleSerializers { - // the constructor reflection picks for each feature's reference.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-cluster-client", typeof(ClusterClientMessageSerializer), (system, _) => new ClusterClientMessageSerializer(system), - new[] { typeof(IClusterClientMessage), typeof(IClusterClientProtocolMessage) }), - new BuiltInSerializer("akka-pubsub", typeof(DistributedPubSubMessageSerializer), (system, _) => new DistributedPubSubMessageSerializer(system), - new[] { typeof(IDistributedPubSubMessage), typeof(SendToOneSubscriber) }), - new BuiltInSerializer("akka-singleton", typeof(ClusterSingletonMessageSerializer), (system, _) => new ClusterSingletonMessageSerializer(system), - new[] { typeof(IClusterSingletonMessage) }), - }; + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-cluster-client", new ClusterClientMessageSerializer(system), + ImmutableHashSet.Create(typeof(IClusterClientMessage), typeof(IClusterClientProtocolMessage))), + SerializerDetails.Create("akka-pubsub", new DistributedPubSubMessageSerializer(system), + ImmutableHashSet.Create(typeof(IDistributedPubSubMessage), typeof(SendToOneSubscriber))), + SerializerDetails.Create("akka-singleton", new ClusterSingletonMessageSerializer(system), + ImmutableHashSet.Create(typeof(IClusterSingletonMessage))) + ); } } diff --git a/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs b/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs index dd521971bb9..a35f599842e 100644 --- a/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs +++ b/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System.Linq; using System.Threading.Tasks; +using Akka.Actor; using Akka.Configuration; using Akka.DistributedData.Serialization; using Akka.Serialization; @@ -43,7 +43,7 @@ public DistributedDataSerializersSpec(ITestOutputHelper output) : base(DDataRows public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new DistributedDataSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(DDataRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(DDataRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs b/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs index 748e9efaa65..97b5fe0e1bd 100644 --- a/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs +++ b/src/contrib/cluster/Akka.DistributedData/Serialization/DistributedDataSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Serialization; namespace Akka.DistributedData.Serialization @@ -17,13 +17,11 @@ namespace Akka.DistributedData.Serialization /// internal sealed class DistributedDataSerializers : ModuleSerializers { - // the constructor reflection picks for reference.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-replicated-data", typeof(ReplicatedDataSerializer), (system, _) => new ReplicatedDataSerializer(system), - new[] { typeof(IReplicatedDataSerialization) }), - new BuiltInSerializer("akka-data-replication", typeof(ReplicatorMessageSerializer), (system, _) => new ReplicatorMessageSerializer(system), - new[] { typeof(IReplicatorMessage) }), - }; + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-replicated-data", new ReplicatedDataSerializer(system), + ImmutableHashSet.Create(typeof(IReplicatedDataSerialization))), + SerializerDetails.Create("akka-data-replication", new ReplicatorMessageSerializer(system), + ImmutableHashSet.Create(typeof(IReplicatorMessage))) + ); } } diff --git a/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs b/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs index 03f60b50651..8897e5a4171 100644 --- a/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs +++ b/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs @@ -8,7 +8,6 @@ #nullable enable using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Akka.Actor; using Akka.Cluster.Configuration; @@ -76,20 +75,19 @@ private static AkkaSerialization Build(ActorSystem system, ModuleSerializerTable public void Should_match_reflection_When_resolving_every_Cluster_conf_row() { var table = new ClusterSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + var details = table.Create((ExtendedActorSystem)Sys); + ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, details); // core's module map names Akka.Cluster, and ClusterSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Cluster").Should().NotBeNull(); var reflected = Build(Sys, NoModules, dynamicTypeLoading: true); var fromTable = Build(Sys, ModuleSerializerTable.Default, dynamicTypeLoading: true); - var settings = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); - foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) + foreach (var entry in details) { - var built = create((ExtendedActorSystem)Sys, settings.GetConfig(alias)); - built.Should().BeOfType(reflected.GetSerializerById(built.Identifier).GetType(), type.Name); - fromTable.GetSerializerById(built.Identifier).Should().BeOfType(type, type.Name); + entry.Serializer.Should().BeOfType(reflected.GetSerializerById(entry.Serializer.Identifier).GetType(), entry.Alias); + fromTable.GetSerializerById(entry.Serializer.Identifier).Should().BeOfType(entry.Serializer.GetType(), entry.Alias); } foreach (var type in BoundSamples) @@ -100,7 +98,7 @@ public void Should_match_reflection_When_resolving_every_Cluster_conf_row() public void Should_have_a_Cluster_conf_row_When_the_table_lists_a_type() { var table = new ClusterSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve Cluster.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs b/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs index 2dd9f916c7e..523ffc3f2cb 100644 --- a/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs +++ b/src/core/Akka.Cluster/Serialization/ClusterSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Cluster.Routing; using Akka.Delivery.Internal; using Akka.Serialization; @@ -19,13 +19,11 @@ namespace Akka.Cluster.Serialization /// internal sealed class ClusterSerializers : ModuleSerializers { - // the constructor reflection picks for Cluster.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-cluster", typeof(ClusterMessageSerializer), (system, _) => new ClusterMessageSerializer(system), - new[] { typeof(IClusterMessage), typeof(ClusterRouterPool) }), - new BuiltInSerializer("reliable-delivery", typeof(ReliableDeliverySerializer), (system, _) => new ReliableDeliverySerializer(system), - new[] { typeof(IDeliverySerializable) }), - }; + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-cluster", new ClusterMessageSerializer(system), + ImmutableHashSet.Create(typeof(IClusterMessage), typeof(ClusterRouterPool))), + SerializerDetails.Create("reliable-delivery", new ReliableDeliverySerializer(system), + ImmutableHashSet.Create(typeof(IDeliverySerializable))) + ); } } diff --git a/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs b/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs index cb7a0ee2ae8..80b22ee6f6c 100644 --- a/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs +++ b/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs @@ -8,7 +8,6 @@ #nullable enable using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Akka.Actor; using Akka.Configuration; @@ -68,20 +67,19 @@ private static AkkaSerialization Build(ActorSystem system, ModuleSerializerTable public void Should_match_reflection_When_resolving_every_persistence_conf_row() { var table = new PersistenceSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + var details = table.Create((ExtendedActorSystem)Sys); + ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, details); // core's module map names Akka.Persistence, and PersistenceSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Persistence").Should().NotBeNull(); var reflected = Build(Sys, NoModules, dynamicTypeLoading: true); var fromTable = Build(Sys, ModuleSerializerTable.Default, dynamicTypeLoading: true); - var settings = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); - foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) + foreach (var entry in details) { - var built = create((ExtendedActorSystem)Sys, settings.GetConfig(alias)); - built.Should().BeOfType(reflected.GetSerializerById(built.Identifier).GetType(), type.Name); - fromTable.GetSerializerById(built.Identifier).Should().BeOfType(type, type.Name); + entry.Serializer.Should().BeOfType(reflected.GetSerializerById(entry.Serializer.Identifier).GetType(), entry.Alias); + fromTable.GetSerializerById(entry.Serializer.Identifier).Should().BeOfType(entry.Serializer.GetType(), entry.Alias); } foreach (var type in BoundSamples) @@ -92,7 +90,7 @@ public void Should_match_reflection_When_resolving_every_persistence_conf_row() public void Should_have_a_persistence_conf_row_When_the_table_lists_a_type() { var table = new PersistenceSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve persistence.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs b/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs index 29d50da1ce4..7d310fb443e 100644 --- a/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs +++ b/src/core/Akka.Persistence/Serialization/PersistenceSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Serialization; namespace Akka.Persistence.Serialization @@ -17,13 +17,11 @@ namespace Akka.Persistence.Serialization /// internal sealed class PersistenceSerializers : ModuleSerializers { - // the constructor reflection picks for persistence.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-persistence-message", typeof(PersistenceMessageSerializer), (system, _) => new PersistenceMessageSerializer(system), - new[] { typeof(IMessage) }), - new BuiltInSerializer("akka-persistence-snapshot", typeof(PersistenceSnapshotSerializer), (system, _) => new PersistenceSnapshotSerializer(system), - new[] { typeof(Snapshot) }), - }; + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-persistence-message", new PersistenceMessageSerializer(system), + ImmutableHashSet.Create(typeof(IMessage))), + SerializerDetails.Create("akka-persistence-snapshot", new PersistenceSnapshotSerializer(system), + ImmutableHashSet.Create(typeof(Snapshot))) + ); } } diff --git a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs index 6d72b255d20..3d3a1f792dc 100644 --- a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs +++ b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs @@ -90,24 +90,23 @@ private static async Task WithSwitchAsync(bool value, Func body) public void Should_match_reflection_When_resolving_every_Remote_conf_row() { var table = new RemoteSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + var details = table.Create((ExtendedActorSystem)Sys); + ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, details); // core's module map names Akka.Remote, and RemoteSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Remote").Should().NotBeNull(); var reflected = Build(Sys, NoModules, dynamicTypeLoading: true); var fromTable = Build(Sys, ModuleSerializerTable.Default, dynamicTypeLoading: true); - var settings = Sys.Settings.Config.GetConfig("akka.actor.serialization-settings"); - foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) + foreach (var entry in details) { - // the factory builds what reflection builds, under the same id - var built = create((ExtendedActorSystem)Sys, settings.GetConfig(alias)); - built.Should().BeOfType(reflected.GetSerializerById(built.Identifier).GetType(), type.Name); - fromTable.GetSerializerById(built.Identifier).Should().BeOfType(type, type.Name); + // the table builds what reflection builds, under the same id + entry.Serializer.Should().BeOfType(reflected.GetSerializerById(entry.Serializer.Identifier).GetType(), entry.Alias); + fromTable.GetSerializerById(entry.Serializer.Identifier).Should().BeOfType(entry.Serializer.GetType(), entry.Alias); } - foreach (var type in table.Serializers.SelectMany(s => s.Bindings)) + foreach (var type in details.SelectMany(d => d.UseFor)) { var expected = reflected.FindSerializerForType(type); var actual = fromTable.FindSerializerForType(type); @@ -115,7 +114,7 @@ public void Should_match_reflection_When_resolving_every_Remote_conf_row() actual.Identifier.Should().Be(expected.Identifier, type.Name); } - // primitive's settings block reached the factory + // primitive's settings block reached the table fromTable.FindSerializerForType(typeof(string)).Manifest("s") .Should().Be(reflected.FindSerializerForType(typeof(string)).Manifest("s")); } @@ -124,7 +123,7 @@ public void Should_match_reflection_When_resolving_every_Remote_conf_row() public void Should_have_a_Remote_conf_row_When_the_table_lists_a_type() { var table = new RemoteSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve Remote.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs index 4034842cc80..3ceb8f1fd31 100644 --- a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs +++ b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs @@ -6,8 +6,7 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; using Akka.Actor; using Akka.Configuration; using Akka.Dispatch.SysMsg; @@ -23,14 +22,11 @@ namespace Akka.Remote.Serialization /// internal sealed class RemoteSerializers : ModuleSerializers { - // the constructor reflection picks for Remote.conf; each class has one constructor, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-containers", typeof(MessageContainerSerializer), (system, _) => new MessageContainerSerializer(system), - new[] { typeof(ActorSelectionMessage) }), - new BuiltInSerializer("akka-misc", typeof(MiscMessageSerializer), (system, _) => new MiscMessageSerializer(system), - new[] - { + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-containers", new MessageContainerSerializer(system), + ImmutableHashSet.Create(typeof(ActorSelectionMessage))), + SerializerDetails.Create("akka-misc", new MiscMessageSerializer(system), + ImmutableHashSet.Create( typeof(Identify), typeof(ActorIdentity), typeof(IActorRef), @@ -51,18 +47,19 @@ internal sealed class RemoteSerializers : ModuleSerializers typeof(Config), typeof(RemoteWatcher.Heartbeat), typeof(RemoteWatcher.HeartbeatRsp), - typeof(RemoteRouterConfig), - }), - new BuiltInSerializer("primitive", typeof(PrimitiveSerializers), (system, config) => new PrimitiveSerializers(system, config), - new[] { typeof(string), typeof(int), typeof(long) }), - new BuiltInSerializer("proto", typeof(ProtobufSerializer), (system, _) => new ProtobufSerializer(system), - new[] { typeof(Google.Protobuf.IMessage) }), - new BuiltInSerializer("daemon-create", typeof(DaemonMsgCreateSerializer), (system, _) => new DaemonMsgCreateSerializer(system), - new[] { typeof(DaemonMsgCreate) }), - new BuiltInSerializer("akka-system-msg", typeof(SystemMessageSerializer), (system, _) => new SystemMessageSerializer(system), - new[] { typeof(SystemMessage) }), - new BuiltInSerializer("artery-control", typeof(ArteryControlMessageSerializer), (system, _) => new ArteryControlMessageSerializer(system), - new[] { typeof(IArteryControlMessage) }), - }; + typeof(RemoteRouterConfig))), + // the only built-in serializer with its own settings block; it reads that block itself + SerializerDetails.Create("primitive", + new PrimitiveSerializers(system, system.Settings.Config.GetConfig("akka.actor.serialization-settings.primitive")), + ImmutableHashSet.Create(typeof(string), typeof(int), typeof(long))), + SerializerDetails.Create("proto", new ProtobufSerializer(system), + ImmutableHashSet.Create(typeof(Google.Protobuf.IMessage))), + SerializerDetails.Create("daemon-create", new DaemonMsgCreateSerializer(system), + ImmutableHashSet.Create(typeof(DaemonMsgCreate))), + SerializerDetails.Create("akka-system-msg", new SystemMessageSerializer(system), + ImmutableHashSet.Create(typeof(SystemMessage))), + SerializerDetails.Create("artery-control", new ArteryControlMessageSerializer(system), + ImmutableHashSet.Create(typeof(IArteryControlMessage))) + ); } } diff --git a/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs b/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs index a51a0c673f7..25081eca7ea 100644 --- a/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs +++ b/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs @@ -8,7 +8,6 @@ #nullable enable using System; using System.Collections.Generic; -using System.Linq; using System.Runtime.Serialization; using System.Threading.Tasks; using Akka.Actor; @@ -72,21 +71,20 @@ public async Task Should_match_reflection_When_resolving_every_reference_conf_ro await ModuleSerializerSpecs.WithSystem("streams-parity", Config.Empty, StreamsRows, system => { var table = new StreamsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + var details = table.Create((ExtendedActorSystem)system); + ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, details); // core's module map names Akka.Streams, and StreamsSerializers is what it loads for it ModuleSerializerTable.Default.ForAssembly("Akka.Streams").Should().NotBeNull(); var reflected = Build(system, NoModules, dynamicTypeLoading: true); var fromTable = Build(system, ModuleSerializerTable.Default, dynamicTypeLoading: true); - var settings = system.Settings.Config.GetConfig("akka.actor.serialization-settings"); - foreach (var (type, create, alias) in table.Serializers.Select(s => (s.Type, s.Create, s.Alias))) + foreach (var entry in details) { - var built = create((ExtendedActorSystem)system, settings.GetConfig(alias)); - built.Identifier.Should().Be(30); - built.Should().BeOfType(reflected.GetSerializerById(30).GetType(), type.Name); - fromTable.GetSerializerById(30).Should().BeOfType(type, type.Name); + entry.Serializer.Identifier.Should().Be(30); + entry.Serializer.Should().BeOfType(reflected.GetSerializerById(30).GetType(), entry.Alias); + fromTable.GetSerializerById(30).Should().BeOfType(entry.Serializer.GetType(), entry.Alias); } foreach (var type in BoundSamples) @@ -98,7 +96,7 @@ await ModuleSerializerSpecs.WithSystem("streams-parity", Config.Empty, StreamsRo public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() { var table = new StreamsSerializers(); - ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Serializers.Select(s => (s.Alias, s.Type, s.Bindings))); + ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Create((ExtendedActorSystem)Sys)); } [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] diff --git a/src/core/Akka.Streams/Serialization/StreamsSerializers.cs b/src/core/Akka.Streams/Serialization/StreamsSerializers.cs index c6da1160d15..441654a1891 100644 --- a/src/core/Akka.Streams/Serialization/StreamsSerializers.cs +++ b/src/core/Akka.Streams/Serialization/StreamsSerializers.cs @@ -6,8 +6,8 @@ //----------------------------------------------------------------------- #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Immutable; +using Akka.Actor; using Akka.Serialization; using Akka.Streams.Implementation.StreamRef; @@ -18,11 +18,9 @@ namespace Akka.Streams.Serialization /// internal sealed class StreamsSerializers : ModuleSerializers { - // the constructor reflection picks for reference.conf; StreamRefSerializer has one, so it always gets that one - public override IReadOnlyList Serializers { get; } = new[] - { - new BuiltInSerializer("akka-stream-ref", typeof(StreamRefSerializer), (system, _) => new StreamRefSerializer(system), - new[] { typeof(SinkRefImpl), typeof(SourceRefImpl), typeof(IStreamRefsProtocol) }), - }; + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( + SerializerDetails.Create("akka-stream-ref", new StreamRefSerializer(system), + ImmutableHashSet.Create(typeof(SinkRefImpl), typeof(SourceRefImpl), typeof(IStreamRefsProtocol))) + ); } } diff --git a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs index ead71a7f282..5e4362e419c 100644 --- a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs +++ b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs @@ -38,29 +38,28 @@ public static class ModuleSerializerSpecs /// /// Asserts a module's table is a complete, alias-accurate mirror of its config: every registration's alias - /// names that registration's type in `akka.actor.serializers` (and vice versa - no extra alias), and every - /// `akka.actor.serialization-bindings` row matches exactly one registration whose Bindings contains - /// that row's type under that row's alias (and vice versa - no extra binding in the table). + /// names that registration's serializer type in `akka.actor.serializers` (and vice versa - no extra alias), + /// and every `akka.actor.serialization-bindings` row matches exactly one registration whose UseFor + /// contains that row's type under that row's alias (and vice versa - no extra binding in the table). /// /// The module's own reference.conf (or the combined config of its files). - /// Each registration's alias, serializer type, and the types it binds. - public static void AssertTableMatchesConfig( - Config moduleConfig, IEnumerable<(string Alias, Type Type, IReadOnlyList Bindings)> registrations) + /// The module's built serializers, as its ModuleSerializers.Create returns them. + public static void AssertTableMatchesConfig(Config moduleConfig, IEnumerable details) { - var table = registrations.ToList(); + var table = details.ToList(); var configuredTypeByAlias = SerializerRows(moduleConfig) .ToDictionary(r => r.Alias, r => Type.GetType(r.TypeName, throwOnError: true)!); // aliases match in both directions: every table alias is a config row for the same type, and every // config row has a table entry - table.Select(r => (r.Alias, r.Type)).Should().BeEquivalentTo( + table.Select(r => (r.Alias, Type: r.Serializer.GetType())).Should().BeEquivalentTo( configuredTypeByAlias.Select(kv => (Alias: kv.Key, Type: kv.Value))); var configuredBindings = BindingRows(moduleConfig) .Select(r => (Type: Type.GetType(r.TypeName, throwOnError: true)!, r.Alias)); - var tableBindings = table.SelectMany(r => r.Bindings.Select(t => (Type: t, r.Alias))); + var tableBindings = table.SelectMany(r => r.UseFor.Select(t => (Type: t, r.Alias))); // bindings match in both directions: every table binding is a config row under the same alias, and // every config row is bound by exactly one registration under that alias diff --git a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs index 1d0078bb80a..872387aa7b0 100644 --- a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs +++ b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs @@ -67,12 +67,15 @@ public SetupSerializer(ExtendedActorSystem system) : base(system) private sealed class FakeModule : ModuleSerializers { - public override IReadOnlyList Serializers { get; } = new[] + public override ImmutableHashSet Create(ExtendedActorSystem system) { - new BuiltInSerializer("fake-module", typeof(FakeSerializer), - (system, config) => config.IsNullOrEmpty() ? new FakeSerializer(system) : new FakeSerializer(system, config), - new[] { typeof(ModuleMessage), typeof(string), typeof(Identify), typeof(PoisonPill) }) - }; + // a module can read its own settings block, the same way the real PrimitiveSerializers does + var config = system.Settings.Config.GetConfig("akka.actor.serialization-settings.fake-module"); + var serializer = config.IsNullOrEmpty() ? new FakeSerializer(system) : new FakeSerializer(system, config); + + return ImmutableHashSet.Create(SerializerDetails.Create("fake-module", serializer, + ImmutableHashSet.Create(typeof(ModuleMessage), typeof(string), typeof(Identify), typeof(PoisonPill)))); + } } /// Stands in for a module built against a different Akka: its table's constructor hits a missing member. @@ -80,7 +83,7 @@ internal sealed class SkewedModule : ModuleSerializers { public SkewedModule() => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); - public override IReadOnlyList Serializers => throw new NotSupportedException(); + public override ImmutableHashSet Create(ExtendedActorSystem system) => throw new NotSupportedException(); } /// The same skew hit in a static initializer, which arrives wrapped in TypeInitializationException. @@ -88,7 +91,14 @@ internal sealed class StaticSkewedModule : ModuleSerializers { static StaticSkewedModule() => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); - public override IReadOnlyList Serializers => throw new NotSupportedException(); + public override ImmutableHashSet Create(ExtendedActorSystem system) => throw new NotSupportedException(); + } + + /// The same skew, but hit building the serializers instead of loading the table. + internal sealed class CreateSkewedModule : ModuleSerializers + { + public override ImmutableHashSet Create(ExtendedActorSystem system) + => throw new MissingMethodException("Akka.Serialization.Missing", "Member"); } private const string ModuleMessageName = "Akka.Tests.Serialization.ModuleSerializersSpec+ModuleMessage"; @@ -248,9 +258,10 @@ await WithSystem("module-parity-on", config, async system => }); } - [Theory(DisplayName = "Serialization should treat a module whose table fails to load as absent")] + [Theory(DisplayName = "Serialization should treat a module as absent when loading or building it fails")] [InlineData("Akka.Tests.Serialization.ModuleSerializersSpec+SkewedModule, Akka.Tests")] [InlineData("Akka.Tests.Serialization.ModuleSerializersSpec+StaticSkewedModule, Akka.Tests")] + [InlineData("Akka.Tests.Serialization.ModuleSerializersSpec+CreateSkewedModule, Akka.Tests")] [InlineData("No.Such.Module, No.Such.Assembly")] public async Task Should_treat_the_module_as_absent_When_its_table_fails_to_load(string tableTypeName) { diff --git a/src/core/Akka/Serialization/ModuleSerializers.cs b/src/core/Akka/Serialization/ModuleSerializers.cs index 2e1c5b81ed7..73bf6196413 100644 --- a/src/core/Akka/Serialization/ModuleSerializers.cs +++ b/src/core/Akka/Serialization/ModuleSerializers.cs @@ -9,53 +9,60 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; using Akka.Actor; -using Akka.Configuration; namespace Akka.Serialization { - /// - /// INTERNAL API. A serializer row a module's reference.conf names: its alias, its type and factory, and the - /// types its serialization-bindings rows bind to that alias (empty when the alias has none, e.g. a - /// V2 serializer that is registered for reads only). The factory calls the constructor reflection picks for - /// the module's shipped config; a serializer with one constructor always gets that one. Return a serializer; - /// null skips the alias (a safety net, not a feature). - /// - internal sealed record BuiltInSerializer( - string Alias, Type Type, Func Create, IReadOnlyList Bindings); - /// /// INTERNAL API. The serializer rows a first-party module's reference.conf names, so - /// can resolve those rows without . + /// can resolve those rows without . Same shape as + /// - a module's table is, in effect, a built-in + /// . /// internal abstract class ModuleSerializers { - public abstract IReadOnlyList Serializers { get; } + public abstract ImmutableHashSet Create(ExtendedActorSystem system); } /// - /// INTERNAL API. A loaded , indexed by stripped full name for strict matching. + /// INTERNAL API. A module's serializers, built and indexed by stripped full name for strict matching. /// internal sealed class LoadedModule { - private readonly Dictionary _serializers = new(StringComparer.Ordinal); + private readonly Dictionary _serializers = new(StringComparer.Ordinal); private readonly Dictionary _boundTypes = new(StringComparer.Ordinal); - internal LoadedModule(ModuleSerializers module) + private LoadedModule(ImmutableHashSet details) { - foreach (var entry in module.Serializers) + foreach (var entry in details) { - _serializers[KeyOf(entry.Type)] = (entry, entry.Type.Assembly.GetName().Name, IsAkka(entry.Type)); - foreach (var type in entry.Bindings) + var serializerType = entry.Serializer.GetType(); + _serializers[KeyOf(serializerType)] = (entry, serializerType.Assembly.GetName().Name, IsAkka(serializerType)); + foreach (var type in entry.UseFor) _boundTypes[KeyOf(type)] = (type, type.Assembly.GetName().Name, IsAkka(type)); } } - internal BuiltInSerializer? FindSerializer(string name, string? assembly) - => _serializers.TryGetValue(name, out var s) && Accepts(s.Assembly, s.IsAkka, assembly) ? s.Entry : null; + /// Builds 's serializers against ; null when that fails with a version-skew error. + internal static LoadedModule? TryCreate(ModuleSerializers module, ExtendedActorSystem system) + { + try + { + return new LoadedModule(module.Create(system)); + } + catch (Exception e) when (ModuleSerializerTable.IsVersionSkew(e)) + { + // the module, or something its serializers reference, is missing from this build: it counts as absent + return null; + } + } + + internal SerializerDetails? FindSerializer(string name, string? assembly) + => _serializers.TryGetValue(name, out var s) && Accepts(s.Assembly, s.IsAkka, assembly) ? s.Details : null; internal Type? FindBoundType(string name, string? assembly) => _boundTypes.TryGetValue(name, out var t) && Accepts(t.Assembly, t.IsAkka, assembly) ? t.Type : null; @@ -80,7 +87,88 @@ private static bool Accepts(string? actual, bool isAkka, string? assembly) } /// - /// INTERNAL API. Finds a module's table by assembly simple name, loading it lazily and at most once per table. + /// INTERNAL API. Resolves a instance's rows against a module's serializers, calling + /// at most once per module and caching the result for this instance's + /// lifetime. caches the cheap part - the loaded + /// object itself - once per process; building its serializers needs the , which + /// only this, a per-instance cache, has. + /// + internal sealed class ModuleResolver + { + private readonly ExtendedActorSystem _system; + private readonly ModuleSerializerTable _modules; + private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase); + + // modules whose serializer rows this config contains; see FindBoundType for how a binding row uses them + private readonly List _loaded = new(); + + internal ModuleResolver(ExtendedActorSystem system, ModuleSerializerTable modules) + { + _system = system; + _modules = modules; + } + + /// The module 's assembly half names, if any row of it resolves there. + internal SerializerDetails? FindSerializer(string typeName) + { + if (!Akka.Util.TypeExtensions.TrySplitTypeName(typeName, out var name, out var assembly) || assembly is null) + return null; + + var module = ModuleFor(assembly); + if (module is null) + return null; + + var details = module.FindSerializer(name, assembly); + if (details is not null && !_loaded.Contains(module)) + _loaded.Add(module); + return details; + } + + /// + /// Resolves a binding row's type from the module tables, without reflection. Null when no module lists it. + /// + /// + /// Two lookups, in order: + /// + /// The module the row's assembly half names. "Akka.Remote.RemoteWatcher+Heartbeat, Akka.Remote" + /// is answered by Akka.Remote's table, building it if no serializer row has yet. + /// Every module whose serializer rows this config resolved (). This covers + /// bound types that live outside their module: Remote.conf binds "System.String" and + /// "Akka.Actor.Identify, Akka" to its own serializers, and no module owns CoreLib or Akka.dll, so + /// lookup 1 can't answer for them. + /// + /// Lookup 2 only asks modules this config uses. With no Remote serializer rows, a "System.String" + /// binding gets no answer here and still throws with dynamic type loading off, as it did before. The binding + /// loop runs after the serializer loop, so lookup 2's modules are complete by then. Two modules that list + /// the same name list the same , so the order they are asked in doesn't matter. + /// + internal Type? FindBoundType(string name, string? assembly) + { + if (assembly is not null && ModuleFor(assembly)?.FindBoundType(name, assembly) is { } owned) + return owned; + + foreach (var module in _loaded) + { + if (module.FindBoundType(name, assembly) is { } type) + return type; + } + + return null; + } + + private LoadedModule? ModuleFor(string assembly) + { + if (_built.TryGetValue(assembly, out var cached)) + return cached; + + var built = _modules.ForAssembly(assembly) is { } raw ? LoadedModule.TryCreate(raw, _system) : null; + _built[assembly] = built; + return built; + } + } + + /// + /// INTERNAL API. Finds a module's table by assembly simple name, loading it lazily and at most once per process. /// internal sealed class ModuleSerializerTable { @@ -99,13 +187,13 @@ internal sealed class ModuleSerializerTable }); private readonly Dictionary> _modules; - private readonly ConcurrentDictionary _loaded = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _loaded = new(StringComparer.OrdinalIgnoreCase); internal ModuleSerializerTable(IDictionary> modules) => _modules = new Dictionary>(modules, StringComparer.OrdinalIgnoreCase); /// The module shipped as ; null when it is not a known module or fails to load. - internal LoadedModule? ForAssembly(string assembly) + internal ModuleSerializers? ForAssembly(string assembly) { if (!_modules.TryGetValue(assembly, out var load)) return null; @@ -114,21 +202,20 @@ internal ModuleSerializerTable(IDictionary> mod return _loaded.GetOrAdd(assembly, _ => TryLoad(load)); } - private static LoadedModule? TryLoad(Func load) + private static ModuleSerializers? TryLoad(Func load) { try { - // reads the list here, so a missing member also lands in the catch - return load() is { } module ? new LoadedModule(module) : null; + return load(); } catch (Exception e) when (IsVersionSkew(e)) { - // the module's table, or something it references, is missing from this build: the module counts as absent + // the module's table is missing from this build: the module counts as absent return null; } } - private static bool IsVersionSkew(Exception e) + internal static bool IsVersionSkew(Exception e) { while (e is TargetInvocationException or TypeInitializationException && e.InnerException is { } inner) e = inner; diff --git a/src/core/Akka/Serialization/Serialization.cs b/src/core/Akka/Serialization/Serialization.cs index 4989cd753c3..4341ee92026 100644 --- a/src/core/Akka/Serialization/Serialization.cs +++ b/src/core/Akka/Serialization/Serialization.cs @@ -341,9 +341,8 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules : new HashSet(_serializerDetails.Select(d => d.Alias), StringComparer.Ordinal); Dictionary setupTypesByName = null; - // modules whose serializer rows this config contains; see FindModuleBoundType for how the binding rows - // below use them - var loadedModules = new List(); + // resolves HOCON rows against the module tables, building each module's serializers at most once + var moduleResolver = new ModuleResolver(system, modules); foreach (var kvp in serializersConfig) { @@ -351,25 +350,30 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules var serializerTypeName = kvp.Value.GetString(); var serializerConfig = serializerSettingsConfig.GetConfig(kvp.Key); - Serializer serializer; - if (FindSerializerFactory(serializerTypeName, modules, loadedModules) is { } serializerFactory) + if (FindCoreSerializerFactory(serializerTypeName) is { } builtInFactory) { - serializer = serializerFactory(system, serializerConfig); + var serializer = builtInFactory(system, serializerConfig); if (serializer == null) { // a built-in serializer that cannot work under the current feature switches skippedAliases.Add(kvp.Key); continue; } + AddSerializer(kvp.Key, AdaptSerializer(serializer)); + } + else if (moduleResolver.FindSerializer(serializerTypeName) is { } moduleDetails) + { + AddSerializer(kvp.Key, moduleDetails.SerializerV2); } else if (AkkaFeatures.IsDynamicTypeLoadingSupported) { - serializer = CreateSerializerFromTypeName(serializerTypeName, system, serializerConfig); + var serializer = CreateSerializerFromTypeName(serializerTypeName, system, serializerConfig); if (serializer == null) { system.Log.Warning("The type name for serializer '{0}' did not resolve to an actual Type: '{1}'", kvp.Key, serializerTypeName); continue; } + AddSerializer(kvp.Key, serializer); } else if (setupAliases.Contains(kvp.Key)) { @@ -381,8 +385,6 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules throw new ConfigurationException(AkkaFeatures.NotBuiltIn( $"akka.actor.serializers.{kvp.Key}", serializerTypeName, "a SerializationSetup to register the serializer")); } - - AddSerializer(kvp.Key, AdaptSerializer(serializer)); } // Add any serializers that are registered via the SerializationSetup @@ -405,7 +407,7 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules { messageType = builtInType; } - else if (FindModuleBoundType(bindingName, bindingAssembly, modules, loadedModules) is { } moduleType) + else if (moduleResolver.FindBoundType(bindingName, bindingAssembly) is { } moduleType) { messageType = moduleType; } @@ -478,58 +480,12 @@ private static Dictionary SetupTypesByName(IEnumerable - /// Core's built-in factory for , else the factory of the module its assembly half names. - /// - private static Func FindSerializerFactory( - string typeName, ModuleSerializerTable modules, List loadedModules) - { - if (Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(typeName) is { } builtInName && - BuiltInSerializers.TryGetValue(builtInName, out var builtInFactory)) - return builtInFactory; - - if (!Akka.Util.TypeExtensions.TrySplitTypeName(typeName, out var name, out var assembly) || - assembly is null || modules.ForAssembly(assembly) is not { } module) - return null; - - var entry = module.FindSerializer(name, assembly); - if (entry is not null && !loadedModules.Contains(module)) - loadedModules.Add(module); - return entry?.Create; - } - - /// - /// Resolves a binding row's type from the module tables, without reflection. Null when no module lists it. - /// - /// - /// Two lookups, in order: - /// - /// The module the row's assembly half names. "Akka.Remote.RemoteWatcher+Heartbeat, Akka.Remote" - /// is answered by Akka.Remote's table, loading it if no serializer row has yet. - /// Every module whose serializer rows this config resolved (). This - /// covers bound types that live outside their module: Remote.conf binds "System.String" and - /// "Akka.Actor.Identify, Akka" to its own serializers, and no module owns CoreLib or Akka.dll, so - /// lookup 1 can't answer for them. - /// - /// Lookup 2 only asks modules this config uses. With no Remote serializer rows, a "System.String" - /// binding gets no answer here and still throws with dynamic type loading off, as it did before. The binding - /// loop runs after the serializer loop, so is complete by then. Two modules - /// that list the same name list the same , so the order they are asked in doesn't matter. - /// - private static Type FindModuleBoundType( - string name, string assembly, ModuleSerializerTable modules, List loadedModules) - { - if (assembly is not null && modules.ForAssembly(assembly)?.FindBoundType(name, assembly) is { } owned) - return owned; - - foreach (var module in loadedModules) - { - if (module.FindBoundType(name, assembly) is { } type) - return type; - } - - return null; - } + /// Core's own factory for - the two serializers that ship inside Akka.dll itself. + private static Func FindCoreSerializerFactory(string typeName) + => Akka.Util.TypeExtensions.ToBuiltInAkkaTypeName(typeName) is { } builtInName && + BuiltInSerializers.TryGetValue(builtInName, out var builtInFactory) + ? builtInFactory + : null; [RequiresUnreferencedCode("Loads a serializer named under [akka.actor.serializers] by name and activates it. The trimmer cannot tell which type that is, so it may have been trimmed away.")] private static SerializerV2 CreateSerializerFromTypeName(string serializerTypeName, ExtendedActorSystem system, Config serializerConfig) From 5c0768150c120101e7be5648053d05aac24b5e91 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 1 Oct 2026 01:27:36 +0000 Subject: [PATCH 5/6] Fix a module-serializer startup regression: build modules on demand, not eagerly Create(system) built every serializer a module has, including ones no HOCON row ever asked for - triggered both by a genuine serializer row and by a binding row's lookup against the module. Some of those serializers need their own module's config and throw when it is missing, so a config that only ever touches one of a module's rows (or only binds one of its types, with no serializer row at all) could fail to start, even though the same config worked before this table rework. Build a module only from a serializer row that names it; a binding row now only reads modules this config already built, never builds one itself. A binding-only row for a module type with no matching serializer row falls back to reflection (or throws NotBuiltIn, same as today, with the switch off). Make every built-in serializer safe to build without its own module config: PrimitiveSerializers gets an empty settings block instead of null, and ReplicatorMessageSerializer falls back to its reference.conf's own default cache TTL instead of scheduling a repeating timer with a zero interval. Replace the ModuleResolver class with two static helpers and a local dictionary in the Serialization constructor - smaller, and keeps the module lookups where they were before this rework. When a module's Create throws a version-skew exception and dynamic type loading is off, that exception now rides along on the ConfigurationException instead of being hidden behind a plain "not built in, enable the switch" message. Add one spec per module asserting Create does not throw against a system that never loaded that module's own config, plus two Remote-specific regression specs reproducing the exact failures this fixes. --- .../Serialization/MetricsSerializersSpec.cs | 4 + .../Serialization/ShardingSerializersSpec.cs | 4 + .../Serialization/ToolsSerializersSpec.cs | 4 + .../DistributedDataSerializersSpec.cs | 4 + .../ReplicatorMessageSerializer.cs | 2 + .../Serialization/ClusterSerializersSpec.cs | 4 + .../PersistenceSerializersSpec.cs | 4 + .../Serialization/RemoteSerializersSpec.cs | 43 ++++++++ .../Serialization/RemoteSerializers.cs | 7 +- .../Serialization/StreamsSerializersSpec.cs | 4 + .../ModuleSerializerSpecs.cs | 20 ++++ .../Akka/Serialization/ModuleSerializers.cs | 100 +++--------------- src/core/Akka/Serialization/Serialization.cs | 87 +++++++++++++-- 13 files changed, 192 insertions(+), 95 deletions(-) diff --git a/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs index 1298f8d0e73..562c86fd00a 100644 --- a/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Metrics.Tests/Serialization/MetricsSerializersSpec.cs @@ -53,6 +53,10 @@ public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(MetricsRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "MetricsSerializers should build without throwing on a system that never loaded its reference.conf")] + public async Task Should_build_without_throwing_When_its_config_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("metrics-no-config", s => new MetricsSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("metrics-aqn", MetricsRows, Sys); diff --git a/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs index 64ac3a02610..51b65091249 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding.Tests/Serialization/ShardingSerializersSpec.cs @@ -48,6 +48,10 @@ public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(ShardingRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "ShardingSerializers should build without throwing on a system that never loaded its reference.conf")] + public async Task Should_build_without_throwing_When_its_config_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("sharding-no-config", s => new ShardingSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("sharding-aqn", ShardingRows, Sys); diff --git a/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs b/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs index 84e189e77e7..730820f7e09 100644 --- a/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs +++ b/src/contrib/cluster/Akka.Cluster.Tools.Tests/Serialization/ToolsSerializersSpec.cs @@ -54,6 +54,10 @@ public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(ToolsRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "ToolsSerializers should build without throwing on a system that never loaded its reference.conf")] + public async Task Should_build_without_throwing_When_its_config_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("tools-no-config", s => new ToolsSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("tools-aqn", ToolsRows, Sys); diff --git a/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs b/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs index a35f599842e..da08bb6960f 100644 --- a/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs +++ b/src/contrib/cluster/Akka.DistributedData.Tests/Serialization/DistributedDataSerializersSpec.cs @@ -46,6 +46,10 @@ public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(DDataRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "DistributedDataSerializers should build without throwing on a system that never loaded its reference.conf")] + public async Task Should_build_without_throwing_When_its_config_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("ddata-no-config", s => new DistributedDataSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("ddata-aqn", DDataRows, Sys); diff --git a/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs b/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs index 5771645936b..38ed1c8e204 100644 --- a/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs +++ b/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs @@ -158,6 +158,8 @@ public ReplicatorMessageSerializer(Akka.Actor.ExtendedActorSystem system) : base { _ser = new SerializationSupport(system); var cacheTtl = system.Settings.Config.GetTimeSpan("akka.cluster.distributed-data.serializer-cache-time-to-live"); + if (cacheTtl <= TimeSpan.Zero) + cacheTtl = TimeSpan.FromSeconds(10); // reference.conf's own default, for a system that never loaded it _readCache = new SmallCache(4, cacheTtl, m => ReadToProto(m).ToByteArray()); _writeCache = new SmallCache(4, cacheTtl, m => WriteToProto(m).ToByteArray()); diff --git a/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs b/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs index 8897e5a4171..95f52b50123 100644 --- a/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs +++ b/src/core/Akka.Cluster.Tests/Serialization/ClusterSerializersSpec.cs @@ -101,6 +101,10 @@ public void Should_have_a_Cluster_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(ClusterRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "ClusterSerializers should build without throwing on a system that never loaded Cluster.conf")] + public async Task Should_build_without_throwing_When_Cluster_conf_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("cluster-no-config", s => new ClusterSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve Cluster.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("cluster-aqn", ClusterRows, Sys, BoundSamples); diff --git a/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs b/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs index 80b22ee6f6c..7aa7698554e 100644 --- a/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs +++ b/src/core/Akka.Persistence.Tests/Serialization/PersistenceSerializersSpec.cs @@ -93,6 +93,10 @@ public void Should_have_a_persistence_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(PersistenceRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "PersistenceSerializers should build without throwing on a system that never loaded persistence.conf")] + public async Task Should_build_without_throwing_When_persistence_conf_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("persistence-no-config", s => new PersistenceSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve persistence.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("persistence-aqn", PersistenceRows, Sys, BoundSamples); diff --git a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs index 3d3a1f792dc..83a65f8a9db 100644 --- a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs +++ b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs @@ -13,6 +13,7 @@ using Akka.Actor; using Akka.Configuration; using Akka.Dispatch.SysMsg; +using Akka.Remote; using Akka.Remote.Artery; using Akka.Remote.Configuration; using Akka.Remote.Serialization; @@ -126,6 +127,10 @@ public void Should_have_a_Remote_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(RemoteRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "RemoteSerializers should build without throwing on a system that never loaded Remote.conf")] + public async Task Should_build_without_throwing_When_Remote_conf_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("remote-no-config", s => new RemoteSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve Remote.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("remote-aqn", RemoteRows, Sys); @@ -199,6 +204,44 @@ await ModuleSerializerSpecs.WithSystem("remote-absent", local, null, system => }); } + /// + /// Regression: a lone proto row used to build every Remote serializer, including primitive, + /// to answer it - and PrimitiveSerializers threw on the null config a system that never loaded + /// Remote.conf hands it. RemoteSerializers.Create must not throw just because this config only + /// ever asks it for one of its serializers. + /// + [Fact(DisplayName = "Serialization should start and resolve a lone proto row with no other Remote.conf rows present")] + public async Task Should_resolve_a_lone_proto_row_When_no_other_Remote_config_is_present() + { + var config = ConfigurationFactory.ParseString(@" + akka.actor.serializers.proto = ""Akka.Remote.Serialization.ProtobufSerializer, Akka.Remote"" + akka.actor.serialization-bindings { ""Google.Protobuf.IMessage, Google.Protobuf"" = proto }"); + + await ModuleSerializerSpecs.WithSystem("remote-lone-proto", config, null, system => + { + ((ExtendedActorSystem)system).Serialization.FindSerializerForType(typeof(Google.Protobuf.IMessage)) + .Should().BeOfType(); + }); + } + + /// + /// Regression: a binding-only row used to build Remote's whole module just to answer lookup 1, with the + /// same throw as above. A binding row alone is not reason enough to build a module - this one resolves + /// through plain reflection instead, exactly as it would if Akka.Remote were not involved at all. + /// + [Fact(DisplayName = "Serialization should start with a RemoteWatcher+Heartbeat binding and no Remote serializer row")] + public async Task Should_start_With_a_binding_only_RemoteWatcher_Heartbeat_row() + { + var config = ConfigurationFactory.ParseString( + @"akka.actor.serialization-bindings { ""Akka.Remote.RemoteWatcher+Heartbeat, Akka.Remote"" = bytes }"); + + await ModuleSerializerSpecs.WithSystem("remote-binding-only-heartbeat", config, null, system => + { + ((ExtendedActorSystem)system).Serialization.FindSerializerForType(typeof(RemoteWatcher.Heartbeat)) + .Should().BeOfType(); + }); + } + /// Reflection hands a non-empty block to a (system, config) constructor this class lacks; the table does not. [Fact(DisplayName = "Serialization should build akka-misc despite a settings block when dynamic type loading is on")] public async Task Should_build_a_one_constructor_serializer_When_it_has_a_settings_block() diff --git a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs index 3ceb8f1fd31..61f9eb41ac8 100644 --- a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs +++ b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs @@ -48,9 +48,12 @@ public override ImmutableHashSet Create(ExtendedActorSystem s typeof(RemoteWatcher.Heartbeat), typeof(RemoteWatcher.HeartbeatRsp), typeof(RemoteRouterConfig))), - // the only built-in serializer with its own settings block; it reads that block itself + // the only built-in serializer with its own settings block; the table reads it, falling back to an + // empty block - same as GetBoolean's own default, legacy behavior off - for a system that never + // loaded Remote.conf (PrimitiveSerializers has no (system)-only constructor to fall back to) SerializerDetails.Create("primitive", - new PrimitiveSerializers(system, system.Settings.Config.GetConfig("akka.actor.serialization-settings.primitive")), + new PrimitiveSerializers(system, + system.Settings.Config.GetConfig("akka.actor.serialization-settings.primitive") ?? Config.Empty), ImmutableHashSet.Create(typeof(string), typeof(int), typeof(long))), SerializerDetails.Create("proto", new ProtobufSerializer(system), ImmutableHashSet.Create(typeof(Google.Protobuf.IMessage))), diff --git a/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs b/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs index 25081eca7ea..4a4ffe504ff 100644 --- a/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs +++ b/src/core/Akka.Streams.Tests/Serialization/StreamsSerializersSpec.cs @@ -99,6 +99,10 @@ public void Should_have_a_reference_conf_row_When_the_table_lists_a_type() ModuleSerializerSpecs.AssertTableMatchesConfig(StreamsRows, table.Create((ExtendedActorSystem)Sys)); } + [Fact(DisplayName = "StreamsSerializers should build without throwing on a system that never loaded its reference.conf")] + public async Task Should_build_without_throwing_When_its_config_is_absent() + => await ModuleSerializerSpecs.AssertBuildsWithoutModuleConfig("streams-no-config", s => new StreamsSerializers().Create(s)); + [Fact(DisplayName = "Serialization should resolve reference.conf rows spelled as Akka.Hosting writes them when dynamic type loading is off")] public async Task Should_resolve_assembly_qualified_names_When_dynamic_type_loading_is_disabled() => await ModuleSerializerSpecs.AssertHostingSpellingResolves("streams-aqn", StreamsRows, Sys, BoundSamples); diff --git a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs index 5e4362e419c..3dfaacf7864 100644 --- a/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs +++ b/src/core/Akka.Tests.Shared.Internals.Xunit3/ModuleSerializerSpecs.cs @@ -139,5 +139,25 @@ await eventFilter.Warning().ExpectAsync(0, () => }); return serialization!; } + + /// + /// Asserts that - ordinarily system => new XSerializers().Create(system) - + /// does not throw against a fresh system that never loaded the module's own reference.conf, only core's + /// own akka.conf. A module's serializers must build even for a system that has no reason to know the + /// module exists yet - building one must never depend on that module's own config being present. + /// + public static async Task AssertBuildsWithoutModuleConfig(string systemName, Action create) + { + var system = ActorSystem.Create(systemName); + try + { + Action act = () => create((ExtendedActorSystem)system); + act.Should().NotThrow(); + } + finally + { + await system.Terminate(); + } + } } } diff --git a/src/core/Akka/Serialization/ModuleSerializers.cs b/src/core/Akka/Serialization/ModuleSerializers.cs index 73bf6196413..9b1948e0876 100644 --- a/src/core/Akka/Serialization/ModuleSerializers.cs +++ b/src/core/Akka/Serialization/ModuleSerializers.cs @@ -19,9 +19,10 @@ namespace Akka.Serialization { /// /// INTERNAL API. The serializer rows a first-party module's reference.conf names, so - /// can resolve those rows without . Same shape as - /// - a module's table is, in effect, a built-in - /// . + /// can resolve those rows without . has the same shape as + /// ; unlike a , HOCON still + /// decides which of these entries are actually registered, and a still wins + /// over both. /// internal abstract class ModuleSerializers { @@ -47,16 +48,22 @@ private LoadedModule(ImmutableHashSet details) } } - /// Builds 's serializers against ; null when that fails with a version-skew error. - internal static LoadedModule? TryCreate(ModuleSerializers module, ExtendedActorSystem system) + /// + /// Builds 's serializers against ; null when that fails + /// with a version-skew error, in which case is that exception - the caller + /// decides whether to surface it. + /// + internal static LoadedModule? TryCreate(ModuleSerializers module, ExtendedActorSystem system, out Exception? skewError) { try { + skewError = null; return new LoadedModule(module.Create(system)); } catch (Exception e) when (ModuleSerializerTable.IsVersionSkew(e)) { // the module, or something its serializers reference, is missing from this build: it counts as absent + skewError = e; return null; } } @@ -87,88 +94,7 @@ private static bool Accepts(string? actual, bool isAkka, string? assembly) } /// - /// INTERNAL API. Resolves a instance's rows against a module's serializers, calling - /// at most once per module and caching the result for this instance's - /// lifetime. caches the cheap part - the loaded - /// object itself - once per process; building its serializers needs the , which - /// only this, a per-instance cache, has. - /// - internal sealed class ModuleResolver - { - private readonly ExtendedActorSystem _system; - private readonly ModuleSerializerTable _modules; - private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase); - - // modules whose serializer rows this config contains; see FindBoundType for how a binding row uses them - private readonly List _loaded = new(); - - internal ModuleResolver(ExtendedActorSystem system, ModuleSerializerTable modules) - { - _system = system; - _modules = modules; - } - - /// The module 's assembly half names, if any row of it resolves there. - internal SerializerDetails? FindSerializer(string typeName) - { - if (!Akka.Util.TypeExtensions.TrySplitTypeName(typeName, out var name, out var assembly) || assembly is null) - return null; - - var module = ModuleFor(assembly); - if (module is null) - return null; - - var details = module.FindSerializer(name, assembly); - if (details is not null && !_loaded.Contains(module)) - _loaded.Add(module); - return details; - } - - /// - /// Resolves a binding row's type from the module tables, without reflection. Null when no module lists it. - /// - /// - /// Two lookups, in order: - /// - /// The module the row's assembly half names. "Akka.Remote.RemoteWatcher+Heartbeat, Akka.Remote" - /// is answered by Akka.Remote's table, building it if no serializer row has yet. - /// Every module whose serializer rows this config resolved (). This covers - /// bound types that live outside their module: Remote.conf binds "System.String" and - /// "Akka.Actor.Identify, Akka" to its own serializers, and no module owns CoreLib or Akka.dll, so - /// lookup 1 can't answer for them. - /// - /// Lookup 2 only asks modules this config uses. With no Remote serializer rows, a "System.String" - /// binding gets no answer here and still throws with dynamic type loading off, as it did before. The binding - /// loop runs after the serializer loop, so lookup 2's modules are complete by then. Two modules that list - /// the same name list the same , so the order they are asked in doesn't matter. - /// - internal Type? FindBoundType(string name, string? assembly) - { - if (assembly is not null && ModuleFor(assembly)?.FindBoundType(name, assembly) is { } owned) - return owned; - - foreach (var module in _loaded) - { - if (module.FindBoundType(name, assembly) is { } type) - return type; - } - - return null; - } - - private LoadedModule? ModuleFor(string assembly) - { - if (_built.TryGetValue(assembly, out var cached)) - return cached; - - var built = _modules.ForAssembly(assembly) is { } raw ? LoadedModule.TryCreate(raw, _system) : null; - _built[assembly] = built; - return built; - } - } - - /// - /// INTERNAL API. Finds a module's table by assembly simple name, loading it lazily and at most once per process. + /// INTERNAL API. Finds a module's table by assembly simple name, loading it lazily and at most once per table. /// internal sealed class ModuleSerializerTable { diff --git a/src/core/Akka/Serialization/Serialization.cs b/src/core/Akka/Serialization/Serialization.cs index 4341ee92026..b10aed0f210 100644 --- a/src/core/Akka/Serialization/Serialization.cs +++ b/src/core/Akka/Serialization/Serialization.cs @@ -341,8 +341,10 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules : new HashSet(_serializerDetails.Select(d => d.Alias), StringComparer.Ordinal); Dictionary setupTypesByName = null; - // resolves HOCON rows against the module tables, building each module's serializers at most once - var moduleResolver = new ModuleResolver(system, modules); + // modules this config has built, keyed by assembly simple name; a module is built here only - never + // from a binding row - the first time one of its own serializer rows names it. The caught exception, + // when building the module itself is what failed, rides along so a later NotBuiltIn throw can show it. + var builtModules = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in serializersConfig) { @@ -360,8 +362,11 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules continue; } AddSerializer(kvp.Key, AdaptSerializer(serializer)); + continue; } - else if (moduleResolver.FindSerializer(serializerTypeName) is { } moduleDetails) + + var (moduleDetails, moduleSkew) = FindModuleSerializer(serializerTypeName, system, modules, builtModules); + if (moduleDetails is not null) { AddSerializer(kvp.Key, moduleDetails.SerializerV2); } @@ -382,8 +387,11 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules } else { - throw new ConfigurationException(AkkaFeatures.NotBuiltIn( - $"akka.actor.serializers.{kvp.Key}", serializerTypeName, "a SerializationSetup to register the serializer")); + var message = AkkaFeatures.NotBuiltIn( + $"akka.actor.serializers.{kvp.Key}", serializerTypeName, "a SerializationSetup to register the serializer"); + // moduleSkew, when present, is the real reason the module's own serializer didn't build - e.g. a + // missing Google.Protobuf.dll - and "enable the switch" alone would hide that from whoever reads this + throw moduleSkew is null ? new ConfigurationException(message) : new ConfigurationException(message, moduleSkew); } } @@ -407,7 +415,7 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules { messageType = builtInType; } - else if (moduleResolver.FindBoundType(bindingName, bindingAssembly) is { } moduleType) + else if (FindModuleBoundType(bindingName, bindingAssembly, builtModules) is { } moduleType) { messageType = moduleType; } @@ -487,6 +495,73 @@ private static Func FindCoreSerializerF ? builtInFactory : null; + /// + /// The module 's assembly half names, building it - calling its + /// - the first time this method is asked about that assembly, and + /// reusing the result (or the lack of one) for every later row from the same assembly. Returns the + /// serializer this row names, and separately, the exception the build itself threw, if it threw a + /// version-skew one - the caller can surface that instead of a plain "not built in" when it has nothing + /// else to resolve the row with. + /// + private static (SerializerDetails Details, Exception SkewError) FindModuleSerializer( + string typeName, ExtendedActorSystem system, ModuleSerializerTable modules, + Dictionary builtModules) + { + if (!Akka.Util.TypeExtensions.TrySplitTypeName(typeName, out var name, out var assembly) || assembly is null) + return (null, null); + + if (!builtModules.TryGetValue(assembly, out var built)) + { + LoadedModule module = null; + Exception skewError = null; + if (modules.ForAssembly(assembly) is { } raw) + module = LoadedModule.TryCreate(raw, system, out skewError); + + built = (module, skewError); + builtModules[assembly] = built; + } + + return (built.Module?.FindSerializer(name, assembly), built.SkewError); + } + + /// + /// Resolves a binding row's type from the modules this config's serializer rows already built + /// (). Null when no built module lists it. Never builds a module itself - + /// a binding row alone is not reason enough to construct every serializer a module has, so a module with + /// no serializer row of its own in this config is invisible here and falls back to reflection, or throws, + /// like any other unresolved row. + /// + /// + /// Two lookups, in order: + /// + /// The module the row's assembly half names, if this config already built it. + /// "Akka.Remote.RemoteWatcher+Heartbeat, Akka.Remote" is answered by Akka.Remote's module, once some + /// Remote.conf serializer row has built it. + /// Every other module this config built. This covers bound types that live outside their module: + /// Remote.conf binds "System.String" and "Akka.Actor.Identify, Akka" to its own serializers, + /// and no module owns CoreLib or Akka.dll, so lookup 1 can't answer for them. + /// + /// Lookup 2 only asks modules this config built. With no Remote serializer rows, a "System.String" + /// binding gets no answer here and still throws with dynamic type loading off, as it did before. The binding + /// loop runs after the serializer loop, so is complete by then. Two modules + /// that list the same name list the same , so the order they are asked in doesn't matter. + /// + private static Type FindModuleBoundType( + string name, string assembly, Dictionary builtModules) + { + if (assembly is not null && builtModules.TryGetValue(assembly, out var owned) && + owned.Module?.FindBoundType(name, assembly) is { } ownedType) + return ownedType; + + foreach (var built in builtModules.Values) + { + if (built.Module?.FindBoundType(name, assembly) is { } type) + return type; + } + + return null; + } + [RequiresUnreferencedCode("Loads a serializer named under [akka.actor.serializers] by name and activates it. The trimmer cannot tell which type that is, so it may have been trimmed away.")] private static SerializerV2 CreateSerializerFromTypeName(string serializerTypeName, ExtendedActorSystem system, Config serializerConfig) { From f066fc5cf60285fc4110ac6988accab7cebf7d43 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 1 Oct 2026 01:56:39 +0000 Subject: [PATCH 6/6] Match the config fallbacks to reference.conf; test that a binding row never builds a module PrimitiveSerializers now falls back to Remote.conf's use-legacy-behavior = on, and the DData cache TTL only falls back when the setting is missing, so an explicit bad value still fails at startup as on dev. Adds a switch-off spec for a binding-only Remote row and checks that a skew hit in Create is kept as the inner exception. --- .../ReplicatorMessageSerializer.cs | 5 ++--- .../Serialization/RemoteSerializersSpec.cs | 18 ++++++++++++++++-- .../Serialization/RemoteSerializers.cs | 8 ++++---- .../Serialization/ModuleSerializersSpec.cs | 4 ++++ src/core/Akka/Serialization/Serialization.cs | 6 +++--- 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs b/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs index 38ed1c8e204..ced515b7fb1 100644 --- a/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs +++ b/src/contrib/cluster/Akka.DistributedData/Serialization/ReplicatorMessageSerializer.cs @@ -157,9 +157,8 @@ private TVal Get(TKey key, int startIndex) public ReplicatorMessageSerializer(Akka.Actor.ExtendedActorSystem system) : base(system) { _ser = new SerializationSupport(system); - var cacheTtl = system.Settings.Config.GetTimeSpan("akka.cluster.distributed-data.serializer-cache-time-to-live"); - if (cacheTtl <= TimeSpan.Zero) - cacheTtl = TimeSpan.FromSeconds(10); // reference.conf's own default, for a system that never loaded it + // reference.conf's default, for a system that never loaded it + var cacheTtl = system.Settings.Config.GetTimeSpan("akka.cluster.distributed-data.serializer-cache-time-to-live", TimeSpan.FromSeconds(10)); _readCache = new SmallCache(4, cacheTtl, m => ReadToProto(m).ToByteArray()); _writeCache = new SmallCache(4, cacheTtl, m => WriteToProto(m).ToByteArray()); diff --git a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs index 83a65f8a9db..b82f3bb7888 100644 --- a/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs +++ b/src/core/Akka.Remote.Tests/Serialization/RemoteSerializersSpec.cs @@ -226,8 +226,7 @@ await ModuleSerializerSpecs.WithSystem("remote-lone-proto", config, null, system /// /// Regression: a binding-only row used to build Remote's whole module just to answer lookup 1, with the - /// same throw as above. A binding row alone is not reason enough to build a module - this one resolves - /// through plain reflection instead, exactly as it would if Akka.Remote were not involved at all. + /// same throw as above. With the switch on it resolves through plain reflection instead. /// [Fact(DisplayName = "Serialization should start with a RemoteWatcher+Heartbeat binding and no Remote serializer row")] public async Task Should_start_With_a_binding_only_RemoteWatcher_Heartbeat_row() @@ -242,6 +241,21 @@ await ModuleSerializerSpecs.WithSystem("remote-binding-only-heartbeat", config, }); } + /// A binding row alone doesn't build a module, so with the switch off nothing resolves this one. + [Fact(DisplayName = "Serialization should reject a RemoteWatcher+Heartbeat binding with no Remote serializer row When dynamic type loading is off")] + public async Task Should_throw_ConfigurationException_When_a_binding_only_row_names_an_unbuilt_module() + { + var config = ConfigurationFactory.ParseString( + @"akka.actor.serialization-bindings { ""Akka.Remote.RemoteWatcher+Heartbeat, Akka.Remote"" = bytes }"); + + await ModuleSerializerSpecs.WithSystem("remote-binding-only-heartbeat-off", config, null, system => + { + var exception = Assert.Throws( + () => ModuleSerializerSpecs.BuildDefault(system, dynamicTypeLoading: false)); + exception.Message.Should().Contain("RemoteWatcher+Heartbeat"); + }); + } + /// Reflection hands a non-empty block to a (system, config) constructor this class lacks; the table does not. [Fact(DisplayName = "Serialization should build akka-misc despite a settings block when dynamic type loading is on")] public async Task Should_build_a_one_constructor_serializer_When_it_has_a_settings_block() diff --git a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs index 61f9eb41ac8..afe2d6dbe2f 100644 --- a/src/core/Akka.Remote/Serialization/RemoteSerializers.cs +++ b/src/core/Akka.Remote/Serialization/RemoteSerializers.cs @@ -22,6 +22,8 @@ namespace Akka.Remote.Serialization /// internal sealed class RemoteSerializers : ModuleSerializers { + private static readonly Config PrimitiveDefaults = ConfigurationFactory.ParseString("use-legacy-behavior = on"); + public override ImmutableHashSet Create(ExtendedActorSystem system) => ImmutableHashSet.Create( SerializerDetails.Create("akka-containers", new MessageContainerSerializer(system), ImmutableHashSet.Create(typeof(ActorSelectionMessage))), @@ -48,12 +50,10 @@ public override ImmutableHashSet Create(ExtendedActorSystem s typeof(RemoteWatcher.Heartbeat), typeof(RemoteWatcher.HeartbeatRsp), typeof(RemoteRouterConfig))), - // the only built-in serializer with its own settings block; the table reads it, falling back to an - // empty block - same as GetBoolean's own default, legacy behavior off - for a system that never - // loaded Remote.conf (PrimitiveSerializers has no (system)-only constructor to fall back to) + // the only built-in serializer with its own settings block; without Remote.conf, use Remote.conf's default SerializerDetails.Create("primitive", new PrimitiveSerializers(system, - system.Settings.Config.GetConfig("akka.actor.serialization-settings.primitive") ?? Config.Empty), + system.Settings.Config.GetConfig("akka.actor.serialization-settings.primitive") ?? PrimitiveDefaults), ImmutableHashSet.Create(typeof(string), typeof(int), typeof(long))), SerializerDetails.Create("proto", new ProtobufSerializer(system), ImmutableHashSet.Create(typeof(Google.Protobuf.IMessage))), diff --git a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs index 872387aa7b0..1588eac3c50 100644 --- a/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs +++ b/src/core/Akka.Tests/Serialization/ModuleSerializersSpec.cs @@ -275,6 +275,10 @@ await WithSystem("module-skew", (Config?)null, async system => // switch off: the ordinary not-built-in error, not a load failure var exception = await Assert.ThrowsAsync(() => Build(system, table, dynamicTypeLoading: false)); exception.Message.Should().Contain("akka.actor.serializers.fake-module"); + + // a skew hit while building the serializers stays visible + if (tableTypeName.Contains(nameof(CreateSkewedModule))) + exception.InnerException.Should().BeOfType(); }); } diff --git a/src/core/Akka/Serialization/Serialization.cs b/src/core/Akka/Serialization/Serialization.cs index b10aed0f210..141825338e0 100644 --- a/src/core/Akka/Serialization/Serialization.cs +++ b/src/core/Akka/Serialization/Serialization.cs @@ -341,9 +341,9 @@ internal Serialization(ExtendedActorSystem system, ModuleSerializerTable modules : new HashSet(_serializerDetails.Select(d => d.Alias), StringComparer.Ordinal); Dictionary setupTypesByName = null; - // modules this config has built, keyed by assembly simple name; a module is built here only - never - // from a binding row - the first time one of its own serializer rows names it. The caught exception, - // when building the module itself is what failed, rides along so a later NotBuiltIn throw can show it. + // modules this config has built, by assembly name. The first serializer row whose assembly names a + // module builds all of that module's serializers; a binding row never builds one. When the build + // itself fails, the exception is kept so a later NotBuiltIn error can show it. var builtModules = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in serializersConfig)