From 111adc92b6ca1b8c98188b3568e6aa5d11aa53d9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 27 Jun 2026 00:43:28 -0400 Subject: [PATCH] fix(historian-gateway): historize under the historian name, not the mux ref, when HistorianTagname overrides (FU-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../IHistorizedTagSubscriptionSink.cs | 45 +++++++++-- .../AddressSpaceApplier.cs | 38 +++++---- .../ActorHistorizedTagSubscriptionSink.cs | 2 +- .../ContinuousHistorizationRecorder.cs | 81 +++++++++++++------ .../AddressSpaceApplierProvisioningTests.cs | 28 +++++-- .../ContinuousHistorizationRecorderTests.cs | 70 ++++++++++++++-- 6 files changed, 203 insertions(+), 61 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs index 8bf720f7..984c8395 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs @@ -1,5 +1,35 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; +/// +/// A single historized tag the recorder tracks, carrying BOTH identifiers it needs — kept distinct +/// because a HistorianTagname override makes them diverge: +/// +/// +/// — the driver-published reference the per-node dependency mux fans its +/// DependencyValueChanged by (the tag's driver-side FullName). The recorder +/// registers mux interest and matches incoming values by THIS. +/// +/// +/// — the resolved historian tag name the value is written under (a +/// non-alarm historized value variable's HistorianTagname override, else its +/// FullName) — the SAME name the EnsureTags provisioning hook ensures. +/// +/// +/// In the common (no-override) case the two are the same string; an override is the only case they +/// diverge, and conflating them would silently drop that tag's values (interest registered under a +/// key the mux never fans). +/// +/// The driver ref the mux fans by (and the key the recorder registers interest under). +/// The resolved historian tag name the value is historized under. +public sealed record HistorizedTagRef(string MuxRef, string HistorianName) +{ + /// The no-override identity: the mux ref and historian name are the same string (the tag has + /// no HistorianTagname override, so it historizes under its own driver FullName). + /// The driver ref that serves as both the mux key and the historian name. + /// A ref whose and are equal. + public static HistorizedTagRef ForSelf(string reference) => new(reference, reference); +} + /// /// Server-side feed that keeps the continuous-historization recorder's set of historized tag refs /// in step with the deployed address space. The AddressSpaceApplier (in the @@ -18,15 +48,16 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; public interface IHistorizedTagSubscriptionSink { /// - /// Converge the recorder's historized-ref interest by an add/remove delta. The refs are - /// resolved EXACTLY as the EnsureTags provisioning hook resolves them (a non-alarm historized - /// value variable's HistorianTagname override, else its driver-side FullName). - /// The recorder applies the delta to its tracked full set and re-registers mux interest only - /// when the set actually changes. + /// Converge the recorder's historized-ref interest by an add/remove delta. Each ref carries both + /// its (the driver ref the mux fans by) and its + /// (the resolved override-or-FullName the value is + /// historized under) — the same name the EnsureTags provisioning hook ensures. The recorder + /// applies the delta to its tracked full set and re-registers mux interest (keyed by + /// ) only when the registered key-set actually changes. /// /// Historized refs newly historized by this deploy (added/changed-into tags). /// Historized refs no longer historized by this deploy (removed/changed-out tags). - void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed); + void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed); } /// @@ -42,7 +73,7 @@ public sealed class NullHistorizedTagSubscriptionSink : IHistorizedTagSubscripti private NullHistorizedTagSubscriptionSink() { } /// - public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed) + public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed) { } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index f2385dab..b7d636bb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -309,37 +309,38 @@ public sealed class AddressSpaceApplier { try { - List? added = null; - List? removed = null; + List? added = null; + List? removed = null; // Added historized value variables → new interest. foreach (var tag in plan.AddedEquipmentTags) { - if (HistorizedRef(tag) is { } r) (added ??= new List()).Add(r); + if (HistorizedRef(tag) is { } r) (added ??= new List()).Add(r); } // Removed historized value variables → drop interest. foreach (var tag in plan.RemovedEquipmentTags) { - if (HistorizedRef(tag) is { } r) (removed ??= new List()).Add(r); + if (HistorizedRef(tag) is { } r) (removed ??= new List()).Add(r); } // Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName - // change). Compare previous-vs-current resolved refs — an unchanged ref is a no-op. + // change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the + // mux ref and the historian name) — an unchanged pair is a no-op. foreach (var d in plan.ChangedEquipmentTags) { var prev = HistorizedRef(d.Previous); var cur = HistorizedRef(d.Current); - if (string.Equals(prev, cur, StringComparison.Ordinal)) continue; - if (prev is not null) (removed ??= new List()).Add(prev); - if (cur is not null) (added ??= new List()).Add(cur); + if (prev == cur) continue; + if (prev is not null) (removed ??= new List()).Add(prev); + if (cur is not null) (added ??= new List()).Add(cur); } if (added is null && removed is null) return; _historizedSubscriptions.UpdateHistorizedRefs( - added ?? (IReadOnlyList)Array.Empty(), - removed ?? (IReadOnlyList)Array.Empty()); + added ?? (IReadOnlyList)Array.Empty(), + removed ?? (IReadOnlyList)Array.Empty()); } catch (Exception ex) { @@ -350,16 +351,21 @@ public sealed class AddressSpaceApplier } /// - /// Resolve the historized tag ref for EXACTLY as the provisioning hook / - /// materialiser do: a non-alarm historized value variable's HistorianTagname override, - /// else its driver-side FullName. Returns null when the tag is not a historized + /// Resolve the historized tag ref for as a + /// carrying BOTH identifiers the recorder needs: the + /// MuxRef = the driver-side FullName the dependency mux fans values by, and the + /// HistorianName = the value the EnsureTags hook / materialiser write under (a non-alarm + /// historized value variable's HistorianTagname override, else its FullName). The + /// two diverge ONLY when an override is set. Returns null when the tag is not a historized /// value variable (not historized, or a native-alarm condition node). /// /// The equipment tag to resolve a historized ref for. - /// The resolved historian ref, or null when the tag is not a historized value variable. - private static string? HistorizedRef(EquipmentTagPlan tag) => + /// The resolved historized ref pair, or null when the tag is not a historized value variable. + private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) => tag.IsHistorized && tag.Alarm is null - ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) + ? new HistorizedTagRef( + tag.FullName, + string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) : null; private void SafeRebuild() diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ActorHistorizedTagSubscriptionSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ActorHistorizedTagSubscriptionSink.cs index bb8a7c85..40615ab8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ActorHistorizedTagSubscriptionSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ActorHistorizedTagSubscriptionSink.cs @@ -24,7 +24,7 @@ public sealed class ActorHistorizedTagSubscriptionSink : IHistorizedTagSubscript } /// - public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed) + public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed) { ArgumentNullException.ThrowIfNull(added); ArgumentNullException.ThrowIfNull(removed); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs index c667fd26..13ad3092 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs @@ -1,5 +1,6 @@ using Akka.Actor; using Akka.Event; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; @@ -67,12 +68,14 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers /// Converge the recorder's historized-ref interest by an add/remove DELTA — sent by the /// address-space applier (via ) /// after every deploy. The applier only ever sees a plan diff, so it feeds a delta; the recorder - /// holds the full set and re-registers it (see ). The refs are - /// the same ones the EnsureTags provisioning hook resolves (override-or-FullName). + /// holds the full set and re-registers it (see ). Each ref + /// carries both its mux key (, the driver ref the mux fans by) + /// and the resolved historian name (, + /// override-or-FullName — the same name the EnsureTags provisioning hook ensures). /// /// Refs newly historized by this deploy. /// Refs no longer historized by this deploy. - public sealed record UpdateHistorizedRefs(IReadOnlyList Added, IReadOnlyList Removed); + public sealed record UpdateHistorizedRefs(IReadOnlyList Added, IReadOnlyList Removed); /// A point-in-time snapshot of the recorder's counters. /// Un-acked entries currently held in the durable outbox. @@ -93,8 +96,12 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers private readonly IActorRef _dependencyMux; private readonly IHistorianValueWriter _writer; private readonly IHistorizationOutbox _outbox; - private readonly IReadOnlyList _historizedRefs; - private readonly HashSet _historizedSet; + + /// The tracked historized tags, keyed by mux ref ( — the + /// driver ref the mux fans by, and the key mux interest is registered under) → the historian name the + /// value is written under (). A HistorianTagname + /// override is the only case the two diverge; in the common case they are equal. + private readonly Dictionary _refMap; private readonly int _drainBatchSize; private readonly TimeSpan _drainInterval; private readonly TimeSpan _minBackoff; @@ -157,8 +164,15 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers _dependencyMux = dependencyMux ?? throw new ArgumentNullException(nameof(dependencyMux)); _writer = writer ?? throw new ArgumentNullException(nameof(writer)); _outbox = outbox ?? throw new ArgumentNullException(nameof(outbox)); - _historizedRefs = historizedRefs ?? throw new ArgumentNullException(nameof(historizedRefs)); - _historizedSet = new HashSet(_historizedRefs, StringComparer.Ordinal); + ArgumentNullException.ThrowIfNull(historizedRefs); + // The ctor seed is the no-override identity (mux ref == historian name); production seeds an EMPTY + // set and converges via UpdateHistorizedRefs on each deploy (which carries diverging override pairs). + _refMap = new Dictionary(StringComparer.Ordinal); + foreach (string r in historizedRefs) + { + _refMap[r] = r; + } + _drainBatchSize = drainBatchSize > 0 ? drainBatchSize : 64; _drainInterval = drainInterval is { } di && di > TimeSpan.Zero ? di : TimeSpan.FromSeconds(2); _minBackoff = minBackoff is { } mb && mb > TimeSpan.Zero ? mb : TimeSpan.FromSeconds(1); @@ -182,8 +196,9 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers /// protected override void PreStart() { - // Register interest for the historized refs so the mux fans their DependencyValueChanged to us. - _dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_historizedRefs, Self)); + // Register interest by mux ref (the key the mux fans DependencyValueChanged by) so it fans those + // tags' values to us. Historian-name overrides are tracked separately for the write side. + _dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_refMap.Keys.ToList(), Self)); // Seed the steady drain cadence; appends also nudge a prompt drain (see OnValueChangedAsync). Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, _drainInterval); base.PreStart(); @@ -202,8 +217,11 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers private async Task OnValueChangedAsync(VirtualTagActor.DependencyValueChanged msg) { - // Defensive: only historize refs we registered interest for (the mux already scopes to these). - if (!_historizedSet.Contains(msg.TagId)) + // The mux fans values keyed by the driver ref (msg.TagId == the mux ref). Only historize refs we + // registered interest for, and resolve the HISTORIAN NAME to write under — which differs from the + // mux ref when a HistorianTagname override is set. (The mux already scopes to our registered refs; + // this is also the defensive filter.) + if (!_refMap.TryGetValue(msg.TagId, out string? historianName)) { return; } @@ -217,9 +235,11 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers return; } + // Record under the historian name (override-or-FullName), NOT the mux ref — the outbox entry + + // the drain's WriteLiveValues target the historian tag the EnsureTags hook provisioned. var entry = new HistorizationOutboxEntry( Guid.NewGuid(), - msg.TagId, + historianName, numeric, GoodQuality, DateTime.SpecifyKind(msg.TimestampUtc, DateTimeKind.Utc)); @@ -277,31 +297,44 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers /// private void OnUpdateHistorizedRefs(UpdateHistorizedRefs msg) { - var next = new HashSet(_historizedSet, StringComparer.Ordinal); - next.ExceptWith(msg.Removed); - next.UnionWith(msg.Added); - if (next.SetEquals(_historizedSet)) + // Snapshot the registered mux key-set BEFORE applying the delta, so we re-register only when the + // set the mux fans by actually changes (an override-only rename updates the WRITE target but not + // which refs the mux fans — no mux churn for those). + var beforeKeys = new HashSet(_refMap.Keys, StringComparer.Ordinal); + + // Apply removed-then-added so a ref present in BOTH (a HistorianTagname override changed while the + // mux ref / FullName stayed the same) ends mapped to its NEW historian name. + foreach (HistorizedTagRef r in msg.Removed) { - // No net change — stay idempotent (no mux churn). + _refMap.Remove(r.MuxRef); + } + + foreach (HistorizedTagRef r in msg.Added) + { + _refMap[r.MuxRef] = r.HistorianName; + } + + if (_refMap.Keys.ToHashSet(StringComparer.Ordinal).SetEquals(beforeKeys)) + { + // Mux key-set unchanged (no-op delta, or an override-only rename) — the map (write targets) is + // already updated; stay idempotent at the mux (no register/unregister churn). return; } - _historizedSet.Clear(); - _historizedSet.UnionWith(next); - - if (_historizedSet.Count == 0) + if (_refMap.Count == 0) { // The mux has no per-ref unregister; drop ALL interest in one message. _dependencyMux.Tell(new DependencyMuxActor.UnregisterInterest(Self)); } else { - // RegisterInterest REPLACES the prior set at the mux, so one message converges it exactly. - _dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_historizedSet.ToList(), Self)); + // RegisterInterest REPLACES the prior set at the mux, so one message (carrying the mux keys) + // converges it exactly. + _dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_refMap.Keys.ToList(), Self)); } _log.Debug("ContinuousHistorization: historized-ref interest converged to {Count} ref(s).", - _historizedSet.Count); + _refMap.Count); } private void OnDrainTick() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs index ebc121f2..02b60e5f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs @@ -147,17 +147,17 @@ public sealed class AddressSpaceApplierProvisioningTests /// ref deltas the applier feeds it. A flag simulates a faulting feed. private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink { - /// Refs the applier fed as ADDED. - public List Added { get; } = new(); + /// Ref pairs the applier fed as ADDED (mux ref + historian name). + public List Added { get; } = new(); - /// Refs the applier fed as REMOVED. - public List Removed { get; } = new(); + /// Ref pairs the applier fed as REMOVED. + public List Removed { get; } = new(); /// When true, throws synchronously. public bool Throw { get; init; } /// - public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed) + public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList 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); } /// A synchronously-throwing subscription sink must NOT block or break the publish — the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs index 8672598a..d8621346 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs @@ -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())); + new[] { HistorizedTagRef.ForSelf("Pump1.Temp"), HistorizedTagRef.ForSelf("Pump2.Flow") }, + Array.Empty())); var reg = mux.ExpectMsg(); 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(); // PreStart (empty) rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs( - new[] { "A", "B" }, Array.Empty())); + new[] { HistorizedTagRef.ForSelf("A"), HistorizedTagRef.ForSelf("B") }, Array.Empty())); var first = mux.ExpectMsg(); 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(); 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(); // 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(), new[] { "A" })); + rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs( + Array.Empty(), new[] { HistorizedTagRef.ForSelf("A") })); mux.ExpectMsg(); } + [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())); + mux.ExpectMsg(); // PreStart (empty) + + rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs( + new[] { new HistorizedTagRef("Area/Line/Pump1", "HIST.Pump1.Temp") }, + Array.Empty())); + + // Interest is registered under the MUX REF (the driver ref the mux keys fan-out by), NOT the name. + var reg = mux.ExpectMsg(); + 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())); + mux.ExpectMsg(); // PreStart (empty) + + rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs( + new[] { new HistorizedTagRef("Area/Pump1", "HIST.Old") }, Array.Empty())); + var reg = mux.ExpectMsg(); + 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() {