Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/FocasHostSupervisor.cs
Joseph Doherty 5033609944 FOCAS Tier-C PR D — supervisor + backoff + crash-loop breaker + heartbeat monitor. Fourth of 5 PRs for #220. Ships the resilience harness that sits between the driver's IFocasClient requests and the Tier-C Host process, so a crashing Fwlib32.dll takes down only the Host (not the main server), gets respawned on a backoff ladder, and opens a circuit with a sticky operator alert when the crash rate is pathological. Same shape as Galaxy Tier-C so the Admin /hosts surface has a single mental model. New Supervisor/ namespace in Driver.FOCAS (.NET 10, Proxy-side): Backoff with the 5s→15s→60s default ladder + StableRunThreshold that resets the index after a 2-min clean run (so a one-off crash after hours of steady-state doesn't restart from the top); CircuitBreaker with 3-crashes-in-5-min threshold + escalating 1h→4h→manual-reset cooldown ladder + StickyAlertActive flag that persists across cooldowns until AcknowledgeAndReset is called; HeartbeatMonitor tracking ConsecutiveMisses against the 3-misses-kill threshold + LastAckUtc for telemetry; IHostProcessLauncher abstraction over "spawn Host process + produce an IFocasClient connected to it" so the supervisor stays I/O-free and fully testable with a fake launcher that can be told to throw on specific attempts (production wiring over Process.Start + FocasIpcClient.ConnectAsync is the PR E ops-glue concern); FocasHostSupervisor orchestrating them — GetOrLaunchAsync cycles through backoff until either a client is returned or the breaker opens (surfaced as InvalidOperationException so the driver maps to BadDeviceFailure), NotifyHostDeadAsync fans out the unavailable event + terminates the current launcher + records the crash without blocking (so heartbeat-loss detection can short-circuit subscriber fan-out and let the next GetOrLaunchAsync handle the respawn), AcknowledgeAndReset is the operator-clear path, OnUnavailable event for Admin /hosts wiring + ObservedCrashes + BackoffAttempt + StickyAlertActive for telemetry. 14 new unit tests across SupervisorTests.cs: Backoff (default sequence, clamping, RecordStableRun resets), CircuitBreaker (below threshold allowed, opens at threshold, escalates cooldown after second open, ManualReset clears state), HeartbeatMonitor (3 consecutive misses declares dead, ack resets counter), FocasHostSupervisor (first-launch success, retry-with-backoff after transient failure, repeated failures open breaker + surface InvalidOperationException, NotifyHostDeadAsync terminates + fan-outs + increments crash count, AcknowledgeAndReset clears sticky, Dispose terminates). Full FOCAS driver tests now 186/186 green (172 + 14 new). No changes to IFocasClient DI contract; existing FakeFocasClient-based tests unaffected. PR E wires the real Process-based IHostProcessLauncher + NSSM install scripts + MMF post-mortem + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:17:23 -04:00

160 lines
6.3 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Ties <see cref="IHostProcessLauncher"/> + <see cref="Backoff"/> +
/// <see cref="CircuitBreaker"/> + <see cref="HeartbeatMonitor"/> into one object the
/// driver asks for <c>IFocasClient</c>s. On a detected crash (process exit or
/// heartbeat loss) the supervisor fans out <c>BadCommunicationError</c> to all
/// subscribers via the <see cref="OnUnavailable"/> callback, then respawns with
/// backoff unless the breaker is open.
/// </summary>
/// <remarks>
/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
/// pipes, or send heartbeats. Production wires the concrete
/// <see cref="IHostProcessLauncher"/> over <c>FocasIpcClient</c> + <c>Process</c>;
/// tests drive the same state machine with a deterministic launcher stub.
/// </remarks>
public sealed class FocasHostSupervisor : IDisposable
{
private readonly IHostProcessLauncher _launcher;
private readonly Backoff _backoff;
private readonly CircuitBreaker _breaker;
private readonly Func<DateTime> _clock;
private IFocasClient? _current;
private DateTime _currentStartedUtc;
private bool _disposed;
public FocasHostSupervisor(
IHostProcessLauncher launcher,
Backoff? backoff = null,
CircuitBreaker? breaker = null,
Func<DateTime>? clock = null)
{
_launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
_backoff = backoff ?? new Backoff();
_breaker = breaker ?? new CircuitBreaker();
_clock = clock ?? (() => DateTime.UtcNow);
}
/// <summary>Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).</summary>
public event Action<string>? OnUnavailable;
/// <summary>Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.</summary>
public int ObservedCrashes { get; private set; }
/// <summary><c>true</c> if the crash-loop breaker has latched a sticky alert that needs operator reset.</summary>
public bool StickyAlertActive => _breaker.StickyAlertActive;
public int BackoffAttempt => _backoff.AttemptIndex;
/// <summary>
/// Returns the current live client. If none, tries to launch — applying the
/// backoff schedule between attempts and stopping once the breaker opens.
/// </summary>
public async Task<IFocasClient> GetOrLaunchAsync(CancellationToken ct)
{
ThrowIfDisposed();
if (_current is not null && _launcher.IsProcessAlive) return _current;
return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
}
/// <summary>
/// Called by the heartbeat task each time a miss threshold is crossed.
/// Treated as a crash: fan out Bad status + attempt respawn.
/// </summary>
public async Task NotifyHostDeadAsync(string reason, CancellationToken ct)
{
ThrowIfDisposed();
OnUnavailable?.Invoke(reason);
ObservedCrashes++;
try { await _launcher.TerminateAsync(ct).ConfigureAwait(false); }
catch { /* best effort */ }
_current?.Dispose();
_current = null;
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
{
OnUnavailable?.Invoke(cooldown == TimeSpan.MaxValue
? "circuit-breaker-open-manual-reset-required"
: $"circuit-breaker-open-cooldown-{cooldown:g}");
return;
}
// Successful crash recording — do not respawn synchronously; GetOrLaunchAsync will
// pick up the attempt on the next call. Keeps the fan-out fast.
}
/// <summary>Operator action — clear the sticky alert + reset the breaker.</summary>
public void AcknowledgeAndReset()
{
_breaker.ManualReset();
_backoff.RecordStableRun();
}
private async Task<IFocasClient> LaunchWithBackoffAsync(CancellationToken ct)
{
while (true)
{
if (_breaker.StickyAlertActive)
{
if (!_breaker.TryRecordCrash(_clock(), out var cooldown) && cooldown == TimeSpan.MaxValue)
throw new InvalidOperationException(
"FOCAS Host circuit breaker is open and awaiting manual reset. " +
"See Admin /hosts; call AcknowledgeAndReset after investigating the Host log.");
}
try
{
_current = await _launcher.LaunchAsync(ct).ConfigureAwait(false);
_currentStartedUtc = _clock();
// If the launch sequence itself takes long enough to count as a stable run,
// reset the backoff ladder immediately.
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
_backoff.RecordStableRun();
return _current;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OnUnavailable?.Invoke($"launch-failed: {ex.Message}");
ObservedCrashes++;
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
{
var hint = cooldown == TimeSpan.MaxValue
? "manual reset required"
: $"cooldown {cooldown:g}";
throw new InvalidOperationException(
$"FOCAS Host circuit breaker opened after {ObservedCrashes} crashes — {hint}.", ex);
}
var delay = _backoff.Next();
await Task.Delay(delay, ct).ConfigureAwait(false);
}
}
}
/// <summary>Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.</summary>
public void NotifyStableRun()
{
if (_current is null) return;
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
_backoff.RecordStableRun();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { _launcher.TerminateAsync(CancellationToken.None).GetAwaiter().GetResult(); }
catch { /* best effort */ }
_current?.Dispose();
_current = null;
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(FocasHostSupervisor));
}
}