From 1c822eeeed319c8312c8c57c0a7c97193520f58d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 12:52:32 -0400 Subject: [PATCH] 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 --- .../ContinuousHistorizationRecorder.cs | 20 +++++++++++++------ ...ousHistorizationRecorderTypedValueTests.cs | 8 ++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs index 7b2667ea..3eb35004 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs @@ -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. /// /// -/// What is NOT historized: the comms-loss watchdog's BadNoCommunication stamps. -/// DriverHostActor.FanCommsLossToValueNodes 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. +/// Comms loss IS historized (as an outage marker). On a driver disconnect +/// DriverHostActor.FanCommsLossToDependencyMux (#481) fans (null, BadNoCommunication) +/// 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 it is stored as an +/// Empty/BadNoCommunication 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 . +/// (The separate FanCommsLossToValueNodes node stamp is an address-space write only.) /// /// /// Kind gate. Each value is coerced with ; a @@ -57,7 +63,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers /// 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). public static readonly IReadOnlySet DefaultSupportedKinds = - new HashSet { 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. /// Lifetime count of values dropped because they are not historizable - /// (arrays, references) or their kind is not one the backend supports. + /// (arrays, references) or their kind is not one the backend supports. On a numeric-only backend this + /// includes the #481 comms-loss stamps (null + BadNoCommunication ⇒ Empty): every driver + /// disconnect adds one per historized ref of that driver. /// Lifetime count of entries the outbox dropped on capacity overflow. /// 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs index ecca2015..c11e0e54 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTypedValueTests.cs @@ -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));