I'm reading the docs for connection resiliency and transactions, which has the following example:
await strategy.ExecuteAsync(
async () =>
{
using var context = new BloggingContext();
await using var transaction = await context.Database.BeginTransactionAsync();
context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/dotnet" });
await context.SaveChangesAsync();
context.Blogs.Add(new Blog { Url = "http://blogs.msdn.com/visualstudio" });
await context.SaveChangesAsync();
await transaction.CommitAsync();
});
The important thing to note is the using var context = new BloggingContext();
within the delegate.
I'm trying to understand where exactly the retries will happen, if there is any sort of transient failure will it instantly retry the entire delegate or is there some level of retry within each SaveChangesAsync call?
We have a lot of code similar to:
public async Task ExecuteAsync(Func<Task> action)
{
var strategy = _context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
await action();
await transaction.CommitAsync();
});
}
Within the action we may call various repositories that execute SaveChanges but just with the injected instance of DbContext. I haven't actually noticed any errors related to this but if it retries the entire delegate couldn't it result in the same entity being added to the db context and then saved?
Even if we were to rework to have a single SaveChanges there are still cases where we need to start an ambient transaction due to needing third party library db updates to be wrapped in a single transaction (e.g. hangfire).
The short answer is: Yes and it is clearly described in this article from the official EF core documentation.
In general there are two main ways to setup connection resiliency (specific retrying logic) in EF Core DB Context
Globally in Startup.cs when registering the Db Context
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString, provider => provider.EnableRetryOnFailure()));
with the sample above each call to dbContext.SaveChanges
or SaveChangesAsync
or transaction.CommitAsync()
will be retried up to 6 times with a max delay of 30 seconds if a transient connection failure occurs.
The second approach for setting up connection resiliency is creating custom retry execution strategies for specific cases. For example:
var strategy = new SqlServerRetryingExecutionStrategy(dbContext, 4, TimeSpan.FromSeconds(20), null);
await strategy.ExecuteAsync(async () => await ExecuteSamoSpecialLogicAsync());
In the second example we are telling EF core to retry up to 4 times with a max delay of 20 seconds if some transient failure occurs. The first example with the globally registered retry logic uses the default configuration provided by the Microsoft team for retrying.
Normally when you call SaveChanges
or SaveChangesAsync
without starting an explicit transaction a transaction is created and committed by EF Core behind the scenes that ensures that there will be an atomic data modification operation executed against the database i.e if some insert/delete/update statement fails all the scripts executed from EF core by calling SaveChanges
will be roll backed. More on the topic SaveChanges and transactions here
But when you explicitly create/start a transaction using await using var transaction = await context.Database.BeginTransactionAsync();
the retry is being performed over the entire transaction i.e. if the second call to await context.SaveChangesAsync();
fails due to a transient error than the entire transaction alongside with the first call to await context.SaveChangesAsync();
is rolled backed and retried again i.e. a retry will be performed over the entire operation within the ExecuteAsync
lambda function parameter because with explicit transactions until you call transaction.CommitAsync()
the data is not really being committed (saved) in the database.
I don't think you need to rework to have a single SaveChanges
everywhere (in each repository method that modifies the database data) just for benefiting from the retrying behaviour because it should work fine with explicit transaction as well when calling the transaction.CommitAsync()
with 1-2 caveats for which you can read mode here. But in general you should strive to minimize the calls to SaveChanges
(the queries to the database should be as minimum as possible) and also use explicit transactions by calling context.Database.BeginTransactionAsync()
only when you really need to in complex scenarios that involve data modification in many various tables.