Transactional 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
Section titled “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.
dotnet add package Inngest.EntityFrameworkCoreMap 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):
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:
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
Section titled “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.
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:
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. SetMeta.Sessionson the event if it should belong to a session.
How events are sent
Section titled “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.
- Claim. It takes up to
BatchSizeevents that are due. The claim is anUPDATEthroughExecuteUpdatethat 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. - Send. The whole batch goes to Inngest in one request, each event under its own id.
- Record. The events Inngest took get
SentAt. The others stay, withAttemptscounted,LastErrorset andNextAttemptAtmoved 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
Section titled “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
Section titled “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.
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
Section titled “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
Section titled “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
Section titled “Options”The settings are the same for every instance of the app, because each runs a dispatcher. They are checked when the host starts.
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
Section titled “Look at the table”The rows are InngestOutboxMessage objects. The table shows what is waiting and why:
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
Section titled “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.
ExecuteUpdatelives in a different assembly in Entity Framework Core 8 and in 9 and later, so thenet8.0build, 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 onREAD_COMMITTED_SNAPSHOTfor 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
TContextconnects 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 fakeIInngestClientbeforeAddInngest, 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 for events that are not part of a transaction.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.