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 transaction | opens one around the save and commits it¹ |
| inside a transaction you began | writes history in your transaction; you commit |
inside an ambient TransactionScope | opens none; the scope commits or rolls back |
with EnableRetryOnFailure() and no transaction | trigger 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:
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 togetherEnableRetryOnFailure
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:
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.
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:
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 togetherNpgsql 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.