refactor(runtime): delete dead per-value host ingress; batch fan-out review fixes

This commit is contained in:
Joseph Doherty
2026-08-15 13:01:17 -04:00
parent 4f177bc3e8
commit ec6f8bb0ea
7 changed files with 305 additions and 313 deletions
@@ -48,8 +48,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// Children (DriverInstance/VirtualTag/etc.) are fully wired: <c>DispatchDeployment</c> drives
/// <see cref="PushDesiredSubscriptions"/> which runs the spawn-plan via <see cref="IDriverFactory"/>,
/// maintains the <c>FullName→NodeId</c> live-value routing map, and forwards results to the
/// OPC UA publish actor via <c>ForwardToMux</c> (per value) and <c>ForwardBatchToMux</c> (one whole
/// driver production burst per message — the primary path for a batch-capable driver).
/// OPC UA publish actor via <c>ForwardBatchToMux</c> — one whole driver production burst per message,
/// and the ONLY value ingress from driver children (<see cref="DriverInstanceActor"/> wraps even a
/// single data change in an <c>AttributeValueBatchPublished</c>). The per-value
/// <c>AttributeValuePublished</c> survives only as the message <see cref="FanCommsLossToDependencyMux"/>
/// fabricates straight to the dependency mux; the host itself no longer receives it.
/// </summary>
public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
@@ -186,7 +189,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <summary>
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardBatchToMux"/> so a driver value
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
@@ -924,7 +927,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
Receive<DispatchDeployment>(HandleDispatchFromSteady);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
@@ -959,7 +961,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// then Become(Applying)); completing the apply transitions back to Steady.
Receive<FetchedForApply>(HandleFetchedForApply);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
@@ -977,16 +978,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Routes one batched driver production burst — the batched analogue of
/// <see cref="ForwardToMux"/>, and the primary value ingress for a batch-capable driver.
/// Routes one batched driver production burst — the ONLY value ingress from a driver child, since
/// <see cref="DriverInstanceActor"/> wraps even a single data change in an
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/>.
/// <para>
/// The routing is identical per item: each <c>FullReference</c> (the tag's RawPath) resolves
/// through <see cref="_nodeIdByDriverRef"/> to the v3 Batch 4 single-source fan-out — the RAW
/// NodeId AND every referencing UNS NodeId, each carrying IDENTICAL value/status/timestamp — and
/// an unmapped ref is Debug-logged and dropped without disturbing the rest of the burst. What
/// changes is the delivery: the whole burst costs ONE hop to the dependency mux and ONE
/// Each item's <c>FullReference</c> (the tag's RawPath) resolves through
/// <see cref="_nodeIdByDriverRef"/> — rebuilt every apply from the composition's <c>RawTags</c>
/// ∪ <c>UnsReferenceVariables</c> — to the v3 Batch 4 <b>single-source fan-out</b>: the RAW
/// NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every referencing UNS NodeId
/// (<see cref="AddressSpaceRealm.Uns"/>), each carrying IDENTICAL value/status/timestamp, so
/// there is no independent buffer to drift. An unmapped ref is Debug-logged and dropped without
/// disturbing the rest of the burst.
/// </para>
/// <para>
/// The whole burst costs ONE hop to the dependency mux (which filters per item itself, feeding
/// VirtualTag inputs keyed by RawPath) and ONE
/// <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueBatchUpdate"/> to
/// the publish actor, instead of one message per value.
/// the publish actor — never one message per value. Both sinks are optional: without a mux,
/// VirtualTagActor evaluation can't fire (the dev/Mac path, no virtual tags registered);
/// production binds both via the RuntimeActors extension.
/// </para>
/// </summary>
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg)
@@ -998,13 +1008,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// Indexed for-loop, not foreach: IReadOnlyList<AttributeValueItem> enumerates through an interface
// enumerator that would allocate once per burst on the hottest routing seam in the server.
var count = msg.Items.Count;
List<ValueWrite>? writes = null;
for (var idx = 0; idx < msg.Items.Count; idx++)
for (var idx = 0; idx < count; idx++)
{
var item = msg.Items[idx];
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, item.FullReference), out var nodeIds))
{
writes ??= new List<ValueWrite>(msg.Items.Count * 2);
// Sized for the dominant case — every item resolving to its raw node alone, no UNS
// reference — where this is exact and the list never grows. A burst that does fan out
// pays at most a doubling or two, which beats over-allocating on every burst that doesn't.
writes ??= new List<ValueWrite>(count);
foreach (var n in nodeIds)
writes.Add(new ValueWrite(n.NodeId, item.Value, item.StatusCode, item.TimestampUtc, n.Realm));
}
@@ -1020,42 +1034,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueBatchUpdate(writes));
}
private void ForwardToMux(DriverInstanceActor.AttributeValuePublished msg)
{
// Pass driver-published values to the dependency mux when one is wired (VirtualTag inputs,
// keyed by FullReference). Without a mux, VirtualTagActor evaluation can't fire — that's the
// dev/Mac path (no virtual tags registered); production binds the mux via the RuntimeActors
// extension. KEEP this unchanged — VirtualTag inputs are still keyed by FullReference.
_dependencyMux?.Tell(msg);
if (_opcUaPublishActor is null) return;
// Route the value to the OPC UA sink at the variable's ACTUAL NodeId. Equipment-tag variables
// are materialised with folder-scoped NodeIds (EquipmentId/FolderPath/Name), while the driver
// publishes keyed by its wire-ref FullName (FullReference). The _nodeIdByDriverRef map — built
// each apply from the composition's EquipmentTags — resolves (DriverInstanceId, FullName) to
// the folder-scoped NodeId(s) the materialiser placed the variable(s) at, so the value lands
// instead of leaving the variable at BadWaitingForInitialData. One driver ref can back several
// equipment variables (identical machines sharing a register), hence the fan-out.
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
{
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/status/timestamp.
foreach (var n in nodeIds)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
n.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, n.Realm));
}
else
{
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
_localNode, msg.DriverInstanceId, msg.FullReference);
}
}
/// <summary>
/// Routes a native alarm transition (published by a driver child as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
/// node(s). The alarm path analogue of <see cref="ForwardToMux"/>: the transition's <c>ConditionId</c>
/// node(s). The alarm path analogue of <see cref="ForwardBatchToMux"/>: the transition's <c>ConditionId</c>
/// (the dotted alarm full-reference, which equals the authored equipment-tag FullName — NOT the bare
/// <c>SourceNodeId</c> owning object) is resolved by the <see cref="_alarmNodeIdByDriverRef"/> map —
/// built each apply from the alarm-bearing EquipmentTags — to the folder-scoped condition NodeId(s)
@@ -13,11 +13,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// subscriber.
///
/// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role
/// node). The PRIMARY ingress is the batched
/// node). The ONLY driver ingress is the batched
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> — one envelope per driver
/// production burst, which the mux filters per item — while the per-value
/// production burst, which the mux filters per item. The per-value
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> receive stays for the comms-loss fan
/// (<c>DriverHostActor.FanCommsLossToDependencyMux</c> fabricates it) and per-tag drivers. Both
/// ALONE: <c>DriverHostActor.FanCommsLossToDependencyMux</c> fabricates that message and Tells this
/// mux directly, never through the host's own receive. Both
/// produce the same <see cref="VirtualTagActor.DependencyValueChanged"/> per interested subscriber.
/// The mux is deliberately not a DPS subscriber — virtual-tag evaluation is local to each node and
/// would over-emit if it spanned the cluster.
@@ -116,12 +117,19 @@ public sealed class DependencyMuxActor : ReceiveActor
/// <summary>Filters one batched driver production burst per item — the batched analogue of
/// <see cref="OnAttributeValuePublished"/>. Items nobody registered for are dropped (the common case:
/// only a fraction of an address space feeds virtual-tag expressions), so a burst of N values costs one
/// message plus one DependencyValueChanged per genuinely interested (item, subscriber) pair.</summary>
/// message plus one DependencyValueChanged per genuinely interested (item, subscriber) pair.
/// <para>The registered set is NOT just virtual-tag inputs: <c>ContinuousHistorizationRecorder</c>
/// registers interest in every historized ref, so on a historizing node <see cref="_byRef"/> can be
/// large and a sizeable fraction of each burst resolves.</para></summary>
private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg)
{
// Nobody has registered anything — skip the whole burst rather than probing an empty map per item.
if (_byRef.Count == 0) return;
// Indexed for-loop, not foreach: this is the hot value ingress and an IReadOnlyList<T> enumerator
// would allocate once per burst.
for (var i = 0; i < msg.Items.Count; i++)
var count = msg.Items.Count;
for (var i = 0; i < count; i++)
{
var item = msg.Items[i];
if (!_byRef.TryGetValue(item.FullReference, out var subscribers) || subscribers.Count == 0) continue;