diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs index 4189a257..31a2c852 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs @@ -163,8 +163,8 @@ public class SiteAuditTelemetryActor : ReceiveActor // Route every async dependency call through the // per-actor lifecycle token so PostStop cancellation actually // propagates into the queue read, the gRPC push, and the - // mark-forwarded write. OperationCanceledException is swallowed by - // the catch-all below. + // mark-forwarded write. A shutdown-driven OperationCanceledException is + // swallowed silently by the dedicated catch below. var ct = _lifecycleCts.Token; try { @@ -204,6 +204,12 @@ public class SiteAuditTelemetryActor : ReceiveActor .ConfigureAwait(false); } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Graceful stop: PostStop cancelled the lifecycle token while the drain + // was awaiting the queue / gRPC push. Expected on every orderly shutdown or + // failover — not an error (review 2026-10-21 06/COR-2). + } catch (Exception ex) { // Catch-all so a SQLite hiccup or mapper bug never crashes the @@ -382,6 +388,10 @@ public class SiteAuditTelemetryActor : ReceiveActor .ConfigureAwait(false); } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Graceful stop mid-drain — see OnDrainAsync (review 2026-10-21 06/COR-2). + } catch (Exception ex) { _logger.LogError(ex, diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs index bd68e20b..b7f33023 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs @@ -717,4 +717,47 @@ public class SiteAuditTelemetryActorTests : TestKit }, TimeSpan.FromSeconds(10)); }); } + + /// + /// Review 2026-10-21 06/COR-2: a graceful stop while a drain is parked on its + /// queue read surfaces as an ; it must not + /// be logged at Error ("Unexpected error during ... drain") on every orderly shutdown. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task GracefulStop_MidDrain_DoesNotLogError() + { + var reads = new System.Collections.Concurrent.ConcurrentQueue(); + async Task> Parked(CancellationToken ct) + { + reads.Enqueue(1); + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return Array.Empty(); + } + _queue.ReadPendingAsync(Arg.Any(), Arg.Any()) + .Returns(c => Parked(c.Arg())); + _queue.ReadPendingCachedTelemetryAsync(Arg.Any(), Arg.Any()) + .Returns(c => Parked(c.Arg())); + + var logger = new CapturingLogger(); + var actor = Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( + _queue, _client, Opts(), logger, (IOperationTrackingStore?)_trackingStore))); + + await AwaitConditionAsync(() => Task.FromResult(reads.Count >= 1), TimeSpan.FromSeconds(10)); + Watch(actor); + Sys.Stop(actor); + await ExpectTerminatedAsync(actor); + await Task.Delay(300); + + Assert.DoesNotContain(logger.Levels, l => l >= Microsoft.Extensions.Logging.LogLevel.Error); + } + + private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger + { + public System.Collections.Concurrent.ConcurrentQueue Levels { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, + TState state, Exception? exception, Func formatter) => Levels.Enqueue(logLevel); + } }