Configure EF to gracefully handle deadlocks

This commit is contained in:
Thomas Rittson 2025-12-03 13:53:44 +10:00
parent f5393fa643
commit 1c03ef110e
No known key found for this signature in database
GPG Key ID: CDDDA03861C35E27
2 changed files with 78 additions and 60 deletions

View File

@ -47,22 +47,34 @@ public static class EntityFrameworkServiceCollectionExtensions
{ {
if (provider == SupportedDatabaseProviders.Postgres) if (provider == SupportedDatabaseProviders.Postgres)
{ {
options.UseNpgsql(connectionString, b => b.MigrationsAssembly("PostgresMigrations")); options.UseNpgsql(connectionString, b =>
{
b.MigrationsAssembly("PostgresMigrations");
b.EnableRetryOnFailure();
});
// Handle NpgSql Legacy Support for `timestamp without timezone` issue // Handle NpgSql Legacy Support for `timestamp without timezone` issue
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
} }
else if (provider == SupportedDatabaseProviders.MySql) else if (provider == SupportedDatabaseProviders.MySql)
{ {
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
b => b.MigrationsAssembly("MySqlMigrations")); b =>
{
b.MigrationsAssembly("MySqlMigrations");
b.EnableRetryOnFailure();
});
} }
else if (provider == SupportedDatabaseProviders.Sqlite) else if (provider == SupportedDatabaseProviders.Sqlite)
{ {
// SQLite doesn't support EnableRetryOnFailure
options.UseSqlite(connectionString, b => b.MigrationsAssembly("SqliteMigrations")); options.UseSqlite(connectionString, b => b.MigrationsAssembly("SqliteMigrations"));
} }
else if (provider == SupportedDatabaseProviders.SqlServer) else if (provider == SupportedDatabaseProviders.SqlServer)
{ {
options.UseSqlServer(connectionString); options.UseSqlServer(connectionString, b =>
{
b.EnableRetryOnFailure();
});
} }
}); });
} }

View File

@ -826,70 +826,76 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
using var scope = ServiceScopeFactory.CreateScope(); using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
// Use SERIALIZABLE isolation level to prevent race conditions during concurrent calls // Use EF's execution strategy to handle transient failures (including deadlocks)
using var transaction = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); var strategy = dbContext.Database.CreateExecutionStrategy();
try return await strategy.ExecuteAsync(async () =>
{ {
// Check if this organization user already has a default collection // Use SERIALIZABLE isolation level to prevent race conditions during concurrent calls
// SERIALIZABLE ensures this SELECT acquires range locks using var transaction = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var existingDefaultCollection = await (
from c in dbContext.Collections
join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId
where cu.OrganizationUserId == organizationUserId
&& c.OrganizationId == organizationId
&& c.Type == CollectionType.DefaultUserCollection
select c
).FirstOrDefaultAsync();
// If collection already exists, return false (not created) try
if (existingDefaultCollection != null)
{ {
// Check if this organization user already has a default collection
// SERIALIZABLE ensures this SELECT acquires range locks
var existingDefaultCollection = await (
from c in dbContext.Collections
join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId
where cu.OrganizationUserId == organizationUserId
&& c.OrganizationId == organizationId
&& c.Type == CollectionType.DefaultUserCollection
select c
).FirstOrDefaultAsync();
// If collection already exists, return false (not created)
if (existingDefaultCollection != null)
{
await transaction.CommitAsync();
return false;
}
// Create new default collection
var collectionId = CoreHelpers.GenerateComb();
var now = DateTime.UtcNow;
var collection = new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
ExternalId = null,
CreationDate = now,
RevisionDate = now,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null
};
var collectionUser = new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = organizationUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await dbContext.Collections.AddAsync(collection);
await dbContext.CollectionUsers.AddAsync(collectionUser);
await dbContext.SaveChangesAsync();
// Bump user account revision dates
await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collectionId, organizationId);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
return false; return true;
} }
catch
// Create new default collection
var collectionId = CoreHelpers.GenerateComb();
var now = DateTime.UtcNow;
var collection = new Collection
{ {
Id = collectionId, await transaction.RollbackAsync();
OrganizationId = organizationId, throw;
Name = defaultCollectionName, }
ExternalId = null, });
CreationDate = now,
RevisionDate = now,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null
};
var collectionUser = new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = organizationUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await dbContext.Collections.AddAsync(collection);
await dbContext.CollectionUsers.AddAsync(collectionUser);
await dbContext.SaveChangesAsync();
// Bump user account revision dates
await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collectionId, organizationId);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
throw;
}
} }
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId) private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)