diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 3c35df2a82..ae09fb1841 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -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(); + }); } }); } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index ef58975b83..5f1c6f00d8 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -826,70 +826,76 @@ public class CollectionRepository : Repository { - // 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> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId)