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
@@ -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
{
@@ -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/&lt;name&gt;</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)
{