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,14 +13,14 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
/// Verifies the <see cref="ContinuousHistorizationRecorder"/>: it registers historized-ref
/// interest with the dependency mux on start, appends mux-fanned
/// <see cref="VirtualTagActor.DependencyValueChanged"/> values to the durable outbox then drains
/// them to the live-value writer, retains entries when the writer fails, and drops non-numeric
/// values (the SQL analog write path is numeric-only).
/// them to the live-value writer, retains entries when the writer fails, and drops values of a kind
/// the backend does not support (the default kinds are the numeric ones).
/// </summary>
/// <remarks>
/// Adapted from the plan's Task 17: the recorder handles the REAL fan-out message the mux emits —
/// <see cref="VirtualTagActor.DependencyValueChanged"/> (TagId/Value/TimestampUtc, no quality) —
/// not <c>DriverInstanceActor.AttributeValuePublished</c>. The mux drops quality, so the recorder
/// records Good-quality (the same convention the scripted-alarm host uses for mux values).
/// <see cref="VirtualTagActor.DependencyValueChanged"/> (TagId/Value/TimestampUtc/StatusCode) —
/// not <c>DriverInstanceActor.AttributeValuePublished</c>. The typed-value / real-status behaviour is
/// pinned in <see cref="ContinuousHistorizationRecorderTypedValueTests"/>.
/// </remarks>
public sealed class ContinuousHistorizationRecorderTests : TestKit
{
@@ -334,7 +334,7 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
{
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
Assert.Equal(1, status.DroppedNonNumeric);
Assert.Equal(1, status.DroppedUnsupportedKind);
Assert.Equal(0, status.QueuedDepth);
});
Assert.Empty(writer.Snapshot());
@@ -1146,7 +1146,7 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
Assert.Equal(2, status.TotalRecorded);
// The fault is NOT a "value the historian cannot carry" — that counter must stay clean, or an
// ingest-dependency outage is indistinguishable from bad data on the /hosts surface.
Assert.Equal(0, status.DroppedNonNumeric);
Assert.Equal(0, status.DroppedUnsupportedKind);
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // no PreStart re-register == no restart
}
@@ -1467,7 +1467,7 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
_calls++;
foreach (HistorizationValue v in values)
{
_written.Add(new WrittenValue(tag, v.Value.Number, (ushort)v.StatusCode, v.TimestampUtc));
_written.Add(new WrittenValue(tag, v.Value.Number, v.StatusCode, v.TimestampUtc));
}
// A throwing tag overrides every ack rule. Thrown synchronously from inside the awaited
@@ -1569,7 +1569,7 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
}
}
private sealed record WrittenValue(string Tag, double Value, ushort Quality, DateTime? TimestampUtc);
private sealed record WrittenValue(string Tag, double Value, uint Quality, DateTime? TimestampUtc);
/// <summary>In-memory <see cref="IHistorizationOutbox"/> double honouring the FIFO-truncate
/// <see cref="IHistorizationOutbox.RemoveAsync"/> contract (remove the id plus any older entries
@@ -0,0 +1,253 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
/// <summary>
/// The recorder captures the TYPED value and the REAL status code the mux fans
/// (<see cref="VirtualTagActor.DependencyValueChanged.StatusCode"/>), and drops any kind the
/// backend does not support — metered as <see cref="ContinuousHistorizationRecorder.RecorderStatus.DroppedUnsupportedKind"/>.
/// </summary>
public sealed class ContinuousHistorizationRecorderTypedValueTests : TestKit
{
private const string Ref = "Pump1.Temp";
private const uint BadStatus = 0x80000000u;
private static readonly IReadOnlySet<HistorianValueKind> AllKinds = new HashSet<HistorianValueKind>
{
HistorianValueKind.Empty, HistorianValueKind.Double, HistorianValueKind.Int64,
HistorianValueKind.Boolean, HistorianValueKind.String, HistorianValueKind.DateTime,
};
private static readonly IReadOnlySet<HistorianValueKind> NumericKinds = new HashSet<HistorianValueKind>
{
HistorianValueKind.Double, HistorianValueKind.Int64, HistorianValueKind.Boolean,
};
[Fact]
public async Task A_bad_numeric_sample_is_written_with_its_real_status_code()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, supportedKinds: null); // default kinds
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, 42.5, DateTime.UtcNow, BadStatus));
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.StatusCode.ShouldBe(BadStatus);
written.Value.Kind.ShouldBe(HistorianValueKind.Double);
written.Value.Number.ShouldBe(42.5);
}
[Fact]
public async Task A_good_integer_sample_keeps_its_Int64_kind_and_Good_status()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, supportedKinds: null);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, 7, DateTime.UtcNow));
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.StatusCode.ShouldBe(OpcUaStatus.Good);
written.Value.Kind.ShouldBe(HistorianValueKind.Int64);
written.Value.Integer.ShouldBe(7);
written.Value.Number.ShouldBe(7d); // the double view the gateway's numeric path writes
}
[Fact]
public async Task A_string_sample_is_written_when_the_backend_supports_String()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, AllKinds);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, "running", DateTime.UtcNow));
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.Value.Kind.ShouldBe(HistorianValueKind.String);
written.Value.Text.ShouldBe("running");
(await StatusAsync(rec)).DroppedUnsupportedKind.ShouldBe(0);
}
[Fact]
public async Task A_string_sample_is_dropped_and_counted_when_the_backend_does_not_support_String()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, NumericKinds);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, "running", DateTime.UtcNow));
await AwaitDroppedAsync(rec, 1);
await AssertNothingWrittenAfterPositiveControlAsync(rec, writer);
}
[Fact]
public async Task A_null_BadNoCommunication_sample_is_written_as_Empty_when_the_backend_supports_Empty()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, AllKinds);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.Value.Kind.ShouldBe(HistorianValueKind.Empty);
written.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
}
[Fact]
public async Task A_null_BadNoCommunication_sample_is_dropped_when_the_backend_does_not_support_Empty()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, NumericKinds);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, null, DateTime.UtcNow, OpcUaStatus.BadNoCommunication));
await AwaitDroppedAsync(rec, 1);
await AssertNothingWrittenAfterPositiveControlAsync(rec, writer);
}
[Fact]
public async Task An_array_sample_is_always_dropped_even_when_every_kind_is_supported()
{
var writer = new PoolThreadValueWriter();
IActorRef rec = Spawn(writer, AllKinds);
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, new[] { 1, 2, 3 }, DateTime.UtcNow));
await AwaitDroppedAsync(rec, 1);
await AssertNothingWrittenAfterPositiveControlAsync(rec, writer);
}
private IActorRef Spawn(IHistorianValueWriter writer, IReadOnlySet<HistorianValueKind>? supportedKinds)
{
var mux = CreateTestProbe();
return Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref,
writer,
new SimpleOutbox(),
new[] { Ref },
drainInterval: TimeSpan.FromMilliseconds(50),
supportedKinds: supportedKinds));
}
private static async Task<ContinuousHistorizationRecorder.RecorderStatus> StatusAsync(IActorRef rec) =>
await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
private async Task<HistorizationValue> AwaitSingleWriteAsync(PoolThreadValueWriter writer)
{
await AwaitConditionAsync(() => Task.FromResult(writer.Snapshot().Count > 0), TimeSpan.FromSeconds(5));
IReadOnlyList<(string Tag, HistorizationValue Value)> written = writer.Snapshot();
written.Count.ShouldBe(1);
written[0].Tag.ShouldBe(Ref);
return written[0].Value;
}
private async Task AwaitDroppedAsync(IActorRef rec, long expected) =>
await AwaitAssertAsync(async () =>
{
ContinuousHistorizationRecorder.RecorderStatus status = await StatusAsync(rec);
status.DroppedUnsupportedKind.ShouldBe(expected);
status.QueuedDepth.ShouldBe(0);
});
/// <summary>An absence assertion needs a positive control: send a value that MUST be written and
/// prove it is the only one that was — so "nothing else written" is not vacuous.</summary>
private async Task AssertNothingWrittenAfterPositiveControlAsync(IActorRef rec, PoolThreadValueWriter writer)
{
rec.Tell(new VirtualTagActor.DependencyValueChanged(Ref, 1.0, DateTime.UtcNow));
HistorizationValue written = await AwaitSingleWriteAsync(writer);
written.Value.Kind.ShouldBe(HistorianValueKind.Double);
written.Value.Number.ShouldBe(1.0);
}
/// <summary>Records every value written; completes on a POOL thread, as a real gateway write does
/// (never synchronously, never via <c>Task.Yield</c> — Gitea #491).</summary>
private sealed class PoolThreadValueWriter : IHistorianValueWriter
{
private readonly Lock _gate = new();
private readonly List<(string Tag, HistorizationValue Value)> _written = new();
public IReadOnlyList<(string Tag, HistorizationValue Value)> Snapshot()
{
lock (_gate)
{
return _written.ToArray();
}
}
public async Task<bool> WriteLiveValuesAsync(
string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (_gate)
{
foreach (HistorizationValue v in values)
{
_written.Add((tag, v));
}
}
return true;
}
}
/// <summary>Minimal FIFO-truncate outbox; appends complete on a pool thread like a real fsync.</summary>
private sealed class SimpleOutbox : IHistorizationOutbox
{
private readonly Lock _gate = new();
private readonly List<HistorizationOutboxEntry> _entries = new();
public long DroppedCount => 0;
public async ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) =>
await AppendBatchAsync(new[] { entry }, ct).ConfigureAwait(false);
public async ValueTask AppendBatchAsync(IReadOnlyList<HistorizationOutboxEntry> entries, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (_gate)
{
_entries.AddRange(entries);
}
}
public ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct)
{
lock (_gate)
{
return ValueTask.FromResult<IReadOnlyList<HistorizationOutboxEntry>>(_entries.Take(max).ToArray());
}
}
public ValueTask RemoveAsync(Guid id, CancellationToken ct)
{
lock (_gate)
{
int idx = _entries.FindIndex(e => e.Id == id);
if (idx >= 0)
{
_entries.RemoveRange(0, idx + 1);
}
}
return ValueTask.CompletedTask;
}
public ValueTask<int> CountAsync(CancellationToken ct)
{
lock (_gate)
{
return ValueTask.FromResult(_entries.Count);
}
}
public void Dispose()
{
}
}
}