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:
+9
-9
@@ -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
|
||||
|
||||
+253
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user