using System.Diagnostics; using System.Diagnostics.Metrics; using Serilog; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess; /// A point-in-time view of one shard (diagnostics, the readiness check, the runner-state gauge). /// The shard label (0…K-1, or iso). /// Whether it is the isolation worker. /// A worker is live and serving. /// How long it has been down after a failure; null when up or never needed. /// The live worker's pid. /// Start failures and crashes since the last healthy period. /// Evaluations outstanding on the live worker. /// Evaluations queued in the worker (its last heartbeat). /// The worker's managed heap (its last heartbeat). public sealed record ScriptWorkerShardStatus( string Label, bool IsIsolation, bool Up, TimeSpan? DownFor, int? Pid, int ConsecutiveFailures, int InFlight, int Queued, long HeapBytes); /// /// Supervises one shard's worker process (design §3.3, §5.3.5): starts it lazily on first use, reports its end to /// the runner (for attribution, synchronously — see ), and restarts it after /// an exponential backoff — 250 ms doubling to a 30 s cap with ±20 % jitter, reset once a worker has stayed up for /// 60 s. It never runs a script in the host when the worker cannot start (fail closed, §14 #4): /// returns null and the caller reports BadResourceUnavailable. /// /// /// A caller that got Unavailable waits on ; that both tells it when to /// re-evaluate and makes the shard restart proactively (with backoff) instead of waiting for the next trigger, so /// victims of a crash are re-evaluated on the shard's return (design §9). /// internal sealed class WorkerShard : IAsyncDisposable { internal static readonly TimeSpan InitialBackoff = TimeSpan.FromMilliseconds(250); internal static readonly TimeSpan MaxBackoff = TimeSpan.FromSeconds(30); internal static readonly TimeSpan HealthyResetAfter = TimeSpan.FromSeconds(60); /// /// While a shard keeps failing to start, at most one Warning per this interval (lmxopcua#788); the other retries /// log at Debug. The first failure of a down period is the one Error (with the exception). /// internal static readonly TimeSpan DefaultStartFailureWarningInterval = TimeSpan.FromSeconds(60); private static readonly Counter StartsCounter = OtOpcUaTelemetry.Meter.CreateCounter( "otopcua.script.worker.starts", unit: "{process}", description: "ScriptWorker processes started (tag: shard)."); private static readonly Counter ExitsCounter = OtOpcUaTelemetry.Meter.CreateCounter( "otopcua.script.worker.exits", unit: "{process}", description: "ScriptWorker processes ended (tags: shard, reason = clean | killed_deadline | killed_heartbeat | killed_load | crash | protocol | idle | disposed)."); private readonly Func, CancellationToken, Task> _launch; private readonly Action _onExit; private readonly ILogger _log; private readonly object _gate = new(); private readonly Random _jitter = new(); private readonly CancellationTokenSource _disposing = new(); private readonly TimeSpan _startFailureWarningInterval; private WorkerConnection? _current; private Task? _starting; private TaskCompletionSource? _recovered; private bool _recovering; private long _backoffUntil; private long _downSince; private int _consecutiveFailures; private bool _disposed; // The current run of failed starts (lmxopcua#788): how many, since when, and when the last Warning went out. private int _startFailures; private long _startFailingSince; private long _lastStartFailureWarning; public WorkerShard(string label, bool isolation, Func, CancellationToken, Task> launch, Action onExit, ILogger log, TimeSpan? startFailureWarningInterval = null) { _startFailureWarningInterval = startFailureWarningInterval ?? DefaultStartFailureWarningInterval; Label = label; IsIsolation = isolation; _launch = launch; _onExit = onExit; _log = log; // The isolation worker runs one evaluation at a time, so a crash there names exactly one key (design §5.3.3). IsolationGate = isolation ? new SemaphoreSlim(1, 1) : null; } /// The shard label (0…K-1, or iso). public string Label { get; } /// Whether this is the isolation ("penalty box") worker. public bool IsIsolation { get; } /// Serialises evaluations on the isolation worker; null on a normal shard. public SemaphoreSlim? IsolationGate { get; } /// The live worker, if any (diagnostics / tests). public WorkerConnection? Current { get { lock (_gate) return _current is { IsAlive: true } c ? c : null; } } /// Consecutive start failures and crashes since the last healthy period (diagnostics / tests). public int ConsecutiveFailures { get { lock (_gate) return _consecutiveFailures; } } /// How long the shard has been down after a failure; null when it is up or was never needed. public TimeSpan? DownFor { get { lock (_gate) { if (_current is { IsAlive: true } || _downSince == 0) return null; return Stopwatch.GetElapsedTime(_downSince); } } } /// A snapshot for diagnostics and health. /// The status. public ScriptWorkerShardStatus Status() { WorkerConnection? live; int failures; TimeSpan? downFor; lock (_gate) { live = _current is { IsAlive: true } c ? c : null; failures = _consecutiveFailures; downFor = live is null && _downSince != 0 ? Stopwatch.GetElapsedTime(_downSince) : null; } var heartbeat = live?.LastHeartbeat; return new ScriptWorkerShardStatus(Label, IsIsolation, live is not null, downFor, live?.Pid, failures, live?.InFlightCount ?? 0, (int)(heartbeat?.Queued ?? 0), heartbeat?.HeapBytes ?? 0); } /// /// The ready worker, starting one if needed. Waits at most — through a start in progress /// or a backoff — and returns null if no worker is ready by then. A zero grace still kicks a start that is due. /// /// How long to wait. /// Cancellation. /// The worker, or null. public async Task GetReadyAsync(TimeSpan grace, CancellationToken ct) { var deadline = Stopwatch.GetTimestamp() + (long)(grace.TotalSeconds * Stopwatch.Frequency); while (true) { Task starting; TimeSpan wait; lock (_gate) { if (_disposed) return null; if (_current is { IsAlive: true } live) return live; var now = Stopwatch.GetTimestamp(); if (_starting is null && now >= _backoffUntil) { _starting = StartCoreAsync(); } if (_starting is not null) { starting = _starting; wait = TimeSpan.Zero; } else { starting = null!; wait = Stopwatch.GetElapsedTime(now, _backoffUntil); } } var remaining = Stopwatch.GetElapsedTime(Stopwatch.GetTimestamp(), deadline); if (remaining <= TimeSpan.Zero) return null; if (starting is not null) { try { var started = await starting.WaitAsync(remaining, ct).ConfigureAwait(false); if (started is { IsAlive: true }) return started; } catch (TimeoutException) { return null; } continue; } if (wait > remaining) return null; await Task.Delay(wait, ct).ConfigureAwait(false); } } /// /// Completes when this shard has a live worker other than . Starts a background /// recovery (start with backoff, retried at the cap) if none is live, so the caller does not depend on a later /// trigger to bring the shard back. /// /// The worker the caller's evaluation died on, if any. /// A task that completes on recovery (or when the shard is disposed). public Task WhenRecoveredAsync(WorkerConnection? failed) { lock (_gate) { if (_disposed) return Task.CompletedTask; if (_current is { IsAlive: true } live) { // Still the same worker (it answered NOT_LOADED, or the caller lost a race with a restart that has // already happened): a short pause, so a seam cannot spin on a key the worker keeps refusing. return ReferenceEquals(live, failed) ? Task.Delay(InitialBackoff) : Task.CompletedTask; } _recovered ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var task = _recovered.Task; EnsureRecoveringLocked(); return task; } } private void EnsureRecoveringLocked() { if (_recovering || _disposed) return; _recovering = true; _ = Task.Run(RecoverAsync); } /// Stops the live worker if it has had nothing to do for (the isolation worker). /// The idle threshold. /// True when a worker was stopped. public async Task StopIfIdleAsync(TimeSpan idle) { WorkerConnection? victim; // Holding the isolation gate means no evaluation can pick this worker up while it is being stopped. var gate = IsolationGate; try { if (gate is not null && !gate.Wait(0)) return false; } catch (ObjectDisposedException) { return false; } try { lock (_gate) { if (_disposed || _current is not { IsAlive: true } live) return false; if (live.InFlightCount > 0 || live.LoadingCount > 0) return false; if (Stopwatch.GetElapsedTime(live.LastUsedTimestamp) < idle) return false; victim = live; _current = null; } _log.Information("ScriptWorker[{Shard}] pid {Pid} idle for {Idle}; stopping it (it restarts on demand)", Label, victim.Pid, idle); await victim.StopAsync(WorkerExitReason.Idle).ConfigureAwait(false); return true; } finally { gate?.Release(); } } private async Task RecoverAsync() { try { while (true) { // A long grace: GetReadyAsync waits out the backoff itself and returns null only on a failed start. var live = await GetReadyAsync(MaxBackoff * 2, _disposing.Token).ConfigureAwait(false); lock (_gate) { if (_disposed || live is not null) { _recovering = false; TakeRecoveredLocked()?.TrySetResult(); return; } } } } catch (OperationCanceledException) { lock (_gate) { _recovering = false; TakeRecoveredLocked()?.TrySetResult(); } } } private TaskCompletionSource? TakeRecoveredLocked() { var tcs = _recovered; _recovered = null; return tcs; } private async Task StartCoreAsync() { await Task.Yield(); WorkerConnection? connection = null; try { connection = await _launch(Attribute, CancellationToken.None).ConfigureAwait(false); StartsCounter.Add(1, new KeyValuePair("shard", Label)); int failedBefore; long failingSince; lock (_gate) { failedBefore = _startFailures; failingSince = _startFailingSince; _startFailures = 0; _startFailingSince = 0; _lastStartFailureWarning = 0; } if (failedBefore == 0) _log.Information("ScriptWorker[{Shard}] started, pid {Pid}", Label, connection.Pid); else _log.Information("ScriptWorker[{Shard}] started, pid {Pid}, after {Attempts} failed start attempt(s) over {Outage}", Label, connection.Pid, failedBefore, Stopwatch.GetElapsedTime(failingSince)); var started = connection; _ = started.Exited.ContinueWith(t => OnExited(started, t.Result), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default); TaskCompletionSource? recovered; lock (_gate) { _starting = null; if (_disposed) { _ = started.DisposeAsync().AsTask(); return null; } _current = started; _downSince = 0; recovered = TakeRecoveredLocked(); } recovered?.TrySetResult(); return started; } catch (Exception ex) { int attempt; TimeSpan failingFor; bool warn; lock (_gate) { _starting = null; MarkDownLocked(); ScheduleBackoffLocked(); attempt = ++_startFailures; var now = Stopwatch.GetTimestamp(); if (attempt == 1) _startFailingSince = now; failingFor = Stopwatch.GetElapsedTime(_startFailingSince, now); warn = attempt > 1 && (_lastStartFailureWarning == 0 || Stopwatch.GetElapsedTime(_lastStartFailureWarning, now) >= _startFailureWarningInterval); if (attempt == 1 || warn) _lastStartFailureWarning = now; } // lmxopcua#788: one Error with the stack per down period; the retries (every backoff, capped at 30 s, per // shard) are a rate-limited Warning without it, and the rest Debug. Recovery is one Information line above. if (attempt == 1) _log.Error(ex, "ScriptWorker[{Shard}] failed to start; scripts on it report runner unavailable until it does (retrying with backoff)", Label); else if (warn) _log.Warning("ScriptWorker[{Shard}] still failing to start: {Attempts} failed attempt(s) over {Outage}; last error: {Error}", Label, attempt, failingFor, ex.Message); else _log.Debug("ScriptWorker[{Shard}] start attempt {Attempts} failed: {Error}", Label, attempt, ex.Message); if (connection is not null) await connection.DisposeAsync().ConfigureAwait(false); return null; } } /// Synchronous attribution, from 's exit path, before any evaluation completes. private void Attribute(WorkerExit exit) { try { _onExit(this, exit); } catch (Exception ex) { _log.Error(ex, "ScriptWorker[{Shard}] exit handler threw", Label); } } private void OnExited(WorkerConnection connection, WorkerExit exit) { ExitsCounter.Add(1, new KeyValuePair("shard", Label), new KeyValuePair("reason", MetricReason(exit.Reason))); var expected = exit.Reason is WorkerExitReason.Disposed or WorkerExitReason.Idle; lock (_gate) { if (ReferenceEquals(_current, connection)) _current = null; if (!_disposed && !expected) { if (Stopwatch.GetElapsedTime(connection.ReadyTimestamp) >= HealthyResetAfter) _consecutiveFailures = 0; MarkDownLocked(); ScheduleBackoffLocked(); // A worker that was serving and died comes back by itself (after the backoff), so the scripts placed // on it find it warm and its victims are re-evaluated on its return (design §9). EnsureRecoveringLocked(); } } if (!expected) { _log.Warning("ScriptWorker[{Shard}] pid {Pid} ended: {Reason} (exit code {ExitCode}, culprit {Culprit}, {InFlight} evaluation(s) and {Loading} compile(s) in flight)", Label, connection.Pid, exit.Reason, exit.ExitCode, exit.CulpritKey ?? "none", exit.InFlightKeys.Count, exit.LoadingKeys.Count); } _ = connection.DisposeAsync().AsTask(); } private void MarkDownLocked() { if (_downSince == 0) _downSince = Stopwatch.GetTimestamp(); } private void ScheduleBackoffLocked() { _consecutiveFailures++; var delay = NextBackoff(_consecutiveFailures, _jitter.NextDouble()); _backoffUntil = Stopwatch.GetTimestamp() + (long)(delay.TotalSeconds * Stopwatch.Frequency); } /// The restart delay after consecutive failures (design §5.3.5). /// 1 for the first failure. /// A sample in [0, 1) mapped to ±20 %. /// The delay. internal static TimeSpan NextBackoff(int failures, double unitJitter) { var exponent = Math.Clamp(failures - 1, 0, 30); var baseMs = Math.Min(MaxBackoff.TotalMilliseconds, InitialBackoff.TotalMilliseconds * Math.Pow(2, exponent)); var factor = 0.8 + (0.4 * Math.Clamp(unitJitter, 0, 1)); return TimeSpan.FromMilliseconds(Math.Min(MaxBackoff.TotalMilliseconds * 1.2, baseMs * factor)); } private static string MetricReason(WorkerExitReason reason) => reason switch { WorkerExitReason.Clean => "clean", WorkerExitReason.KilledDeadline => "killed_deadline", WorkerExitReason.KilledHeartbeat => "killed_heartbeat", WorkerExitReason.KilledLoadTimeout => "killed_load", WorkerExitReason.ProtocolFault => "protocol", WorkerExitReason.Disposed => "disposed", WorkerExitReason.Idle => "idle", _ => "crash", }; /// Stops the shard's worker. /// A task that completes when the worker has ended. public async ValueTask DisposeAsync() { WorkerConnection? current; Task? starting; TaskCompletionSource? recovered; lock (_gate) { if (_disposed) return; _disposed = true; current = _current; starting = _starting; _current = null; recovered = TakeRecoveredLocked(); } recovered?.TrySetResult(); _disposing.Cancel(); if (starting is not null) { try { current ??= await starting.WaitAsync(TimeSpan.FromSeconds(15)).ConfigureAwait(false); } catch { /* shutting down */ } } if (current is not null) await current.DisposeAsync().ConfigureAwait(false); IsolationGate?.Dispose(); } /// Maps a context kind to the wire enum. /// The kind. /// The wire kind. internal static ContextKind ToWire(ScriptContextKind kind) => kind switch { ScriptContextKind.VirtualTag => ContextKind.VirtualTag, ScriptContextKind.Calculation => ContextKind.Calculation, ScriptContextKind.AlarmPredicate => ContextKind.AlarmPredicate, _ => ContextKind.Unspecified, }; }