# Background worker with Inngest Connect

> A background worker with no HTTP endpoint that runs Inngest functions over Connect, with a web app that only sends events, a Dockerfile and graceful shutdown.

Web page: https://damikun.github.io/inngest-dotnet/examples/connect-worker/

This example splits an app in two: a public web app that accepts uploads and sends events, and a worker in a private
network that processes them. The worker has no inbound port at all. It connects out to Inngest with
[Connect](https://damikun.github.io/inngest-dotnet/guides/connect.md) and receives its work over that connection.

## The events

The web app sends `ImageUploaded`, which triggers the worker, and the worker announces `ThumbnailsReady` when it is
done. The records live in a small class library that both projects reference, so the name and the data of an event are
written once. The library needs only the `Inngest` package:

```csharp title="Contracts/Events.cs"
using Inngest;

public sealed record ImageUploaded(string ImageId, string BlobPath) : IInngestEventData
{
    public static string EventName => "media/image.uploaded";
}

public sealed record ThumbnailsReady(string ImageId, string[] Paths) : IInngestEventData
{
    public static string EventName => "media/thumbnails.ready";
}
```

## The worker

```csharp title="Worker/Program.cs"
using Inngest;

var builder = Host.CreateApplicationBuilder(args);

// Register your IThumbnailer implementation here; any image library works.
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromMinutes(2));
builder.Services
    .AddInngest(options => options.AppId = "media-worker")
    .AddFunction<MakeThumbnails>()
    .AddConnect(connect =>
    {
        // Stable per container, unique between containers.
        connect.InstanceId = Environment.GetEnvironmentVariable("HOSTNAME");
        connect.MaxWorkerConcurrency = 4;
    });

await builder.Build().RunAsync();
```

```csharp title="Worker/MakeThumbnails.cs"
using Inngest;

public sealed class MakeThumbnails(IThumbnailer thumbnailer) : InngestFunction<ImageUploaded>
{
    private static readonly int[] Widths = [160, 480, 1200];

    public override async Task<object?> ExecuteAsync(FunctionContext<ImageUploaded> context, CancellationToken cancellationToken)
    {
        var image = context.Event.Data;

        var paths = await Task.WhenAll(Widths.Select(width =>
            context.Step.RunAsync($"resize-{width}", ct => thumbnailer.ResizeAsync(image.BlobPath, width, ct))));

        await context.Step.SendEventAsync("announce", new ThumbnailsReady(image.ImageId, paths));

        return new { image.ImageId, thumbnails = paths.Length };
    }
}

public interface IThumbnailer
{
    Task<string> ResizeAsync(string blobPath, int width, CancellationToken cancellationToken);
}
```

### Attributes

The function's options go on `MakeThumbnails` as attributes: its id, its trigger and a concurrency limit of 8.

```csharp
[InngestFunction("make-thumbnails")]
[EventTrigger<ImageUploaded>]
[Concurrency(8)]
```

`Worker/Program.cs` registers it with `.AddFunction<MakeThumbnails>()`, without options.

### Code first

Without the attributes, `Worker/Program.cs` registers the function with its options in code: its id, its trigger and
a concurrency limit of 8.

```csharp title="Worker/Program.cs (registration)"
inngest.AddFunction<MakeThumbnails>(new FunctionOptions
{
    Id = "make-thumbnails",
    Triggers = [Trigger.Event<ImageUploaded>()],
    Concurrency = [new ConcurrencyLimit(8)],
});
```

The worker's project uses the worker SDK, references the contracts library and adds the `Inngest.Connect` package, which
brings in `Inngest`:

```xml title="Worker/Worker.csproj"
<Project Sdk="Microsoft.NET.Sdk.Worker">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Inngest.Connect" Version="*" />
    <ProjectReference Include="../Contracts/Contracts.csproj" />
  </ItemGroup>
</Project>
```

## The web app

The web app only sends events, so it needs `AddInngest`, an event key and the contracts library, but no functions and
no `MapInngest`:

```csharp title="Web/Program.cs"
using Inngest;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInngest(options => options.AppId = "media-web");

var app = builder.Build();

app.MapPost("/images/{imageId}", async (string imageId, IInngestClient inngest, CancellationToken cancellationToken) =>
{
    // Store the upload first; then hand the processing to the worker.
    var blobPath = $"uploads/{imageId}";
    await inngest.SendAsync(new ImageUploaded(imageId, blobPath), cancellationToken);
    return Results.Accepted();
});

app.Run();
```

## Running it in containers

```dockerfile title="Worker/Dockerfile"
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish Worker/Worker.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["dotnet", "Worker.dll"]
```

The worker image needs only the .NET runtime, not ASP.NET Core, and exposes no port. Give it the signing key to
connect, and the event key because it sends `ThumbnailsReady`; the web app needs the event key:

```bash
docker run -e INNGEST_SIGNING_KEY=signkey-prod-... -e INNGEST_EVENT_KEY=... media-worker:latest
docker run -p 8080:8080 -e INNGEST_EVENT_KEY=... media-web:latest
```

## How it works

- **No ingress.** The worker dials Inngest; nothing dials the worker. It works from a private subnet, a laptop or a
  network that only allows outbound traffic.
- **Two apps in Inngest.** The worker syncs itself as `media-worker` through its connection. The web app has no
  functions to sync; it only sends events.
- **Capacity per worker.** `MaxWorkerConcurrency` caps how many calls Inngest sends one worker at a time, and
  Inngest spreads the rest over other workers. The function's own concurrency limit caps all workers together.
- **Graceful shutdown.** When the container stops, the worker takes no new work and waits up to two minutes for
  running calls. Calls cut off after that are cancelled and retried elsewhere. Set the orchestrator's grace period a
  little longer than the shutdown timeout.
- **Several workers.** Run as many containers as you need. Each needs its own `InstanceId`, which the container's
  host name provides.
