perf(opcuaserver): non-rebuild applies materialise the delta, not the composition
This commit is contained in:
@@ -503,31 +503,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node
|
||||
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
|
||||
var failedNodes = outcome.FailedNodes;
|
||||
// The UNS Area/Line/Equipment folder hierarchy is materialised in FULL on every apply, rebuild or
|
||||
// not: it is O(areas + lines + equipment) — trivial next to the tag counts the delta passes below
|
||||
// exist to bound — and every node the delta passes add needs its ancestor folders to already
|
||||
// exist, so narrowing it would mean re-deriving each add's ancestor closure (strictly more work).
|
||||
failedNodes += _applier.MaterialiseHierarchy(composition);
|
||||
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
|
||||
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
|
||||
// edge finds its raw target. The driver binds live values to these raw NodeIds.
|
||||
failedNodes += _applier.MaterialiseRawSubtree(composition);
|
||||
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
|
||||
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
|
||||
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
|
||||
failedNodes += _applier.MaterialiseUnsReferences(composition);
|
||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
// alarms are skipped.
|
||||
failedNodes += _applier.MaterialiseScriptedAlarms(composition);
|
||||
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
|
||||
// FolderPath sub-folder) exists under its already-materialised equipment folder so
|
||||
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
|
||||
// each subscription cycle; variables show BadWaitingForInitialData only until the first
|
||||
// publish interval fires.
|
||||
failedNodes += _applier.MaterialiseEquipmentTags(composition);
|
||||
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
|
||||
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
|
||||
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
|
||||
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
|
||||
failedNodes += _applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
failedNodes += MaterialiseNodes(plan, composition, fullComposition: outcome.RebuildCalled);
|
||||
|
||||
// R2-07 T4b — on a NON-rebuild apply (PureAdd / AttributeOnly), the Materialise passes above
|
||||
// just created exactly the added nodes WITHOUT tearing anything down, so announce them with a
|
||||
@@ -537,6 +518,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// them, so the nodes exist when clients re-browse. After a full rebuild the announcement is moot
|
||||
// (subscriptions are dead anyway) — the guard skips it. AnnounceAddedNodes is Safe-wrapped
|
||||
// internally, so a faulting announce can never break the deploy.
|
||||
//
|
||||
// KNOWN, PRE-EXISTING GAP (unchanged by the delta-materialise work above): ComputeAddAnnouncements
|
||||
// only walks the equipment-namespace add sets, so added RAW containers/tags and added UNS
|
||||
// reference variables are materialised but never announced. Model-aware clients pick them up on
|
||||
// their next unprompted browse.
|
||||
if (!outcome.RebuildCalled) _applier.AnnounceAddedNodes(plan);
|
||||
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
@@ -561,6 +547,145 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
|
||||
/// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over
|
||||
/// just this plan's delta (surgical-deploys Task 6). <c>MaterialiseHierarchy</c> is NOT one of them:
|
||||
/// the caller always runs it in full, because parents must exist before any add.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why the delta path exists.</b> The passes are idempotent, but idempotent is not free:
|
||||
/// every <c>EnsureVariable</c> / <c>EnsureFolder</c> / <c>MaterialiseAlarmCondition</c> is a sink call
|
||||
/// that takes the node manager's lock and re-derives the node's identity before discovering there is
|
||||
/// nothing to do. Running them over the whole composition made the cost of ANY apply proportional to
|
||||
/// the size of the served address space rather than to the size of the change: adding one tag to a
|
||||
/// 50k-node server was ~50k sink calls, on the actor's pinned publish thread, with every client's
|
||||
/// publish blocked behind it.</para>
|
||||
/// <para><b>Why <see cref="AddressSpaceApplyOutcome.RebuildCalled"/> is the branch key</b> rather than
|
||||
/// the classifier's kind: a surgical apply that hits a <c>false</c>/throw from the surgical sink falls
|
||||
/// back to a full <c>RebuildAddressSpace()</c> INSIDE <c>Apply</c>, which wipes every node. That plan
|
||||
/// is still classified non-Rebuild, so branching on the kind would re-materialise only the delta onto
|
||||
/// a wiped address space. The outcome flag reports what actually happened.</para>
|
||||
/// <para><b>Why changed UNS references are in the subset.</b> <c>Apply</c> repairs a
|
||||
/// <c>UnsReferenceDelta</c> by REMOVING the previous node and leaving the recreate to this pass, so
|
||||
/// the subset must carry the <i>current</i> side of every changed reference as well as the added ones
|
||||
/// — otherwise a re-pointed, renamed, or backing-edited UNS node is removed and never comes back.</para>
|
||||
/// <para><b>Accepted narrowing.</b> The plan diffs the THIN <c>ScriptedAlarmPlans</c> projection, so
|
||||
/// the added-alarm subset is the rich <c>EquipmentScriptedAlarms</c> rows whose id the plan reports as
|
||||
/// added. An alarm already present in both compositions whose predicate SCRIPT row only now arrived
|
||||
/// (making it materialisable for the first time) is not in that set — but neither is it in any diff
|
||||
/// set at all, so on its own it already produced an empty plan and materialised nothing. The delta
|
||||
/// path extends that pre-existing blindness to the case where it rides along with another change.</para>
|
||||
/// </remarks>
|
||||
/// <param name="plan">The plan just applied — the source of the delta subsets.</param>
|
||||
/// <param name="composition">The full composition, always passed as the passes' lookup context.</param>
|
||||
/// <param name="fullComposition">When true, materialise the whole composition (post-rebuild).</param>
|
||||
/// <returns>The summed count of swallowed per-node sink failures across the passes that ran.</returns>
|
||||
private int MaterialiseNodes(AddressSpacePlan plan, AddressSpaceComposition composition, bool fullComposition)
|
||||
{
|
||||
var applier = _applier!;
|
||||
var failedNodes = 0;
|
||||
|
||||
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
|
||||
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
|
||||
// edge finds its raw target. The driver binds live values to these raw NodeIds.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseRawSubtree(composition);
|
||||
}
|
||||
else if (plan.AddedRawContainers.Count > 0 || plan.AddedRawTags.Count > 0)
|
||||
{
|
||||
failedNodes += applier.MaterialiseRawSubtree(composition, plan.AddedRawContainers, plan.AddedRawTags);
|
||||
}
|
||||
|
||||
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
|
||||
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
|
||||
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseUnsReferences(composition);
|
||||
}
|
||||
else
|
||||
{
|
||||
var unsReferences = DeltaUnsReferences(plan);
|
||||
if (unsReferences.Count > 0) failedNodes += applier.MaterialiseUnsReferences(composition, unsReferences);
|
||||
}
|
||||
|
||||
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
|
||||
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
|
||||
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
|
||||
// alarms are skipped.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseScriptedAlarms(composition);
|
||||
}
|
||||
else
|
||||
{
|
||||
var addedAlarms = DeltaScriptedAlarms(plan, composition);
|
||||
if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms);
|
||||
}
|
||||
|
||||
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
|
||||
// FolderPath sub-folder) exists under its already-materialised equipment folder so
|
||||
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
|
||||
// each subscription cycle; variables show BadWaitingForInitialData only until the first
|
||||
// publish interval fires.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentTags(composition);
|
||||
}
|
||||
else if (plan.AddedEquipmentTags.Count > 0)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags);
|
||||
}
|
||||
|
||||
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
|
||||
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
|
||||
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
|
||||
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
}
|
||||
else if (plan.AddedEquipmentVirtualTags.Count > 0)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentVirtualTags(composition, plan.AddedEquipmentVirtualTags);
|
||||
}
|
||||
|
||||
return failedNodes;
|
||||
}
|
||||
|
||||
/// <summary>The UNS reference variables a non-rebuild apply must materialise: the plan's ADDED references
|
||||
/// plus the CURRENT side of every CHANGED one (whose previous node the applier already removed in place).
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan just applied.</param>
|
||||
/// <returns>The references to materialise (possibly empty).</returns>
|
||||
private static IReadOnlyList<UnsReferenceVariable> DeltaUnsReferences(AddressSpacePlan plan)
|
||||
{
|
||||
if (plan.ChangedUnsReferenceVariables.Count == 0) return plan.AddedUnsReferenceVariables;
|
||||
var list = new List<UnsReferenceVariable>(
|
||||
plan.AddedUnsReferenceVariables.Count + plan.ChangedUnsReferenceVariables.Count);
|
||||
list.AddRange(plan.AddedUnsReferenceVariables);
|
||||
foreach (var d in plan.ChangedUnsReferenceVariables) list.Add(d.Current);
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>The scripted-alarm host plans a non-rebuild apply must materialise: the rich
|
||||
/// <c>EquipmentScriptedAlarms</c> rows whose <c>ScriptedAlarmId</c> the plan reports as ADDED (the plan
|
||||
/// diffs only the thin projection — see the narrowing note on <see cref="MaterialiseNodes"/>).</summary>
|
||||
/// <param name="plan">The plan just applied.</param>
|
||||
/// <param name="composition">The full composition carrying the rich alarm plans.</param>
|
||||
/// <returns>The alarms to materialise (possibly empty).</returns>
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> DeltaScriptedAlarms(
|
||||
AddressSpacePlan plan, AddressSpaceComposition composition)
|
||||
{
|
||||
if (plan.AddedAlarms.Count == 0 || composition.EquipmentScriptedAlarms.Count == 0)
|
||||
return Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
|
||||
var addedIds = new HashSet<string>(plan.AddedAlarms.Select(a => a.ScriptedAlarmId), StringComparer.Ordinal);
|
||||
return composition.EquipmentScriptedAlarms.Where(a => addedIds.Contains(a.ScriptedAlarmId)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants)
|
||||
/// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes
|
||||
|
||||
Reference in New Issue
Block a user