From 86f3584dc766e47be4a3cd37633ed0e33cdf6b17 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 10 Oct 2026 09:00:03 -0700 Subject: [PATCH 1/7] perf(core): cache interface identities and proxy copier dependencies --- .../docs/implementation/serialization.md | 4 + src/Orleans.CodeGenerator/ProxyGenerator.cs | 19 +- .../Manifest/GrainInterfaceTypeResolver.cs | 13 +- .../ProxyCopierResolutionTests.cs | 47 ++++ ...torTests.TestGrainComplexGrain.verified.cs | 4 +- .../GrainInterfaceTypeResolverTests.cs | 138 ++++++++++ .../ProxyCopierResolutionTests.cs | 256 ++++++++++++++++++ 7 files changed, 474 insertions(+), 7 deletions(-) create mode 100644 test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs create mode 100644 test/Orleans.Core.Tests/Manifest/GrainInterfaceTypeResolverTests.cs create mode 100644 test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs diff --git a/docs/site/src/content/docs/implementation/serialization.md b/docs/site/src/content/docs/implementation/serialization.md index 2142d9e8acf..11210ed9b93 100644 --- a/docs/site/src/content/docs/implementation/serialization.md +++ b/docs/site/src/content/docs/implementation/serialization.md @@ -74,6 +74,8 @@ Boxed value-type delegates initialize the existing box. Statically closed value- Grain-reference construction resolves the generated proxy from the registered type manifest and invokes its `(GrainReferenceShared, IdSpan)` constructor. The activator caches the constructor delegate and shares the runtime, interface version, invocation options, and serialization services across references for the same grain type and interface. Each reference retains its own grain key. JIT runtimes use an emitted constructor delegate; NativeAOT uses a cached reflection constructor invoker. Generated `AddInterfaceProxy` registrations preserve the public proxy constructor during trimming. +Generated proxies resolve argument copier dependencies through contracts on the codec provider and retain the resolved copiers in readonly fields. Provider-cached copiers are reused across proxy instances, including closed generic proxies. Closed custom copier factories registered through the type manifest participate in the provider's service cache, so each provider supplies its own completed copier instance. A shared copy context preserves cycles and repeated references across the arguments of one call. + For each grain interface method, generated code captures arguments in an invokable object. The generated proxy submits that object through its proxy base. On the target, generated dispatch metadata invokes the concrete implementation and encodes the response. The request object is serializable like any other Orleans value. Stable method and interface metadata allow caller and target assemblies to evolve independently within the supported versioning rules. Outgoing and incoming call filters wrap the generated invocation; they do not replace serialization or dispatch. @@ -145,6 +147,8 @@ The method identity uses an explicit value, an GetFieldDescriptions( .Where(method => method.MethodTypeParameters.Count == 0) .SelectMany(method => method.GeneratedInvokable.Members); _copierGenerator.GetCopierFieldDescriptions(paramCopiers, fields); + // Resolve copier contracts through the provider's cache and configured factories. + for (var i = 0; i < fields.Count; i++) + { + if (fields[i] is CopierFieldDescription copier) + { + fields[i] = new CopierFieldDescription( + LibraryTypes.DeepCopier_1.ToTypeSyntax(copier.UnderlyingType.ToTypeSyntax()), + copier.FieldName, + copier.UnderlyingType); + } + } + return fields; } @@ -538,7 +550,7 @@ List GetBodyStatements() AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, field.FieldName.ToIdentifierName(), - GetService(field.FieldType)))); + GetCopier(codec.UnderlyingType.ToTypeSyntax())))); } break; } @@ -552,11 +564,10 @@ static ExpressionSyntax Unwrapped(ExpressionSyntax expr) ArgumentList(SeparatedList([Argument(ThisExpression()), Argument(expr)]))); } - static ExpressionSyntax GetService(TypeSyntax type) + static ExpressionSyntax GetCopier(TypeSyntax type) { return InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("OrleansGeneratedCodeHelper"), GenericName(Identifier("GetService"), TypeArgumentList(SingletonSeparatedList(type)))), - ArgumentList(SeparatedList([Argument(ThisExpression()), Argument(IdentifierName(CodecProviderMemberName))]))); + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName(CodecProviderMemberName), GenericName(Identifier("GetDeepCopier"), TypeArgumentList(SingletonSeparatedList(type))))); } } } diff --git a/src/Orleans.Core/Manifest/GrainInterfaceTypeResolver.cs b/src/Orleans.Core/Manifest/GrainInterfaceTypeResolver.cs index 123d65e8be2..01a09105477 100644 --- a/src/Orleans.Core/Manifest/GrainInterfaceTypeResolver.cs +++ b/src/Orleans.Core/Manifest/GrainInterfaceTypeResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using Orleans.Runtime; @@ -13,6 +14,7 @@ public class GrainInterfaceTypeResolver { private readonly IGrainInterfaceTypeProvider[] _providers; private readonly TypeConverter _typeConverter; + private readonly ConcurrentDictionary _interfaceTypes = new(); /// /// Initializes a new instance of the class. @@ -32,11 +34,20 @@ public GrainInterfaceTypeResolver( } /// - /// Returns the for the provided interface. + /// Returns the cached for the provided interface. /// + /// + /// Identities are cached per resolver and CLR interface type, including closed generic arguments. + /// Configured providers define stable identities for the lifetime of the resolver. + /// /// The grain interface. /// The for the provided interface. public GrainInterfaceType GetGrainInterfaceType(Type type) + { + return _interfaceTypes.GetOrAdd(type, static (interfaceType, resolver) => resolver.ResolveGrainInterfaceType(interfaceType), this); + } + + private GrainInterfaceType ResolveGrainInterfaceType(Type type) { if (!type.IsInterface) { diff --git a/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs b/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs new file mode 100644 index 00000000000..a6a7863c2c6 --- /dev/null +++ b/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs @@ -0,0 +1,47 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace Orleans.CodeGenerator.Tests; + +public sealed class ProxyCopierResolutionTests +{ + [Fact] + public async Task ProxyCopierFieldsResolveContractsThroughProviderCaches() + { + const string source = """ + using Orleans; + using System.Collections.Generic; + using System.Threading.Tasks; + namespace TestProject; + [GenerateSerializer] + public sealed class Payload + { + [Id(0)] public int Value { get; set; } + } + public interface IProxyGrain : IGrainWithStringKey + { + ValueTask Send(Payload value, List list, Payload[] array, T generic); + } + """; + var compilation = await TestCompilationHelper.CreateCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new OrleansSerializationSourceGenerator().AsSourceGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updated, out var diagnostics, cancellationToken: TestContext.Current.CancellationToken); + Assert.Empty(diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + Assert.Empty(updated.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + var proxy = driver.GetRunResult().GeneratedTrees.SelectMany(tree => tree.GetRoot(TestContext.Current.CancellationToken).DescendantNodes()) + .OfType().Single(type => type.Identifier.ValueText == "Proxy_IProxyGrain"); + var text = proxy.NormalizeWhitespace().ToFullString(); + Assert.Contains("IDeepCopier", text); + Assert.Contains("IDeepCopier>", text); + Assert.Contains("IDeepCopier", text); + Assert.Contains("IDeepCopier", text); + Assert.Contains("CodecProvider.GetDeepCopier()", text); + Assert.Contains("CodecProvider.GetDeepCopier>()", text); + Assert.Contains("CodecProvider.GetDeepCopier()", text); + Assert.Contains("CodecProvider.GetDeepCopier()", text); + Assert.DoesNotContain("GetService<", text); + Assert.DoesNotContain("Copier_Payload", text); + } +} diff --git a/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs b/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs index 7059a896a4d..e344c56353a 100644 --- a/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs +++ b/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs @@ -104,10 +104,10 @@ public override bool TryCancel() [global::System.CodeDom.Compiler.GeneratedCodeAttribute("OrleansCodeGen", "10.0.0.0"), global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never), global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] internal sealed class Proxy_IComplexGrain : global::Orleans.Runtime.GrainReference, global::TestProject.IComplexGrain { - private readonly OrleansCodeGen.TestProject.Copier_ComplexData _copier_ComplexData_765A40ED2309AF35; + private readonly global::Orleans.Serialization.Cloning.IDeepCopier _copier_ComplexData_765A40ED2309AF35; public Proxy_IComplexGrain(global::Orleans.Runtime.GrainReferenceShared arg0, global::Orleans.Runtime.IdSpan arg1) : base(arg0, arg1) { - _copier_ComplexData_765A40ED2309AF35 = OrleansGeneratedCodeHelper.GetService(this, CodecProvider); + _copier_ComplexData_765A40ED2309AF35 = CodecProvider.GetDeepCopier(); } global::System.Threading.Tasks.Task global::TestProject.IComplexGrain.ProcessData(int arg0, string arg1, global::TestProject.ComplexData arg2, global::System.Threading.CancellationToken arg3) diff --git a/test/Orleans.Core.Tests/Manifest/GrainInterfaceTypeResolverTests.cs b/test/Orleans.Core.Tests/Manifest/GrainInterfaceTypeResolverTests.cs new file mode 100644 index 00000000000..56887656b28 --- /dev/null +++ b/test/Orleans.Core.Tests/Manifest/GrainInterfaceTypeResolverTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Options; +using Orleans.Metadata; +using Orleans.Runtime; +using Orleans.Serialization; +using Orleans.Serialization.Configuration; +using Orleans.Serialization.TypeSystem; +using Xunit; + +namespace UnitTests.Manifest; + +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Runtime")] +public sealed class GrainInterfaceTypeResolverTests(ITestOutputHelper output) +{ + [Fact] + public void WarmConventionLookupDoesNotAllocate() + { + var resolver = CreateResolver(); + var type = typeof(ITestInterface>); + var expected = resolver.GetGrainInterfaceType(type); + for (var i = 0; i < 100; i++) + { + _ = resolver.GetGrainInterfaceType(type); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) + { + _ = resolver.GetGrainInterfaceType(type); + } + + var cachedBytes = GC.GetAllocatedBytesForCurrentThread() - before; + before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) + { + _ = resolver.GetGrainInterfaceTypeByConvention(type); + } + + var conventionBytes = GC.GetAllocatedBytesForCurrentThread() - before; + output.WriteLine($"1000 lookups: cached={cachedBytes} bytes; uncached convention={conventionBytes} bytes"); + Assert.Equal(0, cachedBytes); + Assert.True(conventionBytes > 0); + Assert.Equal(expected, resolver.GetGrainInterfaceTypeByConvention(type)); + } + + [Fact] + public void ConfiguredIdentityIsCachedWithinItsResolver() + { + var firstProvider = new IdentityProvider("first`1"); + var secondProvider = new IdentityProvider("second`1"); + var first = CreateResolver(firstProvider); + var second = CreateResolver(secondProvider); + var type = typeof(ITestInterface); + var firstIdentity = first.GetGrainInterfaceType(type); + var secondIdentity = second.GetGrainInterfaceType(type); + Assert.NotEqual(firstIdentity, secondIdentity); + for (var i = 0; i < 1000; i++) + { + Assert.Equal(firstIdentity, first.GetGrainInterfaceType(type)); + Assert.Equal(secondIdentity, second.GetGrainInterfaceType(type)); + } + + Assert.Equal(1, firstProvider.Calls); + Assert.Equal(1, secondProvider.Calls); + Assert.True(GenericGrainInterfaceType.TryParse(firstIdentity, out var generic)); + Assert.Equal([typeof(int)], generic.GetArguments(CreateConverter())); + var stringIdentity = first.GetGrainInterfaceType(typeof(ITestInterface)); + Assert.NotEqual(firstIdentity, stringIdentity); + Assert.True(GenericGrainInterfaceType.TryParse(stringIdentity, out generic)); + Assert.Equal([typeof(string)], generic.GetArguments(CreateConverter())); + } + + [Fact] + public void ProviderPrecedenceDoesNotChangeConventionLookup() + { + var preferred = new IdentityProvider("preferred`1"); + var fallback = new IdentityProvider("fallback`1"); + var resolver = CreateResolver(preferred, fallback); + var type = typeof(ITestInterface<>); + Assert.Equal(GrainInterfaceType.Create("preferred`1"), resolver.GetGrainInterfaceType(type)); + Assert.NotEqual(resolver.GetGrainInterfaceType(type), resolver.GetGrainInterfaceTypeByConvention(type)); + Assert.Equal(1, preferred.Calls); + Assert.Equal(0, fallback.Calls); + } + + [Fact] + public void FailedResolutionCanBeRetriedAndInvalidTypesAreNotCached() + { + var provider = new IdentityProvider("retry") { Fail = true }; + var resolver = CreateResolver(provider); + Assert.Throws(() => resolver.GetGrainInterfaceType(typeof(ITestInterface))); + provider.Fail = false; + var expected = resolver.GetGrainInterfaceType(typeof(ITestInterface)); + Assert.Equal(expected, resolver.GetGrainInterfaceType(typeof(ITestInterface))); + Assert.Equal(2, provider.Calls); + Assert.Throws(() => resolver.GetGrainInterfaceType(typeof(int))); + Assert.Throws(() => resolver.GetGrainInterfaceType(typeof(int))); + Assert.Equal(2, provider.Calls); + } + + [Fact] + public async Task ConcurrentLookupsPublishTheSameIdentity() + { + var resolver = CreateResolver(); + var results = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run( + () => resolver.GetGrainInterfaceType(typeof(ITestInterface>)), TestContext.Current.CancellationToken))); + Assert.All(results, identity => Assert.Equal(results[0], identity)); + Assert.True(GenericGrainInterfaceType.TryParse(results[0], out var generic)); + Assert.Equal([typeof(List)], generic.GetArguments(CreateConverter())); + } + + private static GrainInterfaceTypeResolver CreateResolver(params IGrainInterfaceTypeProvider[] providers) => new(providers, CreateConverter()); + + private static TypeConverter CreateConverter() => new( + Array.Empty(), Array.Empty(), Array.Empty(), + Options.Create(new TypeManifestOptions { AllowAllTypes = true }), new CachedTypeResolver()); + + private interface ITestInterface { } + + private sealed class IdentityProvider(string identity) : IGrainInterfaceTypeProvider + { + public int Calls; + public bool Fail; + + public bool TryGetGrainInterfaceType(Type type, out GrainInterfaceType result) + { + Interlocked.Increment(ref Calls); + if (Fail) + { + throw new InvalidOperationException("Injected identity resolution failure"); + } + + result = GrainInterfaceType.Create(identity); + return true; + } + } +} diff --git a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs new file mode 100644 index 00000000000..1ea383f89c7 --- /dev/null +++ b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Serialization.Cloning; +using Orleans.Serialization.Configuration; +using Orleans.Serialization.GeneratedCodeHelpers; +using Orleans.Serialization.Invocation; +using Orleans.Serialization.Serializers; +using Xunit; + +namespace Orleans.Serialization.UnitTests; + +[Trait("Category", "BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Serialization")] +public sealed class ProxyCopierResolutionTests +{ + [Fact] + public void WarmProxiesReuseProviderCopiersWithoutConstructingDependencies() + { + var constructions = 0; + var registrations = new ServiceCollection().AddSerializer(); + registrations.AddTransient(services => + { + constructions++; + return ActivatorUtilities.CreateInstance(services); + }); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var copier = provider.GetDeepCopier(); + var pool = services.GetRequiredService(); + var proxy = CreateProxy(pool, provider); + Assert.Same(copier, GetCopier(proxy)); + for (var i = 0; i < 1000; i++) + { + Assert.Same(copier, GetCopier(CreateProxy(pool, provider))); + } + + Assert.Equal(1, constructions); + AssertCopiedArguments(proxy, (value, list, array) => ((ICacheResolutionProxy)proxy).Send(value, list, array)); + } + + [Fact] + public void ClosedGenericProxiesReuseCopiersAndPreserveReferenceIdentity() + { + using var services = new ServiceCollection().AddSerializer().BuildServiceProvider(); + var provider = services.GetRequiredService(); + var pool = services.GetRequiredService(); + var copier = provider.GetDeepCopier(); + var proxy = new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_IGenericCacheResolutionProxy(pool, provider); + var second = new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_IGenericCacheResolutionProxy(pool, provider); + Assert.Same(copier, GetCopier(proxy)); + Assert.Same(copier, GetCopier(second)); + Assert.Same(provider.GetDeepCopier>(), GetCopier>(proxy)); + Assert.Same(provider.GetDeepCopier(), GetCopier(proxy)); + AssertCopiedArguments(proxy, (value, list, array) => ((IGenericCacheResolutionProxy)proxy).Send(value, list, array)); + + var primitiveProxy = new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_IGenericCacheResolutionProxy(pool, provider); + IInvokable? request = null; + primitiveProxy.OnInvoke = value => request = value; + _ = ((IGenericCacheResolutionProxy)primitiveProxy).Send(42, [42], [42]); + Assert.Equal(42, request!.GetArgument(0)); + Assert.Equal([42], Assert.IsType>(request.GetArgument(1))); + Assert.Equal([42], Assert.IsType(request.GetArgument(2))); + Assert.Same(provider.GetDeepCopier(), GetCopier(primitiveProxy)); + } + + [Fact] + public void ClosedFactoriesOverrideGeneratedAndCollectionCopiersPerProvider() + { + var firstCalls = 0; + var secondCalls = 0; + var listCalls = 0; + var firstCopier = new OverrideCopier(); + var secondCopier = new OverrideCopier(); + var listCopier = new ShallowCopier>(); + var firstRegistrations = new ServiceCollection().AddSerializer(); + firstRegistrations.Configure(options => + { + options.AddSerializer(_ => throw new NotSupportedException(), _ => { firstCalls++; return firstCopier; }); + options.AddSerializer>(_ => throw new NotSupportedException(), _ => { listCalls++; return listCopier; }); + }); + var secondRegistrations = new ServiceCollection().AddSerializer(); + secondRegistrations.Configure(options => + options.AddSerializer(_ => throw new NotSupportedException(), _ => { secondCalls++; return secondCopier; })); + using var first = firstRegistrations.BuildServiceProvider(); + using var second = secondRegistrations.BuildServiceProvider(); + for (var i = 0; i < 100; i++) + { + var proxy = CreateProxy(first.GetRequiredService(), first.GetRequiredService()); + Assert.Same(firstCopier, GetCopier(proxy)); + Assert.Same(listCopier, GetCopier>(proxy)); + Assert.Same(secondCopier, GetCopier(CreateProxy(second.GetRequiredService(), second.GetRequiredService()))); + } + + Assert.Equal(1, firstCalls); + Assert.Equal(1, secondCalls); + Assert.Equal(1, listCalls); + var firstProxy = new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_IGenericCacheResolutionProxy( + first.GetRequiredService(), first.GetRequiredService()); + Assert.Same(firstCopier, GetCopier(firstProxy)); + Assert.Same(listCopier, GetCopier>(firstProxy)); + IInvokable? request = null; + firstProxy.OnInvoke = value => request = value; + var input = new CacheResolutionPayload { Value = 42 }; + var list = new List { input }; + _ = ((IGenericCacheResolutionProxy)firstProxy).Send(input, list, []); + Assert.Equal(43, Assert.IsType(request!.GetArgument(0)).Value); + Assert.Same(list, request.GetArgument(1)); + Assert.Equal(1, firstCalls); + Assert.Equal(1, listCalls); + } + + [Fact] + public void FailedFactorySurfacesErrorAndProxyConstructionCanRetry() + { + var calls = 0; + var copier = new OverrideCopier(); + IDeepCopier>? failedDependency = null; + IDeepCopier>? completedDependency = null; + var registrations = new ServiceCollection().AddSerializer(); + registrations.Configure(options => options.AddSerializer( + _ => throw new NotSupportedException(), provider => + { + var dependency = provider.GetDeepCopier>(); + if (++calls == 1) + { + failedDependency = dependency; + throw new InvalidOperationException("Injected copier construction failure"); + } + + completedDependency = dependency; + return copier; + })); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var pool = services.GetRequiredService(); + var error = Assert.Throws(() => CreateProxy(pool, provider)); + Assert.Equal("Injected copier construction failure", error.Message); + Assert.Same(copier, GetCopier(CreateProxy(pool, provider))); + Assert.Same(copier, GetCopier(CreateProxy(pool, provider))); + Assert.Equal(2, calls); + Assert.NotNull(failedDependency); + Assert.NotNull(completedDependency); + Assert.NotSame(failedDependency, completedDependency); + Assert.Same(completedDependency, provider.GetDeepCopier>()); + } + + [Fact] + public async Task ConcurrentProxiesUseOneCompletedFactoryInstance() + { + var calls = 0; + var registrations = new ServiceCollection().AddSerializer(); + registrations.Configure(options => options.AddSerializer( + _ => throw new NotSupportedException(), _ => + { + Interlocked.Increment(ref calls); + return new OverrideCopier(); + })); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var pool = services.GetRequiredService(); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tasks = new Task[32]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Run(async () => + { + await start.Task; + return CreateProxy(pool, provider); + }, TestContext.Current.CancellationToken); + } + + start.SetResult(); + var proxies = await Task.WhenAll(tasks); + var expected = GetCopier(proxies[0]); + Assert.All(proxies, proxy => Assert.Same(expected, GetCopier(proxy))); + Assert.Equal(1, calls); + Assert.Same(expected, provider.GetDeepCopier()); + } + + [Fact] + public void ArbitraryHelperServicesKeepTheirDiLifetimes() + { + var registrations = new ServiceCollection().AddSerializer(); + registrations.AddTransient(); + registrations.AddScoped(); + using var services = registrations.BuildServiceProvider(); + using var firstScope = services.CreateScope(); + using var secondScope = services.CreateScope(); + var first = new CodecProvider(firstScope.ServiceProvider, Microsoft.Extensions.Options.Options.Create(new TypeManifestOptions())); + var second = new CodecProvider(secondScope.ServiceProvider, Microsoft.Extensions.Options.Options.Create(new TypeManifestOptions())); + var caller = new object(); + Assert.NotSame(OrleansGeneratedCodeHelper.GetService(caller, first), OrleansGeneratedCodeHelper.GetService(caller, first)); + var scoped = OrleansGeneratedCodeHelper.GetService(caller, first); + Assert.Same(scoped, OrleansGeneratedCodeHelper.GetService(caller, first)); + Assert.NotSame(scoped, OrleansGeneratedCodeHelper.GetService(caller, second)); + } + + private static void AssertCopiedArguments( + MyInvokableProxyBase proxy, + Func, CacheResolutionPayload[], ValueTask> send) + { + var input = new CacheResolutionPayload { Value = 42 }; + input.Next = input; + input.Children = [input]; + IInvokable? request = null; + proxy.OnInvoke = value => request = value; + _ = send(input, [input], [input]); + var copy = Assert.IsType(request!.GetArgument(0)); + Assert.NotSame(input, copy); + Assert.Equal(42, copy.Value); + Assert.Same(copy, copy.Next); + Assert.Same(copy, copy.Children[0]); + Assert.Same(copy, Assert.IsType>(request.GetArgument(1))[0]); + Assert.Same(copy, Assert.IsType(request.GetArgument(2))[0]); + } + + private static MyInvokableProxyBase CreateProxy(CopyContextPool pool, CodecProvider provider) => + new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_ICacheResolutionProxy(pool, provider); + + private static object GetCopier(MyInvokableProxyBase proxy) => + Assert.Single(proxy.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance), field => field.FieldType == typeof(IDeepCopier)).GetValue(proxy)!; + + public sealed class TransientDependency { } + public sealed class ScopedDependency { } + + private sealed class OverrideCopier : IDeepCopier + { + [return: NotNullIfNotNull(nameof(input))] + public CacheResolutionPayload? DeepCopy(CacheResolutionPayload? input, CopyContext context) => input is null ? null : new() { Value = input.Value + 1 }; + } +} + +[GenerateSerializer] +public sealed class CacheResolutionPayload +{ + [Id(0)] public int Value { get; set; } + [Id(1)] public CacheResolutionPayload? Next { get; set; } + [Id(2)] public List Children { get; set; } = []; +} + +public interface ICacheResolutionProxy : IMyInvokableBaseType +{ + ValueTask Send(CacheResolutionPayload value, List list, CacheResolutionPayload[] array); +} + +public interface IGenericCacheResolutionProxy : IMyInvokableBaseType +{ + ValueTask Send(T value, List list, T[] array); +} From 79ee871d6c3bc2e0820f9dbbbb761b2238a02919 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 10 Oct 2026 09:43:25 -0700 Subject: [PATCH 2/7] perf(serialization): cache concrete copier dependencies --- .../docs/implementation/serialization.md | 2 +- src/Orleans.CodeGenerator/ProxyGenerator.cs | 19 +-- .../OrleansGeneratedCodeHelper.cs | 10 +- .../Serializers/CodecProvider.cs | 8 +- .../ProxyCopierResolutionTests.cs | 17 +-- ...torTests.TestGrainComplexGrain.verified.cs | 4 +- .../ProxyCopierResolutionTests.cs | 128 ++++++++++++++++-- 7 files changed, 142 insertions(+), 46 deletions(-) diff --git a/docs/site/src/content/docs/implementation/serialization.md b/docs/site/src/content/docs/implementation/serialization.md index 11210ed9b93..caa78c38bfb 100644 --- a/docs/site/src/content/docs/implementation/serialization.md +++ b/docs/site/src/content/docs/implementation/serialization.md @@ -74,7 +74,7 @@ Boxed value-type delegates initialize the existing box. Statically closed value- Grain-reference construction resolves the generated proxy from the registered type manifest and invokes its `(GrainReferenceShared, IdSpan)` constructor. The activator caches the constructor delegate and shares the runtime, interface version, invocation options, and serialization services across references for the same grain type and interface. Each reference retains its own grain key. JIT runtimes use an emitted constructor delegate; NativeAOT uses a cached reflection constructor invoker. Generated `AddInterfaceProxy` registrations preserve the public proxy constructor during trimming. -Generated proxies resolve argument copier dependencies through contracts on the codec provider and retain the resolved copiers in readonly fields. Provider-cached copiers are reused across proxy instances, including closed generic proxies. Closed custom copier factories registered through the type manifest participate in the provider's service cache, so each provider supplies its own completed copier instance. A shared copy context preserves cycles and repeated references across the arguments of one call. +Generated proxies retain concrete generated and built-in copier types in readonly fields, allowing direct copier calls. The generated-code helper resolves these dependencies through the codec provider's implementation cache, keyed by the closed concrete copier type. Proxy instances and provider copier lookups reuse completed implementations within the same provider, including closed generic implementations. Interface-typed dependencies follow their configured copier registrations. A shared copy context preserves cycles and repeated references across the arguments of one call. For each grain interface method, generated code captures arguments in an invokable object. The generated proxy submits that object through its proxy base. On the target, generated dispatch metadata invokes the concrete implementation and encodes the response. diff --git a/src/Orleans.CodeGenerator/ProxyGenerator.cs b/src/Orleans.CodeGenerator/ProxyGenerator.cs index ff95b1b51f1..8da7ad250cf 100644 --- a/src/Orleans.CodeGenerator/ProxyGenerator.cs +++ b/src/Orleans.CodeGenerator/ProxyGenerator.cs @@ -68,18 +68,6 @@ private List GetFieldDescriptions( .Where(method => method.MethodTypeParameters.Count == 0) .SelectMany(method => method.GeneratedInvokable.Members); _copierGenerator.GetCopierFieldDescriptions(paramCopiers, fields); - // Resolve copier contracts through the provider's cache and configured factories. - for (var i = 0; i < fields.Count; i++) - { - if (fields[i] is CopierFieldDescription copier) - { - fields[i] = new CopierFieldDescription( - LibraryTypes.DeepCopier_1.ToTypeSyntax(copier.UnderlyingType.ToTypeSyntax()), - copier.FieldName, - copier.UnderlyingType); - } - } - return fields; } @@ -550,7 +538,7 @@ List GetBodyStatements() AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, field.FieldName.ToIdentifierName(), - GetCopier(codec.UnderlyingType.ToTypeSyntax())))); + GetService(field.FieldType)))); } break; } @@ -564,10 +552,11 @@ static ExpressionSyntax Unwrapped(ExpressionSyntax expr) ArgumentList(SeparatedList([Argument(ThisExpression()), Argument(expr)]))); } - static ExpressionSyntax GetCopier(TypeSyntax type) + static ExpressionSyntax GetService(TypeSyntax type) { return InvocationExpression( - MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName(CodecProviderMemberName), GenericName(Identifier("GetDeepCopier"), TypeArgumentList(SingletonSeparatedList(type))))); + MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, IdentifierName("OrleansGeneratedCodeHelper"), GenericName(Identifier("GetService"), TypeArgumentList(SingletonSeparatedList(type)))), + ArgumentList(SeparatedList([Argument(ThisExpression()), Argument(IdentifierName(CodecProviderMemberName))]))); } } } diff --git a/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs b/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs index 53c00627dc0..dc03b8b7b09 100644 --- a/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs +++ b/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs @@ -76,8 +76,12 @@ public void Exit() } /// - /// Unwraps the provided service if it was wrapped. + /// Resolves and unwraps the requested service. /// + /// + /// Concrete deep copier implementations are cached by the codec provider. + /// Other services retain their configured dependency-injection lifetimes. + /// /// The service type. /// The caller. /// The codec provider. @@ -118,7 +122,9 @@ public static TService GetService< } - val = ActivatorUtilities.GetServiceOrCreateInstance(codecProvider.Services); + val = codecProvider is CodecProvider copierProvider && typeof(TService).IsClass && typeof(IDeepCopier).IsAssignableFrom(typeof(TService)) + ? (TService)copierProvider.GetServiceOrCreateInstance(typeof(TService)) + : ActivatorUtilities.GetServiceOrCreateInstance(codecProvider.Services); while (val is IServiceHolder wrapping) { val = wrapping.Value; diff --git a/src/Orleans.Serialization/Serializers/CodecProvider.cs b/src/Orleans.Serialization/Serializers/CodecProvider.cs index 22df77d3f70..2d61d19542e 100644 --- a/src/Orleans.Serialization/Serializers/CodecProvider.cs +++ b/src/Orleans.Serialization/Serializers/CodecProvider.cs @@ -593,15 +593,17 @@ private static void ThrowIfUnsupportedType(Type fieldType) "IL2067", Justification = "Generated manifests and trim-safe manual configuration use the annotated TypeManifestOptions registration methods, which preserve public constructors. Built-in dynamically closed implementations are rooted by DynamicDependency attributes. Other implementation types are resolved from dependency injection before this activation path. The TypeManifestOptions and dictionary boundaries cannot retain the annotations.")] #endif - private object GetServiceOrCreateInstance(Type type, object[]? constructorArguments = null) + internal object GetServiceOrCreateInstance(Type type, object[]? constructorArguments = null) { try { - if (_manifest.SerializerServiceFactories.Count == 0) return ActivateService(type, constructorArguments); + var cacheCopier = type.IsClass && typeof(IDeepCopier).IsAssignableFrom(type); + if (_manifest.SerializerServiceFactories.Count == 0 && !cacheCopier) return ActivateService(type, constructorArguments); if (OrleansGeneratedCodeHelper.TryGetService(type, this) is { } caller) return caller; if (TryGetCached(_serializerServices, type, out var completed)) return completed; if (TryGetSerializerService(type, out var registered)) return registered; - return ConstructService(type, () => ActivateService(type, constructorArguments), beginGraph: false); + var result = ConstructService(type, () => ActivateService(type, constructorArguments), beginGraph: false); + return cacheCopier ? CacheValue(_serializerServices, type, result) : result; } catch (Exception exception) { RecordConstructionFailure(exception); throw; } } diff --git a/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs b/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs index a6a7863c2c6..b42760b60fd 100644 --- a/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs +++ b/test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs @@ -8,7 +8,7 @@ namespace Orleans.CodeGenerator.Tests; public sealed class ProxyCopierResolutionTests { [Fact] - public async Task ProxyCopierFieldsResolveContractsThroughProviderCaches() + public async Task ProxyCopierFieldsPreserveConcreteTypesAndCallSites() { const string source = """ using Orleans; @@ -33,15 +33,12 @@ public interface IProxyGrain : IGrainWithStringKey var proxy = driver.GetRunResult().GeneratedTrees.SelectMany(tree => tree.GetRoot(TestContext.Current.CancellationToken).DescendantNodes()) .OfType().Single(type => type.Identifier.ValueText == "Proxy_IProxyGrain"); var text = proxy.NormalizeWhitespace().ToFullString(); - Assert.Contains("IDeepCopier", text); - Assert.Contains("IDeepCopier>", text); - Assert.Contains("IDeepCopier", text); + Assert.Contains("private readonly OrleansCodeGen.TestProject.Copier_Payload", text); + Assert.Contains("private readonly global::Orleans.Serialization.Codecs.ListCopier", text); + Assert.Contains("private readonly global::Orleans.Serialization.Codecs.ArrayCopier", text); Assert.Contains("IDeepCopier", text); - Assert.Contains("CodecProvider.GetDeepCopier()", text); - Assert.Contains("CodecProvider.GetDeepCopier>()", text); - Assert.Contains("CodecProvider.GetDeepCopier()", text); - Assert.Contains("CodecProvider.GetDeepCopier()", text); - Assert.DoesNotContain("GetService<", text); - Assert.DoesNotContain("Copier_Payload", text); + Assert.Contains("GetService(this, CodecProvider)", text); + Assert.Contains("GetService>(this, CodecProvider)", text); + Assert.DoesNotContain("CodecProvider.GetDeepCopier<", text); } } diff --git a/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs b/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs index e344c56353a..7059a896a4d 100644 --- a/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs +++ b/test/Orleans.CodeGenerator.Tests/snapshots/OrleansSourceGeneratorTests.TestGrainComplexGrain.verified.cs @@ -104,10 +104,10 @@ public override bool TryCancel() [global::System.CodeDom.Compiler.GeneratedCodeAttribute("OrleansCodeGen", "10.0.0.0"), global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never), global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute] internal sealed class Proxy_IComplexGrain : global::Orleans.Runtime.GrainReference, global::TestProject.IComplexGrain { - private readonly global::Orleans.Serialization.Cloning.IDeepCopier _copier_ComplexData_765A40ED2309AF35; + private readonly OrleansCodeGen.TestProject.Copier_ComplexData _copier_ComplexData_765A40ED2309AF35; public Proxy_IComplexGrain(global::Orleans.Runtime.GrainReferenceShared arg0, global::Orleans.Runtime.IdSpan arg1) : base(arg0, arg1) { - _copier_ComplexData_765A40ED2309AF35 = CodecProvider.GetDeepCopier(); + _copier_ComplexData_765A40ED2309AF35 = OrleansGeneratedCodeHelper.GetService(this, CodecProvider); } global::System.Threading.Tasks.Task global::TestProject.IComplexGrain.ProcessData(int arg0, string arg1, global::TestProject.ComplexData arg2, global::System.Threading.CancellationToken arg3) diff --git a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs index 1ea383f89c7..f328bb76386 100644 --- a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs +++ b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs @@ -71,7 +71,47 @@ public void ClosedGenericProxiesReuseCopiersAndPreserveReferenceIdentity() } [Fact] - public void ClosedFactoriesOverrideGeneratedAndCollectionCopiersPerProvider() + public void ConcreteCopiersAreCachedPerProvider() + { + var firstConstructions = 0; + var secondConstructions = 0; + var firstRegistrations = new ServiceCollection().AddSerializer(); + firstRegistrations.AddTransient(services => + { + firstConstructions++; + return ActivatorUtilities.CreateInstance(services); + }); + var secondRegistrations = new ServiceCollection().AddSerializer(); + secondRegistrations.AddTransient(services => + { + secondConstructions++; + return ActivatorUtilities.CreateInstance(services); + }); + using var first = firstRegistrations.BuildServiceProvider(); + using var second = secondRegistrations.BuildServiceProvider(); + var firstProvider = first.GetRequiredService(); + var secondProvider = second.GetRequiredService(); + var firstPool = first.GetRequiredService(); + var secondPool = second.GetRequiredService(); + var firstCopier = GetCopier(CreateProxy(firstPool, firstProvider)); + var secondCopier = GetCopier(CreateProxy(secondPool, secondProvider)); + Assert.IsType(firstCopier); + Assert.IsType(secondCopier); + Assert.NotSame(firstCopier, secondCopier); + for (var i = 0; i < 100; i++) + { + Assert.Same(firstCopier, GetCopier(CreateProxy(firstPool, firstProvider))); + Assert.Same(secondCopier, GetCopier(CreateProxy(secondPool, secondProvider))); + } + + Assert.Same(firstCopier, firstProvider.GetDeepCopier()); + Assert.Same(secondCopier, secondProvider.GetDeepCopier()); + Assert.Equal(1, firstConstructions); + Assert.Equal(1, secondConstructions); + } + + [Fact] + public void ConcreteAndInterfaceDependenciesKeepTheirResolutionSemantics() { var firstCalls = 0; var secondCalls = 0; @@ -92,28 +132,37 @@ public void ClosedFactoriesOverrideGeneratedAndCollectionCopiersPerProvider() using var second = secondRegistrations.BuildServiceProvider(); for (var i = 0; i < 100; i++) { - var proxy = CreateProxy(first.GetRequiredService(), first.GetRequiredService()); + var proxy = CreateGenericProxy(first.GetRequiredService(), first.GetRequiredService()); Assert.Same(firstCopier, GetCopier(proxy)); - Assert.Same(listCopier, GetCopier>(proxy)); - Assert.Same(secondCopier, GetCopier(CreateProxy(second.GetRequiredService(), second.GetRequiredService()))); + Assert.IsType>(GetCopier>(proxy)); + Assert.Same(secondCopier, GetCopier(CreateGenericProxy(second.GetRequiredService(), second.GetRequiredService()))); } Assert.Equal(1, firstCalls); Assert.Equal(1, secondCalls); - Assert.Equal(1, listCalls); + Assert.Equal(0, listCalls); var firstProxy = new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_IGenericCacheResolutionProxy( first.GetRequiredService(), first.GetRequiredService()); Assert.Same(firstCopier, GetCopier(firstProxy)); - Assert.Same(listCopier, GetCopier>(firstProxy)); + Assert.IsType>(GetCopier>(firstProxy)); IInvokable? request = null; firstProxy.OnInvoke = value => request = value; var input = new CacheResolutionPayload { Value = 42 }; var list = new List { input }; _ = ((IGenericCacheResolutionProxy)firstProxy).Send(input, list, []); Assert.Equal(43, Assert.IsType(request!.GetArgument(0)).Value); - Assert.Same(list, request.GetArgument(1)); + var copiedList = Assert.IsType>(request.GetArgument(1)); + Assert.NotSame(list, copiedList); + Assert.Equal(43, Assert.Single(copiedList).Value); Assert.Equal(1, firstCalls); - Assert.Equal(1, listCalls); + Assert.Equal(0, listCalls); + + var concreteProxy = CreateProxy(first.GetRequiredService(), first.GetRequiredService()); + Assert.IsType(GetCopier(concreteProxy)); + request = null; + concreteProxy.OnInvoke = value => request = value; + _ = ((ICacheResolutionProxy)concreteProxy).Send(input, list, []); + Assert.Equal(42, Assert.IsType(request!.GetArgument(0)).Value); } [Fact] @@ -140,10 +189,10 @@ public void FailedFactorySurfacesErrorAndProxyConstructionCanRetry() using var services = registrations.BuildServiceProvider(); var provider = services.GetRequiredService(); var pool = services.GetRequiredService(); - var error = Assert.Throws(() => CreateProxy(pool, provider)); + var error = Assert.Throws(() => CreateGenericProxy(pool, provider)); Assert.Equal("Injected copier construction failure", error.Message); - Assert.Same(copier, GetCopier(CreateProxy(pool, provider))); - Assert.Same(copier, GetCopier(CreateProxy(pool, provider))); + Assert.Same(copier, GetCopier(CreateGenericProxy(pool, provider))); + Assert.Same(copier, GetCopier(CreateGenericProxy(pool, provider))); Assert.Equal(2, calls); Assert.NotNull(failedDependency); Assert.NotNull(completedDependency); @@ -151,6 +200,56 @@ public void FailedFactorySurfacesErrorAndProxyConstructionCanRetry() Assert.Same(completedDependency, provider.GetDeepCopier>()); } + [Fact] + public void FailedConcreteCopierConstructionCanRetry() + { + var calls = 0; + var failure = new InvalidOperationException("Injected concrete copier construction failure"); + var registrations = new ServiceCollection().AddSerializer(); + registrations.AddTransient(services => + { + if (++calls == 1) + { + throw failure; + } + + return ActivatorUtilities.CreateInstance(services); + }); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var pool = services.GetRequiredService(); + Assert.Same(failure, Assert.Throws(() => CreateProxy(pool, provider))); + var proxy = CreateProxy(pool, provider); + Assert.Same(GetCopier(proxy), GetCopier(CreateProxy(pool, provider))); + Assert.Equal(2, calls); + AssertCopiedArguments(proxy, (value, list, array) => ((ICacheResolutionProxy)proxy).Send(value, list, array)); + } + + [Fact] + public async Task ConcurrentProxiesPublishTheSameConcreteCopier() + { + using var services = new ServiceCollection().AddSerializer().BuildServiceProvider(); + var provider = services.GetRequiredService(); + var pool = services.GetRequiredService(); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tasks = new Task[32]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Run(async () => + { + await start.Task; + return CreateProxy(pool, provider); + }, TestContext.Current.CancellationToken); + } + + start.SetResult(); + var proxies = await Task.WhenAll(tasks); + var expected = GetCopier(proxies[0]); + Assert.IsType(expected); + Assert.All(proxies, proxy => Assert.Same(expected, GetCopier(proxy))); + Assert.Same(expected, provider.GetDeepCopier()); + } + [Fact] public async Task ConcurrentProxiesUseOneCompletedFactoryInstance() { @@ -172,7 +271,7 @@ public async Task ConcurrentProxiesUseOneCompletedFactoryInstance() tasks[i] = Task.Run(async () => { await start.Task; - return CreateProxy(pool, provider); + return CreateGenericProxy(pool, provider); }, TestContext.Current.CancellationToken); } @@ -224,8 +323,11 @@ private static void AssertCopiedArguments( private static MyInvokableProxyBase CreateProxy(CopyContextPool pool, CodecProvider provider) => new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_ICacheResolutionProxy(pool, provider); + private static MyInvokableProxyBase CreateGenericProxy(CopyContextPool pool, CodecProvider provider) => + new OrleansCodeGen.Orleans.Serialization.UnitTests.Proxy_IGenericCacheResolutionProxy(pool, provider); + private static object GetCopier(MyInvokableProxyBase proxy) => - Assert.Single(proxy.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance), field => field.FieldType == typeof(IDeepCopier)).GetValue(proxy)!; + Assert.Single(proxy.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance), field => typeof(IDeepCopier).IsAssignableFrom(field.FieldType)).GetValue(proxy)!; public sealed class TransientDependency { } public sealed class ScopedDependency { } From cc04ab8168eac00fdd3455653cf232d099ecf9cf Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 10 Oct 2026 10:28:28 -0700 Subject: [PATCH 3/7] refactor(serialization): generalize provider dependency caching --- .../docs/implementation/serialization.md | 2 +- .../OrleansGeneratedCodeHelper.cs | 16 +--- .../Serializers/CodecProvider.cs | 6 +- .../ProxyCopierResolutionTests.cs | 75 ++++++++++++++++++- 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/docs/site/src/content/docs/implementation/serialization.md b/docs/site/src/content/docs/implementation/serialization.md index caa78c38bfb..e22633fa67c 100644 --- a/docs/site/src/content/docs/implementation/serialization.md +++ b/docs/site/src/content/docs/implementation/serialization.md @@ -74,7 +74,7 @@ Boxed value-type delegates initialize the existing box. Statically closed value- Grain-reference construction resolves the generated proxy from the registered type manifest and invokes its `(GrainReferenceShared, IdSpan)` constructor. The activator caches the constructor delegate and shares the runtime, interface version, invocation options, and serialization services across references for the same grain type and interface. Each reference retains its own grain key. JIT runtimes use an emitted constructor delegate; NativeAOT uses a cached reflection constructor invoker. Generated `AddInterfaceProxy` registrations preserve the public proxy constructor during trimming. -Generated proxies retain concrete generated and built-in copier types in readonly fields, allowing direct copier calls. The generated-code helper resolves these dependencies through the codec provider's implementation cache, keyed by the closed concrete copier type. Proxy instances and provider copier lookups reuse completed implementations within the same provider, including closed generic implementations. Interface-typed dependencies follow their configured copier registrations. A shared copy context preserves cycles and repeated references across the arguments of one call. +Generated proxies retain concrete generated and built-in copier types in readonly fields, allowing direct copier calls. The generated-code helper delegates dependency resolution to the codec provider, which caches registered serializer, codec, copier, activator, and converter implementations by their closed implementation type. Generated dependencies and provider lookups reuse completed implementations within the same provider, including closed generic implementations. Other dependencies retain their configured dependency-injection lifetimes. Interface-typed dependencies follow their configured registrations. A shared copy context preserves cycles and repeated references across the arguments of one call. For each grain interface method, generated code captures arguments in an invokable object. The generated proxy submits that object through its proxy base. On the target, generated dispatch metadata invokes the concrete implementation and encodes the response. diff --git a/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs b/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs index dc03b8b7b09..bfc05f833a9 100644 --- a/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs +++ b/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs @@ -79,7 +79,7 @@ public void Exit() /// Resolves and unwraps the requested service. /// /// - /// Concrete deep copier implementations are cached by the codec provider. + /// Registered serialization implementations are cached by the codec provider. /// Other services retain their configured dependency-injection lifetimes. /// /// The service type. @@ -112,18 +112,8 @@ public static TService GetService< } } - TService val; - if (codecProvider is CodecProvider provider) - { - if (provider.TryGetSerializerService(typeof(TService), out var registered)) - { - return (TService)registered; - } - - } - - val = codecProvider is CodecProvider copierProvider && typeof(TService).IsClass && typeof(IDeepCopier).IsAssignableFrom(typeof(TService)) - ? (TService)copierProvider.GetServiceOrCreateInstance(typeof(TService)) + var val = codecProvider is CodecProvider provider + ? (TService)provider.GetServiceOrCreateInstance(typeof(TService)) : ActivatorUtilities.GetServiceOrCreateInstance(codecProvider.Services); while (val is IServiceHolder wrapping) { diff --git a/src/Orleans.Serialization/Serializers/CodecProvider.cs b/src/Orleans.Serialization/Serializers/CodecProvider.cs index 2d61d19542e..de2b73983bd 100644 --- a/src/Orleans.Serialization/Serializers/CodecProvider.cs +++ b/src/Orleans.Serialization/Serializers/CodecProvider.cs @@ -597,13 +597,13 @@ internal object GetServiceOrCreateInstance(Type type, object[]? constructorArgum { try { - var cacheCopier = type.IsClass && typeof(IDeepCopier).IsAssignableFrom(type); - if (_manifest.SerializerServiceFactories.Count == 0 && !cacheCopier) return ActivateService(type, constructorArguments); + var cacheImplementation = IsRegisteredImplementation(type); + if (_manifest.SerializerServiceFactories.Count == 0 && !cacheImplementation) return ActivateService(type, constructorArguments); if (OrleansGeneratedCodeHelper.TryGetService(type, this) is { } caller) return caller; if (TryGetCached(_serializerServices, type, out var completed)) return completed; if (TryGetSerializerService(type, out var registered)) return registered; var result = ConstructService(type, () => ActivateService(type, constructorArguments), beginGraph: false); - return cacheCopier ? CacheValue(_serializerServices, type, result) : result; + return cacheImplementation ? CacheValue(_serializerServices, type, result) : result; } catch (Exception exception) { RecordConstructionFailure(exception); throw; } } diff --git a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs index f328bb76386..16b1cd2ee63 100644 --- a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs +++ b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs @@ -5,7 +5,9 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Orleans.Serialization.Activators; using Orleans.Serialization.Cloning; +using Orleans.Serialization.Codecs; using Orleans.Serialization.Configuration; using Orleans.Serialization.GeneratedCodeHelpers; using Orleans.Serialization.Invocation; @@ -110,6 +112,58 @@ public void ConcreteCopiersAreCachedPerProvider() Assert.Equal(1, secondConstructions); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RegisteredSerializationImplementationsReuseProviderInstances(bool helperFirst) + { + AssertImplementationCached( + static _ => { }, static provider => provider.GetCodec(), helperFirst); + AssertImplementationCached( + static _ => { }, static provider => provider.GetDeepCopier(), helperFirst); + AssertImplementationCached>( + static _ => { }, static provider => provider.GetCodec>(), helperFirst); + AssertImplementationCached>( + static _ => { }, static provider => provider.GetCodec>(), helperFirst); + AssertImplementationCached>( + static _ => { }, static provider => provider.GetDeepCopier>(), helperFirst); + AssertImplementationCached>( + static _ => { }, static provider => provider.GetCodec(), helperFirst); + AssertImplementationCached>( + static _ => { }, static provider => provider.GetDeepCopier(), helperFirst); + AssertImplementationCached( + static options => options.AddActivator(typeof(PayloadActivator)), + static provider => provider.GetActivator(), helperFirst); + } + + private static void AssertImplementationCached( + Action configure, + Func getImplementation, + bool helperFirst) where TService : class + { + var constructions = 0; + var registrations = new ServiceCollection().AddSerializer(); + registrations.Configure(configure); + registrations.AddTransient(services => + { + constructions++; + return ActivatorUtilities.CreateInstance(services); + }); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var caller = new object(); + var expected = helperFirst + ? OrleansGeneratedCodeHelper.GetService(caller, provider) + : Assert.IsType(getImplementation(provider)); + for (var i = 0; i < 100; i++) + { + Assert.Same(expected, OrleansGeneratedCodeHelper.GetService(caller, provider)); + Assert.Same(expected, getImplementation(provider)); + } + + Assert.Equal(1, constructions); + } + [Fact] public void ConcreteAndInterfaceDependenciesKeepTheirResolutionSemantics() { @@ -283,8 +337,10 @@ public async Task ConcurrentProxiesUseOneCompletedFactoryInstance() Assert.Same(expected, provider.GetDeepCopier()); } - [Fact] - public void ArbitraryHelperServicesKeepTheirDiLifetimes() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ArbitraryHelperServicesKeepTheirDiLifetimes(bool closedFactories) { var registrations = new ServiceCollection().AddSerializer(); registrations.AddTransient(); @@ -292,8 +348,14 @@ public void ArbitraryHelperServicesKeepTheirDiLifetimes() using var services = registrations.BuildServiceProvider(); using var firstScope = services.CreateScope(); using var secondScope = services.CreateScope(); - var first = new CodecProvider(firstScope.ServiceProvider, Microsoft.Extensions.Options.Options.Create(new TypeManifestOptions())); - var second = new CodecProvider(secondScope.ServiceProvider, Microsoft.Extensions.Options.Options.Create(new TypeManifestOptions())); + var options = new TypeManifestOptions(); + if (closedFactories) + { + options.AddSerializerService(static _ => new()); + } + + var first = new CodecProvider(firstScope.ServiceProvider, Microsoft.Extensions.Options.Options.Create(options)); + var second = new CodecProvider(secondScope.ServiceProvider, Microsoft.Extensions.Options.Options.Create(options)); var caller = new object(); Assert.NotSame(OrleansGeneratedCodeHelper.GetService(caller, first), OrleansGeneratedCodeHelper.GetService(caller, first)); var scoped = OrleansGeneratedCodeHelper.GetService(caller, first); @@ -332,6 +394,11 @@ private static object GetCopier(MyInvokableProxyBase proxy) => public sealed class TransientDependency { } public sealed class ScopedDependency { } + public sealed class PayloadActivator : IActivator + { + public CacheResolutionPayload Create() => new(); + } + private sealed class OverrideCopier : IDeepCopier { [return: NotNullIfNotNull(nameof(input))] From dfaa94355191ba89338ee85aebf8ea6411a59b67 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 10 Oct 2026 10:59:55 -0700 Subject: [PATCH 4/7] perf(serialization): avoid warm dependency resolution allocations --- .../docs/implementation/serialization.md | 4 ++ .../Serializers/CodecProvider.cs | 15 ++++-- .../ProxyCopierResolutionTests.cs | 51 ++++++++++++++++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/site/src/content/docs/implementation/serialization.md b/docs/site/src/content/docs/implementation/serialization.md index e22633fa67c..a0d215762b6 100644 --- a/docs/site/src/content/docs/implementation/serialization.md +++ b/docs/site/src/content/docs/implementation/serialization.md @@ -76,6 +76,8 @@ Grain-reference construction resolves the generated proxy from the registered ty Generated proxies retain concrete generated and built-in copier types in readonly fields, allowing direct copier calls. The generated-code helper delegates dependency resolution to the codec provider, which caches registered serializer, codec, copier, activator, and converter implementations by their closed implementation type. Generated dependencies and provider lookups reuse completed implementations within the same provider, including closed generic implementations. Other dependencies retain their configured dependency-injection lifetimes. Interface-typed dependencies follow their configured registrations. A shared copy context preserves cycles and repeated references across the arguments of one call. +The provider retains completed implementations and their dependency graphs for its lifetime, using its existing service cache. Concurrent cold resolutions can construct multiple implementations and converge on the published instance; closed service factories use the provider's coordinated construction graph. Warm lookups reuse completed instances without allocating construction state. + For each grain interface method, generated code captures arguments in an invokable object. The generated proxy submits that object through its proxy base. On the target, generated dispatch metadata invokes the concrete implementation and encodes the response. The request object is serializable like any other Orleans value. Stable method and interface metadata allow caller and target assemblies to evolve independently within the supported versioning rules. Outgoing and incoming call filters wrap the generated invocation; they do not replace serialization or dispatch. @@ -149,6 +151,8 @@ The message also carries the grain interface type and version used by version se The interface-type resolver caches successful identities for its lifetime, keyed by the CLR interface type. Each closed generic interface has its own cache entry with its exact type arguments. Configured identity providers retain their precedence over naming conventions and define stable identities for the resolver's lifetime. +Each entry retains its CLR interface type and formatted identity until the resolver is released. Cache size follows the distinct interfaces and closed generic combinations used by the application. + ### Source and referenced assembly metadata Syntax providers create stable models for source-declared serializable types and proxy interfaces. A separate compilation-dependent stage scans source and referenced assembly metadata for application parts, aliases, compound aliases, serializer registrations, proxy interfaces, implementations, and assembly-level invokable mappings. diff --git a/src/Orleans.Serialization/Serializers/CodecProvider.cs b/src/Orleans.Serialization/Serializers/CodecProvider.cs index de2b73983bd..7eafbfa5073 100644 --- a/src/Orleans.Serialization/Serializers/CodecProvider.cs +++ b/src/Orleans.Serialization/Serializers/CodecProvider.cs @@ -597,19 +597,26 @@ internal object GetServiceOrCreateInstance(Type type, object[]? constructorArgum { try { - var cacheImplementation = IsRegisteredImplementation(type); - if (_manifest.SerializerServiceFactories.Count == 0 && !cacheImplementation) return ActivateService(type, constructorArguments); if (OrleansGeneratedCodeHelper.TryGetService(type, this) is { } caller) return caller; if (TryGetCached(_serializerServices, type, out var completed)) return completed; if (TryGetSerializerService(type, out var registered)) return registered; - var result = ConstructService(type, () => ActivateService(type, constructorArguments), beginGraph: false); - return cacheImplementation ? CacheValue(_serializerServices, type, result) : result; + var cacheImplementation = IsRegisteredImplementation(type); + if (_manifest.SerializerServiceFactories.Count == 0 && !cacheImplementation) return ActivateService(type, constructorArguments); + return CreateService(this, type, constructorArguments, cacheImplementation); } catch (Exception exception) { RecordConstructionFailure(exception); throw; } + + // Allocate captured construction state only on a cache miss. + static object CreateService(CodecProvider provider, Type type, object[]? constructorArguments, bool cacheImplementation) + { + var result = provider.ConstructService(type, () => provider.ActivateService(type, constructorArguments), beginGraph: false); + return cacheImplementation ? provider.CacheValue(provider._serializerServices, type, result) : result; + } } private bool IsRegisteredImplementation(Type type) { + // Manifest implementations have provider lifetime; ordinary DI services keep their configured lifetimes. var definition = type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; return _manifest.SerializerTypes.Contains(type) || _manifest.SerializerTypes.Contains(definition) || _manifest.FieldCodecTypes.Contains(type) || _manifest.FieldCodecTypes.Contains(definition) diff --git a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs index 16b1cd2ee63..5534e0de04a 100644 --- a/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs +++ b/test/Orleans.Serialization.UnitTests/ProxyCopierResolutionTests.cs @@ -20,8 +20,57 @@ namespace Orleans.Serialization.UnitTests; [TestSuite("BVT")] [TestProvider("None")] [TestArea("Serialization")] -public sealed class ProxyCopierResolutionTests +public sealed class ProxyCopierResolutionTests(ITestOutputHelper output) { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WarmRegisteredDependencyResolutionDoesNotAllocate(bool closedFactories) + { + var constructions = 0; + var registrations = new ServiceCollection().AddSerializer(); + registrations.AddTransient(services => + { + constructions++; + return ActivatorUtilities.CreateInstance(services); + }); + if (closedFactories) + { + registrations.Configure(options => + options.AddSerializerService(static _ => new())); + } + + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var caller = new object(); + var expected = OrleansGeneratedCodeHelper.GetService(caller, provider); + for (var i = 0; i < 100; i++) + { + _ = OrleansGeneratedCodeHelper.GetService(caller, provider); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) + { + _ = OrleansGeneratedCodeHelper.GetService(caller, provider); + } + + var cachedBytes = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.Equal(1, constructions); + Assert.Same(expected, provider.GetDeepCopier()); + before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) + { + _ = ActivatorUtilities.GetServiceOrCreateInstance(provider.Services); + } + + var activationBytes = GC.GetAllocatedBytesForCurrentThread() - before; + output.WriteLine($"1000 warm resolutions: cached={cachedBytes} bytes and 0 constructors; uncached activation={activationBytes} bytes and {constructions - 1} constructors; closed factories={closedFactories}"); + Assert.Equal(0, cachedBytes); + Assert.True(activationBytes > 0); + Assert.Equal(1001, constructions); + } + [Fact] public void WarmProxiesReuseProviderCopiersWithoutConstructingDependencies() { From ebe6f5d4a77b4554a650e9229080cdb3d2b54add Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 10 Oct 2026 17:12:51 -0700 Subject: [PATCH 5/7] docs: clarify cached provider and registration contracts --- .../IDs/GrainInterfaceType.cs | 11 +++ .../Configuration/TypeManifestOptions.cs | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/Orleans.Core.Abstractions/IDs/GrainInterfaceType.cs b/src/Orleans.Core.Abstractions/IDs/GrainInterfaceType.cs index 5ab7593821c..e7b1692a3b0 100644 --- a/src/Orleans.Core.Abstractions/IDs/GrainInterfaceType.cs +++ b/src/Orleans.Core.Abstractions/IDs/GrainInterfaceType.cs @@ -93,6 +93,12 @@ public interface IGrainInterfaceTypeProvider /// /// if a corresponding to the provided type was found, otherwise . /// + /// + /// Implementations must return a stable success result and identifier for each CLR interface type + /// for the lifetime of the resolver using this provider. The resolver caches resolved identities, + /// including closed generic interface types. Implementations must support concurrent calls; + /// concurrent cache misses can invoke this method more than once for the same type. + /// bool TryGetGrainInterfaceType(Type type, out GrainInterfaceType grainInterfaceType); } @@ -145,6 +151,11 @@ public interface IGrainInterfaceTypeProviderAttribute /// /// The corresponding to the provided type. /// + /// + /// The identifier must remain stable for each CLR interface type for the lifetime of the resolver + /// using this attribute. Implementations must support concurrent calls, including repeated calls + /// for the same type during concurrent identity resolution. + /// GrainInterfaceType GetGrainInterfaceType(IServiceProvider services, Type type); } diff --git a/src/Orleans.Serialization/Configuration/TypeManifestOptions.cs b/src/Orleans.Serialization/Configuration/TypeManifestOptions.cs index e575040170d..7d7ec0ffc67 100644 --- a/src/Orleans.Serialization/Configuration/TypeManifestOptions.cs +++ b/src/Orleans.Serialization/Configuration/TypeManifestOptions.cs @@ -55,6 +55,12 @@ public sealed class TypeManifestOptions /// The serialized type. /// The factory for the field codec. /// The factory for the deep copier. + /// + /// Each caches the completed codec and copier instances for its lifetime + /// by their closed service types. Factory results and their dependencies therefore have provider lifetime, + /// including instances obtained from transient or scoped dependency-injection registrations. + /// Implementations must support reuse across concurrent serialization and copying operations. + /// public void AddSerializer( Func> codecFactory, Func> copierFactory) @@ -79,6 +85,9 @@ public void AddSerializer( /// The codec provider constructs and caches one instance per service type. Recursive generated /// constructors retain references to in-progress dependencies through the generated-code helper. /// The first registration for a service type is used. + /// Cached instances and their dependencies have the lifetime, including + /// instances obtained from transient or scoped dependency-injection registrations. Services must + /// support concurrent use by serialization implementations. /// During graph construction, dependencies resolve through provider-owned services and these closed /// factories. Supply external dependencies through explicit constructor arguments or factories which /// return captured instances. Ordinary dependency injection resumes after the graph is published. @@ -336,6 +345,11 @@ internal bool DiscoverInterfaces(Type type, Type contractType) /// Adds a serializer implementation type and preserves the members used to inspect and activate it. /// /// The serializer implementation type. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddSerializer( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(ImplementationTypeMembers)] @@ -346,6 +360,11 @@ public void AddSerializer( /// Adds a field codec implementation type and preserves the members used to inspect and activate it. /// /// The field codec implementation type. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddFieldCodec( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(ImplementationTypeMembers)] @@ -356,6 +375,11 @@ public void AddFieldCodec( /// Adds a copier implementation type and preserves the members used to inspect and activate it. /// /// The copier implementation type. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddCopier( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(ImplementationTypeMembers)] @@ -366,6 +390,11 @@ public void AddCopier( /// Adds a converter implementation type and preserves the members used to inspect and activate it. /// /// The converter implementation type. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddConverter( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(ImplementationTypeMembers)] @@ -376,6 +405,11 @@ public void AddConverter( /// Adds an activator implementation type and preserves the members used to inspect and activate it. /// /// The activator implementation type. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddActivator( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(ImplementationTypeMembers)] @@ -399,6 +433,11 @@ private void AddImplementation(HashSet types, InterfaceDiscoveryKinds kind /// /// The codec implementation type. /// The serialized type, or its generic definition for an open generic codec. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddSerializer( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -414,6 +453,11 @@ public void AddSerializer( /// /// The codec implementation type. /// The serialized type, or its generic definition for an open generic codec. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddFieldCodec( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -429,6 +473,11 @@ public void AddFieldCodec( /// /// The base codec implementation type. /// The serialized type, or its generic definition for an open generic codec. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddBaseCodec( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -444,6 +493,11 @@ public void AddBaseCodec( /// /// The value serializer implementation type. /// The serialized type, or its generic definition for an open generic serializer. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddValueSerializer( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -459,6 +513,11 @@ public void AddValueSerializer( /// /// The copier implementation type. /// The copied type, or its generic definition for an open generic copier. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddCopier( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -474,6 +533,11 @@ public void AddCopier( /// /// The base copier implementation type. /// The copied type, or its generic definition for an open generic copier. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddBaseCopier( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -489,6 +553,11 @@ public void AddBaseCopier( /// /// The activator implementation type. /// The activated type, or its generic definition for an open generic activator. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddActivator( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -505,6 +574,11 @@ public void AddActivator( /// The converter implementation type. /// The converted value type, or its generic definition for an open generic converter. /// The surrogate type, or a generic definition using the converter's arguments in the same order. + /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// public void AddConverter( #if NET5_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] @@ -534,9 +608,14 @@ public void AddConverter( /// The converted type or its generic definition. /// The surrogate description, binding parameters to the converter implementation. /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// /// Array nodes used in an executable surrogate description must be source-known closed types /// supplied through . Use the concrete surrogate type /// overload to register a closed converter and its surrogate directly. + /// /// public void AddConverter( #if NET5_0_OR_GREATER @@ -565,10 +644,15 @@ public void AddConverter( /// The target type description. /// The surrogate description for a converter contract. /// + /// Each caches completed instances by closed implementation type for its lifetime. + /// Implementations and their dependencies have provider lifetime, including instances resolved from + /// transient or scoped dependency-injection registrations, and must support concurrent use. + /// /// Target descriptions support structural array matching. Executable surrogate descriptions /// require source-known closed array types supplied through . /// Registered converters handle matching array targets before the intrinsic array codec and /// copier fallbacks. Directly registered codecs and copiers retain priority over converters. + /// /// public void AddSerializationContract( #if NET5_0_OR_GREATER From dd4c09e56d8b9c3623534de55f604e49081a1ff9 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sat, 10 Oct 2026 17:40:21 -0700 Subject: [PATCH 6/7] docs: remove caching implementation-page additions --- .../site/src/content/docs/implementation/serialization.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/site/src/content/docs/implementation/serialization.md b/docs/site/src/content/docs/implementation/serialization.md index a0d215762b6..2142d9e8acf 100644 --- a/docs/site/src/content/docs/implementation/serialization.md +++ b/docs/site/src/content/docs/implementation/serialization.md @@ -74,10 +74,6 @@ Boxed value-type delegates initialize the existing box. Statically closed value- Grain-reference construction resolves the generated proxy from the registered type manifest and invokes its `(GrainReferenceShared, IdSpan)` constructor. The activator caches the constructor delegate and shares the runtime, interface version, invocation options, and serialization services across references for the same grain type and interface. Each reference retains its own grain key. JIT runtimes use an emitted constructor delegate; NativeAOT uses a cached reflection constructor invoker. Generated `AddInterfaceProxy` registrations preserve the public proxy constructor during trimming. -Generated proxies retain concrete generated and built-in copier types in readonly fields, allowing direct copier calls. The generated-code helper delegates dependency resolution to the codec provider, which caches registered serializer, codec, copier, activator, and converter implementations by their closed implementation type. Generated dependencies and provider lookups reuse completed implementations within the same provider, including closed generic implementations. Other dependencies retain their configured dependency-injection lifetimes. Interface-typed dependencies follow their configured registrations. A shared copy context preserves cycles and repeated references across the arguments of one call. - -The provider retains completed implementations and their dependency graphs for its lifetime, using its existing service cache. Concurrent cold resolutions can construct multiple implementations and converge on the published instance; closed service factories use the provider's coordinated construction graph. Warm lookups reuse completed instances without allocating construction state. - For each grain interface method, generated code captures arguments in an invokable object. The generated proxy submits that object through its proxy base. On the target, generated dispatch metadata invokes the concrete implementation and encodes the response. The request object is serializable like any other Orleans value. Stable method and interface metadata allow caller and target assemblies to evolve independently within the supported versioning rules. Outgoing and incoming call filters wrap the generated invocation; they do not replace serialization or dispatch. @@ -149,10 +145,6 @@ The method identity uses an explicit value, an Date: Sun, 11 Oct 2026 07:30:51 -0700 Subject: [PATCH 7/7] fix(serialization): preserve pending interface dependency routing --- .../OrleansGeneratedCodeHelper.cs | 2 +- .../Serializers/CodecProvider.cs | 16 ++- .../SerializerConstructionReviewTests.cs | 126 ++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs b/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs index bfc05f833a9..aa9618d0cce 100644 --- a/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs +++ b/src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs @@ -113,7 +113,7 @@ public static TService GetService< } var val = codecProvider is CodecProvider provider - ? (TService)provider.GetServiceOrCreateInstance(typeof(TService)) + ? (TService)provider.GetService(typeof(TService)) : ActivatorUtilities.GetServiceOrCreateInstance(codecProvider.Services); while (val is IServiceHolder wrapping) { diff --git a/src/Orleans.Serialization/Serializers/CodecProvider.cs b/src/Orleans.Serialization/Serializers/CodecProvider.cs index 7eafbfa5073..ccb68858ecb 100644 --- a/src/Orleans.Serialization/Serializers/CodecProvider.cs +++ b/src/Orleans.Serialization/Serializers/CodecProvider.cs @@ -577,6 +577,20 @@ private static void ThrowIfUnsupportedType(Type fieldType) } } + internal object GetService( +#if NET5_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + Type type) + { + if (TryGetCached(_serializerServices, type, out var completed)) return completed; + if (TryGetSerializerService(type, out var registered)) return registered; + // Interface holders and external dependencies use the construction provider's routing and restrictions. + return IsRegisteredImplementation(type) + ? GetServiceOrCreateInstance(type) + : ActivatorUtilities.GetServiceOrCreateInstance(Services, type); + } + #if NET5_0_OR_GREATER [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(DefaultReferenceTypeActivator<>))] [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(DefaultValueTypeActivator<>))] @@ -593,7 +607,7 @@ private static void ThrowIfUnsupportedType(Type fieldType) "IL2067", Justification = "Generated manifests and trim-safe manual configuration use the annotated TypeManifestOptions registration methods, which preserve public constructors. Built-in dynamically closed implementations are rooted by DynamicDependency attributes. Other implementation types are resolved from dependency injection before this activation path. The TypeManifestOptions and dictionary boundaries cannot retain the annotations.")] #endif - internal object GetServiceOrCreateInstance(Type type, object[]? constructorArguments = null) + private object GetServiceOrCreateInstance(Type type, object[]? constructorArguments = null) { try { diff --git a/test/Orleans.CodeGenerator.Tests/SerializerConstructionReviewTests.cs b/test/Orleans.CodeGenerator.Tests/SerializerConstructionReviewTests.cs index ee8cc9887c0..38e9233abbd 100644 --- a/test/Orleans.CodeGenerator.Tests/SerializerConstructionReviewTests.cs +++ b/test/Orleans.CodeGenerator.Tests/SerializerConstructionReviewTests.cs @@ -17,6 +17,132 @@ namespace Orleans.CodeGenerator.Tests; [TestArea("CodeGen")] public sealed class SerializerConstructionReviewTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ClosedFactoriesResolveInterfaceDependenciesThroughConstructionServices(bool failFirst) + { + var calls = 0; + InterfaceDependencies? failed = null; + var failure = new InvalidOperationException("Injected closed dependency failure"); + var registrations = new ServiceCollection().AddSerializer(); + registrations.Configure(options => + { + options.AddSerializer(typeof(ConstrainedValueSerializer<>)); + options.AddSerializer(typeof(PendingBaseCodec<>)); + options.AddCopier(typeof(ConstrainedBaseCopier<>)); + options.AddActivator(typeof(ConstrainedActivator<>)); + options.AddSerializerService(provider => + { + var result = new InterfaceDependencies( + OrleansGeneratedCodeHelper.GetService>(null!, provider), + OrleansGeneratedCodeHelper.GetService>(null!, provider), + OrleansGeneratedCodeHelper.GetService>>(null!, provider), + OrleansGeneratedCodeHelper.GetService>>(null!, provider), + OrleansGeneratedCodeHelper.GetService>>(null!, provider), + OrleansGeneratedCodeHelper.GetService>>(null!, provider), + OrleansGeneratedCodeHelper.GetService(null!, provider)); + if (++calls == 1 && failFirst) + { + failed = result; + throw failure; + } + + return result; + }); + }); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + if (failFirst) + { + Assert.Same(failure, Assert.Throws(() => + OrleansGeneratedCodeHelper.GetService(null!, provider))); + } + + var dependencies = OrleansGeneratedCodeHelper.GetService(null!, provider); + Assert.Same(dependencies, OrleansGeneratedCodeHelper.GetService(null!, provider)); + Assert.Same(provider.GetCodec(), dependencies.Codec); + Assert.Same(provider.GetDeepCopier(), dependencies.Copier); + Assert.Same(provider.GetBaseCodec>(), dependencies.BaseCodec); + Assert.Same(provider.GetValueSerializer>(), dependencies.ValueSerializer); + Assert.Same(provider.GetBaseCopier>(), dependencies.BaseCopier); + Assert.Same(provider.GetActivator>(), dependencies.Activator); + Assert.Same(provider, dependencies.Provider); + Assert.Equal(42, dependencies.Copier.DeepCopy(42, null!)); + Assert.IsType>(dependencies.Activator.Create()); + Assert.Equal(failFirst ? 2 : 1, calls); + if (failFirst) + { + Assert.NotSame(failed!.BaseCodec, dependencies.BaseCodec); + Assert.NotSame(failed.ValueSerializer, dependencies.ValueSerializer); + Assert.NotSame(failed.BaseCopier, dependencies.BaseCopier); + Assert.NotSame(failed.Activator, dependencies.Activator); + } + } + + private sealed record InterfaceDependencies( + IFieldCodec Codec, + IDeepCopier Copier, + IBaseCodec> BaseCodec, + IValueSerializer> ValueSerializer, + IBaseCopier> BaseCopier, + IActivator> Activator, + IFieldCodecProvider Provider); + + public sealed class PendingBaseCodec : IBaseCodec> + { + public void Serialize(ref Writer writer, ReferenceModel value) + where TBufferWriter : System.Buffers.IBufferWriter => throw new NotSupportedException(); + + public void Deserialize(ref Reader reader, ReferenceModel value) => throw new NotSupportedException(); + } + + [Fact] + public void ClosedFactoryHelperRejectsExternalDiWithoutPublishingDependencies() + { + var externalCalls = 0; + var calls = 0; + var leaves = 0; + Leaf? failedLeaf = null; + InvalidOperationException? caught = null; + var registrations = new ServiceCollection().AddSerializer(); + registrations.AddTransient(_ => + { + externalCalls++; + return new(); + }); + registrations.Configure(options => + { + options.AddSerializerService(_ => new Leaf(++leaves)); + options.AddSerializerService(provider => + { + var leaf = OrleansGeneratedCodeHelper.GetService(null!, provider); + if (++calls == 1) + { + failedLeaf = leaf; + caught = Assert.Throws(() => + OrleansGeneratedCodeHelper.GetService(null!, provider)); + } + + return new Root(leaf); + }); + }); + using var services = registrations.BuildServiceProvider(); + var provider = services.GetRequiredService(); + var failure = Assert.Throws(() => + OrleansGeneratedCodeHelper.GetService(null!, provider)); + Assert.Same(caught, failure); + Assert.Contains("while a serialization graph is unpublished", failure.Message); + var root = OrleansGeneratedCodeHelper.GetService(null!, provider); + Assert.NotSame(failedLeaf, root.Leaf); + Assert.Equal(2, root.Leaf.Generation); + Assert.Equal(2, calls); + Assert.Equal(2, leaves); + Assert.Equal(0, externalCalls); + Assert.Same(root, OrleansGeneratedCodeHelper.GetService(null!, provider)); + Assert.Same(root.Leaf, OrleansGeneratedCodeHelper.GetService(null!, provider)); + } + [Theory] [InlineData(false)] [InlineData(true)]