Skip to content

Transactions ​

A change and its history rows always commit or roll back together. How that happens depends on the transaction SaveChanges runs in:

SaveChanges runs…what Hindsight does
with no transactionopens one around the save and commits it¹
inside a transaction you beganwrites history in your transaction; you commit
inside an ambient TransactionScopeopens none; the scope commits or rolls back
with EnableRetryOnFailure() and no transactiontrigger writer: lets EF Core open one per attempt; interceptor writer: throws InvalidOperationException. See below

¹ The trigger writer only needs its own transaction to push a change context. Without a provider or WithReason, it opens none: the trigger writes history inside the same statement anyway.

Your own transaction ​

Begin a transaction before SaveChanges and Hindsight uses it:

cs
await using var transaction = await db.Database.BeginTransactionAsync();

policy.Status = PolicyStatus.Active;
await db.SaveChangesAsync(); // history is written inside your transaction

// ...more work in the same transaction...

await transaction.CommitAsync(); // the data change and its history commit together

EnableRetryOnFailure ​

A retrying execution strategy can re-run a whole SaveChanges after a transient failure. A transaction opened inside that call would not survive the retry, so EF Core refuses it. Aspire turns the strategy on by default.

The trigger writer needs no transaction of its own here. It asks EF Core to open one inside the execution strategy, and pushes the change context into it. A retried attempt runs in a new transaction and gets the context again, so a plain SaveChanges just works:

cs
services.AddDbContext<AppDbContext>(options => options
    .UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure())
    .UseHindsight(hindsight => hindsight.UseHistoryWriter(HistoryWriter.Trigger)));

The interceptor writer writes history rows after EF Core's own batch, outside the execution strategy, so a retry could not repeat them. A save that would make it open its own transaction throws InvalidOperationException instead, naming the conflict and the fix. The fix, which also works under the trigger writer, is the pattern EF Core recommends for any transaction under retry: run the unit of work through the execution strategy and open the transaction yourself.

cs
var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var transaction = await db.Database.BeginTransactionAsync();

    policy.Status = PolicyStatus.Suspended;
    await db.SaveChangesAsync();

    await transaction.CommitAsync();
});

Your transaction is open before the save starts, so Hindsight uses it, and the change and its history are retried together.

TransactionScope is not a workaround

A retrying execution strategy refuses to run inside an ambient TransactionScope at all, with or without Hindsight: EF Core throws before either writer runs.

Ambient TransactionScope ​

Without EnableRetryOnFailure(), an ambient System.Transactions.TransactionScope works with both writers:

cs
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);

db.Policies.Add(new Policy { Number = "ACME-1002", Premium = 950m });
await db.SaveChangesAsync();

scope.Complete(); // without Complete(), the data change and its history roll back together

Npgsql enlists the connection in the ambient transaction when it opens, and every command after that, including the history writes, runs in it. Hindsight sees the ambient transaction and opens none of its own. scope.Complete() commits the change and its history together; disposing the scope without it rolls both back.

Released under the MIT License.