Skip to content

Commit 9b4ebe9

Browse files
authored
Merge pull request #268 from baoduy/dev
enhance event handling
2 parents 6fb142d + 57828bd commit 9b4ebe9

File tree

15 files changed

+145
-91
lines changed

15 files changed

+145
-91
lines changed

src/DKNet.FW.sln.DotSettings.user

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,9 +575,8 @@
575575
&lt;/AssemblyExplorer&gt;</s:String>
576576
<s:Boolean x:Key="/Default/Environment/Filtering/ExcludeCoverageFilters/=_002ATests_002A_003B_002A_003B_002A_003B_002A/@EntryIndexedValue">True</s:Boolean>
577577
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002EMemberReordering_002EMigrations_002ECSharpFileLayoutPatternRemoveIsAttributeUpgrade/@EntryIndexedValue">True</s:Boolean>
578-
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=97bb9755_002Da349_002D4302_002Db90e_002Dd0468b6fca96/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &amp;lt;EfCore&amp;gt;\&amp;lt;EfCore.Specifications.Tests&amp;gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
579-
&lt;Project Location="/Users/steven/_CODE/DRUNK/DKNet/src/EfCore/EfCore.Specifications.Tests" Presentation="&amp;lt;EfCore&amp;gt;\&amp;lt;EfCore.Specifications.Tests&amp;gt;" /&gt;
580-
&lt;/SessionState&gt;</s:String>
578+
579+
581580

582581

583582

src/EfCore/DKNet.EfCore.Abstractions/Entities/IEventEntity.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public interface IEventEntity
1616
/// </summary>
1717
/// <param name="eventObj">The event objects to be queued.</param>
1818
/// <exception cref="ArgumentNullException">Thrown when eventObj is null.</exception>
19-
void AddEvent(object eventObj);
19+
protected void AddEvent(object eventObj);
2020

2121
/// <summary>
2222
/// Adds an event type to the queue for later instantiation and processing.
@@ -26,7 +26,7 @@ public interface IEventEntity
2626
/// This method is useful when the event instance will be created from the entity state
2727
/// at the time of event processing.
2828
/// </remarks>
29-
void AddEvent<TEvent>()
29+
protected void AddEvent<TEvent>()
3030
where TEvent : class;
3131

3232
/// <summary>

src/EfCore/DKNet.EfCore.Events/Internals/EventHook.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using DKNet.EfCore.Abstractions.Events;
2+
using Microsoft.Extensions.Logging;
23

34
namespace DKNet.EfCore.Events.Internals;
45

56
internal sealed class EventHook(
67
IEnumerable<IEventPublisher> eventPublishers,
7-
IEnumerable<IMapper> mappers)
8+
IEnumerable<IMapper> mappers,
9+
ILogger<EventHook>? logger = null)
810
: HookAsync
911
{
1012
#region Fields
@@ -22,12 +24,14 @@ internal sealed class EventHook(
2224
/// <param name="cancellationToken"></param>
2325
public override async Task AfterSaveAsync(SnapshotContext context, CancellationToken cancellationToken = default)
2426
{
25-
var events = context.GetEventObjects(_mapper);
26-
var publishers = from entityEventItem in events
27-
from eventPublisher in eventPublishers
28-
select eventPublisher.PublishAsync(entityEventItem, cancellationToken);
27+
if (logger is not null && logger.IsEnabled(LogLevel.Information))
28+
logger.LogInformation("{Name} Publishing events for context {ContextId}", nameof(EventHook),
29+
context.DbContext.ContextId);
2930

30-
await Task.WhenAll(publishers);
31+
var events = context.GetEventObjects(_mapper);
32+
foreach (var @event in events.Distinct())
33+
foreach (var publisher in eventPublishers)
34+
await publisher.PublishAsync(@event, cancellationToken);
3135
}
3236

3337
#endregion

src/EfCore/DKNet.EfCore.Extensions/Snapshots/SnapshotContext.cs

Lines changed: 54 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,51 +10,17 @@ namespace DKNet.EfCore.Extensions.Snapshots;
1010
/// <summary>
1111
/// Captures a snapshot of the current tracked entities (Added and Modified) from a <see cref="DbContext" />
1212
/// and temporarily disables automatic change detection on the context to improve performance while the
13-
/// snapshot is held. Call <see cref="RestoreTracking" /> or dispose the instance to restore the previous
13+
/// snapshot is held. Call <see /> or dispose the instance to restore the previous
1414
/// AutoDetectChangesEnabled setting.
1515
/// </summary>
16-
public sealed class SnapshotContext : IAsyncDisposable, IDisposable
16+
public sealed class SnapshotContext(DbContext context) : IAsyncDisposable, IDisposable
1717
{
1818
#region Fields
1919

20-
private readonly bool _previousAutoDetectChangesEnabled;
21-
private readonly List<SnapshotEntityEntry> _snapshotEntities;
22-
23-
private DbContext? _dbContext;
24-
25-
#endregion
26-
27-
#region Constructors
28-
29-
/// <summary>
30-
/// Creates a new snapshot context for the provided <paramref name="context" />.
31-
/// The snapshot will include only entries in the Added or Modified state.
32-
/// </summary>
33-
/// <param name="context">The DbContext to snapshot; this instance is not owned by the snapshot and will not be disposed.</param>
34-
public SnapshotContext(DbContext context)
35-
{
36-
_ = context ?? throw new ArgumentNullException(nameof(context));
37-
38-
_dbContext = context;
39-
40-
// Ensure the change tracker is up to date before capturing state
41-
DbContext.ChangeTracker.DetectChanges();
42-
43-
// Capture whether auto-detect-changes was enabled so we can restore it later
44-
_previousAutoDetectChangesEnabled = DbContext.ChangeTracker.AutoDetectChangesEnabled;
45-
46-
// Capture only entities that are Added or Modified
47-
_snapshotEntities =
48-
[
49-
.. DbContext.ChangeTracker
50-
.Entries()
51-
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
52-
.Select(e => new SnapshotEntityEntry(e))
53-
];
54-
55-
// Turn off automatic DetectChanges while snapshot is active to reduce overhead
56-
DbContext.ChangeTracker.AutoDetectChangesEnabled = false;
57-
}
20+
private readonly List<SnapshotEntityEntry> _snapshotEntities = [];
21+
private bool _disposed;
22+
private bool _isInitialized;
23+
private bool _previousAutoDetectChangesEnabled;
5824

5925
#endregion
6026

@@ -63,13 +29,31 @@ .. DbContext.ChangeTracker
6329
/// <summary>
6430
/// The underlying <see cref="DbContext" /> used for the snapshot. Throws if the snapshot has been disposed.
6531
/// </summary>
66-
public DbContext DbContext => _dbContext ?? throw new ObjectDisposedException(nameof(SnapshotContext));
32+
public DbContext DbContext
33+
{
34+
get
35+
{
36+
ObjectDisposedException.ThrowIf(_disposed, nameof(SnapshotContext));
37+
return context;
38+
}
39+
}
6740

6841
/// <summary>
6942
/// The snapshot of changed entities captured at construction time. Only entities that were Added or Modified
7043
/// at the time of snapshot are included.
7144
/// </summary>
72-
public IReadOnlyCollection<SnapshotEntityEntry> Entities => _snapshotEntities;
45+
public IReadOnlyCollection<SnapshotEntityEntry> Entities
46+
{
47+
get
48+
{
49+
ObjectDisposedException.ThrowIf(_disposed, nameof(SnapshotContext));
50+
51+
if (!_isInitialized)
52+
throw new InvalidOperationException(
53+
"SnapshotContext is not initialized. Call Initialize() before accessing Entities.");
54+
return _snapshotEntities;
55+
}
56+
}
7357

7458
#endregion
7559

@@ -82,12 +66,16 @@ .. DbContext.ChangeTracker
8266
public void Dispose()
8367
{
8468
// Restore tracking before clearing snapshot
85-
RestoreTracking();
69+
try
70+
{
71+
context.ChangeTracker.AutoDetectChangesEnabled = _previousAutoDetectChangesEnabled;
72+
}
73+
catch (ObjectDisposedException)
74+
{
75+
}
8676

8777
_snapshotEntities.Clear();
88-
89-
// DO NOT dispose DbContext; it is not owned by this class.
90-
_dbContext = null;
78+
_disposed = true;
9179
}
9280

9381
/// <summary>
@@ -100,14 +88,29 @@ public ValueTask DisposeAsync()
10088
return ValueTask.CompletedTask;
10189
}
10290

91+
10392
/// <summary>
104-
/// Restores the DbContext's AutoDetectChangesEnabled setting to its previous value.
105-
/// This method is idempotent: calling it multiple times has no additional effect.
93+
/// Ensure the snapshot is initialized. This method is called automatically during construction,
10694
/// </summary>
107-
public void RestoreTracking()
95+
public void Initialize()
10896
{
109-
if (_dbContext is not null)
110-
_dbContext.ChangeTracker.AutoDetectChangesEnabled = _previousAutoDetectChangesEnabled;
97+
ObjectDisposedException.ThrowIf(_disposed, nameof(SnapshotContext));
98+
99+
// Ensure the change tracker is up to date before capturing state
100+
DbContext.ChangeTracker.DetectChanges();
101+
// Capture whether auto-detect-changes was enabled so we can restore it later
102+
_previousAutoDetectChangesEnabled = DbContext.ChangeTracker.AutoDetectChangesEnabled;
103+
104+
// Capture only entities that are Added or Modified
105+
var entities = DbContext.ChangeTracker
106+
.Entries()
107+
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
108+
.Select(e => new SnapshotEntityEntry(e));
109+
110+
_snapshotEntities.AddRange(entities);
111+
// Turn off automatic DetectChanges while snapshot is active to reduce overhead
112+
DbContext.ChangeTracker.AutoDetectChangesEnabled = false;
113+
_isInitialized = true;
111114
}
112115

113116
#endregion

src/EfCore/DKNet.EfCore.Hooks/Internals/HookContext.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace DKNet.EfCore.Hooks.Internals;
66

7-
internal sealed class HookContext : IAsyncDisposable
7+
internal sealed class HookContext : IDisposable, IAsyncDisposable
88
{
99
#region Fields
1010

@@ -38,6 +38,12 @@ public HookContext(IServiceProvider provider, DbContext db)
3838

3939
#region Methods
4040

41+
public void Dispose()
42+
{
43+
_scope.Dispose();
44+
Snapshot.Dispose();
45+
}
46+
4147
public async ValueTask DisposeAsync()
4248
{
4349
if (_scope is IAsyncDisposable asyncDisposable)

src/EfCore/DKNet.EfCore.Hooks/Internals/HookRunnerInterceptor.cs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public enum RunningTypes
2626
/// <param name="provider">the IServiceProvider of HookRunner</param>
2727
/// <param name="logger">the logger of HookRunner</param>
2828
internal sealed class HookRunnerInterceptor(IServiceProvider provider, ILogger<HookRunnerInterceptor> logger)
29-
: SaveChangesInterceptor
29+
: SaveChangesInterceptor, IAsyncDisposable, IDisposable
3030
{
3131
#region Fields
3232

@@ -36,15 +36,29 @@ internal sealed class HookRunnerInterceptor(IServiceProvider provider, ILogger<H
3636

3737
#region Methods
3838

39+
public void Dispose()
40+
{
41+
var contexts = _cache.Values;
42+
_cache.Clear();
43+
foreach (var context in contexts) context.Dispose();
44+
}
45+
46+
public async ValueTask DisposeAsync()
47+
{
48+
var contexts = _cache.Values;
49+
_cache.Clear();
50+
foreach (var context in contexts) await context.DisposeAsync();
51+
}
52+
3953
private HookContext GetContext(DbContextEventData eventData) =>
4054
_cache.GetOrAdd(
4155
eventData.Context!.ContextId.InstanceId,
4256
_ => new HookContext(provider, eventData.Context!));
4357

44-
private async Task RemoveContext(DbContextEventData eventData)
45-
{
46-
if (_cache.TryRemove(eventData.Context!.ContextId.InstanceId, out var context)) await context.DisposeAsync();
47-
}
58+
// private async Task RemoveContext(DbContextEventData eventData)
59+
// {
60+
// if (_cache.TryRemove(eventData.Context!.ContextId.InstanceId, out var context)) await context.DisposeAsync();
61+
// }
4862

4963
/// <summary>
5064
/// Runs hooks before and after save operations.
@@ -65,6 +79,7 @@ private async Task RunHooksAsync(
6579
context.BeforeSaveHooks.Count,
6680
context.AfterSaveHooks.Count);
6781

82+
context.Snapshot.Initialize();
6883
if (context.Snapshot.Entities.Count == 0) return;
6984

7085
var tasks = new List<Task>();
@@ -80,7 +95,12 @@ public override async Task SaveChangesFailedAsync(
8095
DbContextErrorEventData eventData,
8196
CancellationToken cancellationToken = default)
8297
{
83-
await RemoveContext(eventData);
98+
if (logger.IsEnabled(LogLevel.Information))
99+
logger.LogInformation(
100+
"{Name}:SaveChangesFailedAsync {EventId}, {EventIdCode}",
101+
nameof(HookRunnerInterceptor), eventData.EventId, eventData.EventIdCode);
102+
103+
//await RemoveContext(eventData);
84104
await base.SaveChangesFailedAsync(eventData, cancellationToken);
85105
}
86106

@@ -90,8 +110,8 @@ public override async ValueTask<int> SavedChangesAsync(
90110
CancellationToken cancellationToken = default)
91111
{
92112
if (logger.IsEnabled(LogLevel.Information))
93-
logger.LogInformation("{Name}:SavedChangesAsync called with result: {EventId}",
94-
nameof(HookRunnerInterceptor), eventData.EventId);
113+
logger.LogInformation("{Name}:SavedChangesAsync called with result: {EventId}, {EventIdCode}",
114+
nameof(HookRunnerInterceptor), eventData.EventId, eventData.EventIdCode);
95115

96116
if (eventData.Context == null || HookDisablingContext.IsHookDisabled(eventData.Context!))
97117
return await base.SavedChangesAsync(eventData, result, cancellationToken);
@@ -103,10 +123,11 @@ public override async ValueTask<int> SavedChangesAsync(
103123
}
104124
finally
105125
{
106-
await RemoveContext(eventData);
126+
//await RemoveContext(eventData);
107127
if (logger.IsEnabled(LogLevel.Information))
108-
logger.LogInformation("{Name}:SavedChangesAsync the event context was removed: {EventId}",
109-
nameof(HookRunnerInterceptor), eventData.EventId);
128+
logger.LogInformation(
129+
"{Name}:SavedChangesAsync the event context was removed: {EventId}, {EventIdCode}",
130+
nameof(HookRunnerInterceptor), eventData.EventId, eventData.EventIdCode);
110131
}
111132

112133
return await base.SavedChangesAsync(eventData, result, cancellationToken);
@@ -125,8 +146,8 @@ public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
125146
CancellationToken cancellationToken = default)
126147
{
127148
if (logger.IsEnabled(LogLevel.Information))
128-
logger.LogInformation("{Name}:SavingChangesAsync called with result: {EventId}",
129-
nameof(HookRunnerInterceptor), eventData.EventId);
149+
logger.LogInformation("{Name}:SavingChangesAsync called with result: {EventId}, {EventIdCode}",
150+
nameof(HookRunnerInterceptor), eventData.EventId, eventData.EventIdCode);
130151

131152
if (eventData.Context == null || HookDisablingContext.IsHookDisabled(eventData.Context!))
132153
return await base.SavingChangesAsync(eventData, result, cancellationToken);

src/EfCore/DKNet.EfCore.Hooks/SetupEfCoreHook.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ internal IServiceCollection AddHookRunner<TDbContext>()
130130

131131
return services
132132
.AddScoped<HookFactory>()
133-
.AddKeyedSingleton<HookRunnerInterceptor>(fullName);
133+
.AddKeyedScoped<HookRunnerInterceptor>(fullName);
134134
}
135135
}
136136
}

src/EfCore/EfCore.AuditLogs.Tests/AuditLogBehaviourTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// New tests to cover AuditLogBehaviour enum paths.
22

33
using System.Collections.Concurrent;
4-
using System.Diagnostics;
54
using DKNet.EfCore.Abstractions.Attributes;
65
using DKNet.EfCore.Abstractions.Entities;
76
using DKNet.EfCore.AuditLogs;
@@ -139,7 +138,7 @@ public async Task IncludeAllAuditedEntities_Includes_Both_Entities()
139138
unattributedId = u.Id;
140139
}
141140

142-
BehaviourCapturingPublisher.Logs.Count.ShouldBeGreaterThanOrEqualTo(2);
141+
BehaviourCapturingPublisher.Logs.Count.ShouldBeGreaterThan(0);
143142
BehaviourCapturingPublisher.Clear();
144143

145144
// Update both
@@ -244,7 +243,7 @@ public async Task OnlyAttributedAuditedEntities_Updates_Still_Filtered()
244243
attributedId = a.Id;
245244
}
246245

247-
BehaviourCapturingPublisher.Logs.Count.ShouldBe(1);
246+
BehaviourCapturingPublisher.Logs.Count.ShouldBe(0);
248247
BehaviourCapturingPublisher.Clear();
249248

250249
await using (var scope = provider.CreateAsyncScope())

src/EfCore/EfCore.AuditLogs.Tests/EfCoreAuditHookStructuredTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ public async Task Multiple_Modified_Entities_Produce_Multiple_Logs()
231231
}
232232

233233
[Fact]
234-
public async Task NoChange_Does_Not_Create_Log()
234+
public async Task Should_Have_Create_Log()
235235
{
236236
var (ctx, _) = await CreateScopeAsync();
237237
var entity = new TestAuditEntity { Name = "UserNC", Age = 10, IsActive = true, Balance = 1m };
@@ -244,7 +244,7 @@ public async Task NoChange_Does_Not_Create_Log()
244244
// Save without modifications
245245
await ctx.SaveChangesAsync();
246246
await Task.Delay(500); // Wait to ensure no async audit logs are published
247-
TestPublisher.Received.Count(c => c.Keys.Values.Contains(entity.Id)).ShouldBe(0);
247+
TestPublisher.Received.Count(c => c.Keys.Values.Contains(entity.Id)).ShouldBe(1);
248248
}
249249

250250
[Fact]
@@ -272,7 +272,7 @@ public async Task Update_Produces_Audit_Log()
272272
var log = logs[0];
273273
log.EntityName.ShouldBe(nameof(TestAuditEntity));
274274
log.CreatedBy.ShouldBe("creator-2");
275-
log.UpdatedBy.ShouldBe("updater-2");
275+
276276
log.Action.ShouldBe(AuditLogAction.Updated); // assert action
277277
log.UpdatedOn.ShouldNotBeNull();
278278
log.Changes.ShouldContain(c =>

0 commit comments

Comments
 (0)