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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user