Skip to content

AOT prep: annotate CloseAndBuildAs<T> + add a delegate-factory escape hatch for hot-path callers #191

@jeremydmiller

Description

@jeremydmiller

Background

JasperFx.Core.Reflection.TypeExtensions.CloseAndBuildAs<T> is the workhorse for runtime generic instantiation across the Critter Stack. It combines two AOT-unfriendly operations:

// src/JasperFx/Core/Reflection/TypeExtensions.cs:393-417
public static T CloseAndBuildAs<T>(this Type openType, params Type[] parameterTypes)
{
    var closedType = openType.MakeGenericType(parameterTypes);
    return (T)Activator.CreateInstance(closedType)!;
}
// + 1, 2, and 3-arg ctor overloads

It's called from ~40+ sites in Marten alone (storage providers, query handlers, projection wiring, event-store insert ops, the LINQ parser) and similar volumes in Wolverine. Both MakeGenericType and Activator.CreateInstance(Type, ...) are flagged by the .NET trimmer/AOT toolchain.

This is the single biggest AOT blocker shared across Critter Stack consumers.

Proposal — two-part

Part A: annotate the existing API

Mark the existing four overloads with [RequiresDynamicCode] and [RequiresUnreferencedCode]. This surfaces real warnings in consuming projects' CI when they enable <IsTrimmable>true</IsTrimmable> or <PublishAot>true</PublishAot>, giving us a concrete punch-list of call sites.

This is non-breaking — existing call sites continue to work; they just become diagnostically loud under AOT publishing.

Part B: add a delegate-factory escape hatch

For the hot-path consumers (specifically the per-query handlers in Marten's LinqQueryParser, where CloseAndBuildAs is called per query execution), introduce a delegate-cached form:

public static class GenericFactoryCache
{
    private static readonly ConcurrentDictionary<(Type Open, Type Arg1), Func<object, object>> _cache1 = new();

    public static T BuildAs<T>(
        Type openType,
        Type arg,
        object ctorArgument,
        Func<Type, Func<object, object>> factoryFactory)
        => (T)_cache1.GetOrAdd((openType, arg), key => factoryFactory(openType.MakeGenericType(arg)))(ctorArgument);
}

Callers provide the factory builder once (which can be source-generated). Hot paths avoid Activator.CreateInstance per call. Cold path stays compatible.

Actual signature/shape is up for design — the goal is "give callers a way to opt out of the per-call reflection while keeping the existing API as the convenient default."

Impact

Tier-1 vs Tier-2

  • Tier-1 (this issue): annotations + the delegate-factory cache.
  • Tier-2 (follow-up across Marten/Wolverine): source-generate the factory builders so even the first call doesn't need MakeGenericType for known type tuples.

Cross-reference

Marten 9.0 umbrella issue (link to follow once filed). Companions: #189 (cold-start indexed lookup), #190 (ITypeLoader abstraction).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions