Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/site/src/content/docs/implementation/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ 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.
Expand Down Expand Up @@ -145,6 +149,10 @@ The method identity uses an explicit <xref:Orleans.IdAttribute> value, an <xref:

The message also carries the grain interface type and version used by version selection. Type identity resolves the serialized request body; interface/version metadata selects compatible dispatch. These are related compatibility boundaries with separate responsibilities.

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.
Expand Down
13 changes: 12 additions & 1 deletion src/Orleans.Core/Manifest/GrainInterfaceTypeResolver.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Orleans.Runtime;
Expand All @@ -13,6 +14,7 @@ public class GrainInterfaceTypeResolver
{
private readonly IGrainInterfaceTypeProvider[] _providers;
private readonly TypeConverter _typeConverter;
private readonly ConcurrentDictionary<Type, GrainInterfaceType> _interfaceTypes = new();

/// <summary>
/// Initializes a new instance of the <see cref="GrainInterfaceTypeResolver"/> class.
Expand All @@ -32,11 +34,20 @@ public GrainInterfaceTypeResolver(
}

/// <summary>
/// Returns the <see cref="GrainInterfaceType"/> for the provided interface.
/// Returns the cached <see cref="GrainInterfaceType"/> for the provided interface.
/// </summary>
/// <remarks>
/// Identities are cached per resolver and CLR interface type, including closed generic arguments.
/// Configured providers define stable identities for the lifetime of the resolver.
Comment thread
ReubenBond marked this conversation as resolved.
Resolved
/// </remarks>
/// <param name="type">The grain interface.</param>
/// <returns>The <see cref="GrainInterfaceType"/> for the provided interface.</returns>
public GrainInterfaceType GetGrainInterfaceType(Type type)
{
return _interfaceTypes.GetOrAdd(type, static (interfaceType, resolver) => resolver.ResolveGrainInterfaceType(interfaceType), this);
}

private GrainInterfaceType ResolveGrainInterfaceType(Type type)
{
if (!type.IsInterface)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,12 @@
}

/// <summary>
/// Unwraps the provided service if it was wrapped.
/// Resolves and unwraps the requested service.
/// </summary>
/// <remarks>
/// Registered serialization implementations are cached by the codec provider.
/// Other services retain their configured dependency-injection lifetimes.
/// </remarks>
/// <typeparam name="TService">The service type.</typeparam>
/// <param name="caller">The caller.</param>
/// <param name="codecProvider">The codec provider.</param>
Expand Down Expand Up @@ -108,17 +112,9 @@
}
}

TService val;
if (codecProvider is CodecProvider provider)
{
if (provider.TryGetSerializerService(typeof(TService), out var registered))
{
return (TService)registered;
}

}

val = ActivatorUtilities.GetServiceOrCreateInstance<TService>(codecProvider.Services);
var val = codecProvider is CodecProvider provider
? (TService)provider.GetServiceOrCreateInstance(typeof(TService))
: ActivatorUtilities.GetServiceOrCreateInstance<TService>(codecProvider.Services);
Comment thread
ReubenBond marked this conversation as resolved.
Resolved
while (val is IServiceHolder<TService> wrapping)
{
val = wrapping.Value;
Expand Down Expand Up @@ -345,7 +341,7 @@
continue;
}

current = current.MakeGenericMethod(methodTypeParameters);

Check warning on line 344 in src/Orleans.Serialization/GeneratedCodeHelpers/OrleansGeneratedCodeHelper.cs

View workflow job for this annotation

GitHub Actions / Compatibility analyzers (net10.0 core runtime)

Call to 'System.Reflection.MethodInfo.MakeGenericMethod(params Type[])' can not be statically analyzed. It's not possible to guarantee the availability of requirements of the generic method.
}

var parameters = current.GetParameters();
Expand Down
15 changes: 12 additions & 3 deletions src/Orleans.Serialization/Serializers/CodecProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -593,21 +593,30 @@ 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);
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 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.
Comment thread
ReubenBond marked this conversation as resolved.
Resolved
var definition = type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type;
return _manifest.SerializerTypes.Contains(type) || _manifest.SerializerTypes.Contains(definition)
|| _manifest.FieldCodecTypes.Contains(type) || _manifest.FieldCodecTypes.Contains(definition)
Expand Down
44 changes: 44 additions & 0 deletions test/Orleans.CodeGenerator.Tests/ProxyCopierResolutionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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 ProxyCopierFieldsPreserveConcreteTypesAndCallSites()
{
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<T> : IGrainWithStringKey
{
ValueTask Send(Payload value, List<Payload> 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<ClassDeclarationSyntax>().Single(type => type.Identifier.ValueText == "Proxy_IProxyGrain");
var text = proxy.NormalizeWhitespace().ToFullString();
Assert.Contains("private readonly OrleansCodeGen.TestProject.Copier_Payload", text);
Assert.Contains("private readonly global::Orleans.Serialization.Codecs.ListCopier<global::TestProject.Payload>", text);
Assert.Contains("private readonly global::Orleans.Serialization.Codecs.ArrayCopier<global::TestProject.Payload>", text);
Assert.Contains("IDeepCopier<T>", text);
Assert.Contains("GetService<OrleansCodeGen.TestProject.Copier_Payload>(this, CodecProvider)", text);
Assert.Contains("GetService<global::Orleans.Serialization.Cloning.IDeepCopier<T>>(this, CodecProvider)", text);
Assert.DoesNotContain("CodecProvider.GetDeepCopier<", text);
}
}
138 changes: 138 additions & 0 deletions test/Orleans.Core.Tests/Manifest/GrainInterfaceTypeResolverTests.cs
Original file line number Diff line number Diff line change
@@ -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<List<int>>);
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<int>);
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<string>));
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<InvalidOperationException>(() => resolver.GetGrainInterfaceType(typeof(ITestInterface<int>)));
provider.Fail = false;
var expected = resolver.GetGrainInterfaceType(typeof(ITestInterface<int>));
Assert.Equal(expected, resolver.GetGrainInterfaceType(typeof(ITestInterface<int>)));
Assert.Equal(2, provider.Calls);
Assert.Throws<ArgumentException>(() => resolver.GetGrainInterfaceType(typeof(int)));
Assert.Throws<ArgumentException>(() => 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<List<int>>)), 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<int>)], generic.GetArguments(CreateConverter()));
}

private static GrainInterfaceTypeResolver CreateResolver(params IGrainInterfaceTypeProvider[] providers) => new(providers, CreateConverter());

private static TypeConverter CreateConverter() => new(
Array.Empty<ITypeConverter>(), Array.Empty<ITypeNameFilter>(), Array.Empty<ITypeFilter>(),
Options.Create(new TypeManifestOptions { AllowAllTypes = true }), new CachedTypeResolver());

private interface ITestInterface<T> { }

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;
}
}
}
Loading
Loading