perf(opcuaserver): non-rebuild applies materialise the delta, not the composition

This commit is contained in:
Joseph Doherty
2026-08-15 17:26:53 -04:00
parent 14c31941c7
commit bfbdce32e4
4 changed files with 626 additions and 65 deletions
@@ -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;
}