# Transactional outbox

> Save events in the same database transaction as your data with Inngest.EntityFrameworkCore, and let a hosted dispatcher send them to Inngest afterwards, under the same id on every attempt.

Web page: https://damikun.github.io/inngest-dotnet/guides/outbox/

Sending an event right after saving data can go wrong in two ways. If the process dies between the save and the send,
the data is stored and the event is lost. If the event is sent first and the save fails, Inngest starts functions for
work that never happened. `IInngestClient.SendAsync` cannot be part of a database transaction.

The outbox of `Inngest.EntityFrameworkCore` makes the event part of the transaction. `AddInngestEvent` adds a row to a
table of your database through the same `DbContext`, so `SaveChanges` stores the data and the event together, or
neither. A hosted dispatcher reads the table and sends the events to Inngest after the commit.

## Set it up

Add the package. The `net8.0` build is made for Entity Framework Core 8 and the `net10.0` build for Entity Framework
Core 10. Entity Framework Core 9 is not supported: an app on .NET 9 gets the `net8.0` build, which needs Entity Framework
Core 8, and with Entity Framework Core 9 the host refuses to start and says so.

```bash
dotnet add package Inngest.EntityFrameworkCore
```

Map the table in the model of the `DbContext` that holds your data, and add a migration for it as for any other table
(`dotnet ef migrations add AddInngestOutbox`):

```csharp title="ShopContext.cs"
using Microsoft.EntityFrameworkCore;

public sealed class Order
{
    public int Id { get; set; }

    public string Number { get; set; } = string.Empty;

    public string CustomerId { get; set; } = string.Empty;

    public decimal Total { get; set; }
}

public sealed class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        // Maps the InngestOutbox table next to the app's own.
        modelBuilder.AddInngestOutbox();
}
```

Register the outbox on the Inngest builder, and add `UseInngestOutbox` to the options of the context:

```csharp title="Program.cs"
using Inngest;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddInngest(options => options.AppId = "shop")
    .AddEntityFrameworkOutbox<ShopContext>();

builder.Services.AddDbContext<ShopContext>(options => options
    .UseNpgsql(builder.Configuration.GetConnectionString("Shop"))
    .UseInngestOutbox());

var app = builder.Build();
app.MapInngest();
app.Run();
```

`AddEntityFrameworkOutbox` adds the hosted dispatcher of that context. Call it for each `DbContext` that has events to
send; each has its own table, settings and dispatcher. `UseInngestOutbox` is an interceptor that makes the events leave
sooner: it wakes the dispatcher when a save that added events has committed, where without it they leave at the next
poll, 5 seconds later by default. It finds the dispatcher through the application's services that Entity Framework
leaves in the options of the context, so it can be called wherever the options are built, in the callbacks of
`AddDbContext` and `AddDbContextPool` with or without a service provider, of `AddDbContextFactory`, and in
`OnConfiguring`. When the dispatcher starts, it says once, at the information level, if the context has no
`UseInngestOutbox`. `UseInngestOutbox(services)` does the same and also fails when the options are built if the outbox
is not registered.

## Add events to a unit of work

`AddInngestEvent` takes a record that implements `IInngestEventData`, the same records that `SendAsync` takes. It
sends nothing: the event is a row in the unit of work that `SaveChangesAsync` stores.

```csharp title="CheckoutService.cs"
using Inngest;
using Microsoft.EntityFrameworkCore;

public sealed class CheckoutService(ShopContext db)
{
    public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)
    {
        db.Orders.Add(order);
        db.AddInngestEvent(new OrderPlaced(order.Number, order.CustomerId, order.Total));

        // The order and the event are stored together, or neither is.
        await db.SaveChangesAsync(cancellationToken);
    }
}
```

The data is serialized when the event is added, with `InngestOptions.SerializerOptions`, as `SendAsync` does, so what
the SDK could send can be added, and what it could not fails here, with the code that added the event still on the
stack: a name that is empty or reserved, data that is not a JSON object, a type that the serializer options have no
metadata for in an app that trims. In an app that trims, add the event types to a source-generated context in the
options, as for `SendAsync`.

The method returns the row. Its `Id` is the id the event is sent under, and the timestamp of the event is the time
`AddInngestEvent` ran, not the time the dispatcher got to it. For events named by a string, or to set the user data, the
version, the sessions or the timestamp, add an `InngestEvent`:

```csharp
using Inngest;
using Microsoft.EntityFrameworkCore;

db.AddInngestEvent(new InngestEvent("support/ticket.opened", new { TicketId = "t_42" })
{
    Id = "ticket-opened-t_42",
    Version = "2026-10-01",
});

// The id of a typed event is the second argument.
db.AddInngestEvent(new OrderPlaced(order.Number, order.CustomerId, order.Total), id: $"order-placed-{order.Number}");
```

Without an `Id`, the event gets a generated one: a ULID, the format of the ids Inngest gives its own events. Give one
when the event stands for something the code can name, such as an order. The column is the primary key of the table,
so a second attempt at the same work fails the save with a `DbUpdateException` instead of announcing the order twice.
The key is checked against the rows that are still in the table, which are kept for the retention time.

- The id, the name and the version are limited to 255 characters. Longer ones fail when the event is added.
- A database that compares text without regard to case, which is the default of SQL Server and MySQL, takes two ids that
  differ only in case for the same key, so the second one fails the save. PostgreSQL and SQLite tell them apart.
- An event added inside a function does not carry the sessions of the run (`FunctionContext.Sessions`), because the
  dispatcher sends it outside the run. Set `Meta.Sessions` on the event if it should belong to a session.

## How events are sent

The dispatcher of each context repeats three steps. It starts when the host starts and takes no more work when it
stops.

1. **Claim.** It takes up to `BatchSize` events that are due. The claim is an `UPDATE` through `ExecuteUpdate` that writes
   a token of its own and an expiry (`LeaseDuration`) on those rows, and then it reads the rows that hold its token.
   Nothing is locked, so any number of dispatchers, in one process or in many, share the table and an event is
   claimed by one of them at a time. Nothing in it is specific to a provider.
2. **Send.** The whole batch goes to Inngest in one request, each event under its own id.
3. **Record.** The events Inngest took get `SentAt`. The others stay, with `Attempts` counted, `LastError` set and
   `NextAttemptAt` moved on.

Three things start a pass: a save that added events and has committed, the poll interval, and the end of a pass that
found events, which may have left more behind. When the save used a transaction of the code's own, the wake-up comes
with its commit, not with the save. With a `TransactionScope` it comes at the save, before the scope completes, so the
dispatcher finds nothing and the events leave at the next poll. A transaction that is committed outside Entity Framework
Core gives no wake-up, and the poll finds the events.

### At least once, and the repeat of an id

A dispatcher can send a batch and die before it records that, or lose the connection to the database at that point.
The events are then still due when their lease runs out, and another dispatcher sends them again. Delivery is at
least once.

An event keeps the id it was added with, on every attempt, so the repeat is an event with an id that Inngest has seen.
Inngest's documentation says it drops a repeated id for 24 hours. What was checked here: against the dev server
(`inngest/inngest:v1.49.0`) a send that repeated an id seconds after the first was answered with a success and an event
id of its own, and the function it triggers started no run. The dev server's source, read at the v1.49.0 tag, keeps the
key of a run for 24 hours. Not checked: Inngest Cloud, a repeat after hours, and whether a repeat can also resume a run
that waits for the event. A resend that comes later than 24 hours, which takes a dispatcher that was down for as long,
would start runs again.

### When sending fails

An event is never given up and never deleted before it is sent. A failed request leaves its events in the table, and
each is tried again after `RetryDelay`, then after twice that, and so on up to `MaxRetryDelay`, counted per event from
its own failures. `Attempts` and `LastError` say how often and why.

When Inngest turns a request down because of its events (400, 409, 412, 413 or 422), for example an event that is too
large, the events of that request are sent one at a time, so that the one it objects to does not hold back the others.
Each event that is refused stays in the table and is tried again at the retry interval, with an error in the log at
every attempt that names its id and its name. It will not go through until the row is fixed or deleted. If Inngest is
down or the event key is wrong, the events are not split: they fail together, as one request.

Events that are too large for a request together, but not alone, are sent one at a time after every refused batch, so
lower `BatchSize` for large events. A request that takes longer than `SendTimeout` counts as failed. When the host stops, the events a dispatcher had
claimed are given back at once and do not cost an attempt, so another instance sends them without waiting for the lease.

### Order

Events are sent oldest first within a batch, but nothing keeps one event from arriving after a later one: batches go out
from several dispatchers, and a failed event waits while its neighbours pass. Inngest does not promise an order between
events either. If a function needs one, put a sequence number or a version in the data.

### Clocks

The instances compare their own clocks with times that other instances wrote: when a lease ends, when a retry is due
and whether a new event is due. Keep the clocks in step. A dispatcher whose clock runs ahead takes events from one that
is still sending them only if it is ahead by more than `LeaseDuration` minus `SendTimeout`, which is 40 seconds with the
defaults, and a repeat is dropped by its id in any case. An event saved by an instance whose clock runs ahead, and that
runs no dispatcher itself, is not due for the dispatchers of the others until their clocks reach its time, so it
waits as long as the clock is ahead. The `ts` it is sent with is that clock's time too.

### Cleanup

Events that Inngest took stay in the table for `Retention`, one day by default, and are then deleted in batches of
`CleanupBatchSize` with `ExecuteDelete`. The retention time keeps an id that the code chose from being added again while
Inngest still drops it, so it should not be shorter than the time Inngest remembers ids. Events that are not sent are
never deleted.

## Options

The settings are the same for every instance of the app, because each runs a dispatcher. They are checked when the
host starts.

```csharp
inngest.AddEntityFrameworkOutbox<ShopContext>(options =>
{
    options.PollInterval = TimeSpan.FromSeconds(2);
    options.BatchSize = 200;
    options.Retention = TimeSpan.FromDays(2);
});
```

| Option | Default | Meaning |
| - | - | - |
| `DispatcherEnabled` | `true` | Runs the dispatcher in this process. Turn it off in a process that only adds events, and keep it on in at least one other. |
| `PollInterval` | 5 seconds | How long the dispatcher waits for a wake-up before it looks anyway. This is how it finds events that other processes saved and retries that fell due. |
| `BatchSize` | 100 | The most events one claim takes and one request sends. |
| `LeaseDuration` | 1 minute | How long a dispatcher owns what it claimed. After a crash its events wait this long. At least twice `SendTimeout`, so that a request Inngest refuses can still be followed by requests for single events. |
| `SendTimeout` | 20 seconds | How long one request may take, the retries of the SDK's client included. |
| `RetryDelay` | 5 seconds | The wait after an event's first failed attempt. Each further failure doubles it. |
| `MaxRetryDelay` | 5 minutes | The longest wait between attempts. |
| `Retention` | 1 day | How long sent events stay before they are deleted. |
| `CleanupInterval` | 5 minutes | How often sent events past their retention time are deleted. |
| `CleanupBatchSize` | 1000 | How many events one `DELETE` removes. |

## Look at the table

The rows are `InngestOutboxMessage` objects. The table shows what is waiting and why:

```csharp
using Microsoft.EntityFrameworkCore;

var cutoff = DateTime.UtcNow.AddMinutes(-5);
var stuck = await db.Set<InngestOutboxMessage>()
    .Where(message => message.SentAt == null && message.CreatedAt < cutoff)
    .OrderBy(message => message.CreatedAt)
    .ToListAsync(cancellationToken);

foreach (var message in stuck)
{
    Console.WriteLine($"{message.Id} {message.Name}: {message.Attempts} attempts, {message.LastError}");
}
```

All times in the table are UTC. `CreatedAt` is when the row was added. `Timestamp` is the event's own time, which the
code can set to any time, so it says nothing about how long a row has waited. `Data`, `User` and `Sessions` hold the
event as JSON text.

## What to know

- **The table holds the event as the SDK serialized it.** Middleware that changes events as they leave, such as
  encryption, runs when the dispatcher sends them, so the table holds the plain data. Middleware that wraps sends
  sees these events with no run, because no function sends them.
- **Entity Framework Core 8 and 10.** Entity Framework Core 9 is not supported. `ExecuteUpdate` lives in a different
  assembly in Entity Framework Core 8 and in 9 and later, so the `net8.0` build, which an app on .NET 9 gets, only runs
  on Entity Framework Core 8.
- **Providers.** The dispatcher uses only LINQ that Entity Framework Core translates for relational providers. The
  tests of this repository run it on SQLite with Entity Framework Core 8 and 10, and on PostgreSQL with Entity Framework
  Core 10 and Npgsql 10 on .NET 10 only. Other providers, and other versions of the PostgreSQL provider, were not tried.
- **SQL Server.** With its default locking `READ COMMITTED`, a query waits for rows that open transactions have inserted.
  The dispatcher's query reads the part of the index that holds the events a transaction has just added, so one long
  unit of work that adds an event stalls the dispatchers of every instance until it commits, or until the command times
  out. Turn on `READ_COMMITTED_SNAPSHOT` for the database (`ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON`), which
  Azure SQL Database has by default. This was not run on SQL Server; it follows from how its locking works. PostgreSQL
  readers do not wait for writers, which the tests show with an event that a transaction has saved and not committed.
- **One database per registration.** A dispatcher reads the database that `TContext` connects to in a new scope. An app
  that picks the database per tenant is not covered.
- **No Native AOT.** Entity Framework Core does not support it, and the package does not declare itself compatible. The
  events are still serialized from the metadata of the serializer options, without reflection of its own.
- **A host that stops loses nothing.** The events are in the table, and whichever instance runs next sends them.
- **Tests.** After a save, a test can read the rows of `Set<InngestOutboxMessage>()` to see what was added. To run the
  dispatcher too, use SQLite in memory and register a fake `IInngestClient` before `AddInngest`, which keeps a client
  that is already registered. The package's own tests are built that way.

The dispatcher logs under categories that start with `Inngest.EntityFrameworkCore`, with event ids 800 to 810.

See [Sending events](https://damikun.github.io/inngest-dotnet/guides/sending-events.md) for events that are not part of a transaction.
