Skip to content

Expose ILambdaSerializer on ILambdaContext#2378

Merged
GarrettBeatty merged 5 commits into
devfrom
GarrettBeatty/expose-serializer-on-context
May 15, 2026
Merged

Expose ILambdaSerializer on ILambdaContext#2378
GarrettBeatty merged 5 commits into
devfrom
GarrettBeatty/expose-serializer-on-context

Conversation

@GarrettBeatty
Copy link
Copy Markdown
Contributor

@GarrettBeatty GarrettBeatty commented May 14, 2026

Summary

Adds a preview Serializer property to ILambdaContext so user code can reuse the ILambdaSerializer that the Lambda function registered with the runtime — either via LambdaBootstrapBuilder.Create(handler, serializer) (custom runtime) or [assembly: LambdaSerializer(typeof(...))] (class-library mode) — without re-instantiating it.

This is a small additive change to enable downstream features — most immediately, simplifying the AOT story for Amazon.Lambda.DurableExecution (follow-up PR), but the property is generally useful for any user-side library that wants to honor the function's configured serialization choice.

Preview API. The property is marked [Experimental("AWSLAMBDA001")]. Class-library mode requires an updated managed Lambda runtime to populate it; until that managed-runtime release ships, class-library users will see context.Serializer == null. Custom-runtime / NativeAOT users get the full behavior immediately.

Motivation

Today the ILambdaSerializer registered by the function lives entirely inside HandlerWrapper's closure (custom runtime) or inside UserCodeLoader._invokeDelegate (class-library mode) — neither path makes it discoverable to downstream code. Libraries that want to (de)serialize within a handler are forced to either:

  1. Take an ILambdaSerializer as an explicit constructor/method argument, duplicating registration.
  2. Hard-code their own serializer instance (e.g., new DefaultLambdaJsonSerializer()), which diverges from the function's actual configuration and silently breaks AOT scenarios.

Exposing the registered serializer via ILambdaContext removes the duplication and lets libraries inherit the function's AOT-aware serializer choice automatically.

Design

Amazon.Lambda.Core

Adds ILambdaSerializer Serializer { get { return null; } } to ILambdaContext, inside the existing #if NET8_0_OR_GREATER block, matching the precedent set by TenantId and TraceId.

  • Default-implemented → no breaking change for any user-implemented ILambdaContext.
  • netstandard2.0 consumers see no change.
  • Marked [Experimental("AWSLAMBDA001")] so callers explicitly opt in (#pragma warning disable AWSLAMBDA001).

Amazon.Lambda.RuntimeSupport

Two registration paths funnel into the same per-invocation setter:

Path 1 — custom runtime:

  • HandlerWrapper.Serializer exposes the serializer the user passed to GetHandlerWrapper(handler, serializer). All 20 serializer-taking overloads populate this field.
  • LambdaBootstrap constructors that take a HandlerWrapper forward handlerWrapper.Serializer into a private _serializer field.

Path 2 — class-library mode:

  • UserCodeLoader.CustomerSerializerInstance (new) exposes the serializer constructed from [assembly: LambdaSerializer].
  • RuntimeSupportInitializer wraps UserCodeInitializer.InitializeAsync so that, after init runs, it calls bootstrap.SetSerializer(userCodeLoader.CustomerSerializerInstance). This is the only place the new internal LambdaBootstrap.SetSerializer setter is intended to be used.

Common per-invocation hookup:

  • LambdaBootstrap.InvokeOnceAsync calls SetSerializerOnContext(impl) after GetNextInvocationAsync returns the per-invocation LambdaContext.
    LambdaContext.Serializer is a public read-only property with an internal setter ({ get; internal set; }). Constructor injection was deliberately avoided so LambdaContext can still be constructed by older RuntimeSupport callers without signature breakage.

Amazon.Lambda.TestUtilities

TestLambdaContext.Serializer is a writable property so tests can mirror the production wiring.

Usage

From an annotated function

#pragma warning disable AWSLAMBDA001  // ILambdaContext.Serializer is preview
public class Functions
{
    public async Task<MyOutput> Handler(MyInput input, ILambdaContext context)
    {
        // Reuse the registered serializer for an ad-hoc round trip
        // (e.g., to forward a payload to another service).
        using var ms = new MemoryStream();
        context.Serializer.Serialize(input, ms);
        ms.Position = 0;
        var roundTripped = context.Serializer.Deserialize<MyInput>(ms);

        return new MyOutput();
    }
}

From a custom runtime

#pragma warning disable AWSLAMBDA001
var serializer = new SourceGeneratorLambdaJsonSerializer<MyJsonContext>();
await LambdaBootstrapBuilder
    .Create<MyInput, MyOutput>(Handler, serializer)
    .Build()
    .RunAsync();

static Task<MyOutput> Handler(MyInput input, ILambdaContext context)
{
    // context.Serializer is the same SourceGeneratorLambdaJsonSerializer<MyJsonContext>
    // instance — AOT-safe because trim analysis follows the user's registered type.
    Assert.Same(serializer, context.Serializer);
    return Task.FromResult(new MyOutput());
}

From a downstream library

#pragma warning disable AWSLAMBDA001
public static class MyHelper
{
    public static T DeserializeUsingContext<T>(string json, ILambdaContext context)
    {
        if (context.Serializer is null)
            throw new InvalidOperationException(
                "No serializer registered. Pass one to LambdaBootstrapBuilder.Create " +
                "or use [assembly: LambdaSerializer(...)].");

        var bytes = Encoding.UTF8.GetBytes(json);
        using var ms = new MemoryStream(bytes);
        return context.Serializer.Deserialize<T>(ms);
    }
}

Compatibility

  • Amazon.Lambda.Core — Minor version bump. Additive; default-implemented interface member only available on net8.0+.
  • Amazon.Lambda.RuntimeSupport — Minor version bump. New public HandlerWrapper.Serializer property; new internal LambdaBootstrap.SetSerializer; new LambdaContextSerializerIsolated shim. No removed or changed signatures.
  • Amazon.Lambda.TestUtilities — Minor version bump. New public TestLambdaContext.Serializer property.

No removed APIs. No changed signatures. netstandard2.0 consumers see no change. Functions referencing an older Amazon.Lambda.Core continue to run; they just see ILambdaContext.Serializer == null because the Isolated shim catches the TypeLoadException from the missing interface member.

Tests

  • Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/.../LambdaContextSerializerTests.cs — 8 tests covering:
    • LambdaContext.Serializer defaults to null when not set.
    • LambdaContextSerializerIsolated.TrySetSerializer populates the property.
    • LambdaContextSerializerIsolated.TrySetSerializer is null-tolerant on the context argument.
    • HandlerWrapper.Serializer is populated for typed-input/output overloads.
    • HandlerWrapper.Serializer is null for raw-stream overloads.
    • All 7 sample serializer-taking overloads propagate the field (regression guard).
    • End-to-end via LambdaBootstrap.InvokeOnceAsync: a handler invoked through the wrapper sees context.Serializer == registered serializer.
    • End-to-end raw-stream handler: context.Serializer stays null.
  • Libraries/test/Amazon.Lambda.Core.Tests/TestLambdaContextSerializerTest.cs — 2 tests covering the TestLambdaContext.Serializer round-trip.

All 275 existing Amazon.Lambda.RuntimeSupport.UnitTests continue to pass, including NativeAOTTests.EnsureNoTrimWarningsDuringPublish which dotnet publishes NativeAOTFunction and asserts zero trim/AOT warnings.

Related

This is the first of two PRs. The follow-up will use ILambdaContext.Serializer to delete ICheckpointSerializer<T>, ReflectionJsonCheckpointSerializer<T>, and the JsonSerializerContext overloads in Amazon.Lambda.DurableExecution — collapsing 8 WrapAsync overloads into 4 and removing all [RequiresUnreferencedCode]/[RequiresDynamicCode] attributes from that package.

Test plan

  • Build all touched projects in Debug & Release
  • All 275 existing Amazon.Lambda.RuntimeSupport.UnitTests pass
  • All 8 existing Amazon.Lambda.Core.Tests pass
  • 10 new tests pass
  • NativeAOTTests.EnsureNoTrimWarningsDuringPublish passes (no new trim warnings)
  • AOT publish of NativeAOTFunction completes with zero IL2026/IL3050 warnings
  • CI green

@GarrettBeatty GarrettBeatty force-pushed the GarrettBeatty/expose-serializer-on-context branch from 1080325 to 3971b6f Compare May 14, 2026 22:34
@GarrettBeatty GarrettBeatty changed the title Garrett beatty/expose serializer on context Expose ILambdaSerializer on ILambdaContext May 14, 2026
Adds an optional `Serializer` property to `ILambdaContext` (default-implemented
to `null` on net8.0+, matching the existing TenantId/TraceId pattern), and has
RuntimeSupport propagate the serializer registered with `HandlerWrapper` /
`LambdaBootstrapBuilder.Create` through `RuntimeApiClient` to the per-invocation
`LambdaContext`. User code can now reuse the configured serializer for
ad-hoc serialization without re-instantiating it.
@GarrettBeatty GarrettBeatty force-pushed the GarrettBeatty/expose-serializer-on-context branch from 3971b6f to 69f9619 Compare May 14, 2026 22:35
Copy link
Copy Markdown
Member

@normj normj left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional things that have to be done to make this work:

  • UserCodeLoader is used for the class library programming model and it takes care of grabbing the ILambdaSerializer via the LambdaSerializerAttribute assembly attribute. Some of that instance needs to be communicate back to your code setting the serializer on the context.
  • Because in class library mode you might have an old Amazon.Lambda.Core that doesn't have the code changes to hold on to the ILambdaSerializer you have to make the code extra defensive. Don't change the constructor of ILambdaContext and make an internal setter. RuntimeSupport has access to internal methods. Then put the code setting the serializer in a separate method and make the calling method do a try/catch around the RuntimeSupport method for setting. You can see examples in RuntimeSupport with classes that have the "Isolated" suffix.
  • Mark the property in Amazon.Lambda.Core for getting the ILambdaSerializer as preview because it won't work correctly for class library mode till the changes are deployed to the managed runtime.

Comment thread Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs Outdated
- Doc wording: clarify that the Lambda function (not "the runtime") registers
  the serializer, and call out both registration paths (LambdaBootstrapBuilder
  for custom runtimes, [assembly: LambdaSerializer] for class-library mode).
- Mark ILambdaContext.Serializer as preview ([Experimental("AWSLAMBDA001")])
  since class-library mode requires an updated managed runtime to populate it.
- Plumb [assembly: LambdaSerializer] for class-library mode: UserCodeLoader
  exposes the constructed serializer; RuntimeSupportInitializer pushes it onto
  LambdaBootstrap inside the wrapped initializer callback.
- Defensive forward-compat: replace constructor injection with an internal
  setter on LambdaContext, and route the assignment through a new
  LambdaContextSerializerIsolated shim. The Isolated pattern (mirroring
  TraceProviderIsolated and the SnapStart helpers) defers JIT resolution of
  the new ILambdaContext.Serializer member to a single one-line method, so a
  TypeLoadException from a stale Amazon.Lambda.Core in the user's function
  can be caught at the call site without crashing the invoke loop. After the
  first failure, _disableSerializerOnContext short-circuits subsequent attempts.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a preview serializer surface to Lambda context plumbing so handlers and downstream libraries can reuse the runtime-registered ILambdaSerializer without creating a separate serializer instance.

Changes:

  • Adds ILambdaContext.Serializer as a preview API in Amazon.Lambda.Core.
  • Propagates registered serializers through HandlerWrapper, LambdaBootstrap, and class-library initialization.
  • Adds TestLambdaContext.Serializer and unit coverage for the new serializer behavior.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs Adds preview Serializer property to the context interface.
Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs Stores serializers on serializer-based handler wrappers.
Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs Carries the serializer into each invocation context.
Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/UserCodeLoader.cs Exposes the class-library serializer instance after initialization.
Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs Adds serializer storage on the runtime context implementation.
Libraries/src/Amazon.Lambda.RuntimeSupport/Helpers/LambdaContextSerializerIsolated.cs Adds isolated helper for setting the serializer on context.
Libraries/src/Amazon.Lambda.RuntimeSupport/RuntimeSupportInitializer.cs Wires class-library serializer initialization into bootstrap setup.
Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs Adds writable serializer property for tests.
Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/LambdaContextSerializerTests.cs Adds RuntimeSupport serializer propagation tests.
Libraries/test/Amazon.Lambda.Core.Tests/TestLambdaContextSerializerTest.cs Adds TestLambdaContext serializer tests.
.autover/changes/6e13a012-1f93-4e55-90b5-d2dd480d086c.json Records minor-version changelog entries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs
Comment thread Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/UserCodeLoader.cs Outdated
- LambdaContextSerializerIsolated: replace cref pointing at the
  Amazon.Lambda.Core namespace with plain text so XML doc generation
  doesn't trip the warnings-as-errors build.
- UserCodeLoader.CustomerSerializerInstance: rewrite the doc comment to
  match the implementation — the cross-version cast happens in
  RuntimeSupportInitializer, not in the Isolated shim.
- TestLambdaContext.Serializer: apply [Experimental("AWSLAMBDA001")] so
  the test-utility surface mirrors the preview opt-in on
  ILambdaContext.Serializer.
- Tests: rename HandlerWrapper_AllSerializerOverloads_PropagateSerializer
  to HandlerWrapper_SerializerOverloadFamilies_PropagateSerializer to
  reflect that it samples one overload per family rather than asserting
  every overload signature. Add two tests covering the class-library
  serializer path: UserCodeLoader.Init resolving an [assembly:
  LambdaSerializer] attribute, and LambdaBootstrap.SetSerializer flowing
  that instance to ILambdaContext.Serializer through the invoke loop.
@GarrettBeatty
Copy link
Copy Markdown
Contributor Author

i also deployed a custom container to test.

Serializer-on-context: real Lambda deployment

End-to-end verification of ILambdaContext.Serializer (PR #2378) against
real AWS Lambda. Both registration paths exercised by deploying one
container image as two separate functions that flip behavior on an env
var.

Inside the deployed Lambda

Two functions, one container image at
147997163238.dkr.ecr.us-east-1.amazonaws.com/serializer-verify:latest.
Same binary; behavior switches on SERIALIZER_TEST_MODE.

The image

provided.al2023 base + a self-contained .NET 8 publish of
Bootstrap.csproj copied to /var/runtime/. The image bundles the
locally-built Amazon.Lambda.RuntimeSupport
(project reference, not
NuGet) — that's the whole point. The managed Lambda runtime never enters
the picture; our bootstrap is the runtime.

/var/runtime/bootstrap                          ← entrypoint (self-contained .NET app)
/var/runtime/HandlerLib.dll                     ← class-library handler with [assembly: LambdaSerializer]
/var/runtime/Amazon.Lambda.RuntimeSupport.dll   ← built from this PR's branch
/var/runtime/Amazon.Lambda.Core.dll             ← built from this PR (has new ILambdaContext.Serializer)
/var/runtime/Amazon.Lambda.Serialization.SystemTextJson.dll
... (rest of the .NET 8 self-contained runtime)

What bootstrap does

It's a tiny Program.Main that branches on SERIALIZER_TEST_MODE:

classlib mode — exercises the [assembly: LambdaSerializer] path:

var initializer = new RuntimeSupportInitializer(_HANDLER);  // "HandlerLib::HandlerLib.Function::Handler"
await initializer.RunLambdaBootstrap();

That's exactly what the managed Lambda runtime does in production.
RuntimeSupportInitializer constructs a UserCodeLoader, which
reflects over HandlerLib.dll, finds
[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer<HandlerLib.SerializerContext>))],
instantiates it, and stashes it on
UserCodeLoader.CustomerSerializerInstance. The wrapped initializer
then calls
bootstrap.SetSerializer((ILambdaSerializer)userCodeLoader.CustomerSerializerInstance).
Each invoke loop iteration calls SetSerializerOnContext(context) via
the Isolated shim.

custom mode — exercises the LambdaBootstrapBuilder path:

var serializer = new SourceGeneratorLambdaJsonSerializer<CustomCtx>();
await LambdaBootstrapBuilder.Create(fn, serializer).Build().RunAsync();

The serializer goes into HandlerWrapper.Serializer, then forward-flows
through the LambdaBootstrap(handlerWrapper, ...) ctor into
_serializer, then onto each invocation's context.Serializer.

The handlers themselves

Both handlers do the same thing — read context.Serializer, round-trip
the input through it, and return what they observed.

HandlerLib (classlib mode, HandlerLib/Handler.cs):

[assembly: LambdaSerializer(typeof(SourceGeneratorLambdaJsonSerializer<HandlerLib.SerializerContext>))]

public static Output Handler(Input input, ILambdaContext context)
{
    var serializer = context.Serializer;
    bool roundTripOk = false;
    if (serializer is not null)
    {
        using var ms = new MemoryStream();
        serializer.Serialize(input, ms);
        ms.Position = 0;
        var roundTripped = serializer.Deserialize<Input>(ms);
        roundTripOk = roundTripped?.Name == input.Name && roundTripped?.Value == input.Value;
    }
    return new Output
    {
        Mode = "classlib",
        SerializerType = serializer?.GetType().FullName ?? "<null>",
        Echoed = input.Name,
        RoundTripOk = roundTripOk
    };
}

Custom mode (inline lambda in Bootstrap/Program.cs): identical
shape, except the handler is a delegate passed to
LambdaBootstrapBuilder.Create, and the serializer is
SourceGeneratorLambdaJsonSerializer<CustomCtx> constructed in Main.

The deployed Lambda functions

Function Image config CMD Env
serializer-verify-classlib HandlerLib::HandlerLib.Function::Handler (Lambda passes this as argv[0], which sets _HANDLER) SERIALIZER_TEST_MODE=classlib, DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true
serializer-verify-custom unused (custom mode ignores _HANDLER but provided.al2023 requires something) SERIALIZER_TEST_MODE=custom, DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true

Both: provided.al2023, x86_64, 512 MB, 30 s timeout, IAM role
serializer-verify-exec with just AWSLambdaBasicExecutionRole.

The actual responses

Region us-east-1, payload {"Name":"hello","Value":42}:

serializer-verify-classlib:
{"Mode":"classlib",
 "SerializerType":"...SourceGeneratorLambdaJsonSerializer`1[[HandlerLib.SerializerContext, HandlerLib, ...]]",
 "Echoed":"hello",
 "RoundTripOk":true}

serializer-verify-custom:
{"Mode":"custom",
 "SerializerType":"...SourceGeneratorLambdaJsonSerializer`1[[SerializerVerifyBootstrap.CustomCtx, bootstrap, ...]]",
 "Echoed":"hello",
 "RoundTripOk":true}

The two SerializerTypes being different (and matching the type
each path registered) is the part that proves the wiring: classlib
found the type from HandlerLib's [assembly: LambdaSerializer]
attribute via reflection, custom found the one constructed inline in
Program.Main. RoundTripOk:true proves context.Serializer isn't
just non-null but actually functional in-handler.v to the bootstrap, which
in turn populates _HANDLER.
4. provided.al2023 requires a CMD even in custom-runtime mode.
Set Command=[unused] on the custom-mode function.

@GarrettBeatty GarrettBeatty marked this pull request as ready for review May 15, 2026 15:03
@GarrettBeatty GarrettBeatty requested review from a team as code owners May 15, 2026 15:03
@GarrettBeatty GarrettBeatty requested review from normj and philasmar May 15, 2026 15:03
"Name": "Amazon.Lambda.Core",
"Type": "Minor",
"ChangelogMessages": [
"Add ILambdaSerializer Serializer property to ILambdaContext (default-implemented to null on net8.0+) so user code can access the serializer registered with the runtime"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call out the property is preview. There will be another release where we take the preview flag off once the managed runtime has been deployed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

/// <see cref="ILambdaContext"/>, allowing user code to reuse it.
/// Null for handlers that don't take a typed input/output.
/// </summary>
public ILambdaSerializer Serializer { get; private set; }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be internal. No reason to expand our public API contract HandlerWrapper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

@GarrettBeatty GarrettBeatty requested a review from normj May 15, 2026 20:49
@GarrettBeatty GarrettBeatty merged commit 4c9eac9 into dev May 15, 2026
5 of 6 checks passed
@GarrettBeatty GarrettBeatty deleted the GarrettBeatty/expose-serializer-on-context branch May 15, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants