diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 6ac9dabc..457cba2f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -138,6 +138,38 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture.Instance))); } + /// + /// Stops a purge actor and blocks until it is actually dead. + /// + /// + /// + /// Deferred-work register row 95, the same leak shape row 82 fixed in + /// PartitionPurgeTests / NotifyDispatcherAuditTrailTests. Every actor this + /// class creates ticks every 100 ms + /// () and was only ever stopped + /// implicitly by 's Dispose → Shutdown — which is + /// bounded by a timeout and does NOT fail when that timeout expires. So on a loaded + /// machine an actor kept ticking while LATER test classes ran: the stub-repo tests burn a + /// DI scope and a thread every 100 ms for the rest of the assembly run, and + /// EndToEnd_RealPartition_…'s actor keeps switching partitions out of the shared + /// database that other classes in this assembly seed + /// against. + /// + /// + /// Stopping the actor inside the test that created it removes the dependency on + /// ActorSystem-shutdown timing entirely. + /// + /// + private void StopPurgeActor(IActorRef actor) + { + Watch(actor); + Sys.Stop(actor); + // A stop is processed after the in-flight tick completes, so this also guarantees no + // partition switch is still running against the fixture DB. + ExpectTerminated(actor, TimeSpan.FromSeconds(30)); + Unwatch(actor); + } + private static AuditLogPurgeOptions FastTickOptions(TimeSpan? interval = null) => new() { IntervalHours = 24, @@ -163,7 +195,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture= 1 enumerator call, got {repo.ThresholdQueries.Count}"), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); + + StopPurgeActor(actor); } // --------------------------------------------------------------------- @@ -192,7 +226,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture 42L, }; - CreateActor(repo, FastTickOptions()); + var actor = CreateActor(repo, FastTickOptions()); AwaitAssert( () => @@ -202,6 +236,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture() }; - CreateActor(repo, FastTickOptions()); + var actor = CreateActor(repo, FastTickOptions()); // Wait for at least one tick (visible via the enumerator call) then // assert no switch happened. @@ -226,6 +262,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(5)); + StopPurgeActor(actor); Assert.Equal(boundary, msg.MonthBoundary); Assert.Equal(1234L, msg.RowsDeleted); Assert.True(msg.DurationMs >= 0, @@ -269,7 +310,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture @@ -281,6 +322,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture new AuditLogPurgeActor( + var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( sp, Options.Create(purgeOptions), Options.Create(auditOptions), @@ -363,6 +406,11 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture m.MonthBoundary == janBoundary, max: TimeSpan.FromSeconds(30)); + // This is the one actor in the class that mutates the SHARED fixture database, so it + // must not outlive the test and start switching partitions out from under the next + // class's seed rows (register row 95). + StopPurgeActor(actor); + Assert.True(matched.RowsDeleted >= 1, $"Expected RowsDeleted >= 1 for the Jan-2026 partition; got {matched.RowsDeleted}."); @@ -394,7 +442,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture new AuditLogPurgeActor( + var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( sp, purgeOptionsWrapped, auditOptions, @@ -448,6 +498,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture p.Channel == "ApiOutbound"); Assert.Equal(1234, purge.BatchSize); @@ -483,7 +535,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture new AuditLogPurgeActor( + var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( sp, purgeOptions, auditOptions, @@ -496,6 +548,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture new AuditLogPurgeActor( + var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( sp, Options.Create(purgeOptions), auditOptions, @@ -542,6 +598,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture new AuditLogPurgeActor( + var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( sp, Options.Create(FastTickOptions()), Options.Create(new AuditLogOptions()), @@ -588,5 +646,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture= 1, got {snapshot.PurgeFailures}"), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); + + StopPurgeActor(actor); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 411cc2c4..3cd50a0c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -252,6 +252,37 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture.Instance))); } + /// + /// Stops a reconciliation actor and blocks until it is actually dead. + /// + /// + /// + /// Deferred-work register row 95, the same leak shape row 82 fixed in + /// PartitionPurgeTests / NotifyDispatcherAuditTrailTests. Every actor this + /// class creates ticks on a ~0 s repeat interval () and was + /// only ever stopped implicitly by 's Dispose → + /// Shutdown, which is bounded by a timeout and does NOT fail when it expires. A + /// survivor keeps pulling and INSERTING audit rows: in + /// Tick_Duplicates_NotDoubleInserted it holds a real + /// over the shared + /// database (and over a DbContext the test is about to dispose), so it writes into + /// the DB LATER test classes read from. + /// + /// + /// Stopping the actor inside the test that created it removes the dependency on + /// ActorSystem-shutdown timing entirely. + /// + /// + private void StopReconciliationActor(IActorRef actor) + { + Watch(actor); + Sys.Stop(actor); + // A stop is processed after the in-flight tick completes, so this also guarantees no + // pull or insert is still running against the fixture DB. + ExpectTerminated(actor, TimeSpan.FromSeconds(30)); + Unwatch(actor); + } + /// /// Subscribes to the EventStream and collects every /// publication into a list @@ -278,7 +309,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Assert.True(client.Calls.Count >= 1, $"expected >= 1 pull call, got {client.Calls.Count}"), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); + + StopReconciliationActor(actor); } // --------------------------------------------------------------------- @@ -304,7 +337,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture { @@ -313,6 +346,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Assert.Equal(2, repo.InsertCallCount), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); + + StopReconciliationActor(actor); + Assert.Contains(repo.Inserted, e => e.EventId == e1.EventId); Assert.Contains(repo.Inserted, e => e.EventId == e2.EventId); } @@ -371,13 +409,18 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Assert.True(client.Calls.Count >= 1), duration: TimeSpan.FromSeconds(3)); + // Stop before reading: this actor holds a real repository over the SHARED fixture + // database (and over the DbContext this test is about to dispose), so it must not + // outlive the test (register row 95). + StopReconciliationActor(actor); + // Even though the pull returned 2 events, only 1 fresh row should // exist in MSSQL alongside the pre-existing one — InsertIfNotExistsAsync // is first-write-wins on EventId. @@ -413,7 +456,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Assert.True(client.Calls.Count >= 2, $"need at least 2 pulls, got {client.Calls.Count}"), duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50)); + StopReconciliationActor(actor); + Assert.Equal(t, client.Calls[1].SinceUtc); Assert.Equal(high.EventId.ToString(), client.Calls[1].AfterId); } @@ -475,13 +524,15 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Assert.True(client.Calls.Count >= 2, $"need at least 2 pulls, got {client.Calls.Count}"), duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50)); + StopReconciliationActor(actor); + Assert.Equal(DateTime.MinValue, client.Calls[1].SinceUtc); Assert.Null(client.Calls[1].AfterId); } @@ -543,7 +594,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture { @@ -552,6 +603,8 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(5)); + StopReconciliationActor(actor); Assert.Equal("siteA", msg.SiteId); Assert.True(msg.Stalled); } @@ -606,7 +660,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(5)); + StopReconciliationActor(actor); Assert.False(second.Stalled); Assert.Equal("siteA", second.SiteId); } @@ -643,7 +698,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Assert.Contains(client.Calls, c => c.SinceUtc == poisonTime), duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(100)); + + StopReconciliationActor(actor); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs index 2104c9a1..c02d5840 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/OutageReconciliationTests.cs @@ -165,6 +165,37 @@ public class OutageReconciliationTests : TestKit, IClassFixture + /// Stops an actor and blocks until it is actually dead. + /// + /// + /// + /// Deferred-work register row 95, the same leak shape row 82 fixed in + /// PartitionPurgeTests / NotifyDispatcherAuditTrailTests. Both tests here + /// already called Sys.Stop, but that is fire-and-forget: it returns before the + /// actor has processed the stop, so an in-flight 100 ms tick could still be pulling and + /// INSERTING into the shared database — and into the + /// the enclosing await using is about to dispose. + /// Worse, the call sat at the very END of the block, so any earlier assertion failure + /// skipped it entirely and left the actor ticking for the rest of the assembly run, + /// stopped only by 's Dispose → Shutdown, which is + /// timeout-bounded and does not fail when it expires. + /// + /// + /// Watch/Stop/ExpectTerminated makes the stop synchronous, and each call site now sits + /// immediately after the last step that needs the actor alive rather than at the end. + /// + /// + private void StopActor(IActorRef actor) + { + Watch(actor); + Sys.Stop(actor); + // A stop is processed after the in-flight tick completes, so this also guarantees no + // pull or insert is still running against the fixture DB. + ExpectTerminated(actor, TimeSpan.FromSeconds(30)); + Unwatch(actor); + } + private static SiteAuditReconciliationOptions FastTickOptions(int batchSize = 256) => new() { ReconciliationIntervalSeconds = 300, @@ -265,6 +296,12 @@ public class OutageReconciliationTests : TestKit, IClassFixture centralIds.Contains(w.EventId)), "every site-written EventId should be present centrally."); - - // Tear the actor down before disposing the harness; the actor's - // PostStop cancels its scheduled timer. - Sys.Stop(reconciliationActor); } } @@ -303,7 +336,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture callsAfterDrain, $"expected additional pull calls after drain to validate idempotency, got {pullClient.CallCount} after {callsAfterDrain}"); + // Both actors have done everything the test needs; stop them HERE, before the + // read-back, so nothing is still writing to the shared fixture DB while the + // duplicate check counts rows and so a failure below cannot leak them + // (register row 95). + StopActor(reconciliationActor); + StopActor(ingest); + // Central count must still be exactly totalEvents — no duplicates // even though the cursor + read-Reconciled-too semantics could // theoretically re-fetch on the second cycle. @@ -347,8 +387,6 @@ public class OutageReconciliationTests : TestKit, IClassFixture r.EventId).Distinct().Count()); - - Sys.Stop(reconciliationActor); } } }