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
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:
+48
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user