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
@@ -36,6 +36,76 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
Assert.Contains("Pump1.Temp", reg.TagRefs);
}
[Fact]
public void UpdateHistorizedRefs_from_empty_registers_the_added_refs()
{
var mux = CreateTestProbe();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));
// PreStart registers the (empty) initial set first.
var initial = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Empty(initial.TagRefs);
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { "Pump1.Temp", "Pump2.Flow" }, Array.Empty<string>()));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "Pump1.Temp", "Pump2.Flow" }, reg.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
}
[Fact]
public void UpdateHistorizedRefs_converges_adding_and_removing()
{
var mux = CreateTestProbe();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: Array.Empty<string>()));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart (empty)
rec.Tell(new ContinuousHistorizationRecorder.UpdateHistorizedRefs(
new[] { "A", "B" }, Array.Empty<string>()));
var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
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" }));
var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
Assert.Equal(new[] { "B", "C" }, second.TagRefs.OrderBy(x => x, StringComparer.Ordinal));
Assert.DoesNotContain("A", second.TagRefs);
}
[Fact]
public void UpdateHistorizedRefs_is_idempotent_when_the_set_is_unchanged()
{
var mux = CreateTestProbe();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: new[] { "A", "B" }));
var initial = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // PreStart {A, B}
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" }));
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public void UpdateHistorizedRefs_draining_to_empty_unregisters_all_interest()
{
var mux = CreateTestProbe();
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(
mux.Ref, new FakeValueWriter(), new InMemoryOutbox(), historizedRefs: new[] { "A" }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(); // 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<string>(), new[] { "A" }));
mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>();
}
[Fact]
public async Task DependencyValueChanged_appends_to_outbox_then_drains_to_writer()
{