Skip to content

Make the EF Core capture safe beside other SaveChanges interceptors (1.0.0-preview.3) - #4

Merged
sadeqabuhattem merged 3 commits into
mainfrom
fix/standalone-save-lifecycle
Nov 14, 2026
Merged

sadeqabuhattem merged 3 commits into
mainfrom
fix/standalone-save-lifecycle

Conversation

@sadeqabuhattem

Copy link
Copy Markdown
Member

Summary

Makes the EF Core capture safe beside another library's SaveChanges interceptor — such as
DotNetBoost.MultiLingual's — registered before it or after it, with or without a retrying strategy,
and fixes four bugs that show up with Auditing alone. This is the content of 1.0.0-preview.3; the
full list is in CHANGELOG.md under [Unreleased].

Commits

  1. Let the engine build an entry without storing it, and report lost atomicity (Core, API).
    AuditRecorder.RecordAsync splits into Prepare and Counted; adds the lost-atomicity
    reporting API.
  2. Make the EF Core capture safe beside another SaveChanges interceptor (the interceptor and
    every new test). The fixes and the redesign share the rewritten interceptor, so they are one
    commit.
  3. Document sharing a DbContext with other SaveChanges interceptors (docs, changelog).

Each commit builds on its own.

What changes

Beside another interceptor, ported from MultiLingual's save lifecycle into
AuditingSaveChangesInterceptor.Lifecycle.cs:

  • A save another interceptor issues from inside this one is nested. It joins the save in
    progress, and only the outermost save writes the deferred entries, commits or rolls back.
    Nested is decided by whether EF has already accepted the outer save's changes. Anything else
    starts over: a save abandoned when a later interceptor's SavingChanges threw, or one being
    replayed under a retrying strategy.
  • With another interceptor registered after it, the capture commits from EF's
    DbContext.SavedChanges event, once every interceptor has finished, so their post-save work is
    inside the transaction.
  • A transaction someone else committed early is detected before the deferred write.

Standalone fixes:

Was Now
A cancelled save left the transaction open, and a retry recorded the creation twice Rolled back, like a failure
A concurrency conflict left the transaction open Rolled back from EF's DbContext.SaveChangesFailed event. A ROLLBACK in the ThrowingConcurrencyException hook itself fails on PostgreSQL and SQL Server, because the connection is still reading the batch
A creation inside a TransactionScope threw Uses the ambient transaction
A save another interceptor suppressed was still captured Not captured
A second context in one scope tracked its entries on the registered context, so they were never written Entries go to the context being saved; a context without the audit tables is refused

Public API (additions only; the surface baselines are updated)

  • AuditingBuilder.OnLostAtomicity(LostAtomicity) and AuditingOptions.OnLostAtomicity
  • enum LostAtomicity { Report, Throw } in Core.Models
  • IAuditObserver.AtomicityLost(string correlationId, int entries): a default interface member,
    so existing observers still compile. LoggingAuditObserver logs it as an error.
  • AuditingTelemetry.AtomicityLost, the dotnetboost.auditing.atomicity.lost counter

Behaviour changes to look at

  • The commit from EF's event is synchronous: one blocking round trip, even under
    SaveChangesAsync, and only when another SaveChanges interceptor follows this one.
  • The capture no longer writes through IAuditStore.AppendAsync. It adds entries to the
    context being saved. A store wrapped to observe entries no longer sees the capture's.
    Explicit IAuditManager calls still go through the store.
  • Rollbacks on failure paths are best-effort, so a broken connection cannot replace the
    exception that explains why the save failed.

Tests

New suites, run in both registration orders and through both SaveChanges and
SaveChangesAsync:

  • TestSupport/CompositionInterceptors.cs: stand-ins for another library, without referencing
    one: a post-save writer committing in SavedChanges or from EF's event, a replaying interceptor
    under a retrying strategy with tracker restore, a later SavedChanges that throws, and others.
  • ProviderTests/EfCoreCompositionTests.cs and EfCoreSaveLifecycleTests.cs on SQLite.
  • IntegrationTests/CompositionTests.cs on PostgreSQL and SQL Server, standalone fixes included.

The targeted SQLite tests were written first and failed on preview.2. The engine tests use the new
API, so they did not compile against it.

Release, locally: UnitTests 189, ProviderTests 288, ApiTests 59, SurfaceTests 7 (each on net8.0
and net10.0), IntegrationTests 280. All pass. Restore passes with fresh vulnerability data, and the
Release build has 0 warnings.

Also checked against the real DotNetBoost.MultiLingual 1.0.0-preview.3, in an application with
Auditing registered first and database-generated keys:

  • A forced failure in MultiLingual's translation write left the product and its Created entry
    committed on preview.2, and rolls back on this branch.
  • With EnableRetryOnFailure, every change was recorded twice on preview.2, and once here.

Still open, documented in docs/limitations.md

  • The deferred write is still a nested SaveChanges that other interceptors see. An interceptor
    sharing the context should count nested saves, as MultiLingual does.
  • A throw from a later interceptor's SavingChanges leaves the transaction open until the
    context's next save, which cleans it up. EF gives no hook in between.
  • An interceptor that reaches the context some other way than AddInterceptors can't be seen
    following this one.

MultiLingual rolls back inside ThrowingConcurrencyException too, so it probably has the same
real-engine bug fixed here. That's for a separate PR in its repository.

🤖 Generated with Claude Code

sadeqabuhattem and others added 3 commits November 14, 2026 21:01
…micity

AuditRecorder.RecordAsync splits into Prepare, which applies the model's rules
and formatting and returns the entry, and Counted, which meters it. The EF Core
capture will use the two to add entries to the context being saved instead of
going through the store.

OnLostAtomicity(LostAtomicity.Report | Throw), IAuditObserver.AtomicityLost and
the dotnetboost.auditing.atomicity.lost counter are what the capture reports
through when another interceptor commits the transaction before the deferred
Created entries are written. AtomicityLost is a default interface member, so
existing observers still compile; LoggingAuditObserver logs it as an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The save lifecycle, ported from DotNetBoost.MultiLingual's design:

- A save another interceptor issues from inside this one is nested: it joins
  the save in progress, and only the outermost save writes the deferred entries,
  commits or rolls back. Nested is decided by whether EF has accepted the outer
  save's changes; anything else starts over, rolling back what an abandoned or
  replayed save left open and detaching the entries it captured.
- With another interceptor after it, the capture commits from EF's
  DbContext.SavedChanges event, once every interceptor has finished.
- A transaction someone else committed early is reported, or fails the save
  under OnLostAtomicity(Throw).

And the standalone fixes: SaveChangesCanceled rolls back; a concurrency conflict
rolls back from EF's SaveChangesFailed event, since a ROLLBACK during the
ThrowingConcurrencyException hook fails on PostgreSQL and SQL Server; an ambient
TransactionScope is used rather than refused; a suppressed save is not captured;
entries go to the context being saved, and a context without the audit tables
is refused. The tracker is read once per save, and rollbacks on failure paths
are best-effort so they cannot hide why the save failed.

Tests: stand-ins for another library in TestSupport (a post-save writer, a
replaying interceptor under a retrying strategy, and others), run in both
registration orders and through SaveChanges and SaveChangesAsync, on SQLite in
ProviderTests and on PostgreSQL and SQL Server in IntegrationTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/limitations.md gains the section on what the capture does in each hook,
which orders are safe, and what to do; and the notes on a throw from a later
SavingChanges, suppressed saves, TransactionScope, and every audited context
mapping the audit tables. The observability and configuration references cover
OnLostAtomicity and the new counter, and the changelog lists preview.3's changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sadeqabuhattem
sadeqabuhattem merged commit e7f311d into main Nov 14, 2026
5 checks passed
@sadeqabuhattem
sadeqabuhattem deleted the fix/standalone-save-lifecycle branch November 14, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant