Fix DbContext concurrency error in parallel company verification
Use IDbContextFactory pattern to create isolated DbContext instances for each cache operation, making parallel verification thread-safe. Changes: - Add IDbContextFactory<ApplicationDbContext> registration - Update CompanyVerifierService to use factory for cache operations - Update tests with InMemoryDatabaseRoot for shared test data Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,19 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Configure DbContext with SQL Server
|
||||
// AddDbContextFactory enables thread-safe parallel operations
|
||||
services.AddDbContextFactory<ApplicationDbContext>(options =>
|
||||
options.UseSqlServer(
|
||||
configuration.GetConnectionString("DefaultConnection"),
|
||||
sqlOptions =>
|
||||
{
|
||||
sqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 3,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(30),
|
||||
errorNumbersToAdd: null);
|
||||
}));
|
||||
|
||||
// Also register DbContext for scoped injection (non-parallel scenarios)
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseSqlServer(
|
||||
configuration.GetConnectionString("DefaultConnection"),
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace TrueCV.Infrastructure.Services;
|
||||
public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
{
|
||||
private readonly CompaniesHouseClient _companiesHouseClient;
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
||||
private readonly ILogger<CompanyVerifierService> _logger;
|
||||
|
||||
private const int FuzzyMatchThreshold = 70;
|
||||
@@ -22,11 +22,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
|
||||
public CompanyVerifierService(
|
||||
CompaniesHouseClient companiesHouseClient,
|
||||
ApplicationDbContext dbContext,
|
||||
IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
||||
ILogger<CompanyVerifierService> logger)
|
||||
{
|
||||
_companiesHouseClient = companiesHouseClient;
|
||||
_dbContext = dbContext;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -123,8 +123,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays);
|
||||
|
||||
// Use factory to create a new DbContext for thread-safe parallel access
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Get recent cached companies
|
||||
var cachedCompanies = await _dbContext.CompanyCache
|
||||
var cachedCompanies = await dbContext.CompanyCache
|
||||
.Where(c => c.CachedAt >= cutoffDate)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -160,7 +163,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
|
||||
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item)
|
||||
{
|
||||
var existingCache = await _dbContext.CompanyCache
|
||||
// Use factory to create a new DbContext for thread-safe parallel access
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var existingCache = await dbContext.CompanyCache
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
|
||||
|
||||
if (existingCache is not null)
|
||||
@@ -183,10 +189,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dbContext.CompanyCache.Add(cacheEntry);
|
||||
dbContext.CompanyCache.Add(cacheEntry);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CompanyVerificationResult CreateVerificationResult(
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
@@ -23,6 +25,8 @@ public class CompanyVerifierServiceTests : IDisposable
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly CompanyVerifierService _sut;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly DbContextOptions<ApplicationDbContext> _dbOptions;
|
||||
private readonly InMemoryDatabaseRoot _dbRoot;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -46,13 +50,25 @@ public class CompanyVerifierServiceTests : IDisposable
|
||||
|
||||
var client = new CompaniesHouseClient(_httpClient, settings, _mockClientLogger.Object);
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
// Use a shared InMemoryDatabaseRoot so all contexts share the same data store
|
||||
_dbRoot = new InMemoryDatabaseRoot();
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: dbName, databaseRoot: _dbRoot)
|
||||
.ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning))
|
||||
.Options;
|
||||
|
||||
_dbContext = new ApplicationDbContext(options);
|
||||
_dbContext = new ApplicationDbContext(_dbOptions);
|
||||
|
||||
_sut = new CompanyVerifierService(client, _dbContext, _mockServiceLogger.Object);
|
||||
// Create a mock factory that returns a new context with the same in-memory database
|
||||
var mockFactory = new Mock<IDbContextFactory<ApplicationDbContext>>();
|
||||
mockFactory.Setup(f => f.CreateDbContext())
|
||||
.Returns(() => new ApplicationDbContext(_dbOptions));
|
||||
mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new ApplicationDbContext(_dbOptions));
|
||||
|
||||
_sut = new CompanyVerifierService(client, mockFactory.Object, _mockServiceLogger.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -313,8 +329,9 @@ public class CompanyVerifierServiceTests : IDisposable
|
||||
// Act
|
||||
await _sut.VerifyCompanyAsync(companyName, null, null);
|
||||
|
||||
// Assert
|
||||
// Assert - use AsNoTracking to get fresh data from the store
|
||||
var cachedEntry = await _dbContext.CompanyCache
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == "77777777");
|
||||
|
||||
cachedEntry.Should().NotBeNull();
|
||||
@@ -348,8 +365,9 @@ public class CompanyVerifierServiceTests : IDisposable
|
||||
// Act
|
||||
await _sut.VerifyCompanyAsync(companyName, null, null);
|
||||
|
||||
// Assert
|
||||
// Assert - use AsNoTracking to get fresh data from the store
|
||||
var cachedEntry = await _dbContext.CompanyCache
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == "55555555");
|
||||
|
||||
cachedEntry.Should().NotBeNull();
|
||||
|
||||
Reference in New Issue
Block a user