TraxLambdaFunction

Abstract base class for AWS Lambda functions that execute Trax trains via direct SDK invocation. Handles service provider lifecycle, envelope-based dispatching, cancellation, and error handling so your Lambda function is just a DI configuration.

Package

dotnet add package Trax.Runner.Lambda

Signature

public abstract class TraxLambdaFunction
{
    protected abstract void ConfigureServices(IServiceCollection services, IConfiguration configuration);
    protected virtual void ConfigureRunner(TraxJobRunnerOptions runner, IConfiguration configuration);
    protected virtual void ConfigureLogging(ILoggingBuilder logging);
    protected virtual IServiceProvider BuildServiceProvider();
    protected virtual TimeSpan TerminalWriteMargin { get; }
 
    public Task<object?> FunctionHandler(
        LambdaEnvelope envelope,
        ILambdaContext context
    );
 
    public Task RunLocalAsync(string[] args);
}

Overridable Members

MemberRequiredDescription
ConfigureServices(IServiceCollection, IConfiguration)YesRegister your Trax effects, mediator, data contexts, and application services. IConfiguration is loaded from appsettings.json (if present) and environment variables. Do not call AddTraxJobRunner() because the base class does this automatically.
ConfigureRunner(TraxJobRunnerOptions, IConfiguration)In practiceSet the runner's posture: runner.SigningKey (shared with UseLambdaWorkers / UseLambdaRun), or runner.AllowUnsignedRequests() for a function only the scheduler's IAM role can invoke. The default sets nothing, so every invocation is refused.
ConfigureLogging(ILoggingBuilder)NoCustomize logging. Default: console logging at Information level.
TerminalWriteMarginNoHow much of ILambdaContext.RemainingTime is held back so a run cancelled by the function timing out can still record its outcome. Default 5 seconds. Cancellation is derived from RemainingTime less this margin: cancelling at RemainingTime itself fires at the instant Lambda freezes or kills the environment, leaving the uncancellable terminal write nowhere to happen, so the row stayed InProgress holding its subject until StaleInProgressTimeout and the reaper then recorded Failed rather than Cancelled. Widen it for a data provider with a slower write path. At most half of the time left is held back: the margin actually used is the smaller of TerminalWriteMargin and RemainingTime / 2, so a function whose timeout is at or below the margin (AWS's default is three seconds) still runs its jobs. The first time an instance has no more time left than the margin it logs a warning to raise the function's timeout. An Execute whose time runs out before its job starts does not start it: the run is recorded Cancelled (only while it is still Pending, written on CancellationToken.None) rather than left Pending for the stale-pending reaper to fail, which would count toward the manifest's retries.
BuildServiceProvider()NoReplace the entire DI graph. The default builds IConfiguration, registers logging, calls ConfigureServices, and finishes with AddTraxJobRunner(runner => ConfigureRunner(runner, configuration)). An override registers AddTraxJobRunner(runner => ...) itself. Override only when you need full control (test harnesses are the typical case). Production code should override ConfigureServices, not this.

Envelope Dispatching

The FunctionHandler entry point receives a LambdaEnvelope directly from the AWS SDK. No API Gateway or Function URL is involved. The envelope's Type field determines the operation:

TypeHandlerDescription
ExecuteITraxRequestHandler.ExecuteJobAsyncFire-and-forget job execution (queue path). Returns RemoteJobResponse.
RunITraxRequestHandler.RunTrainAsyncSynchronous execution with output (run path). Returns RemoteRunResponse.
unknown(none)Throws InvalidOperationException

The LambdaEnvelope is a shared contract defined in Trax.Scheduler:

public record LambdaEnvelope(LambdaRequestType Type, string PayloadJson)
{
    public string? Signature { get; init; }
}
public enum LambdaRequestType { Execute, Run }

Before dispatching, the function checks its posture and, with a SigningKey, the envelope's Signature over the UTF-8 bytes of PayloadJson. A Run must also be fresh and not repeated; its nonce goes to the runner's nonce store, the Trax database by default, so concurrent instances of the function refuse a repeat too. A function with a SigningKey and no UsePostgres or UseSqlite calls runner.UseInMemoryNonceStore() in ConfigureRunner, or registers an INonceStore, or it refuses to start. An Execute is checked for its signature only, because Lambda retries an asynchronous invocation with the same payload; the job's Pending metadata row stops a second run. A refused envelope throws, so the invocation fails and Lambda's retry and dead-letter settings apply.

Examples

Minimal Runner

using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;
using Trax.Runner.Lambda;
 
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
 
public class Function : TraxLambdaFunction
{
    protected override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
    {
        var connString = configuration.GetConnectionString("TraxDatabase")!;
 
        services.AddTrax(trax => trax
            .AddEffects(effects => effects.UsePostgres(connString))
            .AddMediator(typeof(MyTrain).Assembly));
    }
 
    // The same key as UseLambdaWorkers / UseLambdaRun on the scheduler
    protected override void ConfigureRunner(TraxJobRunnerOptions runner, IConfiguration configuration) =>
        runner.SigningKey = Convert.FromBase64String(configuration["Trax:RunnerSigningKey"]!);
}

With Custom Logging and Effects

[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
 
public class Function : TraxLambdaFunction
{
    protected override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
    {
        var connString = configuration.GetConnectionString("TraxDatabase")!;
        var rabbitMq = configuration.GetConnectionString("RabbitMQ")!;
 
        services.AddMyDataContexts(connString);
 
        services.AddTrax(trax => trax
            .AddEffects(effects => effects
                .UsePostgres(connString)
                .SaveTrainParameters()
                .AddJunctionProgress()
                .UseBroadcaster(b => b.UseRabbitMq(rabbitMq)))
            .AddMediator(
                typeof(MyClientTrains.AssemblyMarker).Assembly,
                typeof(MyAdminTrains.AssemblyMarker).Assembly));
    }
 
    protected override void ConfigureLogging(ILoggingBuilder logging)
    {
        logging.AddConsole().SetMinimumLevel(LogLevel.Debug);
    }
}

Local Development

Use RunLocalAsync to run the Lambda function as a local Kestrel web server. This maps POST /trax/execute and POST /trax/run endpoints, which enforce their own posture, and which wrap incoming HTTP request bodies into LambdaEnvelope payloads and execute them through the same handler logic as the Lambda entry point.

// Program.cs
await new Function().RunLocalAsync(args);
Runner postureWhat the local routes accept
SigningKeyRequests with a valid, fresh Trax-Signature header, from any address. The header is checked before the body is read (401), and a body over MaxRequestBodyBytes gets 413
AllowUnsignedRequests()Unsigned requests from this machine's loopback address only, whatever address the server listens on; any other caller gets 401. Unsigned is a posture for the Lambda invocation entry point, which only the scheduler's IAM role can reach. Configure a SigningKey to serve the local routes to another machine, such as a scheduler in a container

This enables a smooth development workflow:

  • Local dev: Scheduler uses UseRemoteWorkers() + UseRemoteRun() to hit the local Kestrel server
  • Production: Scheduler uses UseLambdaWorkers() + UseLambdaRun() for direct SDK invocation

The local server reads its port from appsettings.json (via Kestrel configuration) and exposes the same endpoints that the Lambda would handle in production.

Internally RunLocalAsync delegates the route mapping to an internal ConfigureRoutes(IEndpointRouteBuilder) method. Tests can host the Lambda's HTTP surface against Microsoft.AspNetCore.TestHost by calling ConfigureRoutes directly, without spinning up a real Kestrel listener.

Testing

Two extension points exist specifically for tests:

  1. Override BuildServiceProvider to swap in a fake ITraxRequestHandler (or any other dependency) without exercising AddTraxJobRunner and the full effect/mediator stack.
  2. Call ConfigureRoutes from a TestServer-hosted pipeline to exercise the /trax/execute and /trax/run endpoints in-process. ConfigureRoutes is internal, made visible to the Trax test assemblies via InternalsVisibleTo. TestServer sets no remote address, so a test that exercises the routes unsigned sets HttpContext.Connection.RemoteIpAddress to a loopback address in a middleware first; without it the routes answer 401.
// Unit test: stub the request handler.
private sealed class FakeFunction : TraxLambdaFunction
{
    public ITraxRequestHandler Handler { get; } = new RecordingHandler();
 
    protected override void ConfigureServices(IServiceCollection services, IConfiguration configuration) { }
 
    protected override IServiceProvider BuildServiceProvider()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddSingleton(Handler);
        services.AddSingleton(new TraxJobRunnerOptions().AllowUnsignedRequests());
        services.AddSingleton<RunnerRequestVerifier>();
        return services.BuildServiceProvider();
    }
}

Configuration

The base class automatically builds an IConfiguration from:

  1. appsettings.json (optional, loaded from AppContext.BaseDirectory)
  2. Environment variables

This means you can use appsettings.json for local development and environment variables in Lambda. Both work out of the box. The configuration is passed to ConfigureServices and registered in DI as IConfiguration.

Cold Start Optimization

The service provider is built lazily on the first invocation, not during Lambda container creation. Subsequent invocations within the same container reuse the same provider.

To minimize cold start time:

  • Keep ConfigureServices lean. Only register what the runner needs.
  • Use SkipMigrations(). Migrations should run from the API or CI, not the Lambda.
  • Avoid unnecessary effect providers. If the runner doesn't need broadcasting, don't register it.

How It Works

  1. Lambda runtime creates an instance of your Function class
  2. On the first FunctionHandler invocation, BuildServiceProvider() is called:
    • Builds IConfiguration from appsettings.json + environment variables
    • Creates a ServiceCollection
    • Registers IConfiguration as a singleton
    • Calls ConfigureLogging() (virtual, overridable)
    • Calls ConfigureServices() (your code)
    • Calls AddTraxJobRunner(runner => ConfigureRunner(runner, configuration)) (automatic)
    • Builds and caches the IServiceProvider
    • The whole method is protected virtual, so test harnesses can replace it wholesale.
  3. Each invocation creates a new DI scope and resolves ITraxRequestHandler
  4. Cancellation is derived from ILambdaContext.RemainingTime
  5. The LambdaEnvelope.Type field determines which handler method is called

Error Handling

For Execute requests, exceptions are logged and returned as a RemoteJobResponse with structured error fields (IsError, ErrorMessage, ExceptionType). ErrorMessage is the message of a TrainException or fixed text, and no stack trace is returned. Errors that occur within the train itself are also persisted to the Metadata table by ServiceTrain.Run. However, pre-train errors (e.g., deserialization failures) only appear in the log output. The payload is read case-insensitively, and one that repeats a property (in the same or a different case) is refused with a JsonException, before any handler runs. The Lambda job submitter on the scheduler side does not read the response (fire-and-forget).

For Run requests, exceptions are logged before being rethrown. ITraxRequestHandler.RunTrainAsync returns a RemoteRunResponse that may contain structured error fields. The Lambda run executor on the scheduler side reads the response and reconstructs a TrainException with the full error context.

See Also