fix(opcuaclient): #670 review — subscription loss with a live session, stale recovery latch
M1: a subscription that dies while the session lives (outage longer than the subscription lifetime but shorter than the session timeout) used to end Healthy with fresh keep-alive stamps and frozen values. PublishError BadNoSubscription / BadSubscriptionIdInvalid naming a tracked subscription, or a tracked subscription whose SDK PublishingStopped persists one more keep-alive window + 5 s across good keep-alives, now declares it lost: Degraded, outage published once, and nothing advances LastSuccessfulRead until the session is released, so the #527 watchdog re-initialises and re-subscribes. Both detectors wire-tested. M2: in-place recovery is bound to the session whose keep-alive recovered; one overtaken by a re-initialise no longer re-arms the new session's outage latch or degrades its health (ReadCoreAsync expectedSession). L1 CAS health updates; L2 re-check before each recovery publish; L3 skip CloseSession on a lost session; L4 SDK log forwarding rate-limited (20/min); L5 per-driver SDK LoggerFactory disposed with the driver. Wire test asserts the read stamp stays frozen while the server is down.
This commit is contained in:
@@ -17,19 +17,85 @@ internal static class SdkTelemetry
|
||||
/// <summary>Gets the shared, provider-less telemetry context.</summary>
|
||||
public static ITelemetryContext Context => Instance.Value;
|
||||
|
||||
/// <summary>Creates a telemetry context whose SDK log output at Warning and above goes to <paramref name="logger"/>.</summary>
|
||||
/// <summary>
|
||||
/// Creates a telemetry context whose SDK log output at Warning and above goes to <paramref name="logger"/>,
|
||||
/// rate-limited (a dead upstream makes the SDK warn on every keep-alive and publish for as long as the
|
||||
/// outage lasts). The context owns a <see cref="ILoggerFactory"/>: release it with <see cref="Release"/>
|
||||
/// when the driver is disposed — <see cref="DefaultTelemetry"/> is not itself disposable.
|
||||
/// </summary>
|
||||
/// <param name="logger">The driver instance's logger.</param>
|
||||
/// <returns>The telemetry context.</returns>
|
||||
public static ITelemetryContext ForLogger(ILogger logger) =>
|
||||
DefaultTelemetry.Create(builder => builder
|
||||
.SetMinimumLevel(LogLevel.Warning)
|
||||
.AddProvider(new ForwardingLoggerProvider(logger)));
|
||||
.AddProvider(new ForwardingLoggerProvider(new RateLimitedLogger(logger, MaxEntriesPerWindow, Window))));
|
||||
|
||||
/// <summary>Hands every SDK logger category the one driver logger.</summary>
|
||||
/// <summary>Disposes the logger factory a <see cref="ForLogger"/> context owns. Never the shared one.</summary>
|
||||
/// <param name="context">The context to release.</param>
|
||||
public static void Release(ITelemetryContext context)
|
||||
{
|
||||
if (ReferenceEquals(context, Context)) return;
|
||||
try { (context as TelemetryContextBase)?.LoggerFactory?.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/// <summary>SDK log entries forwarded per <see cref="Window"/> before the rest are dropped and counted.</summary>
|
||||
internal const int MaxEntriesPerWindow = 20;
|
||||
|
||||
/// <summary>The rate-limit window.</summary>
|
||||
internal static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>Hands every SDK logger category the one (rate-limited) driver logger.</summary>
|
||||
private sealed class ForwardingLoggerProvider(ILogger target) : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => target;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards at most <c>max</c> entries per window; the first entry of the next window carries a note of how
|
||||
/// many were suppressed.
|
||||
/// </summary>
|
||||
internal sealed class RateLimitedLogger(ILogger target, int max, TimeSpan window) : ILogger
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private DateTime _windowStart = DateTime.MinValue;
|
||||
private int _count;
|
||||
private int _suppressed;
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => target.BeginScope(state);
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => target.IsEnabled(logLevel);
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
int suppressedNote;
|
||||
lock (_lock)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _windowStart >= window)
|
||||
{
|
||||
suppressedNote = _suppressed;
|
||||
_windowStart = now;
|
||||
_count = 0;
|
||||
_suppressed = 0;
|
||||
}
|
||||
else suppressedNote = 0;
|
||||
|
||||
if (_count >= max)
|
||||
{
|
||||
_suppressed++;
|
||||
return;
|
||||
}
|
||||
_count++;
|
||||
}
|
||||
|
||||
if (suppressedNote > 0)
|
||||
target.LogWarning("OPC UA SDK: {Suppressed} further log entries were suppressed in the last {Window}.",
|
||||
suppressedNote, window);
|
||||
target.Log(logLevel, eventId, state, exception, formatter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user