Skip to content

Commit b74dbcf

Browse files
authored
Merge pull request #257 from baoduy/dev
enhance the exception handling for EfCore inteceptor
2 parents 4728eb5 + 27ba449 commit b74dbcf

File tree

8 files changed

+181
-59
lines changed

8 files changed

+181
-59
lines changed

src/DKNet.FW.sln.DotSettings.user

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,9 @@
591591
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=741f3bef_002D9e13_002D4160_002D8efd_002D6460b3b44d61/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &amp;lt;SlimBus&amp;gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
592592
&lt;Project Location="/Users/steven/_CODE/DRUNK/DKNet/src" Presentation="&amp;lt;SlimBus&amp;gt;" /&gt;
593593
&lt;/SessionState&gt;</s:String>
594+
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=964034e5_002Dde7d_002D4983_002D9106_002D426b33d8b8f6/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &amp;lt;SlimBus&amp;gt;\&amp;lt;SlimBus.Extensions.Tests&amp;gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
595+
&lt;Project Location="/Users/steven/_CODE/DRUNK/DKNet/src/SlimBus/SlimBus.Extensions.Tests" Presentation="&amp;lt;SlimBus&amp;gt;\&amp;lt;SlimBus.Extensions.Tests&amp;gt;" /&gt;
596+
&lt;/SessionState&gt;</s:String>
594597

595598

596599

src/EfCore/EfCore.Specifications.Tests/EfCore.Specifications.Tests.csproj

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,25 @@
1919
<PrivateAssets>all</PrivateAssets>
2020
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2121
</PackageReference>
22-
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer"/>
23-
<PackageReference Include="Microsoft.Extensions.Logging.Console"/>
24-
<PackageReference Include="Testcontainers.MsSql"/>
25-
<PackageReference Include="Bogus"/>
26-
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
27-
<PackageReference Include="Moq"/>
28-
<PackageReference Include="xunit"/>
22+
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
23+
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
24+
<PackageReference Include="Testcontainers.MsSql" />
25+
<PackageReference Include="Bogus" />
26+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
27+
<PackageReference Include="Moq" />
28+
<PackageReference Include="xunit" />
2929
<PackageReference Include="xunit.runner.visualstudio">
3030
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3131
<PrivateAssets>all</PrivateAssets>
3232
</PackageReference>
33-
<PackageReference Include="Shouldly"/>
33+
<PackageReference Include="Shouldly" />
3434
</ItemGroup>
3535

3636
<ItemGroup>
37-
<ProjectReference Include="..\DKNet.EfCore.Specifications\DKNet.EfCore.Specifications.csproj"/>
38-
<ProjectReference Include="..\DKNet.EfCore.Abstractions\DKNet.EfCore.Abstractions.csproj"/>
39-
<ProjectReference Include="..\DKNet.EfCore.Repos\DKNet.EfCore.Repos.csproj"/>
37+
<ProjectReference Include="..\DKNet.EfCore.Specifications\DKNet.EfCore.Specifications.csproj" />
38+
<ProjectReference Include="..\DKNet.EfCore.Abstractions\DKNet.EfCore.Abstractions.csproj" />
39+
<ProjectReference Include="..\DKNet.EfCore.Repos\DKNet.EfCore.Repos.csproj" />
40+
<ProjectReference Include="..\..\SlimBus\DKNet.SlimBus.Extensions\DKNet.SlimBus.Extensions.csproj" />
4041
</ItemGroup>
4142
</Project>
4243

src/SlimBus/DKNet.SlimBus.Extensions/DKNet.SlimBus.Extensions.csproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
<PrivateAssets>all</PrivateAssets>
1818
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1919
</PackageReference>
20-
<PackageReference Include="X.PagedList.EF" />
20+
<PackageReference Include="X.PagedList.EF"/>
2121
</ItemGroup>
2222
<ItemGroup>
2323
<ProjectReference Include="..\..\EfCore\DKNet.EfCore.Events\DKNet.EfCore.Events.csproj"/>
2424
</ItemGroup>
25-
25+
<ItemGroup>
26+
<InternalsVisibleTo Include="SlimBus.Extensions.Tests"/>
27+
</ItemGroup>
2628
</Project>

src/SlimBus/DKNet.SlimBus.Extensions/Interceptors/EfAutoSavePostInterceptor.cs

Lines changed: 18 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,7 @@
55
using SlimMessageBus;
66
using SlimMessageBus.Host.Interceptor;
77

8-
namespace DKNet.SlimBus.Extensions.Behaviors;
9-
10-
internal static class EfAutoSavePostProcessorRegistration
11-
{
12-
#region Properties
13-
14-
public static HashSet<Type> DbContextTypes { get; } = [];
15-
/// <summary>
16-
/// Ensures DbContext save operations are executed with async locking to prevent concurrent saves.
17-
/// </summary>
18-
public static readonly SemaphoreSlim SaveLock = new(1, 1);
19-
20-
#endregion
21-
22-
#region Methods
23-
24-
public static void RegisterDbContextType<TDbContext>()
25-
where TDbContext : DbContext
26-
{
27-
DbContextTypes.Add(typeof(TDbContext));
28-
}
29-
30-
#endregion
31-
}
8+
namespace DKNet.SlimBus.Extensions.Interceptors;
329

3310
internal sealed class EfAutoSavePostInterceptor<TRequest, TResponse>(
3411
IServiceProvider serviceProvider,
@@ -48,7 +25,7 @@ public async Task<TResponse> OnHandle(TRequest request, Func<Task<TResponse>> ne
4825
var requestTypeName = typeof(TRequest).Name;
4926
logger.LogDebug("Handling request of type {RequestType}", requestTypeName);
5027

51-
var response = await next();
28+
var response = await next().ConfigureAwait(false);
5229

5330
if (response is null)
5431
{
@@ -70,27 +47,23 @@ request is Fluents.Queries.IWitPageResponse<TResponse> ||
7047
return response;
7148
}
7249

73-
await EfAutoSavePostProcessorRegistration.SaveLock.WaitAsync(context.CancellationToken);
74-
try
75-
{
76-
var dbContexts = EfAutoSavePostProcessorRegistration.DbContextTypes
77-
.Select(serviceProvider.GetService).OfType<DbContext>().ToArray();
78-
79-
var dbContextCount = dbContexts.Length;
80-
logger.LogDebug("Found {DbContextCount} DbContext(s) for auto-save.", dbContextCount);
81-
82-
foreach (var db in dbContexts.Where(db => db.ChangeTracker.HasChanges()))
83-
{
84-
var dbContextTypeName = db.GetType().Name;
85-
logger.LogDebug("DbContext {DbContextType} has changes. Saving...", dbContextTypeName);
86-
await db.AddNewEntitiesFromNavigations(context.CancellationToken);
87-
await db.SaveChangesAsync(context.CancellationToken);
88-
logger.LogDebug("DbContext {DbContextType} changes saved.", dbContextTypeName);
89-
}
90-
}
91-
finally
50+
var exceptionHandler = serviceProvider.GetService(typeof(IEfCoreExceptionHandler)) as IEfCoreExceptionHandler;
51+
var dbContexts = EfAutoSavePostProcessorRegistration.DbContextTypes
52+
.Select(serviceProvider.GetService).OfType<DbContext>().ToArray();
53+
54+
var dbContextCount = dbContexts.Length;
55+
logger.LogDebug("Found {DbContextCount} DbContext(s) for auto-save.", dbContextCount);
56+
57+
foreach (var db in dbContexts.Where(db => db.ChangeTracker.HasChanges()))
9258
{
93-
EfAutoSavePostProcessorRegistration.SaveLock.Release();
59+
var dbContextTypeName = db.GetType().Name;
60+
logger.LogDebug("DbContext {DbContextType} has changes. Saving...", dbContextTypeName);
61+
62+
await db.AddNewEntitiesFromNavigations(context.CancellationToken).ConfigureAwait(false);
63+
await db.SaveChangesWithConcurrencyHandlingAsync(exceptionHandler, context.CancellationToken)
64+
.ConfigureAwait(false);
65+
66+
logger.LogDebug("DbContext {DbContextType} changes saved.", dbContextTypeName);
9467
}
9568

9669
logger.LogDebug("Auto-save post-processing complete for request {RequestType}.", requestTypeName);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace DKNet.SlimBus.Extensions.Interceptors;
4+
5+
internal static class EfAutoSavePostProcessorRegistration
6+
{
7+
#region Properties
8+
9+
public static HashSet<Type> DbContextTypes { get; } = [];
10+
11+
#endregion
12+
13+
#region Methods
14+
15+
public static void RegisterDbContextType<TDbContext>()
16+
where TDbContext : DbContext
17+
{
18+
DbContextTypes.Add(typeof(TDbContext));
19+
}
20+
21+
#endregion
22+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// <copyright file="EfCoreExceptionHandler.cs" company="https://drunkcoding.net">
2+
// Copyright (c) 2025 Steven Hoang. All rights reserved.
3+
// Licensed under the MIT License. See LICENSE in the project root for license information.
4+
// </copyright>
5+
6+
using Microsoft.EntityFrameworkCore;
7+
8+
namespace DKNet.SlimBus.Extensions.Interceptors;
9+
10+
/// <summary>
11+
/// Represents the possible resolutions for EF Core concurrency exceptions.
12+
/// </summary>
13+
public enum EfConcurrencyResolution
14+
{
15+
/// <summary>
16+
/// Ignores changes made to the entity in case of concurrency conflict.
17+
/// </summary>
18+
IgnoreChanges,
19+
20+
/// <summary>
21+
/// Retries the save operation after resolving concurrency conflict.
22+
/// </summary>
23+
RetrySaveChanges,
24+
25+
/// <summary>
26+
/// Rethrows the concurrency exception to the caller.
27+
/// </summary>
28+
RethrowException
29+
}
30+
31+
/// <summary>
32+
/// Defines a contract for handling EF Core concurrency exceptions.
33+
/// </summary>
34+
public interface IEfCoreExceptionHandler
35+
{
36+
/// <summary>
37+
/// Gets the maximum number of retry attempts for concurrency resolution.
38+
/// </summary>
39+
public int MaxRetryCount => 3;
40+
41+
/// <summary>
42+
/// Handles the <see cref="DbUpdateConcurrencyException" /> and determines the resolution strategy.
43+
/// </summary>
44+
/// <param name="context">The EF Core <see cref="DbContext" /> instance.</param>
45+
/// <param name="exception">The concurrency exception to handle.</param>
46+
/// <param name="cancellationToken">A cancellation token for the async operation.</param>
47+
/// <returns>The concurrency resolution strategy to apply.</returns>
48+
Task<EfConcurrencyResolution> HandlingAsync(DbContext context, DbUpdateConcurrencyException exception,
49+
CancellationToken cancellationToken = default);
50+
}
51+
52+
/// <summary>
53+
/// Provides an implementation for handling EF Core concurrency exceptions and resolving conflicts.
54+
/// </summary>
55+
public sealed class EfCoreExceptionHandler : IEfCoreExceptionHandler
56+
{
57+
#region Methods
58+
59+
/// <inheritdoc />
60+
public async Task<EfConcurrencyResolution> HandlingAsync(DbContext context, DbUpdateConcurrencyException exception,
61+
CancellationToken cancellationToken = default)
62+
{
63+
if (!exception.Message.Contains("but actually affected 0 row(s)", StringComparison.OrdinalIgnoreCase))
64+
return EfConcurrencyResolution.RethrowException;
65+
66+
foreach (var entry in exception.Entries)
67+
{
68+
// Get the current database values for the conflicting entity
69+
var databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken).ConfigureAwait(false);
70+
if (databaseValues == null) continue;
71+
72+
var currentValues = entry.CurrentValues.Clone();
73+
entry.OriginalValues.SetValues(databaseValues);
74+
entry.CurrentValues.SetValues(currentValues);
75+
}
76+
77+
return EfConcurrencyResolution.RetrySaveChanges;
78+
}
79+
80+
#endregion
81+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace DKNet.SlimBus.Extensions.Interceptors;
4+
5+
internal static class EfSaveChangesExtension
6+
{
7+
#region Methods
8+
9+
public static async Task<int> SaveChangesWithConcurrencyHandlingAsync(
10+
this DbContext dbContext,
11+
IEfCoreExceptionHandler? handler = null,
12+
CancellationToken cancellationToken = default)
13+
{
14+
handler ??= new EfCoreExceptionHandler();
15+
var retryCount = 0;
16+
17+
while (true)
18+
try
19+
{
20+
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
21+
}
22+
catch (DbUpdateConcurrencyException ex)
23+
{
24+
var rs = await handler.HandlingAsync(dbContext, ex, cancellationToken).ConfigureAwait(false);
25+
switch (rs)
26+
{
27+
case EfConcurrencyResolution.RethrowException:
28+
throw;
29+
case EfConcurrencyResolution.IgnoreChanges:
30+
return 0;
31+
case EfConcurrencyResolution.RetrySaveChanges:
32+
retryCount += 1;
33+
if (retryCount > handler.MaxRetryCount) return 0;
34+
break;
35+
}
36+
}
37+
}
38+
39+
#endregion
40+
}

src/SlimBus/DKNet.SlimBus.Extensions/SlimBusEfCoreSetup.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
// File: SlimBusEfCoreSetup.cs
55
// Description: DI helpers to wire SlimMessageBus integrations for EF Core (event publisher and auto-save behavior).
66

7-
using DKNet.SlimBus.Extensions.Behaviors;
87
using DKNet.SlimBus.Extensions.Handlers;
8+
using DKNet.SlimBus.Extensions.Interceptors;
99
using Microsoft.EntityFrameworkCore;
1010
using SlimMessageBus.Host.Interceptor;
1111

0 commit comments

Comments
 (0)