diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs index becaec0c..82014323 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionService.cs @@ -61,32 +61,42 @@ public sealed class SiteAuditRetentionService : IHostedService, IDisposable private async Task RunLoopAsync(CancellationToken ct) { - // InitialDelay before the first purge — short so a daily-recycled node - // still purges soon after start rather than waiting a full interval it may - // never reach. try { - await Task.Delay(_options.InitialDelay, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return; - } - - await SafePurgeAsync(ct).ConfigureAwait(false); - - while (!ct.IsCancellationRequested) - { + // InitialDelay before the first purge — short so a daily-recycled node + // still purges soon after start rather than waiting a full interval it may + // never reach. try { - await Task.Delay(_options.ResolvedPurgeInterval, ct).ConfigureAwait(false); + await Task.Delay(_options.InitialDelay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { - break; + return; } await SafePurgeAsync(ct).ConfigureAwait(false); + + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(_options.ResolvedPurgeInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + await SafePurgeAsync(ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutdown landed mid-purge: SafePurgeAsync rethrows OCE by design so the purge + // aborts promptly, but the loop task must complete CLEANLY — StopAsync hands + // _loop straight to the host, and a canceled task there is shutdown-log noise + // (arch-review 04 round 2, R7). Cancellation here IS the clean exit. } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs index 77652d78..cdd4a7df 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditRetentionServiceTests.cs @@ -63,6 +63,29 @@ public class SiteAuditRetentionServiceTests Assert.True(queue.PurgeCalls.Count >= 2); } + [Fact] + public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation() + { + // PurgeExpiredAsync blocks until cancelled, so shutdown lands mid-purge: SafePurgeAsync + // rethrows the OCE by design (abort the purge promptly), but the loop task must still + // complete CLEANLY — StopAsync hands _loop straight to the host, and a canceled task + // there is shutdown-log noise (arch-review 04 round 2, R7). + var queue = new RecordingSiteAuditQueue { BlockUntilCancelled = true }; + var options = Options.Create(new SiteAuditRetentionOptions + { + RetentionDays = 7, + PurgeInterval = TimeSpan.FromHours(24), + InitialDelay = TimeSpan.Zero, + }); + using var svc = new SiteAuditRetentionService(queue, options, NullLogger.Instance); + + await svc.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => queue.PurgeCalls.Count >= 1, TimeSpan.FromSeconds(5)); + + // Must NOT throw TaskCanceledException back into the host's shutdown path. + await svc.StopAsync(CancellationToken.None); + } + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var sw = Stopwatch.StartNew(); @@ -86,7 +109,11 @@ public class SiteAuditRetentionServiceTests public IReadOnlyCollection PurgeCalls => _purgeCalls; public bool ThrowOnFirstCall { get; init; } - public Task PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) + /// When set, PurgeExpiredAsync records the call then blocks until the token + /// cancels — simulating a shutdown that lands while a purge is in flight. + public bool BlockUntilCancelled { get; init; } + + public async Task PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) { var n = Interlocked.Increment(ref _calls); _purgeCalls.Enqueue(olderThanUtc); @@ -95,7 +122,12 @@ public class SiteAuditRetentionServiceTests throw new InvalidOperationException("Simulated transient purge failure."); } - return Task.FromResult(0); + if (BlockUntilCancelled) + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + + return 0; } public Task> ReadPendingAsync(int limit, CancellationToken ct = default)