Skip to content

Make the save interceptor safe beside other SaveChanges interceptors - #3

Merged
sadeqabuhattem merged 7 commits into
mainfrom
fix/composition-gaps
Nov 4, 2026
Merged

sadeqabuhattem merged 7 commits into
mainfrom
fix/composition-gaps

Conversation

@sadeqabuhattem

Copy link
Copy Markdown
Member

Composing this library with DotNetBoost.Auditing in one DbContext (in dotnetboost-sample) raised questions the suite could not answer: nothing ran the save interceptor next to another interceptor that saves again once a save completes. This branch adds those tests, fixes what they found, and records the rest in COMPOSITION-GAPS.md.

The library takes no dependency on any other DotNetBoost library. The tests use a test-only interceptor shaped like Auditing's deferred pass.

Fixed

  • A save issued by another interceptor from inside ours wiped ours (gap 1). An interceptor registered before UseMultiLingual runs its SavedChanges first. A SaveChanges it issued there restarted this interceptor's save: the outer save's translation deletes were lost, and its transaction was committed from inside the nested save. Saves are now counted per context. A nested one joins the outer save, and only the outermost completion writes, commits or rolls back. A save EF never reported as finished (an interceptor's SavingChanges threw) is not mistaken for one still in flight.
  • An interceptor registered after UseMultiLingual did its post-save work outside the transaction (gap 2). When another SaveChanges interceptor follows this one, the commit now waits for EF's DbContext.SavedChanges event. On EF 8 and 10 that event fires once every interceptor's SavedChanges has returned, and not at all when one throws. That commit is synchronous, because the event is, and it is deferred only on such a context.
  • A concurrency conflict or a cancelled save left the owned transaction open. EF reports neither through SaveChangesFailed, only through ThrowingConcurrencyException and SaveChangesCanceled, which are now handled.
  • A rolled-back save left rows another interceptor added during it tracked, under rolled-back keys, so retrying the save on the same context failed on a duplicate key.

Added

  • .OwnTransactions(bool) (EF Core, default true). With false, the application owns the transaction, and a save that writes translations without one is refused before anything is written.
  • Docs:
    • Sharing a DbContext with other SaveChanges interceptors in docs/limitations.md: what each hook does and what to do per setup. It also says why EF's usual acceptAllChangesOnSuccess: false retry pattern breaks beside an interceptor that saves again: the second save inserted a row twice.
    • A checklist for storing text about another library's entity, in docs/reading-and-writing.md.

Still open

  • A retrying strategy with the other interceptor registered first. The commit has to happen inside the strategy's own call, so it cannot wait for the event. The docs say to register UseMultiLingual first there. These are the skipped test rows.
  • Gap 3 (SetAll / GetAllAsync(includeBase: true)) and the rest of gap 4 are features. They will come on their own branches.

Testing

  • SaveCompositionTests and SyncSaveCompositionTests (SQLite) cover every case with the other interceptor registered before and after UseMultiLingual, each with and without a retrying strategy, async and sync.

  • SaveCompositionIntegrationTests runs the core of the matrix on PostgreSQL 17 and SQL Server 2022, with each provider's own EnableRetryOnFailure.

  • To check the tests depend on each fix, I removed each fix in turn, and its tests failed:

    • with the nesting check removed, the refused-save cases fail;
    • with the deferral off, exactly the "registered after" rows fail;
    • with the sync hooks broken, only sync tests fail.
  • Results on net8.0 and net10.0:

    Suite Passed Skipped
    Unit 227 0
    Provider 290 6
    API 55 / 56 0
    Integration 196 6

    The skipped rows are the open case above.

🤖 Generated with Claude Code

sadeqabuhattem and others added 6 commits November 3, 2026 18:30
Composing this library with Auditing in one DbContext raised questions the
suite could not answer: nothing ran the save interceptor next to another
interceptor that saves again once a save completes. SaveCompositionTests adds
one, shaped like Auditing's deferred pass, and runs every case with it
registered before and after UseMultiLingual, with and without a retrying
strategy, in both modes.

Six of the thirty rows fail today and are skipped with the gap they belong to:
registered first, a delete leaves its translation rows behind and a failure
after the follow-up save leaves everything committed; registered second, or
first under a retrying strategy, the follow-up runs after the commit. The
empty product name the sample once wrote did not reproduce in any
arrangement.

COMPOSITION-GAPS.md records the gaps, checked against the code and updated
with these results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An interceptor registered before UseMultiLingual runs its SavedChanges
first. A SaveChanges it issued there (Auditing's deferred pass does)
restarted this interceptor's save: the outer save's translation deletes
were lost, leaving the rows behind, and its transaction was committed
from inside the nested save, so a failure afterwards rolled nothing back.

Saves are now counted per context. A nested save adds its work to the
outer state, skipping entries already pending, and only the outermost
completion writes, commits or rolls back. A nested save's failure is left
to whoever issued it.

EF does not report every save's end. Checked on EF 8 and 10: a throwing
SavingChanges raises no callback at all, a concurrency conflict raises
only ThrowingConcurrencyException, and a cancelled save only
SaveChangesCanceled. The last two are now handled like a failure, which
also fixes an older leak: both left the owned transaction open. For the
first, a save counts as nested only while the outer save's changed
entries have all been accepted, so a save EF never finished cannot make
every later save wait for it.

SaveCompositionTests un-skips the two gap 1 rows and adds a failing
nested save, two saves after a refused one, and a concurrency conflict.
The gap 2 rows stay skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a save writes translations and the caller has no transaction, the
save interceptor opens one and commits it in its own SavedChanges. An
interceptor registered after it, an audit trail say, then does its
post-save work after that commit, outside the transaction, and nothing
reports it.

.OwnTransactions(false) leaves the transaction to the application. The
interceptor then never opens one, the retrying path included, and a save
that writes translations without one is refused in SavingChanges, before
anything reaches the database. The default stays true.

docs/limitations.md gains "Sharing a DbContext with other SaveChanges
interceptors": what each hook does, and what to do per setup. Without a
retrying strategy, the application opens the transaction around every
SaveChanges, which keeps every interceptor inside it in either order.
Under one, keep OwnTransactions on and register UseMultiLingual first, so
this library's retrying path runs the whole save in one transaction.

The tests also showed that EF's usual retry pattern,
SaveChangesAsync(acceptAllChangesOnSuccess: false) then AcceptAllChanges,
breaks beside an interceptor that saves again: the changes are not
accepted yet, so the second save writes them again, inserting a row twice
and failing a delete as a concurrency conflict. The docs warn against it,
and COMPOSITION-GAPS.md drops it from the gap 2 notes. Option A, owning
every save the way the retrying path does, stays open there: it conflicts
with Auditing's proposed fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every composition case called SaveChangesAsync, while the gap 1 and gap 2
changes touched the synchronous members of the save interceptor too.
SyncSaveCompositionTests runs the same cases through SaveChanges(): the
follow-up inside the transaction, rollback after it, a failing follow-up
save, deletes, the OwnTransactions(false) refusal, the documented
application transaction, a save after a refused one, and a concurrency
conflict. The test interceptors gain their synchronous members, and the
application-transaction context overrides SaveChanges(bool) as the docs
advise. Breaking the synchronous concurrency hook or the synchronous
completion count fails these tests and no asynchronous one.

docs/reading-and-writing.md gains "Storing text about another library's
entity": nothing deletes a subject's rows unless the application does,
and PurgeOrphanedTranslationsAsync does not cover them. Name the subject
once, delete through the owning library's hook (an example with Media's
IMediaFilesDeletedHandler and ForMany(...).DeleteAsync()), and use that
library's own id. The orphan section says the purge skips subjects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The save interceptor committed its own transaction in its SavedChanges.
EF runs interceptors in the order they were added, so one registered
after it, an audit trail writing entries that needed the generated keys,
did that work after the commit: outside the transaction, where a failure
could no longer roll the entity back.

When another SaveChanges interceptor follows this one, the commit now
waits for EF's DbContext.SavedChanges event. Checked on EF 8 and 10, it
fires once every interceptor's SavedChanges has returned, and not at all
when one throws: every interceptor then sees SaveChangesFailed, and this
one rolls back the transaction it still holds. Until the event the save
counts as in flight, so a SaveChanges a later interceptor issues in
between is nested in it, and translations it adds are written at the
event, before the commit.

The event is synchronous, so that commit is too, even under
SaveChangesAsync. It is deferred only where it has to be: this
interceptor owns the transaction, and CoreOptionsExtension.Interceptors
lists another SaveChanges interceptor after it.

Retrying such a save on the same context then failed on a duplicate key:
the follow-up row the rollback undid stayed tracked under its rolled-back
key. TrackerSnapshot.Restore now detaches every row the failed save
started tracking, not just translation rows.

The six "registered after" rows of SaveCompositionTests pass, async and
sync; turning the deferral off fails exactly those and the new rollback
case. New cases cover a commit failing in the event, and translations a
follow-up save adds, written and rolled back. Still skipped: a retrying
strategy with the other interceptor registered first, where the commit
must happen inside the strategy. The docs, changelog and gaps notes say
so, and drop Option A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every test of the nested-save and deferred-commit changes ran on SQLite,
which could hide what a real engine does: a real driver's synchronous
commit inside an asynchronous save, the provider's own retrying strategy,
and what a failed statement does to the transaction around it.

SaveCompositionIntegrationTests runs the core of the matrix on PostgreSQL
17 and SQL Server 2022, each with its own EnableRetryOnFailure: the
follow-up inside the transaction through SaveChangesAsync and SaveChanges,
rollback when the follow-up or its own save fails, and deletes. The six
skipped rows are the case still open, a retrying strategy with the other
interceptor registered first.

A failed statement aborts a whole PostgreSQL transaction, so a follow-up
interceptor that swallows its own failed save could have left a commit
that stores nothing. It does not: EF Core sets a savepoint before a
SaveChanges inside an existing transaction and rolls back to it, so on
both engines only the follow-up's own row is lost. The test asserts that.

The follow-up interceptor and its probe become public for these tests,
and the probe can swallow the follow-up's failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The expected name lists in SaveCompositionTests state their element type
  once, as Assert.Equal's type argument, instead of casting the first
  value to string?.
- Begin records the changed entries, and TrackerSnapshot.Restore detaches
  the rows it did not know, through Where rather than an if inside the
  loop.
- ComposedShop.StartAsync gains its summary.

No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sadeqabuhattem
sadeqabuhattem merged commit 1fbc039 into main Nov 4, 2026
10 checks passed
@sadeqabuhattem
sadeqabuhattem deleted the fix/composition-gaps branch November 4, 2026 16:57
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.

2 participants