diff --git a/docs/articles/dbcontext-audit-state-isolation.md b/docs/articles/dbcontext-audit-state-isolation.md new file mode 100644 index 0000000..ac77af1 --- /dev/null +++ b/docs/articles/dbcontext-audit-state-isolation.md @@ -0,0 +1,42 @@ +# DbContext Audit State Isolation + +NCAT isolates mutable SaveChanges audit state by `ApplicationDbContext` instance, even when one dependency-injection scope creates several contexts through `IDbContextFactory`. + +Each context receives a dedicated internal `ApplicationSaveChangesPipeline` state machine for: + +- pending audit entries that contain temporary values; +- retained audit records used to build the canonical mutation manifest; +- active audit context and mutation-batch identity; +- audit record counts; and +- the most recently completed mutation receipt. + +The scoped coordinator uses weak context keys, so disposing a factory-created context does not leave that context permanently retained by the scope. + +## Receipt access + +`IApplicationMutationAuditReceiptAccessor` is context-bound when resolved from dependency injection. It reports the receipt for the scoped `ApplicationDbContext` used by `IApplicationAuditedTransaction`. + +Factory-created contexts can be queried explicitly through `IApplicationMutationAuditReceiptRegistry`: + +```csharp +await using ApplicationDbContext first = await factory.CreateDbContextAsync(cancellationToken); +await using ApplicationDbContext second = await factory.CreateDbContextAsync(cancellationToken); + +await first.SaveChangesAsync(cancellationToken); +await second.SaveChangesAsync(cancellationToken); + +ApplicationMutationAuditReceipt? firstReceipt = + receiptRegistry.GetLastCompletedReceipt(first); +ApplicationMutationAuditReceipt? secondReceipt = + receiptRegistry.GetLastCompletedReceipt(second); +``` + +The registry returns `null` until the specified context completes an audited save. A receipt produced by another context in the same scope is never returned for the requested context. + +## Concurrency boundary + +Different `ApplicationDbContext` instances in one scope may save independently without sharing pending audit state or mutation receipts. + +EF Core does not support concurrent operations on the same `DbContext` instance. This isolation feature does not change that rule. Await each operation on a context before starting another operation on that same context. + +The configured `IApplicationAuditStore`, audit context accessor, actor accessor, value policy, manifest builder, and manifest hasher remain shared according to their registered lifetimes. Custom scoped implementations that maintain mutable state must therefore provide their own concurrency safety when multiple contexts use them simultaneously. diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 0711a4a..5b5c87d 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -76,6 +76,8 @@ href: ef-core-save-pipeline.md - name: Audit Accountability Integration href: audit-accountability-integration.md + - name: DbContext Audit State Isolation + href: dbcontext-audit-state-isolation.md - name: Durable Audit-Completion Outbox href: audit-completion-outbox.md - name: Audit Reconciliation and Recovery diff --git a/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs b/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs new file mode 100644 index 0000000..f3f717c --- /dev/null +++ b/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs @@ -0,0 +1,18 @@ +namespace ProjectTemplate.Infrastructure.Data.Auditing; + +/// +/// Exposes the most recently completed mutation receipt for one application database context. +/// +internal sealed class ApplicationDbContextMutationAuditReceiptAccessor( + ApplicationDbContext dbContext, + IApplicationMutationAuditReceiptRegistry receiptRegistry) + : IApplicationMutationAuditReceiptAccessor +{ + private readonly ApplicationDbContext _dbContext = + dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + private readonly IApplicationMutationAuditReceiptRegistry _receiptRegistry = + receiptRegistry ?? throw new ArgumentNullException(nameof(receiptRegistry)); + + public ApplicationMutationAuditReceipt? LastCompletedReceipt => + _receiptRegistry.GetLastCompletedReceipt(_dbContext); +} diff --git a/src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs b/src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs new file mode 100644 index 0000000..3519e01 --- /dev/null +++ b/src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs @@ -0,0 +1,14 @@ +namespace ProjectTemplate.Infrastructure.Data.Auditing; + +/// +/// Resolves the most recently completed mutation audit receipt for a specific application database context. +/// +public interface IApplicationMutationAuditReceiptRegistry +{ + /// + /// Gets the most recently completed receipt produced by . + /// + /// The application database context whose receipt should be resolved. + /// The context-specific receipt, or when the context has not completed an audited save. + ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext); +} diff --git a/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs b/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs new file mode 100644 index 0000000..2e18663 --- /dev/null +++ b/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs @@ -0,0 +1,94 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Options; +using ProjectTemplate.Infrastructure.Data.Auditing; +using ProjectTemplate.Infrastructure.Data.Options; + +namespace ProjectTemplate.Infrastructure.Data; + +/// +/// Routes save lifecycle callbacks to one mutable pipeline instance per application database context. +/// +internal sealed class ContextIsolatedApplicationSaveChangesPipeline : + IApplicationSaveChangesPipeline, + IApplicationMutationAuditReceiptRegistry +{ + private readonly ConditionalWeakTable _pipelines = []; + private readonly ICurrentActorAccessor _currentActorAccessor; + private readonly IOptions _dataAccessOptions; + private readonly IApplicationAuditStore? _auditStore; + private readonly IApplicationAuditContextAccessor? _auditContextAccessor; + private readonly IApplicationAuditValuePolicy? _auditValuePolicy; + private readonly IApplicationMutationManifestBuilder _manifestBuilder; + private readonly IApplicationMutationManifestHasher _manifestHasher; + + public ContextIsolatedApplicationSaveChangesPipeline( + ICurrentActorAccessor currentActorAccessor, + IOptions dataAccessOptions, + IApplicationMutationManifestBuilder manifestBuilder, + IApplicationMutationManifestHasher manifestHasher, + IApplicationAuditStore? auditStore = null, + IApplicationAuditContextAccessor? auditContextAccessor = null, + IApplicationAuditValuePolicy? auditValuePolicy = null) + { + ArgumentNullException.ThrowIfNull(currentActorAccessor); + ArgumentNullException.ThrowIfNull(dataAccessOptions); + ArgumentNullException.ThrowIfNull(manifestBuilder); + ArgumentNullException.ThrowIfNull(manifestHasher); + + _currentActorAccessor = currentActorAccessor; + _dataAccessOptions = dataAccessOptions; + _auditStore = auditStore; + _auditContextAccessor = auditContextAccessor; + _auditValuePolicy = auditValuePolicy; + _manifestBuilder = manifestBuilder; + _manifestHasher = manifestHasher; + } + + public bool ApplyBeforeSaveChanges(ApplicationDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyBeforeSaveChanges(dbContext); + } + + public ValueTask ApplyBeforeSaveChangesAsync( + ApplicationDbContext dbContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyBeforeSaveChangesAsync(dbContext, cancellationToken); + } + + public bool ApplyAfterSaveChanges(ApplicationDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyAfterSaveChanges(dbContext); + } + + public ValueTask ApplyAfterSaveChangesAsync( + ApplicationDbContext dbContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyAfterSaveChangesAsync(dbContext, cancellationToken); + } + + public ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + return _pipelines.TryGetValue(dbContext, out ApplicationSaveChangesPipeline? pipeline) + ? pipeline.LastCompletedReceipt + : null; + } + + private ApplicationSaveChangesPipeline GetPipeline(ApplicationDbContext dbContext) + { + return _pipelines.GetValue(dbContext, _ => new ApplicationSaveChangesPipeline( + _currentActorAccessor, + _dataAccessOptions, + _auditStore, + _auditContextAccessor, + _auditValuePolicy, + _manifestBuilder, + _manifestHasher)); + } +} diff --git a/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs b/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs index 72e3479..2c479e3 100644 --- a/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs +++ b/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; using ProjectTemplate.Infrastructure.Data.Auditing; using ProjectTemplate.Infrastructure.Data.ExternalLogins; using ProjectTemplate.Infrastructure.Data.Options; @@ -54,19 +53,12 @@ public static IServiceCollection AddApplicationInfrastructureDataAccess( services.TryAddScoped(); } - services.TryAddScoped(serviceProvider => - new ApplicationSaveChangesPipeline( - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService>(), - serviceProvider.GetService(), - serviceProvider.GetService(), - serviceProvider.GetService(), - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())); + services.TryAddScoped(); services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); - services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); + serviceProvider.GetRequiredService()); + services.TryAddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.TryAddScoped(); services.TryAddScoped(); services.AddDbContext(options => ConfigureProvider( diff --git a/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs b/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs new file mode 100644 index 0000000..c21df42 --- /dev/null +++ b/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs @@ -0,0 +1,158 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ProjectTemplate.Infrastructure.Data; +using ProjectTemplate.Infrastructure.Data.Auditing; +using ProjectTemplate.Infrastructure.Data.Entities; +using ProjectTemplate.Infrastructure.Data.Extensions; + +namespace ProjectTemplate.Web.Tests; + +public sealed class ApplicationSaveChangesPipelineContextIsolationTests +{ + [Fact] + public async Task SaveChangesAsync_TwoFactoryContextsInOneScope_KeepAuditStateAndReceiptsIsolated() + { + string databasePath = Path.Combine( + Path.GetTempPath(), + $"ncat-audit-context-isolation-{Guid.NewGuid():N}.db"); + + try + { + IConfiguration configuration = CreateConfiguration(databasePath); + ServiceCollection services = new(); + services.AddLogging(); + services.AddScoped(); + services.AddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddApplicationInfrastructureDataAccess(configuration); + + await using ServiceProvider serviceProvider = services.BuildServiceProvider( + new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + IDbContextFactory factory = scope.ServiceProvider + .GetRequiredService>(); + IApplicationMutationAuditReceiptRegistry receiptRegistry = scope.ServiceProvider + .GetRequiredService(); + BlockingApplicationAuditStore auditStore = scope.ServiceProvider + .GetRequiredService(); + + await using ApplicationDbContext firstContext = await factory.CreateDbContextAsync( + TestContext.Current.CancellationToken); + await using ApplicationDbContext secondContext = await factory.CreateDbContextAsync( + TestContext.Current.CancellationToken); + + _ = await firstContext.Database.EnsureDeletedAsync(TestContext.Current.CancellationToken); + _ = await firstContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + firstContext.ExternalLoginAccounts.Add(CreateAccount("first-context-user")); + secondContext.ExternalLoginAccounts.Add(CreateAccount("second-context-user")); + + Task firstSave = firstContext.SaveChangesAsync(TestContext.Current.CancellationToken); + await auditStore.WaitUntilFirstAppendIsBlockedAsync(TestContext.Current.CancellationToken); + + int secondResult = await secondContext.SaveChangesAsync(TestContext.Current.CancellationToken); + auditStore.ReleaseFirstAppend(); + int firstResult = await firstSave; + + AuditRecord firstAuditRecord = Assert.Single( + firstContext.ChangeTracker.Entries()).Entity; + AuditRecord secondAuditRecord = Assert.Single( + secondContext.ChangeTracker.Entries()).Entity; + ApplicationMutationAuditReceipt firstReceipt = Assert.IsType( + receiptRegistry.GetLastCompletedReceipt(firstContext)); + ApplicationMutationAuditReceipt secondReceipt = Assert.IsType( + receiptRegistry.GetLastCompletedReceipt(secondContext)); + + Assert.Equal(2, firstResult); + Assert.Equal(2, secondResult); + Assert.NotEqual(firstAuditRecord.MutationBatchId, secondAuditRecord.MutationBatchId); + Assert.Equal(firstAuditRecord.MutationBatchId, firstReceipt.MutationBatchId); + Assert.Equal(secondAuditRecord.MutationBatchId, secondReceipt.MutationBatchId); + Assert.Contains("first-context-user", firstAuditRecord.CurrentValues, StringComparison.Ordinal); + Assert.DoesNotContain("second-context-user", firstAuditRecord.CurrentValues, StringComparison.Ordinal); + Assert.Contains("second-context-user", secondAuditRecord.CurrentValues, StringComparison.Ordinal); + Assert.DoesNotContain("first-context-user", secondAuditRecord.CurrentValues, StringComparison.Ordinal); + } + finally + { + File.Delete(databasePath); + } + } + + private static IConfiguration CreateConfiguration(string databasePath) + { + return new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:ApplicationDatabase"] = $"Data Source={databasePath};Pooling=False", + ["ProjectTemplate:DataAccess:Provider"] = "Sqlite", + ["ProjectTemplate:DataAccess:ConnectionStringName"] = "ApplicationDatabase", + ["ProjectTemplate:DataAccess:Auditing:Enabled"] = "true", + ["ProjectTemplate:DataAccess:Auditing:StorageMode"] = "Local" + }) + .Build(); + } + + private static ExternalLoginAccount CreateAccount(string providerUserId) + { + return new ExternalLoginAccount + { + LocalUserId = Guid.NewGuid(), + ProviderName = "GitHub", + ProviderUserId = providerUserId, + DisplayName = providerUserId, + Email = $"{providerUserId}@example.com", + CreatedOnUtc = new DateTime(2026, 7, 19, 12, 0, 0, DateTimeKind.Utc) + }; + } + + private sealed class BlockingApplicationAuditStore : IApplicationAuditStore + { + private readonly TaskCompletionSource _firstAppendBlocked = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseFirstAppend = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _appendCount; + + public void Append(ApplicationDbContext dbContext, AuditRecord auditRecord) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(auditRecord); + dbContext.AuditRecords.Add(auditRecord); + } + + public async ValueTask AppendAsync( + ApplicationDbContext dbContext, + AuditRecord auditRecord, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(auditRecord); + + if (Interlocked.Increment(ref _appendCount) == 1) + { + _firstAppendBlocked.SetResult(); + await _releaseFirstAppend.Task.WaitAsync(cancellationToken); + } + + dbContext.AuditRecords.Add(auditRecord); + } + + public Task WaitUntilFirstAppendIsBlockedAsync(CancellationToken cancellationToken) + { + return _firstAppendBlocked.Task.WaitAsync(cancellationToken); + } + + public void ReleaseFirstAppend() + { + _releaseFirstAppend.TrySetResult(); + } + } +} diff --git a/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs b/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs index 5eefee5..d5dd05c 100644 --- a/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs +++ b/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs @@ -45,12 +45,18 @@ public void AddApplicationInfrastructureDataAccess_NonWebContainer_ResolvesDbCon IApplicationSaveChangesPipeline saveChangesPipeline = scope.ServiceProvider .GetRequiredService(); + IApplicationMutationAuditReceiptRegistry receiptRegistry = scope.ServiceProvider + .GetRequiredService(); + ApplicationSaveChangesInterceptor saveChangesInterceptor = scope.ServiceProvider .GetRequiredService(); using ApplicationDbContext context = scope.ServiceProvider .GetRequiredService(); + IApplicationMutationAuditReceiptAccessor receiptAccessor = scope.ServiceProvider + .GetRequiredService(); + IDbContextFactory dbContextFactory = scope.ServiceProvider .GetRequiredService>(); @@ -58,7 +64,9 @@ public void AddApplicationInfrastructureDataAccess_NonWebContainer_ResolvesDbCon Assert.Equal(SystemCurrentActorAccessor.ActorName, actorAccessor.CurrentActor); Assert.IsType(auditStore); - Assert.IsType(saveChangesPipeline); + Assert.NotNull(saveChangesPipeline); + Assert.Same(saveChangesPipeline, receiptRegistry); + Assert.Null(receiptAccessor.LastCompletedReceipt); Assert.NotNull(saveChangesInterceptor); Assert.Equal("Microsoft.EntityFrameworkCore.Sqlite", context.Database.ProviderName); Assert.Equal("Microsoft.EntityFrameworkCore.Sqlite", factoryContext.Database.ProviderName); @@ -102,6 +110,8 @@ public void AddApplicationInfrastructureDataAccess_DisabledProvider_SkipsDbConte Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); + Assert.Null(serviceProvider.GetService()); + Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService>());