feat(historian-gateway): feed historized refs to the recorder on deploy (close continuous-historization ref-feed gap)
v2-ci / build (pull_request) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (pull_request) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (pull_request) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (pull_request) Has been skipped

The ContinuousHistorizationRecorder was spawned with an EMPTY historized-ref
set, so it registered interest in nothing and historized nothing. This feeds it
the currently-historized tag refs on every address-space deploy/redeploy so its
DependencyMuxActor interest converges to exactly the historized set (the same
refs the EnsureTags provisioning hook resolves: override-or-FullName).

Design — delta convergence (the plan is a pure DIFF):
- New seam IHistorizedTagSubscriptionSink (Core.Abstractions/Historian) with a
  Null no-op singleton, mirroring how IHistorianProvisioning decouples the T15
  hook. AddressSpaceApplier gains a DEFAULTED ctor param (Null sink) so all ~80
  existing call sites + the production site compile unchanged.
- Apply() only ever sees a plan diff (an incremental/surgical apply carries a
  delta, not the full set), so the applier feeds an add/remove DELTA computed
  from AddedEquipmentTags / RemovedEquipmentTags / ChangedEquipmentTags. The
  recorder keeps the full set and re-registers it. The feed is a single
  non-blocking Tell behind the sink, wrapped in try/catch so a faulting feed
  never blocks or breaks a deploy (same discipline as the provisioning hook).
- Recorder.UpdateHistorizedRefs(added, removed) converges the tracked set, then
  — only when it actually changed — sends ONE RegisterInterest with the full set
  (the mux's RegisterInterest is a full-REPLACE) or one UnregisterInterest when
  it drains to empty (the mux has no per-ref unregister). An unchanged delta is
  a no-op (no mux churn).
- DI: the recorder is now spawned BEFORE the applier so the adapter
  (ActorHistorizedTagSubscriptionSink) can wrap its IActorRef; the Null sink is
  used when continuous historization is off/unwired.

Tests: recorder convergence (add-from-empty, add+remove converge, idempotent,
drain-to-empty unregisters); applier feeds resolved added refs, removed+renamed
deltas, and survives a throwing sink. Build clean (0 warnings on touched
projects); Runtime/OpcUaServer/Gateway/AdminUI suites green.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 23:21:18 -04:00
parent 2124f21ab6
commit 2982cc4bb5
7 changed files with 451 additions and 42 deletions
@@ -0,0 +1,40 @@
using Akka.Actor;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// <see cref="IHistorizedTagSubscriptionSink"/> adapter that bridges the address-space applier (in
/// the OpcUaServer layer) to the <see cref="ContinuousHistorizationRecorder"/> actor in Runtime —
/// keeping the applier free of any actor/Runtime dependency (it sees only the abstraction). Each
/// feed is a single non-blocking <see cref="ICanTell.Tell"/> (a fire-and-forget mailbox post), so it
/// never blocks the OPC UA publish thread the applier runs on; the recorder converges its mux
/// interest from the delta off the actor thread.
/// </summary>
public sealed class ActorHistorizedTagSubscriptionSink : IHistorizedTagSubscriptionSink
{
private readonly IActorRef _recorder;
/// <summary>Initializes a new instance of the <see cref="ActorHistorizedTagSubscriptionSink"/> class.</summary>
/// <param name="recorder">The continuous-historization recorder actor to feed historized-ref deltas to.</param>
public ActorHistorizedTagSubscriptionSink(IActorRef recorder)
{
ArgumentNullException.ThrowIfNull(recorder);
_recorder = recorder;
}
/// <inheritdoc />
public void UpdateHistorizedRefs(IReadOnlyList<string> added, IReadOnlyList<string> removed)
{
ArgumentNullException.ThrowIfNull(added);
ArgumentNullException.ThrowIfNull(removed);
if (added.Count == 0 && removed.Count == 0)
{
// Nothing to converge — skip the mailbox post entirely.
return;
}
_recorder.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(added, removed));
}
}
@@ -63,6 +63,17 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
private GetStatus() { }
}
/// <summary>
/// Converge the recorder's historized-ref interest by an add/remove DELTA — sent by the
/// address-space applier (via <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistorizedTagSubscriptionSink"/>)
/// 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 <see cref="OnUpdateHistorizedRefs"/>). The refs are
/// the same ones the EnsureTags provisioning hook resolves (override-or-FullName).
/// </summary>
/// <param name="Added">Refs newly historized by this deploy.</param>
/// <param name="Removed">Refs no longer historized by this deploy.</param>
public sealed record UpdateHistorizedRefs(IReadOnlyList<string> Added, IReadOnlyList<string> Removed);
/// <summary>A point-in-time snapshot of the recorder's counters.</summary>
/// <param name="QueuedDepth">Un-acked entries currently held in the durable outbox.</param>
/// <param name="TotalRecorded">Lifetime count of values appended to the outbox.</param>
@@ -155,6 +166,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
_currentBackoff = _minBackoff;
ReceiveAsync<VirtualTagActor.DependencyValueChanged>(OnValueChangedAsync);
Receive<UpdateHistorizedRefs>(OnUpdateHistorizedRefs);
Receive<DrainTick>(_ => OnDrainTick());
Receive<DrainResult>(OnDrainResult);
ReceiveAsync<GetStatus>(async _ =>
@@ -243,6 +255,55 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
Self.Tell(DrainTick.Instance);
}
/// <summary>
/// Converge the tracked historized-ref set by the supplied add/remove delta, then — only when
/// the set actually changed — re-register interest with the mux so its fan-out matches exactly
/// the currently-historized refs.
/// <para>
/// <b>Convergence (grounded against <see cref="DependencyMuxActor"/>).</b> The mux's
/// <see cref="DependencyMuxActor.RegisterInterest"/> is a full-REPLACE (it drops the
/// subscriber's prior ref set and installs the new one), and its
/// <see cref="DependencyMuxActor.UnregisterInterest"/> drops ALL of a subscriber's interest
/// (no per-ref form). So a single <c>RegisterInterest</c> carrying the full tracked set
/// converges the mux to exactly that set in one message (added refs become fanned, removed
/// refs stop), and an empty set is converged with one <c>UnregisterInterest</c>. The delta is
/// applied removed-then-added so a ref that appears in BOTH (one tag dropped it while another
/// adopted it in the same deploy) ends up registered.
/// </para>
/// <para>
/// <b>Idempotent.</b> A delta that produces no net change to the tracked set sends NOTHING to
/// the mux — no spurious register/unregister churn.
/// </para>
/// </summary>
private void OnUpdateHistorizedRefs(UpdateHistorizedRefs msg)
{
var next = new HashSet<string>(_historizedSet, StringComparer.Ordinal);
next.ExceptWith(msg.Removed);
next.UnionWith(msg.Added);
if (next.SetEquals(_historizedSet))
{
// No net change — stay idempotent (no mux churn).
return;
}
_historizedSet.Clear();
_historizedSet.UnionWith(next);
if (_historizedSet.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));
}
_log.Debug("ContinuousHistorization: historized-ref interest converged to {Count} ref(s).",
_historizedSet.Count);
}
private void OnDrainTick()
{
if (_draining)
@@ -207,10 +207,58 @@ public static class ServiceCollectionExtensions
var mux = system.ActorOf(DependencyMuxActor.Props(), DependencyMuxActorName);
registry.Register<DependencyMuxActorKey>(mux);
// Continuous-historization recorder — gated on ContinuousHistorization:Enabled AND the
// gateway-backed IHistorianValueWriter + the durable IHistorizationOutbox being registered
// (the Host registers both ONLY when historization is enabled and the ServerHistorian gateway
// is configured). The recorder taps the dependency mux's value fan-out, so it is spawned after
// (and fed) the same `mux` ref the DriverHostActor uses. It is spawned BEFORE the applier so
// the applier's historized-ref subscription sink can wrap this recorder's IActorRef and feed it
// the add/remove delta of historized refs on every deploy (closing the T18 ref-feed gap).
IActorRef? continuousRecorder = null;
var continuousOptions = resolver.GetService<ContinuousHistorizationOptions>();
if (continuousOptions is { Enabled: true })
{
var valueWriter = resolver.GetService<IHistorianValueWriter>();
var outbox = resolver.GetService<IHistorizationOutbox>();
if (valueWriter is not null && outbox is not null)
{
// Initial ref set is EMPTY: the deployed address space (and thus the historized-ref
// set) is built later at deploy time, not here. The applier's per-deploy add/remove
// feed populates the recorder's interest from that point on.
continuousRecorder = system.ActorOf(
ContinuousHistorizationRecorder.Props(
dependencyMux: mux,
writer: valueWriter,
outbox: outbox,
historizedRefs: Array.Empty<string>(),
drainBatchSize: continuousOptions.DrainBatchSize,
drainInterval: TimeSpan.FromSeconds(continuousOptions.DrainIntervalSeconds),
minBackoff: TimeSpan.FromSeconds(continuousOptions.MinBackoffSeconds),
maxBackoff: TimeSpan.FromSeconds(continuousOptions.MaxBackoffSeconds)),
ContinuousHistorizationRecorderActorName);
registry.Register<ContinuousHistorizationRecorderKey>(continuousRecorder);
}
else
{
loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions")
.LogWarning("ContinuousHistorization is enabled but IHistorianValueWriter and/or IHistorizationOutbox are not registered; the recorder will not be spawned. Expected only in misconfigured deployments or test harnesses.");
}
}
// Historized-ref subscription sink fed by the applier on every deploy. When the recorder was
// spawned, an adapter wraps its IActorRef (a non-blocking Tell of the add/remove delta);
// otherwise the Null no-op sink, so the applier behaves identically when historization is off.
IHistorizedTagSubscriptionSink historizedSubscriptions = continuousRecorder is not null
? new ActorHistorizedTagSubscriptionSink(continuousRecorder)
: NullHistorizedTagSubscriptionSink.Instance;
// OPC UA publish actor — pinned dispatcher, owns the address-space side of the
// pipeline. AddressSpaceApplier is constructed here so the actor + applier share the
// same sink reference (when DeferredAddressSpaceSink swaps later, both see it).
var applier = new AddressSpaceApplier(addressSpaceSink, loggerFactory.CreateLogger<AddressSpaceApplier>());
var applier = new AddressSpaceApplier(
addressSpaceSink,
loggerFactory.CreateLogger<AddressSpaceApplier>(),
historizedSubscriptions: historizedSubscriptions);
var publishActor = system.ActorOf(
OpcUaPublishActor.Props(
sink: addressSpaceSink,
@@ -247,46 +295,6 @@ public static class ServiceCollectionExtensions
HistorianAdapterActor.Props(historianSink, roleInfo.LocalNode),
HistorianAdapterActorName);
registry.Register<HistorianAdapterActorKey>(historian);
// Continuous-historization recorder — gated on ContinuousHistorization:Enabled AND the
// gateway-backed IHistorianValueWriter + the durable IHistorizationOutbox being registered
// (the Host registers both ONLY when historization is enabled and the ServerHistorian gateway
// is configured). The recorder taps the dependency mux's value fan-out, so it is spawned after
// (and fed) the same `mux` ref the DriverHostActor uses.
//
// HISTORIZED-REF SET — DOCUMENTED GAP (T18 minimal wiring). The deployed address space (and
// thus the set of historized tag refs) is built later at deploy time, not here at actor-spawn
// time, so there is no clean ref set to resolve in WithOtOpcUaRuntimeActors. Per the plan, T18
// spawns the recorder with an EMPTY initial ref set and registers its key; populating the refs
// (a later SetHistorizedRefs feed driven off the deployed composition) is the remaining wiring
// and a tracked follow-on. With an empty set the recorder registers interest in nothing and
// historizes nothing until that feed lands — the actor + outbox + writer + meters are wired.
var continuousOptions = resolver.GetService<ContinuousHistorizationOptions>();
if (continuousOptions is { Enabled: true })
{
var valueWriter = resolver.GetService<IHistorianValueWriter>();
var outbox = resolver.GetService<IHistorizationOutbox>();
if (valueWriter is not null && outbox is not null)
{
var recorder = system.ActorOf(
ContinuousHistorizationRecorder.Props(
dependencyMux: mux,
writer: valueWriter,
outbox: outbox,
historizedRefs: Array.Empty<string>(),
drainBatchSize: continuousOptions.DrainBatchSize,
drainInterval: TimeSpan.FromSeconds(continuousOptions.DrainIntervalSeconds),
minBackoff: TimeSpan.FromSeconds(continuousOptions.MinBackoffSeconds),
maxBackoff: TimeSpan.FromSeconds(continuousOptions.MaxBackoffSeconds)),
ContinuousHistorizationRecorderActorName);
registry.Register<ContinuousHistorizationRecorderKey>(recorder);
}
else
{
loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions")
.LogWarning("ContinuousHistorization is enabled but IHistorianValueWriter and/or IHistorizationOutbox are not registered; the recorder will not be spawned. Expected only in misconfigured deployments or test harnesses.");
}
}
});
return builder;