diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs index 325b59b3..920ed4ad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs @@ -381,22 +381,39 @@ public class EventLogPurgeServiceTests : IDisposable // expired row before this method's Assert ever executes. With Task.Yield() as the // very first statement, Invoke returns as soon as MoveNext() hits the (always // IsCompleted == false) yield point, before RunPurge has been called at all. + // + // Made deterministic at integration (2026-08-20): "Invoke returned, row still present" + // on its own is a RACE — the yielded continuation runs RunPurge on a pool thread and + // can delete the row before this thread reaches its assert (lost 2/2 full-suite + // sweeps, green in isolation). So the purge is parked on a gate instead: RunPurge + // consults the isActiveNode check BEFORE any delete, and the check blocks until the + // test releases it. Fixed code: Invoke returns while the gate is held (the continuation + // is the one waiting). Unfixed code: Invoke itself blocks on the gate, so the + // bounded wait below fails — no timing assumption in either direction. InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31)); Assert.Equal(1, GetEventCount()); - var purge = CreatePurgeService(); + using var gate = new ManualResetEventSlim(initialState: false); + var purge = CreatePurgeService(isActiveNode: () => { gate.Wait(); return true; }); var executeAsync = typeof(EventLogPurgeService).GetMethod( "ExecuteAsync", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(executeAsync); using var cts = new CancellationTokenSource(); - var task = (Task)executeAsync!.Invoke(purge, [cts.Token])!; + Task? executeTask = null; + var invoke = Task.Factory.StartNew( + () => executeTask = (Task)executeAsync!.Invoke(purge, [cts.Token])!, + TaskCreationOptions.LongRunning); - // Reflection Invoke has already returned control here — a synchronous - // RunPurge() ahead of any await would have already deleted the row by now. + // With the gate held, Invoke can only return if ExecuteAsync yielded before RunPurge. + var returned = await Task.WhenAny(invoke, Task.Delay(TimeSpan.FromSeconds(10))) == invoke; + Assert.True(returned, + "ExecuteAsync did not return control before running the initial purge — " + + "it is running RunPurge synchronously ahead of its first await."); Assert.Equal(1, GetEventCount()); - // The initial purge still runs, just after control has been yielded back. + // Release the purge; it still runs, just after control was yielded back. + gate.Set(); var deadline = DateTime.UtcNow.AddSeconds(5); while (GetEventCount() != 0 && DateTime.UtcNow < deadline) { @@ -407,7 +424,7 @@ public class EventLogPurgeServiceTests : IDisposable await cts.CancelAsync(); try { - await task; + await executeTask!; } catch (OperationCanceledException) {