fix(historian): document comms-loss outage markers; freeze DefaultSupportedKinds

Review round 1: the #481 FanCommsLossToDependencyMux fan-out DOES reach the recorder. Per the
controller ruling it is historized as an Empty/BadNoCommunication outage marker when the backend
supports Empty and counted in DroppedUnsupportedKind otherwise; remarks and the counter doc now say
so. DefaultSupportedKinds is a FrozenSet. The drop-leg test now runs under the default (gateway)
kinds, and the Empty leg pins status 0x80310000.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:52:32 -04:00
parent 04b882f5c6
commit 1c822eeeed
2 changed files with 20 additions and 8 deletions
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -28,10 +29,15 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// historized as such when the backend can store it.
/// </para>
/// <para>
/// <b>What is NOT historized:</b> the comms-loss watchdog's <c>BadNoCommunication</c> stamps.
/// <c>DriverHostActor.FanCommsLossToValueNodes</c> writes those to the address-space NODES only
/// (status-only sink write) and never reaches the mux, so the recorder never sees them. Only a
/// value a driver itself publishes through the mux is recorded.
/// <b>Comms loss IS historized (as an outage marker).</b> On a driver disconnect
/// <c>DriverHostActor.FanCommsLossToDependencyMux</c> (#481) fans <c>(null, BadNoCommunication)</c>
/// through the mux for every value ref of the driver, so the recorder receives it like any other
/// sample (it cannot, and does not try to, tell it apart from a driver's own null + Bad publish).
/// When the backend supports <see cref="HistorianValueKind.Empty"/> it is stored as an
/// Empty/<c>BadNoCommunication</c> sample — the marker that records the gap, so reads and
/// aggregates do not interpolate across the outage. On a numeric-only backend (the gateway's
/// default kinds) it is dropped and counted in <see cref="RecorderStatus.DroppedUnsupportedKind"/>.
/// (The separate <c>FanCommsLossToValueNodes</c> node stamp is an address-space write only.)
/// </para>
/// <para>
/// <b>Kind gate.</b> Each value is coerced with <see cref="HistorianValue.TryFromObject"/>; a
@@ -57,7 +63,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// <summary>The value kinds captured when no backend provider states its own: the numeric kinds the
/// gateway's SQL analog live-value path has always carried (Double, Int64, Boolean).</summary>
public static readonly IReadOnlySet<HistorianValueKind> DefaultSupportedKinds =
new HashSet<HistorianValueKind> { HistorianValueKind.Double, HistorianValueKind.Int64, HistorianValueKind.Boolean };
new[] { HistorianValueKind.Double, HistorianValueKind.Int64, HistorianValueKind.Boolean }.ToFrozenSet();
private const string DrainTimerKey = "drain";
@@ -104,7 +110,9 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// durably committed a partial prefix: the outbox does not report how far it got, so the recorder cannot
/// credit that prefix. Those entries still drain; they are simply never counted here.</param>
/// <param name="DroppedUnsupportedKind">Lifetime count of values dropped because they are not historizable
/// (arrays, references) or their kind is not one the backend supports.</param>
/// (arrays, references) or their kind is not one the backend supports. On a numeric-only backend this
/// includes the #481 comms-loss stamps (<c>null</c> + <c>BadNoCommunication</c> ⇒ Empty): every driver
/// disconnect adds one per historized ref of that driver.</param>
/// <param name="OutboxDropped">Lifetime count of entries the outbox dropped on capacity overflow.</param>
/// <param name="OutboxAppendFailures">Lifetime count of FAILED BATCH APPENDS at the durable boundary —
/// ONE per failed burst whatever its size, never one per value (the granularity changed with the batched
@@ -88,6 +88,7 @@ public sealed class ContinuousHistorizationRecorderTypedValueTests : TestKit
[Fact]
public async Task A_null_BadNoCommunication_sample_is_written_as_Empty_when_the_backend_supports_Empty()
{
// The #481 comms-loss fan-out shape: stored as an Empty/BadNoCommunication outage marker.
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, AllKinds);
@@ -96,13 +97,16 @@ public sealed class ContinuousHistorizationRecorderTypedValueTests : TestKit
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.Value.Kind.ShouldBe(HistorianValueKind.Empty);
written.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
written.StatusCode.ShouldBe(0x80310000u);
}
[Fact]
public async Task A_null_BadNoCommunication_sample_is_dropped_when_the_backend_does_not_support_Empty()
public async Task A_null_BadNoCommunication_sample_is_dropped_under_the_default_gateway_kinds()
{
// The #481 comms-loss fan-out shape (DriverHostActor.FanCommsLossToDependencyMux): on the default
// (numeric-only, gateway) kinds it is metered as an unsupported kind and nothing is appended.
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, NumericKinds);
IActorRef rec = Spawn(writer, supportedKinds: null);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));