Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/TelemetryLocalHubTombstoneSweepTests.cs
T
Joseph Doherty 79d6b0a4b2 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
2026-10-22 06:36:12 -04:00

70 lines
2.8 KiB
C#

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;
}
}