Skip to main content

Fixed: A Retried EF Core Transaction Saves the Row Twice

· 4 min read
Jagdish Kumawat
Founder @ Dewiride

The connection dropped during a transaction, EF Core retried it as it should, and the request created two rows, with different names, from one request. MySQL had rolled the first attempt back. The extra row came from my DbContext, which still remembered the first attempt's entity.

What I Was Doing​

My .NET C# Web API uses EF Core 10.0.14, with a DbContext from dependency injection and a retrying execution strategy. Each attempt adds a category and changes a product:

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

db.Categories.Add(new Category { Name = $"Retry {Guid.NewGuid():N}"[..14], CreatedAtUtc = clock.GetUtcNow().UtcDateTime });

var product = await db.Products.FirstAsync(p => p.Id == id, ct);
product.ReorderLevel += 1;

await db.SaveChangesAsync(ct);

await transaction.CommitAsync(ct);
},
cancellationToken);

The connection dropped while the first attempt was saving.

What I Expected​

One retry, and one new category.

What I Got​

Output
{"attempts":2,"saved":["Retry 275b98d6","Retry 28d05299"]}

The response showing attempts 2 and two saved categories, Retry 275b98d6 and Retry 28d05299, from one request.

Two attempts, as expected, and two rows, which wasn't.

What I Checked​

The log. The first attempt failed, and the retry ran:

API log
Attempt 1
A transient exception occurred during execution. The operation will be retried after 0ms.
Attempt 2

Whether MySQL kept the first attempt. It didn't: the connection dropped before the commit, so MySQL rolled it back. Both rows came from the second attempt's one SaveChangesAsync.

The EF Core docs. The sample on the connection resiliency page creates a new DbContext inside the delegate, so there's nothing left over from an earlier attempt. My DbContext comes from dependency injection, and it lives for the whole request.

Why It Happens​

Add puts the entity in the DbContext's change tracker as Added. A failed SaveChangesAsync doesn't take it out. My delegate then ran again on the same DbContext, and added another. The second SaveChangesAsync inserted everything still marked Added, which was both.

The Fix​

Start every attempt with an empty tracker:

An endpoint (excerpt)
await strategy.ExecuteAsync(
async ct =>
{
db.ChangeTracker.Clear();

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

// ...

ChangeTracker.Clear stops tracking everything, so each attempt works as if it were the first.

After the Fix​

Output
{"attempts":2,"saved":["Retry df418ebd"]}

The response showing attempts 2 and only one saved category, Retry df418ebd.

Two attempts and one row.

Common Mistakes​

MistakeWhat happensDo this instead
Adding entities outside the delegateA retry can't redo that partPut the whole unit inside
Calling Clear only in a catchEasy to miss a pathCall it first, in every attempt
Using entities tracked before the strategyClear detaches them tooLoad what you need inside
Assuming this covers a lost commitThat's a different problemAdd a key

Troubleshooting​

SymptomLikely causeFix
Two rows, and the first attempt committedA lost commitThe fix
A counter goes up by 2The change was applied on an entity kept from beforeLoad it inside the delegate
Duplicate entry on the retryThe leftover entity hits a unique indexThe same fix

Conclusion​

A retry runs your delegate again on the same DbContext, with the first attempt's entities still in it. Start each attempt with db.ChangeTracker.Clear(), or create a new context inside the delegate.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.