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 | 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, and Hindsight fails early with a clear message instead:
services.AddDbContext<AppDbContext>(options => options
.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure())
.UseHindsight(hindsight => hindsight.UseHistoryWriter(HistoryWriter.Trigger)));A save that makes Hindsight open its own transaction then throws InvalidOperationException, naming the conflict and the fix. The fix 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.