feat(historian-gateway): ContinuousHistorizationRecorder actor (outbox->WriteLiveValues, backoff)
Continuous-historization engine for non-Galaxy driver tags. Registers interest with the per-node DependencyMuxActor for the historized refs and taps the VirtualTagActor.DependencyValueChanged values the mux fans: coerce to numeric -> append to the durable IHistorizationOutbox (crash boundary) -> off-thread drain writes batches through IHistorianValueWriter and acks (FIFO-truncates) on success, backing off (exponential, capped) on failure. Non-numeric values are dropped + metered (SQL analog path is numeric-only). - New seam IHistorianValueWriter + HistorizationValue in Core.Abstractions so Runtime stays free of the gRPC driver. - GatewayHistorianValueWriter (driver) adapts IHistorianGatewayClient. WriteLiveValues: HistorizationValue -> HistorianLiveValue proto, WriteAck Success||Queued -> true; non-throwing (errors -> false for retry). - Drain runs via PipeTo(Self) so the mailbox never blocks on the gateway write; appends awaited on the actor thread to stay serialized. Adaptation vs plan: the mux fans DependencyValueChanged (TagId/Value/ TimestampUtc, no quality), not DriverInstanceActor.AttributeValuePublished, so values are recorded Good-quality (192) by the same convention the scripted-alarm host uses. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </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).
|
||||
/// </remarks>
|
||||
public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||||
{
|
||||
[Fact]
|
||||
public void Registers_interest_for_historized_refs_on_start()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter();
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, historizedRefs: new[] { "Pump1.Temp" }));
|
||||
|
||||
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||||
Assert.Contains("Pump1.Temp", reg.TagRefs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DependencyValueChanged_appends_to_outbox_then_drains_to_writer()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter { Succeed = true };
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, new[] { "Pump1.Temp" }));
|
||||
|
||||
rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Temp", 42.0, DateTime.UtcNow));
|
||||
|
||||
await AwaitAssertAsync(() =>
|
||||
Assert.Contains(writer.Snapshot(), w => w.Tag == "Pump1.Temp" && w.Value == 42.0));
|
||||
await AwaitAssertAsync(async () =>
|
||||
Assert.Equal(0, await outbox.CountAsync(default))); // acked -> truncated
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_failure_keeps_entry_for_retry()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter { Succeed = false };
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, new[] { "Pump1.Temp" }));
|
||||
|
||||
rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Temp", 7.0, DateTime.UtcNow));
|
||||
|
||||
await AwaitAssertAsync(async () =>
|
||||
Assert.Equal(1, await outbox.CountAsync(default))); // not acked -> retained for retry
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_numeric_value_is_dropped_with_metric()
|
||||
{
|
||||
var mux = CreateTestProbe();
|
||||
var writer = new FakeValueWriter();
|
||||
var outbox = new InMemoryOutbox();
|
||||
|
||||
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
|
||||
mux.Ref, writer, outbox, new[] { "Pump1.Name" }));
|
||||
|
||||
rec.Tell(new VirtualTagActor.DependencyValueChanged("Pump1.Name", "text", DateTime.UtcNow));
|
||||
|
||||
// A string value can't ride the SQL analog write path -> dropped (metered), never appended.
|
||||
await AwaitAssertAsync(async () =>
|
||||
{
|
||||
var status = await rec.Ask<ContinuousHistorizationRecorder.RecorderStatus>(
|
||||
ContinuousHistorizationRecorder.GetStatus.Instance, TimeSpan.FromSeconds(3));
|
||||
Assert.Equal(1, status.DroppedNonNumeric);
|
||||
Assert.Equal(0, status.QueuedDepth);
|
||||
});
|
||||
Assert.Empty(writer.Snapshot());
|
||||
}
|
||||
|
||||
/// <summary>In-memory <see cref="IHistorianValueWriter"/> double: records every value written and
|
||||
/// returns <see cref="Succeed"/> as the ack. Thread-safe — the recorder drains off the actor thread.</summary>
|
||||
private sealed class FakeValueWriter : IHistorianValueWriter
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly List<WrittenValue> _written = new();
|
||||
|
||||
public bool Succeed { get; init; } = true;
|
||||
|
||||
public IReadOnlyList<WrittenValue> Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _written.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> WriteLiveValuesAsync(
|
||||
string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (HistorizationValue v in values)
|
||||
{
|
||||
_written.Add(new WrittenValue(tag, v.Value, v.Quality, v.TimestampUtc));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(Succeed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record WrittenValue(string Tag, double Value, ushort 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
|
||||
/// ahead of it) and the optional drop-oldest capacity. Thread-safe.</summary>
|
||||
private sealed class InMemoryOutbox : IHistorizationOutbox
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly List<HistorizationOutboxEntry> _entries = new();
|
||||
private readonly int _capacity;
|
||||
private long _dropped;
|
||||
|
||||
public InMemoryOutbox(int capacity = 0) => _capacity = capacity;
|
||||
|
||||
public long DroppedCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _dropped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
while (_capacity > 0 && _entries.Count > _capacity)
|
||||
{
|
||||
_entries.RemoveAt(0); // drop oldest on overflow
|
||||
_dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
IReadOnlyList<HistorizationOutboxEntry> batch = _entries.Take(max).ToArray();
|
||||
return ValueTask.FromResult(batch);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask RemoveAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int idx = _entries.FindIndex(e => e.Id == id);
|
||||
if (idx >= 0)
|
||||
{
|
||||
// FIFO ack: remove the target plus everything ahead of it in the buffer.
|
||||
_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