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:
Joseph Doherty
2026-06-26 18:18:34 -04:00
parent 8b4028de84
commit bbfbc7b215
4 changed files with 681 additions and 0 deletions
@@ -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()
{
}
}
}