perf(opcuaserver): non-rebuild applies materialise the delta, not the composition
This commit is contained in:
@@ -256,10 +256,11 @@ public sealed class AddressSpaceApplier
|
||||
// today's cost of a full rebuild killing every subscription server-wide.
|
||||
//
|
||||
// WHERE the recreate happens: the publish actor's materialise passes, which run after Apply
|
||||
// returns — full-composition MaterialiseUnsReferences today, narrowed to the plan's added +
|
||||
// changed-current references by Task 6 of the surgical-deploys plan. Either way the ordering
|
||||
// holds (remove here, recreate strictly after), and the recreated node inherits its shape /
|
||||
// historian tagname from the FULL composition's backing raw tag.
|
||||
// returns. On a NON-rebuild apply that pass is now narrowed to the plan's added references
|
||||
// PLUS the CURRENT side of every delta removed here (surgical-deploys Task 6) — dropping the
|
||||
// currents from that subset would leave a re-pointed / renamed reference removed and never
|
||||
// recreated. Either way the ordering holds (remove here, recreate strictly after), and the
|
||||
// recreated node inherits its shape / historian tagname from the UnsReferenceVariable record.
|
||||
foreach (var d in allApplied ? unsRefDeltas : Enumerable.Empty<AddressSpacePlan.UnsReferenceDelta>())
|
||||
{
|
||||
if (!SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
@@ -699,6 +700,14 @@ public sealed class AddressSpaceApplier
|
||||
/// clients browsing the server see proper folder structure instead of flat tag ids.
|
||||
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
|
||||
/// present, so re-applies are cheap.
|
||||
/// <para><b>This pass has NO subset overload, deliberately — it always runs over the FULL
|
||||
/// composition</b>, unlike the five node-materialising passes below (which take an optional
|
||||
/// <c>only*</c> delta subset so a non-rebuild apply touches the plan's added nodes instead of the
|
||||
/// whole address space). Two reasons: it is O(areas + lines + equipment), which is a rounding error
|
||||
/// next to the tag/reference counts the subsetting exists to bound; and every added node materialised
|
||||
/// by the other passes needs its ANCESTOR folders to already exist, so narrowing this one to the
|
||||
/// plan's delta would mean re-deriving the ancestor closure of every add — strictly more work than
|
||||
/// re-ensuring the folders.</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the hierarchy to materialise.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
@@ -739,18 +748,33 @@ public sealed class AddressSpaceApplier
|
||||
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
|
||||
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
|
||||
/// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent.
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="onlyContainers"/> /
|
||||
/// <paramref name="onlyTags"/> narrow WHAT is materialised to a plan's delta while
|
||||
/// <paramref name="composition"/> stays the FULL composition for context lookups — specifically the
|
||||
/// native-alarm notifier map (<see cref="BuildEquipmentIdByFolderPath"/>), which must see every
|
||||
/// equipment on the node, not just the ones this deploy touched. A <c>null</c> subset means "the whole
|
||||
/// composition" (the rebuild path), so every pre-existing call site is unchanged. Ordering holds in
|
||||
/// both modes: the composer sorts <c>RawContainers</c> by NodeId and the planner sorts its added set by
|
||||
/// the same key, so a parent's RawPath still precedes each child's.</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition carrying the Raw subtree.</param>
|
||||
/// <param name="composition">The FULL composition — the context for lookups even when a subset is passed.</param>
|
||||
/// <param name="onlyContainers">Materialise only these containers; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <param name="onlyTags">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass.</returns>
|
||||
public int MaterialiseRawSubtree(AddressSpaceComposition composition)
|
||||
public int MaterialiseRawSubtree(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<RawContainerNode>? onlyContainers = null,
|
||||
IReadOnlyList<RawTagPlan>? onlyTags = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0;
|
||||
var containers = onlyContainers ?? composition.RawContainers;
|
||||
var tags = onlyTags ?? composition.RawTags;
|
||||
if (containers.Count == 0 && tags.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each
|
||||
// child's; EnsureFolder is idempotent, so a re-apply is cheap.
|
||||
foreach (var c in composition.RawContainers)
|
||||
foreach (var c in containers)
|
||||
{
|
||||
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||
}
|
||||
@@ -761,7 +785,7 @@ public sealed class AddressSpaceApplier
|
||||
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
|
||||
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
|
||||
|
||||
foreach (var t in composition.RawTags)
|
||||
foreach (var t in tags)
|
||||
{
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
@@ -817,8 +841,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})",
|
||||
composition.RawContainers.Count, composition.RawTags.Count, failed);
|
||||
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, subset={Subset}, failed={Failed})",
|
||||
containers.Count, tags.Count, onlyContainers is not null || onlyTags is not null, failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
@@ -839,16 +863,25 @@ public sealed class AddressSpaceApplier
|
||||
/// shape edit produce the coincident <c>UnsReferenceDelta</c> that drives this node's repair
|
||||
/// (remove-previous in <c>Apply</c>, recreate here). A back-lookup would re-introduce the split the
|
||||
/// 2026-08-15 review found: a record that says one thing and a materialised node built from another.</para>
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
|
||||
/// materialised to a plan's delta (its added references PLUS the <i>current</i> side of every changed
|
||||
/// one — <c>Apply</c> removed the previous node, so omitting the currents would leave a re-pointed or
|
||||
/// renamed reference removed and never recreated). <paramref name="composition"/> stays the FULL
|
||||
/// composition for context; a <c>null</c> subset means "the whole composition" (the rebuild path).</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition carrying the UNS reference variables.</param>
|
||||
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
|
||||
/// <param name="only">Materialise only these references; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass.</returns>
|
||||
public int MaterialiseUnsReferences(AddressSpaceComposition composition)
|
||||
public int MaterialiseUnsReferences(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<UnsReferenceVariable>? only = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.UnsReferenceVariables.Count == 0) return 0;
|
||||
var references = only ?? composition.UnsReferenceVariables;
|
||||
if (references.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
foreach (var v in composition.UnsReferenceVariables)
|
||||
foreach (var v in references)
|
||||
{
|
||||
var isArray = v.BackingIsArray;
|
||||
uint? arrayLength = v.BackingArrayLength;
|
||||
@@ -872,8 +905,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})",
|
||||
composition.UnsReferenceVariables.Count, failed);
|
||||
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, subset={Subset}, failed={Failed})",
|
||||
references.Count, only is not null, failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
@@ -890,14 +923,21 @@ public sealed class AddressSpaceApplier
|
||||
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
|
||||
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
|
||||
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
|
||||
/// materialised to a plan's added tags; <paramref name="composition"/> stays the FULL composition for
|
||||
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
|
||||
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
|
||||
/// <param name="only">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
|
||||
public int MaterialiseEquipmentTags(AddressSpaceComposition composition)
|
||||
public int MaterialiseEquipmentTags(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<EquipmentTagPlan>? only = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.EquipmentTags.Count == 0) return 0;
|
||||
var equipmentTags = only ?? composition.EquipmentTags;
|
||||
if (equipmentTags.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
|
||||
@@ -905,7 +945,7 @@ public sealed class AddressSpaceApplier
|
||||
// equipment folder, which MaterialiseHierarchy already created (never re-create
|
||||
// the equipment folder here).
|
||||
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tag in composition.EquipmentTags)
|
||||
foreach (var tag in equipmentTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
@@ -919,7 +959,7 @@ public sealed class AddressSpaceApplier
|
||||
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
|
||||
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
|
||||
// Per-variable idempotency relies on the sink's own EnsureVariable.
|
||||
foreach (var tag in composition.EquipmentTags)
|
||||
foreach (var tag in equipmentTags)
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||
? tag.EquipmentId
|
||||
@@ -948,9 +988,10 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, failed={Failed})",
|
||||
composition.EquipmentTags.Count,
|
||||
composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
|
||||
equipmentTags.Count,
|
||||
equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
only is not null,
|
||||
failed);
|
||||
return failed;
|
||||
}
|
||||
@@ -967,21 +1008,28 @@ public sealed class AddressSpaceApplier
|
||||
/// different equipments never collide in the sink (which keys on NodeId). Variables start
|
||||
/// BadWaitingForInitialData; <c>VirtualTagActor</c> fills live values in a later milestone.
|
||||
/// Idempotent (per-variable idempotency relies on the sink's own <c>EnsureVariable</c>).
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
|
||||
/// materialised to a plan's added VirtualTags; <paramref name="composition"/> stays the FULL
|
||||
/// composition for context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the equipment VirtualTags to materialise.</param>
|
||||
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
|
||||
/// <param name="only">Materialise only these VirtualTags; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
|
||||
public int MaterialiseEquipmentVirtualTags(AddressSpaceComposition composition)
|
||||
public int MaterialiseEquipmentVirtualTags(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<EquipmentVirtualTagPlan>? only = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.EquipmentVirtualTags.Count == 0) return 0;
|
||||
var virtualTags = only ?? composition.EquipmentVirtualTags;
|
||||
if (virtualTags.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder
|
||||
// (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the
|
||||
// equipment folder, which MaterialiseHierarchy already created (never re-create it here).
|
||||
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var v in composition.EquipmentVirtualTags)
|
||||
foreach (var v in virtualTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
|
||||
var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath);
|
||||
@@ -995,7 +1043,7 @@ public sealed class AddressSpaceApplier
|
||||
// forwards historized results to IHistoryWriter. It is intentionally NOT materialised as an SDK
|
||||
// Historizing/HistoryRead variable here (no server-side OPC UA HistoryRead for vtags), so these
|
||||
// stay plain read-only computed-output nodes.
|
||||
foreach (var v in composition.EquipmentVirtualTags)
|
||||
foreach (var v in virtualTags)
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
|
||||
? v.EquipmentId
|
||||
@@ -1006,9 +1054,10 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment}, failed={Failed})",
|
||||
composition.EquipmentVirtualTags.Count,
|
||||
composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
"AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
|
||||
virtualTags.Count,
|
||||
virtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
only is not null,
|
||||
failed);
|
||||
return failed;
|
||||
}
|
||||
@@ -1021,18 +1070,25 @@ public sealed class AddressSpaceApplier
|
||||
/// Disabled alarms are skipped — they expose no node. Must run AFTER
|
||||
/// <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
|
||||
/// <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
|
||||
/// materialised to a plan's added alarms; <paramref name="composition"/> stays the FULL composition for
|
||||
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
|
||||
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
|
||||
/// <param name="only">Materialise only these alarms; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
|
||||
public int MaterialiseScriptedAlarms(AddressSpaceComposition composition)
|
||||
public int MaterialiseScriptedAlarms(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<EquipmentScriptedAlarmPlan>? only = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
if (composition.EquipmentScriptedAlarms.Count == 0) return 0;
|
||||
var alarms = only ?? composition.EquipmentScriptedAlarms;
|
||||
if (alarms.Count == 0) return 0;
|
||||
|
||||
var materialised = 0;
|
||||
var failed = 0;
|
||||
foreach (var alarm in composition.EquipmentScriptedAlarms)
|
||||
foreach (var alarm in alarms)
|
||||
{
|
||||
if (!alarm.Enabled) continue;
|
||||
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
@@ -1040,10 +1096,11 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment}, failed={Failed})",
|
||||
"AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment}, subset={Subset}, failed={Failed})",
|
||||
materialised,
|
||||
composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
|
||||
alarms.Where(a => a.Enabled)
|
||||
.Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
only is not null,
|
||||
failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -345,6 +345,100 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
sink.Variables.ShouldHaveSingleItem().HistorianTagname.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 surgical-deploys (Task 6) — the subset overload materialises ONLY the tags/containers it
|
||||
/// is handed, while still resolving native-alarm notifier targets from the FULL composition's equipment
|
||||
/// map. Both halves matter: narrowing the iteration is the whole point (a one-tag deploy used to re-walk
|
||||
/// every node on the server), and the map has to stay full because the equipment a raw alarm fans to is
|
||||
/// not part of any raw-tag delta — building it from the subset would silently drop the fan-out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_with_a_subset_ensures_only_the_subset_but_resolves_notifiers_from_the_full_composition()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var untouched = new RawTagPlan("t0", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
||||
Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
|
||||
var added = new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||
ReferencingEquipmentPaths: new[] { "filling/line1/station1" });
|
||||
var composition = new AddressSpaceComposition(
|
||||
new[] { new UnsAreaProjection("a1", "filling") },
|
||||
new[] { new UnsLineProjection("l1", "a1", "line1") },
|
||||
new[] { new EquipmentNode("EQ-1", "station1", "l1") },
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawContainers = new[]
|
||||
{
|
||||
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
|
||||
new RawContainerNode("Plant/A", "Plant", "A", RawNodeKind.Driver),
|
||||
new RawContainerNode("Plant/A/dev", "Plant/A", "dev", RawNodeKind.Device),
|
||||
},
|
||||
RawTags = new[] { untouched, added },
|
||||
};
|
||||
|
||||
// The delta of a one-tag add: no new containers, exactly one new tag.
|
||||
var failed = NewApplier(sink).MaterialiseRawSubtree(
|
||||
composition,
|
||||
onlyContainers: Array.Empty<RawContainerNode>(),
|
||||
onlyTags: new[] { added });
|
||||
|
||||
failed.ShouldBe(0);
|
||||
sink.Folders.ShouldBeEmpty(); // the three existing containers were NOT re-ensured
|
||||
sink.Variables.ShouldBeEmpty(); // the untouched value tag was NOT re-ensured
|
||||
sink.Conditions.ShouldHaveSingleItem().NodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||
// …and the notifier still resolved through the FULL composition's Area/Line/Equipment map.
|
||||
sink.NotifierWirings.ShouldHaveSingleItem().FolderNodeIds.ShouldBe(new[] { "EQ-1" });
|
||||
}
|
||||
|
||||
/// <summary>A null subset (the rebuild path) still means "the whole composition" — the overload is additive
|
||||
/// and every pre-existing call site keeps its behaviour.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_with_no_subset_still_materialises_the_whole_composition()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawContainers = new[] { new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder) },
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t0", "Plant/a", "Plant", "drv", "a", "Float", Writable: true, Alarm: null,
|
||||
ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||
new RawTagPlan("t1", "Plant/b", "Plant", "drv", "b", "Float", Writable: true, Alarm: null,
|
||||
ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
sink.Folders.Select(f => f.NodeId).ShouldBe(new[] { "Plant" });
|
||||
sink.Variables.Select(v => v.NodeId).ShouldBe(new[] { "Plant/a", "Plant/b" });
|
||||
}
|
||||
|
||||
/// <summary>2026-08-15 surgical-deploys (Task 6) — the UNS-reference subset overload materialises only the
|
||||
/// references it is handed (with their Organizes edges), leaving every other reference in the composition
|
||||
/// untouched.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseUnsReferences_with_a_subset_ensures_only_the_subset()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var untouched = new UnsReferenceVariable("r0", "EQ-1", "filling/line1/station1/speed", "speed",
|
||||
"Plant/A/dev/speed", "Float", Writable: true);
|
||||
var added = new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/temp", "temp",
|
||||
"Plant/A/dev/temp", "Float", Writable: false);
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
UnsReferenceVariables = new[] { untouched, added },
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseUnsReferences(composition, only: new[] { added });
|
||||
|
||||
sink.Variables.ShouldHaveSingleItem().NodeId.ShouldBe("filling/line1/station1/temp");
|
||||
sink.References.ShouldHaveSingleItem().Target.ShouldBe("Plant/A/dev/temp");
|
||||
}
|
||||
|
||||
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
|
||||
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
|
||||
+287
-2
@@ -607,10 +607,291 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// 2026-08-15 surgical-deploys (Task 6) — a NON-rebuild apply materialises the plan's DELTA, not the whole
|
||||
// composition. The passes are idempotent, but idempotent is not free: every EnsureVariable / EnsureFolder
|
||||
// is a sink call that takes the node manager's lock, so re-walking the composition made the cost of ANY
|
||||
// apply proportional to the size of the served address space (a one-tag add on a 50k-node server was
|
||||
// ~50k sink calls on the publish thread) instead of to the size of the change.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>THE headline test. Deploy three raw tags, then deploy the same three PLUS one more: the
|
||||
/// second apply must touch ONLY the added tag. Everything already standing — the three container folders
|
||||
/// and the three existing variables — must not be re-ensured.</summary>
|
||||
[Fact]
|
||||
public void Pure_add_materialises_only_the_added_nodes()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var dep1 = SeedRawTagDeployment(db, new[] { "A", "B", "C" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/C"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// One new tag on an otherwise identical configuration.
|
||||
var dep2 = SeedRawTagDeployment(db, new[] { "A", "B", "C", "D" });
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/D"), duration: PresenceBudget);
|
||||
Thread.Sleep(200); // let any (unwanted) extra pass finish before counting
|
||||
|
||||
var second = sink.Calls.Skip(afterFirst).ToList();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// Exactly one variable ensured — the added tag. The three that were already standing are untouched.
|
||||
second.Where(c => c.StartsWith("EV:")).ShouldBe(new[] { "EV:Plant/Modbus/dev1/D" });
|
||||
// …and no container folder was re-ensured either (the raw containers did not change).
|
||||
second.ShouldNotContain(c => c.StartsWith("EF:"));
|
||||
}
|
||||
|
||||
/// <summary>The falsification control for the headline test: when the plan DOES force a full rebuild, the
|
||||
/// address space has just been wiped, so every node in the composition must be re-materialised — the delta
|
||||
/// subsets would leave the server serving almost nothing.</summary>
|
||||
[Fact]
|
||||
public void Rebuild_still_materialises_the_full_composition()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// Renaming the equipment is a ChangedEquipment delta ⇒ the classifier's default-closed Rebuild.
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
||||
AwaitAssert(
|
||||
() => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/b"),
|
||||
duration: PresenceBudget);
|
||||
|
||||
var second = sink.Calls.Skip(afterFirst).ToList();
|
||||
// The FULL raw subtree came back: both containers and both tags, not just a delta.
|
||||
second.ShouldContain("EF:Plant");
|
||||
second.ShouldContain("EF:Plant/Modbus/dev1");
|
||||
second.ShouldContain("EV:Plant/Modbus/dev1/a");
|
||||
second.ShouldContain("EV:Plant/Modbus/dev1/b");
|
||||
// …and so did the UNS reference variable (whose own row never changed).
|
||||
second.ShouldContain("EV:filling/line1/station1-renamed/a");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The delta subset MUST carry the CURRENT side of every changed UNS reference, not just the added
|
||||
/// ones. <c>AddressSpaceApplier.Apply</c> repairs a <c>UnsReferenceDelta</c> by REMOVING the previous
|
||||
/// node in place and leaving the recreate to the materialise pass; a subset built from
|
||||
/// <c>AddedUnsReferenceVariables</c> alone would remove a re-pointed reference and never bring it back.
|
||||
/// Here the reference is re-pointed from raw tag <c>a</c> to raw tag <c>b</c> (which also moves the
|
||||
/// effective name, and so the UNS NodeId).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Changed_uns_reference_is_recreated_after_surgical_remove()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// Re-point the SAME reference row at raw tag "b".
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "b");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/b"), duration: PresenceBudget);
|
||||
|
||||
var second = sink.Calls.Skip(afterFirst).ToList();
|
||||
sink.RebuildCalls.ShouldBe(0); // repaired in place, not by wiping the server
|
||||
second.ShouldContain("RV:filling/line1/station1/a"); // the previous node was removed…
|
||||
second.ShouldContain("EV:filling/line1/station1/b"); // …and the current one recreated
|
||||
// …with its Organizes edge pointing at the NEW backing raw path.
|
||||
sink.ReferenceEdges.ShouldContain(("filling/line1/station1/b", "Plant/Modbus/dev1/b"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same repair for the shape the 2026-08-15 review found (commit 875ec9fe): the reference row is
|
||||
/// untouched, but its BACKING raw tag becomes historized. The coincident <c>UnsReferenceDelta</c> must
|
||||
/// still reach the materialise pass, or the UNS mirror keeps serving no history while the raw node
|
||||
/// serves it — the exact split the <c>Backing*</c> fields exist to close.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Changed_uns_reference_is_recreated_when_the_backing_tag_flips_historian()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a",
|
||||
historianTagnameForA: "WW.A");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
|
||||
AwaitAssert(
|
||||
() => sink.VariableDetails.ShouldContain(
|
||||
v => v.NodeId == "filling/line1/station1/a" && v.HistorianTagname == "WW.A"),
|
||||
duration: PresenceBudget);
|
||||
|
||||
var second = sink.Calls.Skip(afterFirst).ToList();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
second.ShouldContain("RV:filling/line1/station1/a"); // removed…
|
||||
second.ShouldContain("EV:filling/line1/station1/a"); // …and re-ensured, now carrying the tagname
|
||||
}
|
||||
|
||||
/// <summary>An AttributeOnly apply (here a raw tag gaining a historian override, with no UNS reference to
|
||||
/// repair) is handled entirely by the surgical in-place update: no materialise pass has anything to do, so
|
||||
/// not a single node is re-ensured.</summary>
|
||||
[Fact]
|
||||
public void Attribute_only_apply_skips_all_materialise_passes_except_hierarchy()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }, historizedTag: "A");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("UT:Plant/Modbus/dev1/A"), duration: PresenceBudget);
|
||||
Thread.Sleep(200); // let any (unwanted) extra pass finish before counting
|
||||
|
||||
var second = sink.Calls.Skip(afterFirst).ToList();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
second.Where(c => c.StartsWith("EV:")).ShouldBeEmpty(); // nothing re-ensured
|
||||
second.Where(c => c.StartsWith("EF:")).ShouldBeEmpty();
|
||||
second.ShouldContain("UT:Plant/Modbus/dev1/A"); // the in-place attribute update DID happen
|
||||
}
|
||||
|
||||
/// <summary>Seal a deployment whose raw tree is <c>Plant/Modbus/dev1</c> carrying one tag per name in
|
||||
/// <paramref name="tagNames"/> (RawPath <c>Plant/Modbus/dev1/<name></c>). There is no UNS topology, so
|
||||
/// the composition is raw-only. <paramref name="historizedTag"/> names one tag that carries an
|
||||
/// <c>isHistorized</c> TagConfig, which is an in-place-updatable (surgical) attribute change.
|
||||
/// <para><b>Deliberately NOT a <c>params</c> method, and deliberately not overloaded.</b> The first draft
|
||||
/// was <c>(dbFactory, string? historizedTag = null, params string[] tagNames)</c> alongside a convenience
|
||||
/// <c>(dbFactory, params string[] tagNames)</c>; <c>Seed(db, "A", "B")</c> then bound to the FIRST overload
|
||||
/// in expanded form — historizing a tag called "A" and seeding only "B" — which compiled clean and made a
|
||||
/// delta test silently assert against a one-tag composition.</para></summary>
|
||||
private static Guid SeedRawTagDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
string[] tagNames,
|
||||
string? historizedTag = null)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = Array.Empty<object>(),
|
||||
UnsLines = Array.Empty<object>(),
|
||||
Equipment = Array.Empty<object>(),
|
||||
RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = tagNames.Select(n => new
|
||||
{
|
||||
TagId = $"t-{n}",
|
||||
DeviceId = "dev-1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = n,
|
||||
DataType = "Float",
|
||||
AccessLevel = 0,
|
||||
TagConfig = string.Equals(n, historizedTag, StringComparison.Ordinal)
|
||||
? "{\"isHistorized\":true}"
|
||||
: "{}",
|
||||
}).ToArray(),
|
||||
UnsTagReferences = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
return SealArtifact(dbFactory, artifact);
|
||||
}
|
||||
|
||||
/// <summary>Seal a deployment carrying BOTH a raw tree (<c>Plant/Modbus/dev1</c> with tags <c>a</c> +
|
||||
/// <c>b</c>) and a UNS topology (<c>filling</c> / <c>line1</c> / <paramref name="equipmentName"/>) whose
|
||||
/// single <c>UnsTagReference</c> row (id <c>ref-1</c>, stable across deploys) points at
|
||||
/// <paramref name="referencedTag"/>. Re-pointing that row across two deploys yields a
|
||||
/// <c>UnsReferenceDelta</c>; renaming the equipment yields a rebuild-forcing <c>ChangedEquipment</c>.
|
||||
/// <paramref name="historianTagnameForA"/> historizes raw tag <c>a</c> with that override, which makes the
|
||||
/// reference's <c>Backing*</c> mirror fields differ (the 875ec9fe repair path).</summary>
|
||||
private static Guid SeedUnsRawDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
string equipmentName,
|
||||
string referencedTag,
|
||||
string? historianTagnameForA = null)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" } },
|
||||
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line1" } },
|
||||
Equipment = new[] { new { EquipmentId = "eq-1", UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" } },
|
||||
RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "t-a", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "a",
|
||||
DataType = "Float", AccessLevel = 0,
|
||||
TagConfig = historianTagnameForA is null
|
||||
? "{}"
|
||||
: $"{{\"isHistorized\":true,\"historianTagname\":\"{historianTagnameForA}\"}}",
|
||||
},
|
||||
new
|
||||
{
|
||||
TagId = "t-b", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "b",
|
||||
DataType = "Float", AccessLevel = 0, TagConfig = "{}",
|
||||
},
|
||||
},
|
||||
UnsTagReferences = new[]
|
||||
{
|
||||
new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = $"t-{referencedTag}" },
|
||||
},
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
return SealArtifact(dbFactory, artifact);
|
||||
}
|
||||
|
||||
/// <summary>Seal <paramref name="artifact"/> as a new deployment and return its id.</summary>
|
||||
private static Guid SealArtifact(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, byte[] artifact)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id,
|
||||
RevisionHash = new string('e', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||
{
|
||||
/// <summary>Gets the list of recorded sink calls.</summary>
|
||||
public ConcurrentQueue<string> Calls { get; } = new();
|
||||
/// <summary>Gets every EnsureVariable call with the attributes the applier resolved for it (the
|
||||
/// ordered <see cref="Calls"/> queue only carries node ids).</summary>
|
||||
public ConcurrentQueue<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> VariableDetails { get; } = new();
|
||||
/// <summary>Gets every AddReference (Organizes) edge the applier wired.</summary>
|
||||
public ConcurrentQueue<(string Source, string Target)> ReferenceEdges { get; } = new();
|
||||
/// <summary>Gets or sets the count of rebuild address space calls.</summary>
|
||||
public int RebuildCalls;
|
||||
/// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
|
||||
@@ -653,14 +934,18 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> Calls.Enqueue($"EV:{variableNodeId}");
|
||||
{
|
||||
Calls.Enqueue($"EV:{variableNodeId}");
|
||||
VariableDetails.Enqueue((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
|
||||
}
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||
=> ReferenceEdges.Enqueue((sourceNodeId, targetNodeId));
|
||||
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user