@@ -17,6 +17,11 @@ public class TriggerSessionSaveChangesInterceptor : ISaveChangesInterceptor, IRe
1717
1818 ITriggerSession ? _triggerSession ;
1919 int _parallelSaveChangesCount ;
20+ int _afterSaveFailedTriggersRaisedDepth ;
21+ bool _subscribedToSaveChangesFailed ;
22+
23+ // Triggers can call SaveChanges on the same context, so this tracks the nesting depth whose AfterSaveFailed triggers already ran
24+ private bool AfterSaveFailedTriggersRaised => _parallelSaveChangesCount > 0 && _afterSaveFailedTriggersRaisedDepth == _parallelSaveChangesCount ;
2025
2126 private void EnlistTriggerSession ( DbContextEventData eventData )
2227 {
@@ -50,17 +55,28 @@ private void EnlistTriggerSession(DbContextEventData eventData)
5055 }
5156 }
5257
58+ if ( ! _subscribedToSaveChangesFailed )
59+ {
60+ eventData . Context ! . SaveChangesFailed += OnSaveChangesFailed ;
61+ _subscribedToSaveChangesFailed = true ;
62+ }
63+
5364 _parallelSaveChangesCount += 1 ;
5465 }
5566
56- private void DelistTriggerSession ( DbContextEventData eventData )
67+ private void DelistTriggerSession ( DbContext ? context )
5768 {
5869 Debug . Assert ( _triggerSession != null ) ;
5970
6071#if DEBUG
61- Debug . Assert ( _capturedDbContext == eventData . Context ) ;
72+ Debug . Assert ( _capturedDbContext == context ) ;
6273#endif
6374
75+ if ( AfterSaveFailedTriggersRaised )
76+ {
77+ _afterSaveFailedTriggersRaisedDepth = 0 ;
78+ }
79+
6480 _parallelSaveChangesCount -= 1 ;
6581
6682 if ( _parallelSaveChangesCount == 0 )
@@ -90,7 +106,7 @@ public InterceptionResult<int> SavingChanges(DbContextEventData eventData, Inter
90106 catch
91107 {
92108 // We're aborting the SaveChanges call, delist the trigger session now
93- DelistTriggerSession ( eventData ) ;
109+ DelistTriggerSession ( eventData . Context ) ;
94110 throw ;
95111 }
96112 finally
@@ -126,7 +142,7 @@ public async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEven
126142 catch
127143 {
128144 // We're aborting the SaveChanges call, delist the trigger session now
129- DelistTriggerSession ( eventData ) ;
145+ DelistTriggerSession ( eventData . Context ) ;
130146 throw ;
131147 }
132148 finally
@@ -145,7 +161,7 @@ public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
145161 _triggerSession . RaiseAfterSaveTriggers ( ) ;
146162 _triggerSession . RaiseAfterSaveCompletedTriggers ( ) ;
147163
148- DelistTriggerSession ( eventData ) ;
164+ DelistTriggerSession ( eventData . Context ) ;
149165
150166 return result ;
151167 }
@@ -163,7 +179,7 @@ public async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData even
163179 _triggerSession . RaiseAfterSaveCompletedTriggers ( ) ;
164180 await _triggerSession . RaiseAfterSaveCompletedAsyncTriggers ( cancellationToken ) . ConfigureAwait ( false ) ;
165181
166- DelistTriggerSession ( eventData ) ;
182+ DelistTriggerSession ( eventData . Context ) ;
167183
168184 return result ;
169185 }
@@ -172,52 +188,94 @@ public void SaveChangesFailed(DbContextErrorEventData eventData)
172188 {
173189 try
174190 {
175- RaiseAfterSaveFailedTriggers ( eventData . Exception ) ;
191+ if ( ! AfterSaveFailedTriggersRaised )
192+ {
193+ RaiseAfterSaveFailedTriggers ( eventData . Exception ) ;
194+ }
176195 }
177196 finally
178197 {
179- DelistTriggerSession ( eventData ) ;
198+ DelistTriggerSession ( eventData . Context ) ;
180199 }
181200 }
182201
183202 public async Task SaveChangesFailedAsync ( DbContextErrorEventData eventData , CancellationToken cancellationToken = default )
184203 {
185204 try
186205 {
187- await RaiseAfterSaveFailedAsyncTriggers ( eventData . Exception , cancellationToken ) . ConfigureAwait ( false ) ;
206+ if ( ! AfterSaveFailedTriggersRaised )
207+ {
208+ await RaiseAfterSaveFailedAsyncTriggers ( eventData . Exception , cancellationToken ) . ConfigureAwait ( false ) ;
209+ }
188210 }
189211 finally
190212 {
191- DelistTriggerSession ( eventData ) ;
213+ DelistTriggerSession ( eventData . Context ) ;
192214 }
193215 }
194216
217+ // An interceptor registered after us can still suppress the exception, so the session is only released once SaveChanges
218+ // reports its final outcome: SavedChanges, SaveChangesFailed, SaveChangesCanceled or the DbContext.SaveChangesFailed event.
195219 public InterceptionResult ThrowingConcurrencyException ( ConcurrencyExceptionEventData eventData , InterceptionResult result )
196220 {
197- if ( ! result . IsSuppressed )
221+ if ( ! result . IsSuppressed && ! AfterSaveFailedTriggersRaised )
198222 {
199- SaveChangesFailed ( eventData ) ;
223+ try
224+ {
225+ RaiseAfterSaveFailedTriggers ( eventData . Exception ) ;
226+ }
227+ finally
228+ {
229+ _afterSaveFailedTriggersRaisedDepth = _parallelSaveChangesCount ;
230+ }
200231 }
201232
202233 return result ;
203234 }
204235
205236 public async ValueTask < InterceptionResult > ThrowingConcurrencyExceptionAsync ( ConcurrencyExceptionEventData eventData , InterceptionResult result , CancellationToken cancellationToken = default )
206237 {
207- if ( ! result . IsSuppressed )
238+ if ( ! result . IsSuppressed && ! AfterSaveFailedTriggersRaised )
208239 {
209- await SaveChangesFailedAsync ( eventData , cancellationToken ) . ConfigureAwait ( false ) ;
240+ try
241+ {
242+ await RaiseAfterSaveFailedAsyncTriggers ( eventData . Exception , cancellationToken ) . ConfigureAwait ( false ) ;
243+ }
244+ finally
245+ {
246+ _afterSaveFailedTriggersRaisedDepth = _parallelSaveChangesCount ;
247+ }
210248 }
211249
212250 return result ;
213251 }
214252
253+ private void OnSaveChangesFailed ( object ? sender , SaveChangesFailedEventArgs eventArgs )
254+ {
255+ if ( eventArgs . Exception is not DbUpdateConcurrencyException || _triggerSession is null )
256+ {
257+ return ;
258+ }
259+
260+ try
261+ {
262+ if ( ! AfterSaveFailedTriggersRaised )
263+ {
264+ RaiseAfterSaveFailedTriggers ( eventArgs . Exception ) ;
265+ }
266+ }
267+ finally
268+ {
269+ DelistTriggerSession ( sender as DbContext ) ;
270+ }
271+ }
272+
215273 public void SaveChangesCanceled ( DbContextEventData eventData )
216- => DelistTriggerSession ( eventData ) ;
274+ => DelistTriggerSession ( eventData . Context ) ;
217275
218276 public Task SaveChangesCanceledAsync ( DbContextEventData eventData , CancellationToken cancellationToken = default )
219277 {
220- DelistTriggerSession ( eventData ) ;
278+ DelistTriggerSession ( eventData . Context ) ;
221279 return Task . CompletedTask ;
222280 }
223281
@@ -226,6 +284,7 @@ public void ResetState()
226284 _triggerSession ? . Dispose ( ) ;
227285 _triggerSession = null ;
228286 _parallelSaveChangesCount = 0 ;
287+ _afterSaveFailedTriggersRaisedDepth = 0 ;
229288#if DEBUG
230289 _capturedDbContext = null ;
231290#endif
0 commit comments