Merge pull request 'Phase 2 PR 13 — port GalaxyRuntimeProbeManager + per-platform ScanState probing' (#12) from phase-2-pr13-runtime-probe into v2
This commit was merged in pull request #12.
This commit is contained in:
@@ -7,6 +7,7 @@ using MessagePack;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
@@ -40,6 +41,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||||
|
|
||||||
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
||||||
|
private readonly GalaxyRuntimeProbeManager _probeManager;
|
||||||
|
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
|
||||||
|
|
||||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
||||||
{
|
{
|
||||||
@@ -62,8 +65,39 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
||||||
|
|
||||||
|
// PR 13: per-platform runtime probes. ScanState subscriptions fire OnProbeCallback,
|
||||||
|
// which runs the state machine and raises StateChanged on transitions we care about.
|
||||||
|
// We forward each transition through the same OnHostStatusChanged IPC event that the
|
||||||
|
// gateway-level ConnectionStateChanged uses — tagged with the platform's TagName so the
|
||||||
|
// Admin UI can show per-host health independently from the top-level transport status.
|
||||||
|
_probeManager = new GalaxyRuntimeProbeManager(
|
||||||
|
subscribe: (probe, cb) => _mx.SubscribeAsync(probe, cb),
|
||||||
|
unsubscribe: probe => _mx.UnsubscribeAsync(probe));
|
||||||
|
_onProbeStateChanged = (_, t) =>
|
||||||
|
{
|
||||||
|
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||||
|
{
|
||||||
|
HostName = t.TagName,
|
||||||
|
RuntimeStatus = t.NewState switch
|
||||||
|
{
|
||||||
|
HostRuntimeState.Running => "Running",
|
||||||
|
HostRuntimeState.Stopped => "Stopped",
|
||||||
|
_ => "Unknown",
|
||||||
|
},
|
||||||
|
LastObservedUtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
_probeManager.StateChanged += _onProbeStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposed for tests. Production flow: DiscoverAsync completes → backend calls
|
||||||
|
/// <c>SyncProbesAsync</c> with the runtime hosts (WinPlatform + AppEngine gobjects) to
|
||||||
|
/// advise ScanState per host.
|
||||||
|
/// </summary>
|
||||||
|
internal GalaxyRuntimeProbeManager ProbeManager => _probeManager;
|
||||||
|
|
||||||
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -103,6 +137,21 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
|
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
|
||||||
|
// so ScanState subscriptions track the current runtime set. Best-effort — probe
|
||||||
|
// failures don't block Discover from returning, since the gateway-level signal from
|
||||||
|
// MxAccessClient.ConnectionStateChanged still flows and the Admin UI degrades to
|
||||||
|
// that level if any per-host probe couldn't advise.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targets = hierarchy
|
||||||
|
.Where(o => o.CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||||
|
|| o.CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine)
|
||||||
|
.Select(o => new HostProbeTarget(o.TagName, o.CategoryId));
|
||||||
|
await _probeManager.SyncAsync(targets).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch { /* swallow — Discover succeeded; probes are a diagnostic enrichment */ }
|
||||||
|
|
||||||
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -405,6 +454,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_probeManager.StateChanged -= _onProbeStateChanged;
|
||||||
|
_probeManager.Dispose();
|
||||||
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
||||||
_historian?.Dispose();
|
_historian?.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-platform + per-AppEngine runtime probe. Subscribes to <c><TagName>.ScanState</c>
|
||||||
|
/// for each $WinPlatform and $AppEngine gobject, tracks Unknown → Running → Stopped
|
||||||
|
/// transitions, and fires <see cref="StateChanged"/> so <see cref="Backend.MxAccessGalaxyBackend"/>
|
||||||
|
/// can forward per-host events through the existing IPC <c>OnHostStatusChanged</c> event.
|
||||||
|
/// Pure-logic state machine with an injected clock so it's deterministically testable —
|
||||||
|
/// port of v1 <c>GalaxyRuntimeProbeManager</c> without the OPC UA node-manager coupling.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// State machine rules (documented in v1's <c>runtimestatus.md</c> and preserved here):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>ScanState</c> is on-change-only — a stably-Running host may go hours without a
|
||||||
|
/// callback. Running → Stopped is driven by an explicit <c>ScanState=false</c> callback,
|
||||||
|
/// never by starvation.</item>
|
||||||
|
/// <item>Unknown → Running is a startup transition and does NOT fire StateChanged (would
|
||||||
|
/// paint every host as "just recovered" at startup, which is noise).</item>
|
||||||
|
/// <item>Stopped → Running and Running → Stopped fire StateChanged. Unknown → Stopped
|
||||||
|
/// fires StateChanged because that's a first-known-bad signal operators need.</item>
|
||||||
|
/// <item>All public methods are thread-safe. Callbacks fire outside the internal lock to
|
||||||
|
/// avoid lock inversion with caller-owned state.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class GalaxyRuntimeProbeManager : IDisposable
|
||||||
|
{
|
||||||
|
public const int CategoryWinPlatform = 1;
|
||||||
|
public const int CategoryAppEngine = 3;
|
||||||
|
public const string ProbeAttribute = ".ScanState";
|
||||||
|
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||||
|
private readonly Func<string, Task> _unsubscribe;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
// probe tag → per-host state
|
||||||
|
private readonly Dictionary<string, HostProbeState> _byProbe = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// tag name → probe tag (for reverse lookup on the desired-set diff)
|
||||||
|
private readonly Dictionary<string, string> _probeByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires on every state transition that operators should react to. See class remarks
|
||||||
|
/// for the rules on which transitions fire.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler<HostStateTransition>? StateChanged;
|
||||||
|
|
||||||
|
public GalaxyRuntimeProbeManager(
|
||||||
|
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||||
|
Func<string, Task> unsubscribe)
|
||||||
|
: this(subscribe, unsubscribe, () => DateTime.UtcNow) { }
|
||||||
|
|
||||||
|
internal GalaxyRuntimeProbeManager(
|
||||||
|
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||||
|
Func<string, Task> unsubscribe,
|
||||||
|
Func<DateTime> clock)
|
||||||
|
{
|
||||||
|
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||||
|
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of probes currently advised. Test/dashboard hook.</summary>
|
||||||
|
public int ActiveProbeCount
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _byProbe.Count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot every currently-tracked host's state. One entry per probe.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<HostProbeSnapshot> SnapshotStates()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _byProbe.Select(kv => new HostProbeSnapshot(
|
||||||
|
TagName: kv.Value.TagName,
|
||||||
|
State: kv.Value.State,
|
||||||
|
LastChangedUtc: kv.Value.LastStateChangeUtc)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query the current runtime state for <paramref name="tagName"/>. Returns
|
||||||
|
/// <see cref="HostRuntimeState.Unknown"/> when the host is not tracked.
|
||||||
|
/// </summary>
|
||||||
|
public HostRuntimeState GetState(string tagName)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_probeByTagName.TryGetValue(tagName, out var probe)
|
||||||
|
&& _byProbe.TryGetValue(probe, out var state))
|
||||||
|
return state.State;
|
||||||
|
return HostRuntimeState.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diff the desired host set (filtered $WinPlatform / $AppEngine from the latest Discover)
|
||||||
|
/// against the currently-tracked set and advise / unadvise as needed. Idempotent:
|
||||||
|
/// calling twice with the same set does nothing.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncAsync(IEnumerable<HostProbeTarget> desiredHosts)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
var desired = desiredHosts
|
||||||
|
.Where(h => !string.IsNullOrWhiteSpace(h.TagName))
|
||||||
|
.ToDictionary(h => h.TagName, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
List<string> toAdvise;
|
||||||
|
List<string> toUnadvise;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
toAdvise = desired.Keys
|
||||||
|
.Where(tag => !_probeByTagName.ContainsKey(tag))
|
||||||
|
.ToList();
|
||||||
|
toUnadvise = _probeByTagName.Keys
|
||||||
|
.Where(tag => !desired.ContainsKey(tag))
|
||||||
|
.Select(tag => _probeByTagName[tag])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var tag in toAdvise)
|
||||||
|
{
|
||||||
|
var probe = tag + ProbeAttribute;
|
||||||
|
_probeByTagName[tag] = probe;
|
||||||
|
_byProbe[probe] = new HostProbeState
|
||||||
|
{
|
||||||
|
TagName = tag,
|
||||||
|
State = HostRuntimeState.Unknown,
|
||||||
|
LastStateChangeUtc = _clock(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var probe in toUnadvise)
|
||||||
|
{
|
||||||
|
_byProbe.Remove(probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var removedTag in _probeByTagName.Keys.Where(t => !desired.ContainsKey(t)).ToList())
|
||||||
|
{
|
||||||
|
_probeByTagName.Remove(removedTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tag in toAdvise)
|
||||||
|
{
|
||||||
|
var probe = tag + ProbeAttribute;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _subscribe(probe, OnProbeCallback);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Rollback on subscribe failure so a later Tick can't transition a never-advised
|
||||||
|
// probe into a false Stopped state. Callers can re-Sync later to retry.
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_byProbe.Remove(probe);
|
||||||
|
_probeByTagName.Remove(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var probe in toUnadvise)
|
||||||
|
{
|
||||||
|
try { await _unsubscribe(probe); } catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public entry point for tests and internal callbacks. Production flow: MxAccessClient's
|
||||||
|
/// SubscribeAsync delivers VTQ updates through the callback wired in <see cref="SyncAsync"/>,
|
||||||
|
/// which calls this method under the lock to update state and fires
|
||||||
|
/// <see cref="StateChanged"/> outside the lock for any transition that matters.
|
||||||
|
/// </summary>
|
||||||
|
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
HostStateTransition? transition = null;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_byProbe.TryGetValue(probeTag, out var state)) return;
|
||||||
|
|
||||||
|
var isRunning = vtq.Quality >= 192 && vtq.Value is bool b && b;
|
||||||
|
var now = _clock();
|
||||||
|
var previous = state.State;
|
||||||
|
state.LastCallbackUtc = now;
|
||||||
|
|
||||||
|
if (isRunning)
|
||||||
|
{
|
||||||
|
state.GoodUpdateCount++;
|
||||||
|
if (previous != HostRuntimeState.Running)
|
||||||
|
{
|
||||||
|
state.State = HostRuntimeState.Running;
|
||||||
|
state.LastStateChangeUtc = now;
|
||||||
|
if (previous == HostRuntimeState.Stopped)
|
||||||
|
{
|
||||||
|
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Running, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state.FailureCount++;
|
||||||
|
if (previous != HostRuntimeState.Stopped)
|
||||||
|
{
|
||||||
|
state.State = HostRuntimeState.Stopped;
|
||||||
|
state.LastStateChangeUtc = now;
|
||||||
|
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Stopped, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transition is { } t)
|
||||||
|
{
|
||||||
|
StateChanged?.Invoke(this, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_byProbe.Clear();
|
||||||
|
_probeByTagName.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HostProbeState
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = "";
|
||||||
|
public HostRuntimeState State { get; set; }
|
||||||
|
public DateTime LastStateChangeUtc { get; set; }
|
||||||
|
public DateTime? LastCallbackUtc { get; set; }
|
||||||
|
public long GoodUpdateCount { get; set; }
|
||||||
|
public long FailureCount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostRuntimeState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HostStateTransition(
|
||||||
|
string TagName,
|
||||||
|
HostRuntimeState OldState,
|
||||||
|
HostRuntimeState NewState,
|
||||||
|
DateTime AtUtc);
|
||||||
|
|
||||||
|
public sealed record HostProbeSnapshot(
|
||||||
|
string TagName,
|
||||||
|
HostRuntimeState State,
|
||||||
|
DateTime LastChangedUtc);
|
||||||
|
|
||||||
|
public readonly record struct HostProbeTarget(string TagName, int CategoryId)
|
||||||
|
{
|
||||||
|
public bool IsRuntimeHost =>
|
||||||
|
CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||||
|
|| CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class GalaxyRuntimeProbeManagerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeSubscriber
|
||||||
|
{
|
||||||
|
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
|
||||||
|
public readonly ConcurrentQueue<string> UnsubCalls = new();
|
||||||
|
public bool FailSubscribeFor { get; set; }
|
||||||
|
public string? FailSubscribeTag { get; set; }
|
||||||
|
|
||||||
|
public Task Subscribe(string probe, Action<string, Vtq> cb)
|
||||||
|
{
|
||||||
|
if (FailSubscribeFor && string.Equals(probe, FailSubscribeTag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("subscribe refused");
|
||||||
|
Subs[probe] = cb;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Unsubscribe(string probe)
|
||||||
|
{
|
||||||
|
UnsubCalls.Enqueue(probe);
|
||||||
|
Subs.TryRemove(probe, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vtq Good(bool scanState) => new(scanState, DateTime.UtcNow, 192);
|
||||||
|
private static Vtq Bad() => new(null, DateTime.UtcNow, 0);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_subscribes_to_ScanState_per_host()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[]
|
||||||
|
{
|
||||||
|
new HostProbeTarget("PlatformA", GalaxyRuntimeProbeManager.CategoryWinPlatform),
|
||||||
|
new HostProbeTarget("EngineB", GalaxyRuntimeProbeManager.CategoryAppEngine),
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(2);
|
||||||
|
subs.Subs.ShouldContainKey("PlatformA.ScanState");
|
||||||
|
subs.Subs.ShouldContainKey("EngineB.ScanState");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_is_idempotent_on_repeat_call_with_same_set()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
var targets = new[] { new HostProbeTarget("PlatformA", 1) };
|
||||||
|
|
||||||
|
await mgr.SyncAsync(targets);
|
||||||
|
await mgr.SyncAsync(targets);
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(1);
|
||||||
|
subs.Subs.Count.ShouldBe(1);
|
||||||
|
subs.UnsubCalls.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_unadvises_removed_hosts()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[]
|
||||||
|
{
|
||||||
|
new HostProbeTarget("PlatformA", 1),
|
||||||
|
new HostProbeTarget("PlatformB", 1),
|
||||||
|
});
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(1);
|
||||||
|
subs.UnsubCalls.ShouldContain("PlatformB.ScanState");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_failure_rolls_back_host_entry_so_later_transitions_do_not_fire_stale_events()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber { FailSubscribeFor = true, FailSubscribeTag = "PlatformA.ScanState" };
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(0); // rolled back
|
||||||
|
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_to_Running_does_not_fire_StateChanged()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||||
|
|
||||||
|
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Running);
|
||||||
|
transitions.Count.ShouldBe(0); // startup transition, operators don't care
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Running_to_Stopped_fires_StateChanged_with_both_states()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||||
|
|
||||||
|
transitions.Count.ShouldBe(1);
|
||||||
|
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||||
|
t!.TagName.ShouldBe("PlatformA");
|
||||||
|
t.OldState.ShouldBe(HostRuntimeState.Running);
|
||||||
|
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stopped_to_Running_fires_StateChanged_for_recovery()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Stopped→Running (fires)
|
||||||
|
|
||||||
|
transitions.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_to_Stopped_fires_StateChanged_for_first_known_bad_signal()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||||
|
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
// First callback is bad-quality — we must flag the host Stopped so operators see it.
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Bad());
|
||||||
|
|
||||||
|
transitions.Count.ShouldBe(1);
|
||||||
|
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||||
|
t!.OldState.ShouldBe(HostRuntimeState.Unknown);
|
||||||
|
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repeated_Good_Running_callbacks_do_not_fire_duplicate_events()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
var count = 0;
|
||||||
|
mgr.StateChanged += (_, _) => Interlocked.Increment(ref count);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||||
|
|
||||||
|
count.ShouldBe(0); // only the silent Unknown→Running on the first, no events after
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_callback_for_non_tracked_probe_is_dropped()
|
||||||
|
{
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||||
|
|
||||||
|
mgr.OnProbeCallback("ProbeForSomeoneElse.ScanState", Good(true));
|
||||||
|
|
||||||
|
mgr.ActiveProbeCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Snapshot_reports_current_state_for_every_tracked_host()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var subs = new FakeSubscriber();
|
||||||
|
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||||
|
|
||||||
|
await mgr.SyncAsync(new[]
|
||||||
|
{
|
||||||
|
new HostProbeTarget("PlatformA", 1),
|
||||||
|
new HostProbeTarget("EngineB", 3),
|
||||||
|
});
|
||||||
|
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Running
|
||||||
|
subs.Subs["EngineB.ScanState"]("EngineB.ScanState", Bad()); // Stopped
|
||||||
|
|
||||||
|
var snap = mgr.SnapshotStates();
|
||||||
|
snap.Count.ShouldBe(2);
|
||||||
|
snap.ShouldContain(s => s.TagName == "PlatformA" && s.State == HostRuntimeState.Running);
|
||||||
|
snap.ShouldContain(s => s.TagName == "EngineB" && s.State == HostRuntimeState.Stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsRuntimeHost_recognizes_WinPlatform_and_AppEngine_category_ids()
|
||||||
|
{
|
||||||
|
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryWinPlatform).IsRuntimeHost.ShouldBeTrue();
|
||||||
|
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryAppEngine).IsRuntimeHost.ShouldBeTrue();
|
||||||
|
new HostProbeTarget("X", 4 /* $Area */).IsRuntimeHost.ShouldBeFalse();
|
||||||
|
new HostProbeTarget("X", 11 /* $ApplicationObject */).IsRuntimeHost.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user