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)
{
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
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
else if (provider == SupportedDatabaseProviders.MySql)
{
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
b => b.MigrationsAssembly("MySqlMigrations"));
b =>
{
b.MigrationsAssembly("MySqlMigrations");
b.EnableRetryOnFailure();
});
}
else if (provider == SupportedDatabaseProviders.Sqlite)
{
// SQLite doesn't support EnableRetryOnFailure
options.UseSqlite(connectionString, b => b.MigrationsAssembly("SqliteMigrations"));
}
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();
var dbContext = GetDatabaseContext(scope);
// Use SERIALIZABLE isolation level to prevent race conditions during concurrent calls
using var transaction = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
// Use EF's execution strategy to handle transient failures (including deadlocks)
var strategy = dbContext.Database.CreateExecutionStrategy();
try
return await strategy.ExecuteAsync(async () =>
{
// 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();
// Use SERIALIZABLE isolation level to prevent race conditions during concurrent calls
using var transaction = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
// If collection already exists, return false (not created)
if (existingDefaultCollection != null)
try
{
// 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();
return false;
return true;
}
// Create new default collection
var collectionId = CoreHelpers.GenerateComb();
var now = DateTime.UtcNow;
var collection = new Collection
catch
{
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();
return true;
}
catch
{
await transaction.RollbackAsync();
throw;
}
await transaction.RollbackAsync();
throw;
}
});
}
private async Task<HashSet<Guid>> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)