Repository navigation
Make the save interceptor safe beside other SaveChanges interceptors - #3
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inCOMPOSITION-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
UseMultiLingualruns itsSavedChangesfirst. ASaveChangesit 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'sSavingChangesthrew) is not mistaken for one still in flight.UseMultiLingualdid its post-save work outside the transaction (gap 2). When anotherSaveChangesinterceptor follows this one, the commit now waits for EF'sDbContext.SavedChangesevent. On EF 8 and 10 that event fires once every interceptor'sSavedChangeshas 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.SaveChangesFailed, only throughThrowingConcurrencyExceptionandSaveChangesCanceled, which are now handled.Added
.OwnTransactions(bool)(EF Core, defaulttrue). Withfalse, the application owns the transaction, and a save that writes translations without one is refused before anything is written.docs/limitations.md: what each hook does and what to do per setup. It also says why EF's usualacceptAllChangesOnSuccess: falseretry pattern breaks beside an interceptor that saves again: the second save inserted a row twice.docs/reading-and-writing.md.Still open
UseMultiLingualfirst there. These are the skipped test rows.SetAll/GetAllAsync(includeBase: true)) and the rest of gap 4 are features. They will come on their own branches.Testing
SaveCompositionTestsandSyncSaveCompositionTests(SQLite) cover every case with the other interceptor registered before and afterUseMultiLingual, each with and without a retrying strategy, async and sync.SaveCompositionIntegrationTestsruns the core of the matrix on PostgreSQL 17 and SQL Server 2022, with each provider's ownEnableRetryOnFailure.To check the tests depend on each fix, I removed each fix in turn, and its tests failed:
Results on net8.0 and net10.0:
The skipped rows are the open case above.
🤖 Generated with Claude Code