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
@@ -29,6 +29,7 @@ public sealed class AddressSpaceApplier
private readonly IOpcUaAddressSpaceSink _sink;
private readonly ILogger<AddressSpaceApplier> _logger;
private readonly IHistorianProvisioning _provisioning;
private readonly IHistorizedTagSubscriptionSink _historizedSubscriptions;
/// <summary>Initializes a new instance of the AddressSpaceApplier class.</summary>
/// <param name="sink">The OPC UA address space sink to apply changes to.</param>
@@ -41,16 +42,26 @@ public sealed class AddressSpaceApplier
/// dispatched fire-and-forget off <see cref="Apply"/> (which runs on the OPC UA publish actor's
/// pinned thread), so it can never block or break a deploy.
/// </param>
/// <param name="historizedSubscriptions">
/// Optional continuous-historization feed — when an address space is (re)applied, the add/remove
/// delta of historized tag refs (resolved EXACTLY as the provisioning hook above) is pushed to the
/// recorder so its dependency-mux interest converges to the currently-historized set. Defaults
/// (a <c>null</c> argument) to the no-op <see cref="NullHistorizedTagSubscriptionSink"/>, so every
/// existing call site compiles and behaves unchanged. The feed is a single non-blocking post off
/// <see cref="Apply"/> and is wrapped so it can never block or break a deploy.
/// </param>
public AddressSpaceApplier(
IOpcUaAddressSpaceSink sink,
ILogger<AddressSpaceApplier> logger,
IHistorianProvisioning? provisioning = null)
IHistorianProvisioning? provisioning = null,
IHistorizedTagSubscriptionSink? historizedSubscriptions = null)
{
ArgumentNullException.ThrowIfNull(sink);
ArgumentNullException.ThrowIfNull(logger);
_sink = sink;
_logger = logger;
_provisioning = provisioning ?? NullHistorianProvisioning.Instance;
_historizedSubscriptions = historizedSubscriptions ?? NullHistorizedTagSubscriptionSink.Instance;
}
/// <summary>
@@ -196,6 +207,11 @@ public sealed class AddressSpaceApplier
// or break the deploy — Apply has already produced its outcome and returns it regardless.
ProvisionHistorizedTags(plan);
// Alongside provisioning: feed the continuous-historization recorder the add/remove delta of
// historized tag refs this plan changes, so its dependency-mux interest converges to exactly the
// currently-historized set. Same non-blocking + throw-safe discipline as the provisioning hook.
FeedHistorizedRefs(plan);
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt);
}
@@ -275,6 +291,77 @@ public sealed class AddressSpaceApplier
}
}
/// <summary>
/// Feed the continuous-historization recorder the add/remove delta of historized tag refs this
/// plan changes, so its dependency-mux interest converges to exactly the currently-historized set
/// after every deploy. The plan is a pure DIFF (an incremental/surgical apply carries a delta, not
/// the full set), so a delta feed is the only convergent design this hook can produce — the
/// recorder keeps the full set and re-registers it. Each ref is resolved EXACTLY as
/// <see cref="ProvisionHistorizedTags"/> / <c>MaterialiseEquipmentTags</c> resolve it
/// (override-or-FullName), and only non-alarm historized value variables count (native-alarm tags
/// materialise as Part 9 condition nodes, never historized value variables). Runs on the OPC UA
/// publish actor's pinned thread, so the only work here is building two small ref lists; the
/// downstream feed is a single non-blocking post behind the sink. The whole hook is wrapped so a
/// faulting feed can never block or break a deploy.
/// </summary>
/// <param name="plan">The plan whose historized-ref changes drive the recorder's interest set.</param>
private void FeedHistorizedRefs(AddressSpacePlan plan)
{
try
{
List<string>? added = null;
List<string>? removed = null;
// Added historized value variables → new interest.
foreach (var tag in plan.AddedEquipmentTags)
{
if (HistorizedRef(tag) is { } r) (added ??= new List<string>()).Add(r);
}
// Removed historized value variables → drop interest.
foreach (var tag in plan.RemovedEquipmentTags)
{
if (HistorizedRef(tag) is { } r) (removed ??= new List<string>()).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.
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<string>()).Add(prev);
if (cur is not null) (added ??= new List<string>()).Add(cur);
}
if (added is null && removed is null) return;
_historizedSubscriptions.UpdateHistorizedRefs(
added ?? (IReadOnlyList<string>)Array.Empty<string>(),
removed ?? (IReadOnlyList<string>)Array.Empty<string>());
}
catch (Exception ex)
{
// A synchronous fault in the feed (or in building the ref lists) must not break the deploy.
// Apply has already produced its outcome.
_logger.LogWarning(ex, "AddressSpaceApplier: historized-ref subscription feed faulted; deploy unaffected");
}
}
/// <summary>
/// Resolve the historized tag ref for <paramref name="tag"/> EXACTLY as the provisioning hook /
/// materialiser do: a non-alarm historized value variable's <c>HistorianTagname</c> override,
/// else its driver-side <c>FullName</c>. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node).
/// </summary>
/// <param name="tag">The equipment tag to resolve a historized ref for.</param>
/// <returns>The resolved historian ref, or <c>null</c> when the tag is not a historized value variable.</returns>
private static string? HistorizedRef(EquipmentTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null;
private void SafeRebuild()
{
try