fix(runtime): lifecycle housekeeping — reprovision stamp, Stale drops, off-thread DB probe, tombstone sweep (A4)
- DriverHostActor.StopChild removes the driver's _lastReprovisionUtc stamp (and its deferred-reprovision timer): the map grew per removed id and a re-added driver inherited a backoff it never earned (02/L2). - Stale now registers explicit drops for AttributeValueBatchPublished, AttributeAlarmPublished, ArtifactForApply and ApplyWedged instead of letting them hit Unhandled (02/H5). - DbHealthProbeActor runs its SELECT on the thread pool and pipes the outcome back (single-flight), with a 2 s command timeout on relational providers, so a hung SQL Server no longer pins the actor thread and the cached GetStatus answer stays reachable (02/P7). - TelemetryLocalHub eviction tombstones are timestamped and swept past a 10 min TTL on the next Evict; a TimeProvider ctor is the test seam (02/L4). - Tests: ValuePublishes.Single helper replaces the per-value AttributeValuePublished in CalculationDependencyFlowTests (02/U4, test half; the production receive is owned by Lane E). - R05/8 audit: PollGroupEngine.Unsubscribe has no production caller; every driver already uses UnsubscribeAsync (no change). Refs #731 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -2489,6 +2489,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
|
||||
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||
// Likewise a child's value / alarm burst: Stale is entered from Bootstrap's catch before any child is
|
||||
// spawned, so none should arrive — but the orphan-replay BeginDirectApply in that same try block has
|
||||
// already started a pipe-back before the catch can run, so "cannot happen" rests on ordering. Explicit
|
||||
// drops, so a burst that does land is a logged drop rather than an Unhandled dead-letter.
|
||||
Receive<DriverInstanceActor.AttributeValueBatchPublished>(m =>
|
||||
_log.Debug("DriverHost {Node}: dropping a {Count}-item value burst from {Id} while Stale (no address space)",
|
||||
_localNode, m.Items.Count, m.DriverInstanceId));
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(m =>
|
||||
_log.Debug("DriverHost {Node}: dropping a native alarm transition ({Cond}) from {Id} while Stale",
|
||||
_localNode, m.Args.ConditionId, m.DriverInstanceId));
|
||||
// A late artifact pipe-back from the apply that was in flight when the host fell Stale: that apply is
|
||||
// lost (the coordinator's deadline and the boot-time orphan replay recover it). Warning, not Debug —
|
||||
// an apply outcome is being discarded.
|
||||
Receive<ArtifactForApply>(m =>
|
||||
_log.Warning("DriverHost {Node}: dropping the artifact for {Id} (rev {Rev}) that arrived while Stale; the apply is abandoned",
|
||||
_localNode, m.DeploymentId, m.Revision));
|
||||
Receive<ApplyWedged>(m =>
|
||||
_log.Debug("DriverHost {Node}: late apply-wedge tick for {Id} while Stale; dropping", _localNode, m.DeploymentId));
|
||||
// Likewise the 03/S15 recovery audit: a timer armed before the host went Stale has nothing to report
|
||||
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
|
||||
Receive<CommsRecoveryAudit>(_ => { });
|
||||
@@ -3771,6 +3789,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// id lifts it (the tracker via _invokerFactory.Create, the hub via Readmit).
|
||||
_invokerFactory.Release(driverInstanceId);
|
||||
_telemetryHub?.Evict(driverInstanceId);
|
||||
// The reprovision rate-limit stamp belongs to the incarnation history that just ended. Kept, it grew the
|
||||
// map by one entry per id ever reprovisioned-then-removed AND made a later re-add of the same id inherit a
|
||||
// backoff it never earned (its first crash was deferred as a "crash loop").
|
||||
_lastReprovisionUtc.Remove(driverInstanceId);
|
||||
Timers.Cancel(ReprovisionTimerKey(driverInstanceId));
|
||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,37 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Single-flight cached health probe against the ConfigDb. Reads cached state via
|
||||
/// <c>Ask<DbHealthStatus></c>; a single SELECT 1 runs at most every <c>RefreshInterval</c>.
|
||||
/// <c>Ask<DbHealthStatus></c>; a single SELECT runs at most every <c>RefreshInterval</c>.
|
||||
/// Consumed by both the host's <c>/health/ready</c> endpoint and
|
||||
/// <c>RedundancyStateActor</c>'s stale calc.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The probe runs OFF the mailbox (a thread-pool task that pipes its outcome back), with a bounded
|
||||
/// command timeout. It used to run synchronously on the actor thread, relying on SqlClient's implicit
|
||||
/// 15 s connect / 30 s command timeouts — so a hanging SQL Server pinned a dispatcher thread for up to
|
||||
/// 30 s per tick, later ticks queued behind it, and the cached <see cref="GetStatus"/> answer was
|
||||
/// unreachable for exactly as long as the database was. Single-flight: a tick that lands while a probe
|
||||
/// is still running is skipped rather than stacked.
|
||||
/// </remarks>
|
||||
public sealed class DbHealthProbeActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Bound on the probe's SQL command, relational providers only (the in-memory provider the
|
||||
/// actor tests run against has no command timeout).</summary>
|
||||
public static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
public sealed class GetStatus { public static readonly GetStatus Instance = new(); private GetStatus() { } }
|
||||
public sealed record DbHealthStatus(bool Reachable, DateTime AsOfUtc, string? LastError);
|
||||
public sealed class Tick { public static readonly Tick Instance = new(); private Tick() { } }
|
||||
|
||||
/// <summary>Piped back by the off-thread probe with its outcome.</summary>
|
||||
private sealed record ProbeCompleted(DbHealthStatus Status);
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private DbHealthStatus _last = new(false, DateTime.MinValue, "not probed yet");
|
||||
private bool _probeInFlight;
|
||||
|
||||
/// <summary>Gets or sets the timer scheduler for periodic health probes.</summary>
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
@@ -38,28 +54,54 @@ public sealed class DbHealthProbeActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
Receive<GetStatus>(_ => Sender.Tell(_last));
|
||||
Receive<Tick>(_ => RunProbe());
|
||||
Receive<Tick>(_ => StartProbe());
|
||||
Receive<ProbeCompleted>(msg =>
|
||||
{
|
||||
_probeInFlight = false;
|
||||
_last = msg.Status;
|
||||
if (!msg.Status.Reachable)
|
||||
_log.Warning("DbHealthProbe: probe failed: {Error}", msg.Status.LastError);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
RunProbe();
|
||||
StartProbe();
|
||||
Timers.StartPeriodicTimer("probe", Tick.Instance, RefreshInterval);
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
private void StartProbe()
|
||||
{
|
||||
if (_probeInFlight)
|
||||
{
|
||||
_log.Debug("DbHealthProbe: previous probe still running; skipping this tick");
|
||||
return;
|
||||
}
|
||||
|
||||
_probeInFlight = true;
|
||||
var factory = _dbFactory;
|
||||
// Never touches actor state; the outcome comes back as a message.
|
||||
Task.Run(() => Probe(factory)).PipeTo(
|
||||
Self,
|
||||
success: status => new ProbeCompleted(status),
|
||||
failure: ex => new ProbeCompleted(new DbHealthStatus(false, DateTime.UtcNow, ex.Message)));
|
||||
}
|
||||
|
||||
private static DbHealthStatus Probe(IDbContextFactory<OtOpcUaConfigDbContext> factory)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory.CreateDbContext();
|
||||
using var db = factory.CreateDbContext();
|
||||
// Local to this lease — the pool resets it on return (see DriverHostActor.LoadArtifactBlob).
|
||||
if (db.Database.IsRelational())
|
||||
db.Database.SetCommandTimeout(CommandTimeout);
|
||||
_ = db.Deployments.AsNoTracking().Take(1).ToList();
|
||||
_last = new DbHealthStatus(true, DateTime.UtcNow, null);
|
||||
return new DbHealthStatus(true, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_last = new DbHealthStatus(false, DateTime.UtcNow, ex.Message);
|
||||
_log.Warning(ex, "DbHealthProbe: probe failed");
|
||||
return new DbHealthStatus(false, DateTime.UtcNow, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,27 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||
/// </remarks>
|
||||
public sealed class TelemetryLocalHub : ITelemetryLocalHub
|
||||
{
|
||||
/// <summary>
|
||||
/// How long an eviction tombstone (see <see cref="_evicted"/>) is kept. It only has to outlive the
|
||||
/// removed child's last late write — an in-flight capability call that completes after
|
||||
/// <c>StopChild</c>, bounded by the longest capability timeout (seconds) plus the child's own stop —
|
||||
/// so ten minutes is a wide margin. Without a sweep a driver removed for good kept its id forever.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan EvictionTombstoneTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
/// <summary>Initializes a hub on the system clock.</summary>
|
||||
public TelemetryLocalHub() : this(TimeProvider.System) { }
|
||||
|
||||
/// <summary>Initializes a hub on <paramref name="time"/> — the tombstone-sweep test seam.</summary>
|
||||
/// <param name="time">The clock the eviction tombstones are stamped and swept by.</param>
|
||||
public TelemetryLocalHub(TimeProvider time)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(time);
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// Plain Dictionary, NOT ConcurrentDictionary: every access is already under _gate, and
|
||||
// ConcurrentDictionary.Values would materialize a fresh List (and take its own internal lock) on
|
||||
@@ -42,9 +62,10 @@ public sealed class TelemetryLocalHub : ITelemetryLocalHub
|
||||
private readonly ConcurrentDictionary<string, TelemetryItem.Health> _healthCache = new();
|
||||
private readonly ConcurrentDictionary<(string InstanceId, string HostName), TelemetryItem.Resilience> _resilienceCache = new();
|
||||
|
||||
// Instances evicted and not since readmitted. Their late Health/Resilience items are dropped, so a
|
||||
// removed driver's still-stopping child cannot re-prime the cache it was just evicted from. Under _gate.
|
||||
private readonly HashSet<string> _evicted = new(StringComparer.Ordinal);
|
||||
// Instances evicted and not since readmitted, stamped with the eviction time. Their late Health/Resilience
|
||||
// items are dropped, so a removed driver's still-stopping child cannot re-prime the cache it was just
|
||||
// evicted from. Swept past EvictionTombstoneTtl on the next Evict (never on the Emit hot path). Under _gate.
|
||||
private readonly Dictionary<string, DateTimeOffset> _evicted = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Emit(TelemetryItem item)
|
||||
@@ -55,9 +76,9 @@ public sealed class TelemetryLocalHub : ITelemetryLocalHub
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case TelemetryItem.Health h when _evicted.Contains(h.E.DriverInstanceId):
|
||||
case TelemetryItem.Health h when _evicted.ContainsKey(h.E.DriverInstanceId):
|
||||
return;
|
||||
case TelemetryItem.Resilience r when _evicted.Contains(r.E.DriverInstanceId):
|
||||
case TelemetryItem.Resilience r when _evicted.ContainsKey(r.E.DriverInstanceId):
|
||||
return;
|
||||
case TelemetryItem.Health h:
|
||||
_healthCache[h.E.DriverInstanceId] = h;
|
||||
@@ -80,7 +101,9 @@ public sealed class TelemetryLocalHub : ITelemetryLocalHub
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_evicted.Add(driverInstanceId);
|
||||
var now = _time.GetUtcNow();
|
||||
SweepExpiredTombstones(now);
|
||||
_evicted[driverInstanceId] = now;
|
||||
_healthCache.TryRemove(driverInstanceId, out _);
|
||||
foreach (var key in _resilienceCache.Keys)
|
||||
if (string.Equals(key.InstanceId, driverInstanceId, StringComparison.Ordinal))
|
||||
@@ -97,6 +120,24 @@ public sealed class TelemetryLocalHub : ITelemetryLocalHub
|
||||
_evicted.Remove(driverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>Drops tombstones older than <see cref="EvictionTombstoneTtl"/>. Called under <see cref="_gate"/>
|
||||
/// from <see cref="Evict"/> only — the set is bounded by distinct removed ids, so a linear pass per
|
||||
/// eviction is cheap, and Emit never pays for it.</summary>
|
||||
private void SweepExpiredTombstones(DateTimeOffset now)
|
||||
{
|
||||
if (_evicted.Count == 0) return;
|
||||
List<string>? expired = null;
|
||||
foreach (var (id, evictedAt) in _evicted)
|
||||
{
|
||||
if (now - evictedAt >= EvictionTombstoneTtl)
|
||||
(expired ??= new List<string>()).Add(id);
|
||||
}
|
||||
|
||||
if (expired is null) return;
|
||||
foreach (var id in expired)
|
||||
_evicted.Remove(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITelemetrySubscription Subscribe(int boundedCapacity)
|
||||
{
|
||||
|
||||
+6
-6
@@ -64,7 +64,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
||||
{
|
||||
published.Enqueue(e);
|
||||
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
mux.Tell(ValuePublishes.Single(
|
||||
"calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
||||
// compute dedups) until the computed value has flowed all the way through.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
mux.Tell(ValuePublishes.Single(
|
||||
"src-drv", "src/a", 21.0, OpcUaStatus.Good, Ts));
|
||||
|
||||
// Leg 1 — dep flow: src/a=21 → A computes 42 and publishes on calc/engine/out.
|
||||
@@ -118,14 +118,14 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
||||
{
|
||||
published.Enqueue(e);
|
||||
var quality = e.Snapshot.StatusCode == 0u ? OpcUaStatus.Good : OpcUaStatus.Bad;
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished("calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
|
||||
mux.Tell(ValuePublishes.Single("calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
|
||||
};
|
||||
var adapter = Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter2");
|
||||
|
||||
// Leg 1 — before redeploy: src/a=2 → keep=20, remove=7.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 2.0, OpcUaStatus.Good, Ts));
|
||||
mux.Tell(ValuePublishes.Single("src-drv", "src/a", 2.0, OpcUaStatus.Good, Ts));
|
||||
published.ShouldContain(e => e.FullReference == "calc/keep" && Equals(e.Snapshot.Value, 20.0));
|
||||
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
|
||||
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
|
||||
@@ -150,8 +150,8 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
||||
// new result; the REMOVED tag never publishes a value derived from the post-redeploy input.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/b", 50.0, OpcUaStatus.Good, Ts));
|
||||
mux.Tell(new DriverInstanceActor.AttributeValuePublished("src-drv", "src/a", 3.0, OpcUaStatus.Good, Ts));
|
||||
mux.Tell(ValuePublishes.Single("src-drv", "src/b", 50.0, OpcUaStatus.Good, Ts));
|
||||
mux.Tell(ValuePublishes.Single("src-drv", "src/a", 3.0, OpcUaStatus.Good, Ts));
|
||||
|
||||
// (a) new dep B flows through the RE-REGISTERED interest → new tag computes: 50 + 100 = 150.
|
||||
published.ShouldContain(e => e.FullReference == "calc/add" && Equals(e.Snapshot.Value, 150.0));
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 L2: <c>DriverHostActor._lastReprovisionUtc</c> was never pruned — a driver removed by a deploy
|
||||
/// after a supervision crash kept its stamp, so the map grew with every id ever reprovisioned-then-removed,
|
||||
/// and (the observable half) a driver removed and later re-added inherited the OLD stamp: its first crash
|
||||
/// after the re-add was deferred by the backoff as if it were crash-looping, although the new incarnation had
|
||||
/// never crashed. <c>StopChild</c> now drops the stamp.
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorReprovisionStampTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
||||
private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64));
|
||||
private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
|
||||
private const string DriverId = "DI-1";
|
||||
private const string SpeedPath = "Plant/Modbus/dev1/speed";
|
||||
|
||||
[Fact]
|
||||
public void A_driver_removed_and_re_added_does_not_inherit_the_old_incarnations_reprovision_stamp()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// Far longer than the presence budget below: an inherited stamp would defer the re-added driver's
|
||||
// reprovision past it, which is the failure this test reports.
|
||||
var backoff = TimeSpan.FromSeconds(60);
|
||||
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||
var withDriver = new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) };
|
||||
var none = Array.Empty<(string, string, string, string, string?)>();
|
||||
|
||||
var revA = V3ArtifactSeeder.SeedV3Deployment(db, RevA, withDriver, none, enabled: true);
|
||||
var (host, _, _, coordinator) = V3ArtifactSeeder.SpawnHostAndApplyWithCoordinator(
|
||||
this, db, TestNode, revA, RevA, factory, reprovisionBackoff: backoff);
|
||||
AwaitAssert(() => factory.Created.Count.ShouldBe(1), PresenceBudget);
|
||||
AwaitAssert(() => factory.Created[0].SubscribeCount.ShouldBe(1), PresenceBudget);
|
||||
|
||||
// Crash 1 on the first incarnation: reprovisioned immediately, which stamps the id.
|
||||
factory.Created[0].FireMalformedDataChange(SpeedPath);
|
||||
AwaitAssert(() => factory.Created[0].InitializeCount.ShouldBe(2), PresenceBudget);
|
||||
|
||||
// Deploy B removes the driver (StopChild), deploy C re-adds it: a brand-new driver object.
|
||||
var revB = V3ArtifactSeeder.SeedV3Deployment(
|
||||
db, RevB, Array.Empty<(string, string, string, string, string, bool)>(), none, enabled: true);
|
||||
V3ArtifactSeeder.Dispatch(host, coordinator, revB, RevB);
|
||||
var revC = V3ArtifactSeeder.SeedV3Deployment(db, RevC, withDriver, none, enabled: true);
|
||||
V3ArtifactSeeder.Dispatch(host, coordinator, revC, RevC);
|
||||
AwaitAssert(() => factory.Created.Count.ShouldBe(2), PresenceBudget);
|
||||
var reAdded = factory.Created[1];
|
||||
AwaitAssert(() => reAdded.SubscribeCount.ShouldBe(1), PresenceBudget);
|
||||
|
||||
// Its FIRST crash must reprovision immediately — it has no history of its own.
|
||||
reAdded.FireMalformedDataChange(SpeedPath);
|
||||
AwaitAssert(
|
||||
() => reAdded.InitializeCount.ShouldBe(
|
||||
2, "the re-added driver's first reprovision was deferred by the REMOVED incarnation's stamp"),
|
||||
duration: TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 H5: the <c>Stale</c> behaviour registered no receive for a driver child's value/alarm bursts (nor
|
||||
/// for a late <c>ArtifactForApply</c> / <c>ApplyWedged</c> pipe-back), so any that landed there hit
|
||||
/// <c>Unhandled</c> — dead-letter noise and a dropped message with no log naming why. They are now explicit
|
||||
/// drops. The two private pipe-back records cannot be constructed from a test; the two public child
|
||||
/// messages are pinned here, and the private pair by the same registration pattern (compile-time).
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorStaleDropTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("stale-drop-test");
|
||||
|
||||
[Fact]
|
||||
public void Child_value_and_alarm_bursts_landing_in_Stale_are_dropped_not_unhandled()
|
||||
{
|
||||
var unhandled = CreateTestProbe();
|
||||
Sys.EventStream.Subscribe(unhandled.Ref, typeof(UnhandledMessage));
|
||||
|
||||
// ThrowingDbFactory ⇒ Bootstrap's catch ⇒ Become(Stale).
|
||||
var host = Sys.ActorOf(DriverHostActor.Props(
|
||||
new ThrowingDbFactory(), TestNode, CreateTestProbe().Ref,
|
||||
localRoles: new HashSet<string> { "driver" }));
|
||||
// Barrier: Stale answers RouteNodeWrite synchronously, so a reply proves the host is in Stale.
|
||||
var asker = CreateTestProbe();
|
||||
host.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(PresenceBudget).Reason!.ShouldContain("stale");
|
||||
|
||||
// Positive control: an unknown message IS reported as unhandled by this host.
|
||||
host.Tell("not a message the host knows");
|
||||
unhandled.ExpectMsg<UnhandledMessage>(m => ReferenceEquals(m.Recipient, host) || m.Recipient.Equals(host), PresenceBudget);
|
||||
|
||||
host.Tell(new DriverInstanceActor.AttributeValueBatchPublished(
|
||||
"DI-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/x/y", 1.0, OpcUaStatus.Good, DateTime.UtcNow) }));
|
||||
host.Tell(new DriverInstanceActor.AttributeAlarmPublished(
|
||||
"DI-1", new AlarmEventArgs(new Handle(), "Plant/x/alarm", "Plant/x/alarm", "AlarmCondition", "test",
|
||||
AlarmSeverity.High, DateTime.UtcNow)));
|
||||
// Barrier again, then the absence window.
|
||||
host.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(PresenceBudget);
|
||||
|
||||
unhandled.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
private sealed class Handle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "stale-test";
|
||||
}
|
||||
|
||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => throw new InvalidOperationException("db unreachable");
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,37 @@ public static class V3ArtifactSeeder
|
||||
return (actor, mux, coordinator);
|
||||
}
|
||||
|
||||
private static void Dispatch(IActorRef actor, TestProbe coordinator, DeploymentId deploymentId, RevisionHash rev)
|
||||
/// <summary>The same arrangement as <see cref="SpawnHostAndApply"/>, also returning the coordinator probe so a
|
||||
/// test can <see cref="Dispatch"/> FURTHER deployments to the same host (remove a driver, re-add it).</summary>
|
||||
/// <param name="kit">The calling TestKit, supplying the ActorSystem + probes.</param>
|
||||
/// <param name="db">The factory whose deployment was seeded by <see cref="SeedV3Deployment"/>.</param>
|
||||
/// <param name="localNode">The host's local node id.</param>
|
||||
/// <param name="deploymentId">The deployment to dispatch.</param>
|
||||
/// <param name="rev">The revision hash the deployment was sealed under.</param>
|
||||
/// <param name="driverFactory">Optional driver factory forwarded to <c>DriverHostActor.Props</c>.</param>
|
||||
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit.</param>
|
||||
/// <returns>The host actor plus the publish, mux and coordinator probes wired into it.</returns>
|
||||
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux, TestProbe Coordinator) SpawnHostAndApplyWithCoordinator(
|
||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
|
||||
IDriverFactory? driverFactory = null,
|
||||
TimeSpan? reprovisionBackoff = null)
|
||||
{
|
||||
var publish = kit.CreateCleanOutcomePublishProbe();
|
||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff);
|
||||
|
||||
Dispatch(actor, coordinator, deploymentId, rev);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
|
||||
|
||||
return (actor, publish, mux, coordinator);
|
||||
}
|
||||
|
||||
/// <summary>Dispatches <paramref name="deploymentId"/> to <paramref name="actor"/> and waits for its Applied ack.</summary>
|
||||
/// <param name="actor">The host.</param>
|
||||
/// <param name="coordinator">The coordinator probe the host acks to.</param>
|
||||
/// <param name="deploymentId">The deployment to dispatch.</param>
|
||||
/// <param name="rev">Its revision hash.</param>
|
||||
public static void Dispatch(IActorRef actor, TestProbe coordinator, DeploymentId deploymentId, RevisionHash rev)
|
||||
{
|
||||
actor.Tell(new DispatchDeployment(deploymentId, rev, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(RuntimeActorTestBase.PresenceBudget)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the ONE message shape a driver child sends for its values — a
|
||||
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> — for tests that push a single value into the
|
||||
/// dependency mux by hand. The per-value <see cref="DriverInstanceActor.AttributeValuePublished"/> has no
|
||||
/// production sender (02/U4); tests should not keep one alive either.
|
||||
/// </summary>
|
||||
public static class ValuePublishes
|
||||
{
|
||||
/// <summary>A one-item batch for <paramref name="fullReference"/>.</summary>
|
||||
/// <param name="driverInstanceId">The publishing driver's id.</param>
|
||||
/// <param name="fullReference">The value's reference (RawPath).</param>
|
||||
/// <param name="value">The value.</param>
|
||||
/// <param name="statusCode">The full OPC UA status code.</param>
|
||||
/// <param name="timestampUtc">The source timestamp.</param>
|
||||
/// <returns>The batch message.</returns>
|
||||
public static DriverInstanceActor.AttributeValueBatchPublished Single(
|
||||
string driverInstanceId, string fullReference, object? value, uint statusCode, DateTime timestampUtc) =>
|
||||
new(driverInstanceId, new[] { new DriverInstanceActor.AttributeValueItem(fullReference, value, statusCode, timestampUtc) });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 P7: <see cref="DbHealthProbeActor"/> ran its SELECT synchronously on the actor thread, so a
|
||||
/// hanging SQL connect/command held the dispatcher thread for the implicit 15 s / 30 s SqlClient timeouts and
|
||||
/// queued every later tick (and every <c>GetStatus</c> Ask) behind it. The probe now runs off the mailbox
|
||||
/// and pipes its outcome back, so the cached answer stays available while a probe hangs.
|
||||
/// </summary>
|
||||
public sealed class DbHealthProbeOffThreadTests : RuntimeActorTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetStatus_answers_from_cache_while_a_probe_is_hung()
|
||||
{
|
||||
var factory = new HangingDbFactory();
|
||||
try
|
||||
{
|
||||
var actor = Sys.ActorOf(DbHealthProbeActor.Props(factory));
|
||||
AwaitCondition(() => factory.Entered > 0, PresenceBudget);
|
||||
|
||||
var status = await actor.Ask<DbHealthProbeActor.DbHealthStatus>(
|
||||
DbHealthProbeActor.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
|
||||
status.Reachable.ShouldBeFalse();
|
||||
status.LastError.ShouldBe("not probed yet");
|
||||
factory.Entered.ShouldBe(1, "a second tick must not stack a second probe behind a hung one");
|
||||
}
|
||||
finally
|
||||
{
|
||||
factory.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_probe_that_completes_updates_the_cached_status()
|
||||
{
|
||||
// Positive control for the off-thread path: the piped-back outcome lands in the cache.
|
||||
var db = NewInMemoryDbFactory();
|
||||
var actor = Sys.ActorOf(DbHealthProbeActor.Props(db));
|
||||
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await actor.Ask<DbHealthProbeActor.DbHealthStatus>(
|
||||
DbHealthProbeActor.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
status.Reachable.ShouldBeTrue();
|
||||
}, PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>Blocks every <c>CreateDbContext</c> until released — a connect that never answers.</summary>
|
||||
private sealed class HangingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
private readonly ManualResetEventSlim _release = new(false);
|
||||
private int _entered;
|
||||
|
||||
public int Entered => Volatile.Read(ref _entered);
|
||||
|
||||
public void Release() => _release.Set();
|
||||
|
||||
public OtOpcUaConfigDbContext CreateDbContext()
|
||||
{
|
||||
Interlocked.Increment(ref _entered);
|
||||
_release.Wait();
|
||||
throw new InvalidOperationException("released without a database");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,22 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health;
|
||||
|
||||
public sealed class HealthProbeActorTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>Verifies that the DB health probe actor returns reachable status against an in-memory database.</summary>
|
||||
/// <summary>Verifies that the DB health probe actor returns reachable status against an in-memory database.
|
||||
/// The probe runs off the mailbox (02/P7), so the first Ask may still see "not probed yet" — poll.</summary>
|
||||
[Fact]
|
||||
public async Task DbHealthProbeActor_returns_reachable_against_in_memory_db()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var actor = Sys.ActorOf(DbHealthProbeActor.Props(db));
|
||||
|
||||
var status = await actor.Ask<DbHealthProbeActor.DbHealthStatus>(
|
||||
DbHealthProbeActor.GetStatus.Instance, PresenceBudget);
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await actor.Ask<DbHealthProbeActor.DbHealthStatus>(
|
||||
DbHealthProbeActor.GetStatus.Instance, PresenceBudget);
|
||||
|
||||
status.Reachable.ShouldBeTrue();
|
||||
status.LastError.ShouldBeNull();
|
||||
status.Reachable.ShouldBeTrue();
|
||||
status.LastError.ShouldBeNull();
|
||||
}, PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the peer OPC UA probe actor reports Ok true against a live listener.</summary>
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 L4: <see cref="TelemetryLocalHub.Evict"/>'s tombstone set only ever shrank on a re-spawn of the
|
||||
/// SAME id, so a driver removed for good kept its id forever. The tombstone is load-bearing (#609 — it holds
|
||||
/// back the removed child's late writes), so it cannot simply be cleared; it is now timestamped and swept
|
||||
/// once older than <see cref="TelemetryLocalHub.EvictionTombstoneTtl"/>, which outlives any in-flight
|
||||
/// capability call by a wide margin.
|
||||
/// </summary>
|
||||
public sealed class TelemetryLocalHubTombstoneSweepTests
|
||||
{
|
||||
private static DriverHealthChanged Health(string instanceId) =>
|
||||
new("cluster-a", instanceId, "Healthy", null, null, 0, DateTime.UtcNow);
|
||||
|
||||
private static List<TelemetryItem> Drain(ITelemetrySubscription sub)
|
||||
{
|
||||
var items = new List<TelemetryItem>();
|
||||
while (sub.Reader.TryRead(out var item)) items.Add(item);
|
||||
return items;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tombstone_holds_late_writes_until_the_ttl_then_is_swept()
|
||||
{
|
||||
var clock = new ManualTime { Now = new DateTimeOffset(2026, 10, 22, 12, 0, 0, TimeSpan.Zero) };
|
||||
var hub = new TelemetryLocalHub(clock);
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-a")));
|
||||
hub.Evict("drv-a");
|
||||
|
||||
// Inside the TTL: the late write is dropped (the #609 contract) — and another eviction's sweep does
|
||||
// not lift a tombstone that is still young.
|
||||
clock.Now += TelemetryLocalHub.EvictionTombstoneTtl / 2;
|
||||
hub.Evict("drv-b");
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-a")));
|
||||
using (var sub = hub.Subscribe(16))
|
||||
Drain(sub).ShouldBeEmpty("a late write inside the TTL must still be held back");
|
||||
|
||||
// Past the TTL: the next eviction sweeps drv-a's tombstone and its writes land again.
|
||||
clock.Now += TelemetryLocalHub.EvictionTombstoneTtl;
|
||||
hub.Evict("drv-c");
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-a")));
|
||||
using (var sub = hub.Subscribe(16))
|
||||
Drain(sub).OfType<TelemetryItem.Health>().Single().E.DriverInstanceId.ShouldBe("drv-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Readmit_lifts_a_tombstone_before_its_ttl()
|
||||
{
|
||||
var clock = new ManualTime { Now = DateTimeOffset.UtcNow };
|
||||
var hub = new TelemetryLocalHub(clock);
|
||||
hub.Evict("drv-a");
|
||||
hub.Readmit("drv-a");
|
||||
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-a")));
|
||||
using var sub = hub.Subscribe(16);
|
||||
Drain(sub).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
private sealed class ManualTime : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; }
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user