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,48 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Server-side feed that keeps the continuous-historization recorder's set of historized tag refs
/// in step with the deployed address space. The <c>AddressSpaceApplier</c> (in the
/// OpcUaServer layer) calls this on every deploy with the add/remove DELTA of historized refs the
/// plan changes — the applier only ever sees a diff (an incremental/surgical apply carries a delta,
/// not the full set), so the recorder behind this seam keeps the full set and converges it.
/// </summary>
/// <remarks>
/// The feed is <b>non-blocking</b> and best-effort: the production adapter is a single
/// fire-and-forget actor <c>Tell</c>, so it never blocks the OPC UA publish thread the applier runs
/// on, and the applier wraps the call so a faulting feed can never break a deploy. The applier
/// references this abstraction (not the Runtime recorder) so the OpcUaServer layer keeps no
/// dependency on Akka / the actor system — exactly mirroring how <see cref="IHistorianProvisioning"/>
/// decouples the EnsureTags provisioning hook.
/// </remarks>
public interface IHistorizedTagSubscriptionSink
{
/// <summary>
/// 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 <c>HistorianTagname</c> override, else its driver-side <c>FullName</c>).
/// The recorder applies the delta to its tracked full set and re-registers mux interest only
/// when the set actually changes.
/// </summary>
/// <param name="added">Historized refs newly historized by this deploy (added/changed-into tags).</param>
/// <param name="removed">Historized refs no longer historized by this deploy (removed/changed-out tags).</param>
void UpdateHistorizedRefs(IReadOnlyList<string> added, IReadOnlyList<string> removed);
}
/// <summary>
/// No-op <see cref="IHistorizedTagSubscriptionSink"/> — the applier's safe default when continuous
/// historization is disabled or unwired (no recorder to feed). Every call is a no-op and never
/// touches an actor system.
/// </summary>
public sealed class NullHistorizedTagSubscriptionSink : IHistorizedTagSubscriptionSink
{
/// <summary>The shared singleton instance.</summary>
public static readonly NullHistorizedTagSubscriptionSink Instance = new();
private NullHistorizedTagSubscriptionSink() { }
/// <inheritdoc />
public void UpdateHistorizedRefs(IReadOnlyList<string> added, IReadOnlyList<string> removed)
{
}
}