How-to guide

Run trains on AWS Lambda

This puts chosen trains on an AWS Lambda function: their queued runs through UseLambdaWorkers, their synchronous runs through UseLambdaRun. The scheduler invokes the function directly through the AWS SDK, so the function needs no URL or API Gateway. Whether Lambda is the right place for a train is covered in Remote Execution.

1. Write the function

Create a class library for the function, referencing Trax.Runner.Lambda and the assemblies that hold your trains, and derive from TraxLambdaFunction:

using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.JunctionProvider.Progress.Extensions;
using Trax.Mediator.Extensions;
using Trax.Runner.Lambda;
using Trax.Scheduler.Configuration;
 
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
 
public class Function : TraxLambdaFunction
{
    protected override void ConfigureServices(IServiceCollection services, IConfiguration configuration) =>
        services.AddTrax(trax => trax
            .AddEffects(effects => effects
                .UsePostgres(configuration.GetConnectionString("TraxDatabase")!)
                .SkipMigrations()
                .AddJunctionProgress())
            .AddMediator(typeof(Function).Assembly));
 
    protected override void ConfigureRunner(TraxJobRunnerOptions runner, IConfiguration configuration) =>
        runner.SigningKey = Convert.FromBase64String(configuration["Trax:RunnerSigningKey"]!);
}

ConfigureServices registers what the trains need and nothing else; the base class adds the job runner. AddJunctionProgress() lets a cancel from the dashboard reach the train at its next junction. ConfigureRunner gives the runner its posture. A signing key, at least 32 bytes, is the posture to use; runner.AllowUnsignedRequests() is for a function only the scheduler's IAM role can invoke. Without either, every invocation is refused.

Configuration comes from appsettings.json next to the binary and from environment variables, so set ConnectionStrings__TraxDatabase and Trax__RunnerSigningKey on the function.

2. Deploy it

Deploy the function with your usual tooling, with the handler set to the FunctionHandler method of your Function class. Give it a timeout comfortably longer than your longest train plus five seconds: the function cancels a job five seconds (TerminalWriteMargin) before its own timeout so the run can record its outcome.

Keep the payloads within Lambda's limits: an asynchronous invocation (a queued run) carries at most 256 KB of input, and a synchronous response (a run's output) at most 6 MB. A train whose input is larger stores it elsewhere and passes a reference.

3. Let the scheduler invoke it

Grant the scheduler's role lambda:InvokeFunction on the function:

{
  "Effect": "Allow",
  "Action": "lambda:InvokeFunction",
  "Resource": "arn:aws:lambda:us-east-1:123456789012:function:content-shield-runner"
}

Then route the trains to it, with the same signing key:

using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
using Trax.Scheduler.Lambda.Extensions;
 
var signingKey = Convert.FromBase64String(builder.Configuration["Trax:RunnerSigningKey"]!);
 
builder.Services.AddTrax(trax => trax
    .AddEffects(effects => effects.UsePostgres(connectionString))
    .AddMediator(typeof(IReviewContentTrain).Assembly)
    .AddScheduler(scheduler => scheduler
        .UseLambdaWorkers(
            lambda =>
            {
                lambda.FunctionName = "content-shield-runner";
                lambda.SigningKey = signingKey;
            },
            routing => routing.ForTrain<IReviewContentTrain>())
        .UseLambdaRun(lambda =>
        {
            lambda.FunctionName = "content-shield-runner";
            lambda.SigningKey = signingKey;
        })));

Trains you do not route keep running on the scheduler's local workers.

4. Run the function locally

RunLocalAsync serves the function as a Kestrel server with POST /trax/execute and POST /trax/run, running each request through the same handler as Lambda. Give the function project a Program.cs:

await new Function().RunLocalAsync(args);

Set the port in the function's appsettings.json under Kestrel:Endpoints. In released Trax.Runner.Lambda versions the server reads appsettings.json from the current directory, so start it from the project directory or pass --contentRoot with the output directory; from the next release it reads it from the output directory.

Point a local scheduler at it with the HTTP transports and the same key, and switch to the Lambda transports in production:

using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
 
var signingKey = Convert.FromBase64String(builder.Configuration["Trax:RunnerSigningKey"]!);
 
builder.Services.AddTrax(trax => trax
    .AddEffects(effects => effects.UsePostgres(connectionString))
    .AddMediator(typeof(IReviewContentTrain).Assembly)
    .AddScheduler(scheduler => scheduler
        .UseRemoteWorkers(
            remote =>
            {
                remote.BaseUrl = "http://localhost:5399/trax/execute";
                remote.SigningKey = signingKey;
            },
            routing => routing.ForTrain<IReviewContentTrain>())
        .UseRemoteRun(remote =>
        {
            remote.BaseUrl = "http://localhost:5399/trax/run";
            remote.SigningKey = signingKey;
        })));

A runner with AllowUnsignedRequests() accepts local requests only from the loopback address, so a scheduler in a container needs the signing key.

5. Test it end to end

To run the real function in a test, with no AWS, serve it with RunLocalAsync on a free port from a subclass that supplies the test configuration, and point the scheduler's UseRemoteWorkers and UseRemoteRun at that port:

using Trax.Scheduler.Extensions;
 
internal sealed class TestRunner(IConfiguration configuration) : Function
{
    protected override IServiceProvider BuildServiceProvider()
    {
        IServiceCollection services = new ServiceCollection();
        services.AddSingleton(configuration);
        services.AddLogging();
        ConfigureServices(services, configuration);
        services.AddTraxJobRunner(runner => ConfigureRunner(runner, configuration));
        return services.BuildServiceProvider();
    }
}
var url = "http://127.0.0.1:5399";   // a free port
using var stop = new CancellationTokenSource();
var runner = new TestRunner(testConfiguration)
    .RunLocalAsync([$"--Kestrel:Endpoints:Http:Url={url}"], stop.Token);
 
// ... the tests ...
 
await stop.CancelAsync();
await runner;

The overload that takes a token is new in the next Trax.Runner.Lambda release; without it the server runs until the test process ends. To test the function's own wiring without a database, override BuildServiceProvider to register a fake ITraxRequestHandler with the runner options and RunnerRequestVerifier, and call FunctionHandler with an envelope.

6. Keep cold starts short

The function builds its service provider on the first invocation of each instance and reuses it after. To keep that first invocation fast:

  • Register only what the trains need in ConfigureServices.
  • Call SkipMigrations(), as above, and run migrations from the API host or CI.
  • Leave out effect providers the function does not use, such as a broadcaster.

SDK Reference

TraxLambdaFunction | UseLambdaWorkers | UseLambdaRun | UseRemoteWorkers | UseRemoteRun