Transactional Enqueue with EF Core
Enqueue a job inside your own EF Core transaction so the business write and the job commit or roll back together.
Enqueue a job inside your own EF Core transaction so the business write and the job commit or roll back together.
The problem this solves shows up the moment a job depends on data you just wrote. You insert an order, then enqueue a job to send the receipt. If the insert commits but the enqueue fails, the customer never hears from you. If the enqueue lands but the insert rolls back, a Worker picks up a job that points at an order that does not exist. The usual fix is an outbox table and a relay process. Transactional Enqueue removes the need for one: BackWave writes the Job row on the transaction you already own, so the business write and the Job commit or roll back as a unit.
What "rides along on your transaction" actually means#
BackWave never opens the business transaction. You begin it on your DbContext, you commit it, you roll it back. The only thing BackWave does is read the open transaction off the context and write its Job row on the same connection inside it. When you commit, the Job becomes durable and visible to the cluster in the same atomic step as your own rows. When you roll back, the Job never existed.
This is a Storage Adapter capability, not a universal guarantee. The adapter has to be able to enlist in an ADO.NET transaction you hand it, and your DbContext and the BackWave store have to target the same database. Get those two things lined up and the atomicity is real; miss them and BackWave tells you loudly rather than degrading in silence.
One thing worth stating up front, because it shapes everything below: there is no EF Core specific job store, no DbSet for jobs, no SaveChanges interceptor, and no change-tracked Job entity. The BackWave.EntityFrameworkCore package is one static extension method. It reads the transaction off your context and forwards to the regular client. The Job is written by a raw INSERT on the connection, not through EF's change tracker. Keep that picture in mind and the sharp edges later make sense.
The shape of the call#
The whole EF Core surface is a single EnqueueAsync extension method on BackWaveClient. Its arguments, in order:
jobis the payload, serialized through its registration. Its registration decides the Wire Name, the serializer, and the default Queue.unitOfWorkis theDbContextwhose open transaction the Job joins. You begin a transaction on it first.dueTimeis required. There is no implicit "now" on this overload. PassDateTimeOffset.UtcNowto run the Job as soon as a Worker is free, or a future instant to defer it.queuedefaults tonull, which uses the Job type's registered Queue. Pass a string to override it for this one enqueue.
It returns the new Job's id, which you can hold onto and use later as a dependency parent.
There is no tags parameter on this overload. If you need tags, set them on the Job type's registration so the default carries through.
End to end#
Here is the canonical pattern: open a transaction, write your row, enqueue the Job, commit.
await using var transaction = await context.Database.BeginTransactionAsync();
context.Orders.Add(order);
await context.SaveChangesAsync();
// The job rides on the EF transaction: it commits or rolls back atomically with the order.
var jobId = await client.EnqueueAsync(new SendReceipt(order.Id), context, dueTime: DateTimeOffset.UtcNow);
await transaction.CommitAsync(); // the order and the job commit togetherThe DbContext here is a plain EF Core context over your business database. Nothing in it is BackWave-specific:
public sealed class SampleDbContext(DbContextOptions<SampleDbContext> options) : DbContext(options)
{
public DbSet<SampleBusinessRow> BusinessRows => Set<SampleBusinessRow>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<SampleBusinessRow>().ToTable("sample_business");
}The one rule that makes the atomicity work: register that context against the same connection string as your BackWave store.
services.AddDbContext<SampleDbContext>(options => options.UseNpgsql(connectionString));BackWave's tables live alongside your business tables in the same database. The context and the store share a connection target, so a single transaction can span both. That is the whole arrangement. You do not register anything from BackWave.EntityFrameworkCore in DI; the package's entire public surface is the extension method you call. Its only dependency is Microsoft.EntityFrameworkCore.Relational, so the provider comes from your application, not from BackWave.
When the rollback path matters#
The reason to reach for this is the failure case, so it is worth seeing it work. Roll the transaction back and the Job is gone with the row:
await using var transaction = await context.Database.BeginTransactionAsync();
context.Orders.Add(order);
await context.SaveChangesAsync();
var jobId = await client.EnqueueAsync(new SendReceipt(order.Id), context, dueTime: DateTimeOffset.UtcNow);
if (somethingWentWrong)
{
await transaction.RollbackAsync();
// Both the order and the job were rolled back. No Worker will ever see this job.
}A rollback, or disposing the transaction without committing, means the Job never existed. No Worker can claim it because it was never durable.
When the Job actually becomes visible#
This is the part that surprises people, so it is worth being precise. The INSERT for the Job happens during the EnqueueAsync await, synchronously, on your open transaction. It does not wait for SaveChangesAsync and it does not wait for commit. What waits for commit is visibility and durability.
That means right after EnqueueAsync returns, the Job is written into your transaction but invisible to everyone else. A read on a separate connection, including BackWave's own Monitor reads and any GetJobAsync, returns nothing until you commit:
var jobId = await client.EnqueueAsync(new SendReceipt(order.Id), context, dueTime: DateTimeOffset.UtcNow);
// Invisible to the rest of the cluster until the unit of work commits.
// A read on a fresh connection returns null here.
await transaction.CommitAsync();
// After commit: the job is Scheduled and claimable.Once you commit, the Job enters the Scheduled state and a Worker can claim it. Every enqueued Job is a Scheduled Job; "enqueue now" is the same code path as a future schedule, with a due time of now.
You do not need a second SaveChanges#
Because BackWave writes the Job with a raw INSERT rather than through the change tracker, SaveChangesAsync and EnqueueAsync are two independent writes on the same transaction. The Job is already inserted by the time EnqueueAsync returns. You do not need a later SaveChangesAsync to persist it. All of it, your entities and the Job row, is bound by the single CommitAsync. The order in the samples, Add then SaveChangesAsync then EnqueueAsync then CommitAsync, is the one to follow.
Multiple jobs in one transaction#
Enqueueing several Jobs through the same unit of work before a single commit works as you would expect. All of them are written on the transaction and all become Scheduled together when you commit. The one caveat: a duplicate Job id inside the same transaction is rejected at the call site as an InvalidOperationException. You will not hit that with freshly minted ids, which is the normal case.
The sharp edges#
An open transaction is required, and its absence throws#
EF Core wraps each SaveChanges in its own implicit transaction, but that transaction is not ambient and BackWave will not use it. You must call BeginTransaction or BeginTransactionAsync yourself. If there is no open transaction on the context when you call EnqueueAsync, it throws an InvalidOperationException whose message names BeginTransaction as the fix.
This is deliberate. The alternative would be silently falling back to a plain, non-transactional enqueue, which would defeat the entire point and hide a correctness bug. BackWave throws instead. If you genuinely want a non-transactional enqueue, that is an explicit choice: call the base client.EnqueueAsync(job, dueTime) overload with no DbContext at all.
The capability is not universal#
If you hand a transaction to a store whose adapter does not support Transactional Enqueue, the client throws a NotSupportedException before it touches the store. The message names SupportsTransactionalEnqueue so the cause is unmistakable.
Where the capability stands across the production adapters:
- Postgres supports it. It enlists in
NpgsqlTransaction. - SQL Server supports it. It enlists in
SqlTransaction. - SQLite supports it, but only the co-resident deployment can exploit it (see below). It enlists in
SqliteTransaction.
You can guard for this at runtime when a deployment might vary:
if (!store.SupportsTransactionalEnqueue)
{
// This store deployment cannot enlist in your transaction.
// Fall back, or refuse the operation, rather than enqueue non-atomically.
}The DbContext provider must match the store provider#
Each adapter casts your transaction to its provider's native type and throws an ArgumentException if it gets the wrong one. With the EF overload this can only go wrong if your DbContext uses a different provider than your BackWave store. They have to be the same provider against the same database. A Postgres context and a SQL Server store cannot share a transaction, and BackWave will not pretend otherwise.
This also rules out cross-database and cross-engine atomicity. There is no distributed transaction, no TransactionScope across two engines, no MSDTC. Atomicity requires one database (one file, for SQLite).
SQLite: co-resident only#
SQLite has two deployments, and the difference matters here. In the co-resident deployment, BackWave's tables live in your application's own SQLite file. The EF transaction and the Job commit in one file, in one transaction. That is the tightest Transactional Enqueue there is. In the dedicated deployment, BackWave runs on its own file with your business data elsewhere, and no single transaction can span two files, so it forgoes Transactional Enqueue even though the underlying adapter reports the capability.
The SQLite adapter also runs a same-file guard. On the first transactional enqueue per connection it checks that your SqliteConnection is attached to the same on-disk file BackWave is configured for, comparing canonical paths. If they differ, it throws rather than commit a Job invisibly into the wrong file. In-memory and temporary databases are skipped by the check.
A note on latency, not correctness#
Under transactional enqueue some adapters hold back the Wake-Up Hint, the latency-only nudge that tells the Worker pump a new Job has arrived. ADO.NET exposes no commit callback, so firing the hint before commit would wake the pump to a row it cannot see. Postgres is the exception: it signals through pg_notify, which is itself transactional, so its hint fires on commit. Either way this only affects how quickly a Worker notices the Job. The Wake-Up Hint is never correctness-bearing; polling picks the Job up regardless.
Where to go next#
- Define and enqueue a job for the plain, non-transactional enqueue path.
- Jobs and Handlers for how registration sets the Wire Name, serializer, and default Queue.
- Job lifecycle for what happens after a Job becomes Scheduled.
- EF Core storage for wiring your context and the store against one database.
- Storage overview for which adapters support Transactional Enqueue.
- Client API reference for every enqueue overload and its parameters.