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:
2026-01-20 16:54:58 +01:00
parent f1ccd217d8
commit 04a7c3628a
3 changed files with 50 additions and 13 deletions

View File

@@ -19,6 +19,19 @@ public static class DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{ {
// Configure DbContext with SQL Server // 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 => services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer( options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"), configuration.GetConnectionString("DefaultConnection"),

View File

@@ -14,7 +14,7 @@ namespace TrueCV.Infrastructure.Services;
public sealed class CompanyVerifierService : ICompanyVerifierService public sealed class CompanyVerifierService : ICompanyVerifierService
{ {
private readonly CompaniesHouseClient _companiesHouseClient; private readonly CompaniesHouseClient _companiesHouseClient;
private readonly ApplicationDbContext _dbContext; private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly ILogger<CompanyVerifierService> _logger; private readonly ILogger<CompanyVerifierService> _logger;
private const int FuzzyMatchThreshold = 70; private const int FuzzyMatchThreshold = 70;
@@ -22,11 +22,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
public CompanyVerifierService( public CompanyVerifierService(
CompaniesHouseClient companiesHouseClient, CompaniesHouseClient companiesHouseClient,
ApplicationDbContext dbContext, IDbContextFactory<ApplicationDbContext> dbContextFactory,
ILogger<CompanyVerifierService> logger) ILogger<CompanyVerifierService> logger)
{ {
_companiesHouseClient = companiesHouseClient; _companiesHouseClient = companiesHouseClient;
_dbContext = dbContext; _dbContextFactory = dbContextFactory;
_logger = logger; _logger = logger;
} }
@@ -123,8 +123,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
{ {
var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays); 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 // Get recent cached companies
var cachedCompanies = await _dbContext.CompanyCache var cachedCompanies = await dbContext.CompanyCache
.Where(c => c.CachedAt >= cutoffDate) .Where(c => c.CachedAt >= cutoffDate)
.ToListAsync(); .ToListAsync();
@@ -160,7 +163,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item) 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); .FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
if (existingCache is not null) if (existingCache is not null)
@@ -183,10 +189,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
CachedAt = DateTime.UtcNow CachedAt = DateTime.UtcNow
}; };
_dbContext.CompanyCache.Add(cacheEntry); dbContext.CompanyCache.Add(cacheEntry);
} }
await _dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
} }
private static CompanyVerificationResult CreateVerificationResult( private static CompanyVerificationResult CreateVerificationResult(

View File

@@ -3,6 +3,8 @@ using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using FluentAssertions; using FluentAssertions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Moq; using Moq;
@@ -23,6 +25,8 @@ public class CompanyVerifierServiceTests : IDisposable
private readonly ApplicationDbContext _dbContext; private readonly ApplicationDbContext _dbContext;
private readonly CompanyVerifierService _sut; private readonly CompanyVerifierService _sut;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly DbContextOptions<ApplicationDbContext> _dbOptions;
private readonly InMemoryDatabaseRoot _dbRoot;
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -46,13 +50,25 @@ public class CompanyVerifierServiceTests : IDisposable
var client = new CompaniesHouseClient(_httpClient, settings, _mockClientLogger.Object); var client = new CompaniesHouseClient(_httpClient, settings, _mockClientLogger.Object);
var options = new DbContextOptionsBuilder<ApplicationDbContext>() // Use a shared InMemoryDatabaseRoot so all contexts share the same data store
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) _dbRoot = new InMemoryDatabaseRoot();
var dbName = Guid.NewGuid().ToString();
_dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: dbName, databaseRoot: _dbRoot)
.ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning))
.Options; .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() public void Dispose()
@@ -313,8 +329,9 @@ public class CompanyVerifierServiceTests : IDisposable
// Act // Act
await _sut.VerifyCompanyAsync(companyName, null, null); await _sut.VerifyCompanyAsync(companyName, null, null);
// Assert // Assert - use AsNoTracking to get fresh data from the store
var cachedEntry = await _dbContext.CompanyCache var cachedEntry = await _dbContext.CompanyCache
.AsNoTracking()
.FirstOrDefaultAsync(c => c.CompanyNumber == "77777777"); .FirstOrDefaultAsync(c => c.CompanyNumber == "77777777");
cachedEntry.Should().NotBeNull(); cachedEntry.Should().NotBeNull();
@@ -348,8 +365,9 @@ public class CompanyVerifierServiceTests : IDisposable
// Act // Act
await _sut.VerifyCompanyAsync(companyName, null, null); await _sut.VerifyCompanyAsync(companyName, null, null);
// Assert // Assert - use AsNoTracking to get fresh data from the store
var cachedEntry = await _dbContext.CompanyCache var cachedEntry = await _dbContext.CompanyCache
.AsNoTracking()
.FirstOrDefaultAsync(c => c.CompanyNumber == "55555555"); .FirstOrDefaultAsync(c => c.CompanyNumber == "55555555");
cachedEntry.Should().NotBeNull(); cachedEntry.Should().NotBeNull();