fix(historian-gateway): historize under the historian name, not the mux ref, when HistorianTagname overrides (FU-3)

The continuous-historization recorder conflated two identifiers into one string:
the dependency mux fans DependencyValueChanged keyed by the driver FullName
(the mux ref), but a value must be historized under the resolved historian name
(HistorianTagname override, else FullName). In the common no-override case the
two are equal, so it worked; with an override they diverge and the recorder
registered mux interest under a key the mux never fans — that tag's values were
never captured (and, had they been, would have been written under the mux ref).

Carry BOTH identifiers through the seam: a new HistorizedTagRef(MuxRef,
HistorianName) record on IHistorizedTagSubscriptionSink. The applier resolves
MuxRef = FullName and HistorianName = override-or-FullName. The recorder now
keeps a muxRef->historianName map: it registers/filters mux interest by MuxRef
but writes the outbox entry (and drains) under HistorianName. The convergence
handler re-registers the mux only when the registered key-set changes, so an
override-only rename (same FullName) updates the write target without mux churn.

Tests: a divergent-override recorder test (interest by mux ref, value written
under the override name, never the mux ref) + an override-rename no-churn test;
the applier feed tests now assert the full (mux ref, historian name) pairs.
Runtime 348/0, OpcUaServer 327/0; 0 warnings. Closes FU-3.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-27 00:43:28 -04:00
parent b2276b5b04
commit 111adc92b6
6 changed files with 203 additions and 61 deletions
@@ -147,17 +147,17 @@ public sealed class AddressSpaceApplierProvisioningTests
/// ref deltas the applier feeds it. A <see cref="Throw"/> flag simulates a faulting feed.</summary>
private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink
{
/// <summary>Refs the applier fed as ADDED.</summary>
public List<string> Added { get; } = new();
/// <summary>Ref pairs the applier fed as ADDED (mux ref + historian name).</summary>
public List<HistorizedTagRef> Added { get; } = new();
/// <summary>Refs the applier fed as REMOVED.</summary>
public List<string> Removed { get; } = new();
/// <summary>Ref pairs the applier fed as REMOVED.</summary>
public List<HistorizedTagRef> Removed { get; } = new();
/// <summary>When true, <see cref="UpdateHistorizedRefs"/> throws synchronously.</summary>
public bool Throw { get; init; }
/// <inheritdoc />
public void UpdateHistorizedRefs(IReadOnlyList<string> added, IReadOnlyList<string> removed)
public void UpdateHistorizedRefs(IReadOnlyList<HistorizedTagRef> added, IReadOnlyList<HistorizedTagRef> removed)
{
if (Throw) throw new InvalidOperationException("boom");
Added.AddRange(added);
@@ -182,7 +182,13 @@ public sealed class AddressSpaceApplierProvisioningTests
applier.Apply(plan);
sink.Added.ShouldBe(new[] { "Pump1.Temp", "40001" }, ignoreOrder: true); // override + FullName fallback
// Each ref carries BOTH identifiers: the override tag feeds (mux ref = FullName "ref", historian
// name = override "Pump1.Temp"); the no-override tag feeds (FullName "40001" as both).
sink.Added.ShouldBe(new[]
{
new HistorizedTagRef("ref", "Pump1.Temp"),
new HistorizedTagRef("40001", "40001"),
}, ignoreOrder: true);
sink.Removed.ShouldBeEmpty();
}
@@ -218,8 +224,14 @@ public sealed class AddressSpaceApplierProvisioningTests
applier.Apply(plan);
sink.Added.ShouldBe(new[] { "Pump1.B" }, ignoreOrder: true);
sink.Removed.ShouldBe(new[] { "Pump1.Old", "Pump1.A" }, ignoreOrder: true);
// All three tags default FullName "ref" (the mux ref); the override rename changes only the
// historian name, so the changed tag feeds removed (ref, Pump1.A) + added (ref, Pump1.B).
sink.Added.ShouldBe(new[] { new HistorizedTagRef("ref", "Pump1.B") }, ignoreOrder: true);
sink.Removed.ShouldBe(new[]
{
new HistorizedTagRef("ref", "Pump1.Old"),
new HistorizedTagRef("ref", "Pump1.A"),
}, ignoreOrder: true);
}
/// <summary>A synchronously-throwing subscription sink must NOT block or break the publish — the
@@ -1,6 +1,7 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -49,7 +50,8 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
Assert.Empty(initial.TagRefs);
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { "Pump1.Temp", "Pump2.Flow" }, Array.Empty<string>()));
new[] { HistorizedTagRef.ForSelf("Pump1.Temp"), HistorizedTagRef.ForSelf("Pump2.Flow") },
Array.Empty<HistorizedTagRef>()));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "Pump1.Temp", "Pump2.Flow" }, reg.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
@@ -65,13 +67,14 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { "A", "B" }, Array.Empty<string>()));
new[] { HistorizedTagRef.ForSelf("A"), HistorizedTagRef.ForSelf("B") }, Array.Empty<HistorizedTagRef>()));
var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "A", "B" }, first.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
// Add "C", remove "A" → converge to {B, C}. The mux's RegisterInterest is a full-REPLACE, so the
// recorder sends ONE RegisterInterest carrying exactly the converged set (C registered, A gone).
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(new[] { "C" }, new[] { "A" }));
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { HistorizedTagRef.ForSelf("C") }, new[] { HistorizedTagRef.ForSelf("A") }));
var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "B", "C" }, second.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
Assert.DoesNotContain("A", second.TagRefs);
@@ -88,7 +91,8 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
Assert.Equal(new[] { "A", "B" }, initial.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
// A delta with no net effect (add an already-present ref, remove an absent one) → no mux churn.
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(new[] { "A" }, new[] { "Z" }));
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { HistorizedTagRef.ForSelf("A") }, new[] { HistorizedTagRef.ForSelf("Z") }));
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
@@ -102,10 +106,66 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart {A}
// The mux has no per-ref unregister; an empty converged set drops ALL interest in one message.
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(Array.Empty<string>(), new[] { "A" }));
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
Array.Empty<HistorizedTagRef>(), new[] { HistorizedTagRef.ForSelf("A") }));
mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>();
}
[Fact]
public async Task HistorianTagname_override_registers_by_mux_ref_but_writes_under_historian_name()
{
// FU-3: a HistorianTagname override makes the mux ref (driver FullName the mux fans by) DIVERGE
// from the historian name (the value is stored under). Interest MUST be registered under the mux
// ref (else the mux never fans this tag), and the value MUST be written under the override name.
var mux = CreateTestProbe();
var writer = new FakeValueWriter { Succeed = true };
var outbox = new InMemoryOutbox();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref, writer, outbox, historizedRefs: Array.Empty<string>()));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { new HistorizedTagRef("Area/Line/Pump1", "HIST.Pump1.Temp") },
Array.Empty<HistorizedTagRef>()));
// Interest is registered under the MUX REF (the driver ref the mux keys fan-out by), NOT the name.
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "Area/Line/Pump1" }, reg.TagRefs);
// The mux fans the value keyed by the driver ref; the recorder must historize it under the OVERRIDE.
rec.Tell(new VirtualTagActor.DependencyValueChanged("Area/Line/Pump1", 55.0, DateTime.UtcNow));
await AwaitAssertAsync(() =>
Assert.Contains(writer.Snapshot(), w => w.Tag == "HIST.Pump1.Temp" && w.Value == 55.0));
// ...and NEVER under the mux ref (the pre-fix behaviour wrote under msg.TagId == the mux ref).
Assert.DoesNotContain(writer.Snapshot(), w => w.Tag == "Area/Line/Pump1");
}
[Fact]
public void Override_rename_with_same_mux_ref_updates_target_without_mux_churn()
{
// An override changing while the driver FullName (mux ref) stays the same: removed+added carry the
// SAME mux ref with different historian names. The recorder must update the write target but NOT
// re-register the mux (the fanned key-set is unchanged).
var mux = CreateTestProbe();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { new HistorizedTagRef("Area/Pump1", "HIST.Old") }, Array.Empty<HistorizedTagRef>()));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "Area/Pump1" }, reg.TagRefs);
// Override renamed: same mux ref, new historian name. Key-set unchanged → no further mux message.
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { new HistorizedTagRef("Area/Pump1", "HIST.New") },
new[] { new HistorizedTagRef("Area/Pump1", "HIST.Old") }));
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task DependencyValueChanged_appends_to_outbox_then_drains_to_writer()
{