Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/articles/dbcontext-audit-state-isolation.md
Original file line number Diff line number Diff line change
@@ -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<ApplicationDbContext>`.

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.
2 changes: 2 additions & 0 deletions docs/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace ProjectTemplate.Infrastructure.Data.Auditing;

/// <summary>
/// Exposes the most recently completed mutation receipt for one application database context.
/// </summary>
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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ProjectTemplate.Infrastructure.Data.Auditing;

/// <summary>
/// Resolves the most recently completed mutation audit receipt for a specific application database context.
/// </summary>
public interface IApplicationMutationAuditReceiptRegistry
{
/// <summary>
/// Gets the most recently completed receipt produced by <paramref name="dbContext" />.
/// </summary>
/// <param name="dbContext">The application database context whose receipt should be resolved.</param>
/// <returns>The context-specific receipt, or <see langword="null" /> when the context has not completed an audited save.</returns>
ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Routes save lifecycle callbacks to one mutable pipeline instance per application database context.
/// </summary>
internal sealed class ContextIsolatedApplicationSaveChangesPipeline :
IApplicationSaveChangesPipeline,
IApplicationMutationAuditReceiptRegistry
{
private readonly ConditionalWeakTable<ApplicationDbContext, ApplicationSaveChangesPipeline> _pipelines = [];
private readonly ICurrentActorAccessor _currentActorAccessor;
private readonly IOptions<DataAccessOptions> _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> 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<bool> 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<bool> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,19 +53,12 @@ public static IServiceCollection AddApplicationInfrastructureDataAccess(
services.TryAddScoped<IApplicationAuditStore, LocalApplicationAuditStore>();
}

services.TryAddScoped(serviceProvider =>
new ApplicationSaveChangesPipeline(
serviceProvider.GetRequiredService<ICurrentActorAccessor>(),
serviceProvider.GetRequiredService<IOptions<DataAccessOptions>>(),
serviceProvider.GetService<IApplicationAuditStore>(),
serviceProvider.GetService<IApplicationAuditContextAccessor>(),
serviceProvider.GetService<IApplicationAuditValuePolicy>(),
serviceProvider.GetRequiredService<IApplicationMutationManifestBuilder>(),
serviceProvider.GetRequiredService<IApplicationMutationManifestHasher>()));
services.TryAddScoped<ContextIsolatedApplicationSaveChangesPipeline>();
services.TryAddScoped<IApplicationSaveChangesPipeline>(serviceProvider =>
serviceProvider.GetRequiredService<ApplicationSaveChangesPipeline>());
services.TryAddScoped<IApplicationMutationAuditReceiptAccessor>(serviceProvider =>
serviceProvider.GetRequiredService<ApplicationSaveChangesPipeline>());
serviceProvider.GetRequiredService<ContextIsolatedApplicationSaveChangesPipeline>());
services.TryAddScoped<IApplicationMutationAuditReceiptRegistry>(serviceProvider =>
serviceProvider.GetRequiredService<ContextIsolatedApplicationSaveChangesPipeline>());
services.TryAddScoped<IApplicationMutationAuditReceiptAccessor, ApplicationDbContextMutationAuditReceiptAccessor>();
services.TryAddScoped<ApplicationSaveChangesInterceptor>();

services.AddDbContext<ApplicationDbContext>(options => ConfigureProvider(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BlockingApplicationAuditStore>();
services.AddScoped<IApplicationAuditStore>(serviceProvider =>
serviceProvider.GetRequiredService<BlockingApplicationAuditStore>());
services.AddApplicationInfrastructureDataAccess(configuration);

await using ServiceProvider serviceProvider = services.BuildServiceProvider(
new ServiceProviderOptions
{
ValidateOnBuild = true,
ValidateScopes = true
});
await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope();

IDbContextFactory<ApplicationDbContext> factory = scope.ServiceProvider
.GetRequiredService<IDbContextFactory<ApplicationDbContext>>();
IApplicationMutationAuditReceiptRegistry receiptRegistry = scope.ServiceProvider
.GetRequiredService<IApplicationMutationAuditReceiptRegistry>();
BlockingApplicationAuditStore auditStore = scope.ServiceProvider
.GetRequiredService<BlockingApplicationAuditStore>();

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<int> 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<AuditRecord>()).Entity;
AuditRecord secondAuditRecord = Assert.Single(
secondContext.ChangeTracker.Entries<AuditRecord>()).Entity;
ApplicationMutationAuditReceipt firstReceipt = Assert.IsType<ApplicationMutationAuditReceipt>(
receiptRegistry.GetLastCompletedReceipt(firstContext));
ApplicationMutationAuditReceipt secondReceipt = Assert.IsType<ApplicationMutationAuditReceipt>(
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<string, string?>
{
["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();
}
}
}
Loading