fix(historian): record the real status code and typed values; backend-supported kinds gate capture

The recorder now appends the typed HistorianValue and the mux's own StatusCode instead of a
double + hard-coded Good, and drops any kind not in the backend's SupportedKinds (resolved
optionally from IHistorianBackendProvider; absent => {Double, Int64, Boolean}, today's behaviour).
RecorderStatus.DroppedNonNumeric -> DroppedUnsupportedKind; GoodQuality const removed; stale
"mux carries no quality" remarks rewritten (comms-loss node stamps are not historized).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:49:05 -04:00
parent ff7c6c531a
commit 04b882f5c6
5 changed files with 326 additions and 54 deletions
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// thread on the actor mailbox. The outbox exposes both gauge sources cheaply and
/// non-blockingly — <see cref="IHistorizationOutbox.CountAsync"/> completes synchronously (it
/// just reads an in-memory FIFO count) and <see cref="IHistorizationOutbox.DroppedCount"/> is a
/// plain property. The recorder's other counters (TotalRecorded / DroppedNonNumeric /
/// plain property. The recorder's other counters (TotalRecorded / DroppedUnsupportedKind /
/// OutboxAppendFailures) remain available via its <c>GetStatus</c> Ask for a health hook, but are
/// not surfaced as gauges here (Ask-in-gauge is the awkward path the plan calls out).
/// </para>
@@ -11,25 +11,34 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// values the mux fans for those refs — in production a
/// <see cref="VirtualTagActor.DependencyValueBatch"/> per driver burst, with the per-value
/// <see cref="VirtualTagActor.DependencyValueChanged"/> receive retained for direct senders and tests:
/// value → coerce to numeric → append to the durable <see cref="IHistorizationOutbox"/> (the crash
/// value → coerce to a typed <see cref="HistorianValue"/> (kind-filtered) → append to the durable <see cref="IHistorizationOutbox"/> (the crash
/// boundary) → a self-paced drain writes the outbox through <see cref="IHistorianValueWriter"/>'s
/// live-value path and acks (truncates) on success.
/// </summary>
/// <remarks>
/// <para>
/// <b>Message path (grounded against the real actors).</b> The mux fans each value — carrying
/// only <c>(TagId, Value, TimestampUtc)</c> and <em>no quality</em> — to every registered
/// subscriber, batched as a <see cref="VirtualTagActor.DependencyValueBatch"/> on the production
/// path and as a bare <see cref="VirtualTagActor.DependencyValueChanged"/> from a direct sender
/// or a test.
/// The recorder reuses that existing fan-out (no change to <c>DriverHostActor</c>), so values
/// arrive Good-quality by the same convention the scripted-alarm host uses (the mux only
/// forwards driver-published values); historized samples are recorded with
/// <see cref="GoodQuality"/>.
/// <b>Message path (grounded against the real actors).</b> The mux fans each value as
/// <c>(TagId, Value, TimestampUtc, StatusCode)</c> — <see cref="DependencyMuxActor"/> forwards
/// the driver publish's own <see cref="VirtualTagActor.DependencyValueChanged.StatusCode"/> — to
/// every registered subscriber, batched as a <see cref="VirtualTagActor.DependencyValueBatch"/> on
/// the production path and as a bare <see cref="VirtualTagActor.DependencyValueChanged"/> from a
/// direct sender or a test. The recorder reuses that existing fan-out (no change to
/// <c>DriverHostActor</c>) and records each sample with its REAL status code, so a driver's own
/// Bad/Uncertain publish (usually a <c>null</c> value ⇒ <see cref="HistorianValueKind.Empty"/>) is
/// historized as such when the backend can store it.
/// </para>
/// <para>
/// <b>Numeric-only gate.</b> The SQL analog live-value write path is numeric-only, so a
/// non-coercible value (string/null/reference) is dropped and metered rather than appended.
/// <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.
/// </para>
/// <para>
/// <b>Kind gate.</b> Each value is coerced with <see cref="HistorianValue.TryFromObject"/>; a
/// value it cannot carry (arrays, references, structures) or whose kind is not in the backend's
/// supported set (<see cref="IHistorianBackendProvider.SupportedKinds"/>; default
/// <see cref="DefaultSupportedKinds"/>) is dropped and metered as
/// <see cref="RecorderStatus.DroppedUnsupportedKind"/> rather than appended.
/// </para>
/// <para>
/// <b>Batched ingest, non-blocking drain.</b> A mux burst arrives as one
@@ -45,16 +54,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// </remarks>
public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
{
/// <summary>OPC-DA quality byte recorded for mux-fanned values (the mux drops quality; driver-published
/// values are Good by the same convention the scripted-alarm host applies).
/// <para>
/// Note (archreview 06/C-7): the historian gateway's <c>WriteLiveValues</c> SQL path <b>discards</b>
/// quality (server-managed column, 0.2.0 Contracts C-002 — see
/// <c>GatewayHistorianValueWriter</c>'s remarks). This constant is therefore what a future
/// quality-honoring gateway path <em>would</em> receive; capturing real per-node quality here will
/// NOT persist until the gateway honors the field.
/// </para></summary>
public const ushort GoodQuality = 192;
/// <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 };
private const string DrainTimerKey = "drain";
@@ -100,7 +103,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// BURST, on a successful group commit — so it UNDER-counts when a batch append fails after the outbox
/// 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="DroppedNonNumeric">Lifetime count of values dropped for not being numeric-coercible.</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>
/// <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
@@ -113,13 +117,13 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// <param name="AbandonedEntries">Lifetime count of entries discarded because their historian tag
/// kept failing while other tags were writing successfully (Gitea #538).</param>
/// <param name="IngestFaults">Lifetime count of values dropped because the per-value gate/coercion step
/// threw — distinct from <paramref name="DroppedNonNumeric"/>, which counts values the historian's
/// numeric-only write path cannot carry. Non-zero here means an ingest dependency is faulting (most
/// threw — distinct from <paramref name="DroppedUnsupportedKind"/>, which counts values the backend
/// cannot carry. Non-zero here means an ingest dependency is faulting (most
/// plausibly the supplied capture gate), not that the data was unsuitable.</param>
public sealed record RecorderStatus(
int QueuedDepth,
long TotalRecorded,
long DroppedNonNumeric,
long DroppedUnsupportedKind,
long OutboxDropped,
long OutboxAppendFailures,
bool LastDrainSucceeded,
@@ -157,6 +161,9 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// many are outstanding at once so a wide batch cannot flood the gateway.</summary>
private readonly int _drainConcurrency;
/// <summary>The value kinds the backend can store; any other kind is dropped at capture.</summary>
private readonly IReadOnlySet<HistorianValueKind> _supportedKinds;
/// <summary>Consecutive failed write passes per historian tag, counted only across passes that
/// proved the backend reachable. Reset on the tag's first success. Mutated exclusively inside
/// <see cref="DrainOnceAsync"/>, which <see cref="_draining"/> keeps single-flight.</summary>
@@ -169,7 +176,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
private TimeSpan _currentBackoff;
private DateTime _nextAllowedDrainUtc = DateTime.MinValue;
private long _totalRecorded;
private long _droppedNonNumeric;
private long _droppedUnsupportedKind;
private long _outboxAppendFailures;
private long _suppressedNotPrimary;
private long _abandonedEntries;
@@ -204,6 +211,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// accumulate, while other tags are succeeding, before its entries are abandoned.</param>
/// <param name="drainConcurrency">Max historian tags one drain pass writes concurrently (must be
/// positive; 1 is the fully sequential drain).</param>
/// <param name="supportedKinds">The value kinds the backend can store
/// (<see cref="IHistorianBackendProvider.SupportedKinds"/>); null ⇒ <see cref="DefaultSupportedKinds"/>.</param>
/// <returns>Props for the actor.</returns>
public static Props Props(
IActorRef dependencyMux,
@@ -216,10 +225,11 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
TimeSpan? maxBackoff = null,
Func<bool>? captureGate = null,
int maxWriteAttempts = 5,
int drainConcurrency = 8) =>
int drainConcurrency = 8,
IReadOnlySet<HistorianValueKind>? supportedKinds = null) =>
Akka.Actor.Props.Create(() => new ContinuousHistorizationRecorder(
dependencyMux, writer, outbox, historizedRefs, drainBatchSize, drainInterval, minBackoff, maxBackoff,
captureGate, maxWriteAttempts, drainConcurrency));
captureGate, maxWriteAttempts, drainConcurrency, supportedKinds));
/// <summary>Initializes a new instance of the <see cref="ContinuousHistorizationRecorder"/> class.</summary>
/// <param name="dependencyMux">The per-node dependency mux to register historized-ref interest with.</param>
@@ -237,6 +247,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// least 1.</param>
/// <param name="drainConcurrency">Max historian tags one drain pass writes concurrently. Clamped to
/// at least 1; 1 is the fully sequential drain.</param>
/// <param name="supportedKinds">The value kinds the backend can store; null ⇒
/// <see cref="DefaultSupportedKinds"/>.</param>
public ContinuousHistorizationRecorder(
IActorRef dependencyMux,
IHistorianValueWriter writer,
@@ -248,7 +260,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
TimeSpan? maxBackoff = null,
Func<bool>? captureGate = null,
int maxWriteAttempts = 5,
int drainConcurrency = 8)
int drainConcurrency = 8,
IReadOnlySet<HistorianValueKind>? supportedKinds = null)
{
_dependencyMux = dependencyMux ?? throw new ArgumentNullException(nameof(dependencyMux));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
@@ -276,6 +289,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
_captureGate = captureGate ?? (static () => true);
_maxWriteAttempts = maxWriteAttempts > 0 ? maxWriteAttempts : 1;
_drainConcurrency = drainConcurrency > 0 ? drainConcurrency : 1;
_supportedKinds = supportedKinds ?? DefaultSupportedKinds;
ReceiveAsync<VirtualTagActor.DependencyValueChanged>(OnValueChangedAsync);
ReceiveAsync<VirtualTagActor.DependencyValueBatch>(OnValueBatchAsync);
@@ -337,8 +351,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
/// </summary>
/// <remarks>
/// <para>
/// <b>The gates stay per value.</b> The ref filter, the Gitea #537 capture gate and the numeric
/// coercion are evaluated for each item in turn — the capture gate in particular is a live read
/// <b>The gates stay per value.</b> The ref filter, the Gitea #537 capture gate and the typed-value
/// coercion + kind gate are evaluated for each item in turn — the capture gate in particular is a live read
/// per value, never hoisted to once per batch, so a promotion resumes capture on the very next
/// sample (pinned by <c>The_capture_gate_is_re_read_per_value_so_promotion_resumes_capture</c>).
/// Only the durable append is batched.
@@ -393,10 +407,10 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
// Defensive: keep one malformed item from dropping the rest of the burst (and from
// restarting the actor). Category only — no value content is logged.
//
// Metered under its own counter rather than folded into DroppedNonNumeric: the realistic
// Metered under its own counter rather than folded into DroppedUnsupportedKind: the realistic
// source is a throwing _captureGate (a supplied delegate reading the redundancy role view),
// not a value the historian cannot carry, and conflating the two would make the
// non-numeric metric lie about a redundancy fault.
// unsupported-kind metric lie about a redundancy fault.
//
// Logged once per run of faults (the same latch idiom the capture gate uses above): the
// plausible fault modes are per-VALUE, so an unlatched Warning would emit one line per
@@ -524,13 +538,14 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
+ "resuming value capture.");
}
if (!(HistorianValue.TryFromObject(msg.Value, out HistorianValue historianValue)
&& historianValue.TryGetDouble(out double numeric)))
if (!HistorianValue.TryFromObject(msg.Value, out HistorianValue historianValue)
|| !_supportedKinds.Contains(historianValue.Kind))
{
// The SQL analog live-value path is numeric-only — drop non-coercible values (string/null/
// reference) and meter the drop. No value content is logged.
_droppedNonNumeric++;
_log.Debug("ContinuousHistorization: dropped non-numeric value for a historized ref.");
// Not historizable at all (array/reference/structure), or a kind this backend cannot store
// (e.g. String/Empty on the gateway's numeric-only SQL analog path) — drop and meter. No value
// content is logged.
_droppedUnsupportedKind++;
_log.Debug("ContinuousHistorization: dropped a value of a kind the historian backend does not support.");
return;
}
@@ -549,8 +564,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
entries.Add(new HistorizationOutboxEntry(
Guid.NewGuid(),
historianName,
HistorianValue.FromDouble(numeric),
0u, // Good
historianValue,
msg.StatusCode, // the REAL status the driver published, not an assumed Good
timestampUtc));
}
}
@@ -870,7 +885,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
return new RecorderStatus(
depth,
_totalRecorded,
_droppedNonNumeric,
_droppedUnsupportedKind,
_outbox.DroppedCount,
_outboxAppendFailures,
_lastDrainSucceeded,
@@ -509,7 +509,11 @@ public static class ServiceCollectionExtensions
maxWriteAttempts: continuousOptions.MaxWriteAttempts,
// Bounded-parallel per-tag drain: the groups in one pass are independent
// gateway round-trips, so a sequential pass costs `tags × latency`.
drainConcurrency: continuousOptions.DrainConcurrency),
drainConcurrency: continuousOptions.DrainConcurrency,
// The backend states which value kinds it can store; the recorder drops the
// rest at capture. No provider registered ⇒ null ⇒ the recorder's numeric
// default {Double, Int64, Boolean} (the gateway's historic behaviour).
supportedKinds: resolver.GetService<IHistorianBackendProvider>()?.SupportedKinds),
ContinuousHistorizationRecorderActorName);
registry.Register<ContinuousHistorizationRecorderKey>(continuousRecorder);
}