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()
{