fix(opcuaserver): degraded applies re-diff instead of losing nodes; in-place UNS mirror updates; sever inverse edges
C1 — a non-rebuild apply reporting failedNodes > 0 no longer advances _lastApplied. Pre-delta-materialisation the full passes re-ensured every node on every apply, which silently repaired any prior swallowed sink fault. The delta path removed that accidental self-healing: a failed node sat inside _lastApplied, never appeared in a diff set again, and EnsureVariable early-returns on the ones that landed — so it was lost until an unrelated change forced a rebuild. The advance also moved to AFTER the materialise passes, so a throwing pass leaves _lastApplied at the previous composition too. A rebuild still advances even with failures (the ratchet is its resync path). I2 — a UnsReferenceDelta whose identity is unchanged (same NodeId, EquipmentId and BackingRawPath) is now applied in place via UpdateTagAttributes(Uns) instead of remove+recreate, so the mirror's MonitoredItems survive an operator historizing or reshaping the raw tag behind them. Remove+recreate stays for identity-changing deltas (re-point, effective-name edit). I3 — RemoveVariableNode severs the inverse half of every outgoing Organizes edge, the variable-node counterpart of UnwireAlarmNotifiers. Rebuilds used to sweep these up; surgical applies made rebuilds rare, so they accumulated on the raw node for the life of the process. I5 — added UNS reference variables are announced (their equipment folder is the parent, already the Uns realm). Raw adds still are not; the gap comment now says so honestly rather than calling it neutral and pre-existing. I4 + M1 — corrected two comments that no longer described the code: the "re-point → rebuild" claim in ApplyPureRemove, and the "N lock acquisitions" rationale for the delta passes (Ensure* early-return on a dictionary probe before the lock).
This commit is contained in:
@@ -1401,6 +1401,31 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 review I5 — an added UNS reference variable announces its EQUIPMENT folder, which is the
|
||||
/// parent <c>MaterialiseUnsReferences</c> hangs it under and a UNS-realm node (the realm
|
||||
/// <c>AnnounceAddedNodes</c> hardcodes). Before the surgical-deploy branch a plan carrying adds went
|
||||
/// through a full rebuild, so every client re-browsed regardless and the missing announcement was
|
||||
/// invisible; now the add is surgical and an unannounced node stays unseen until some later browse.
|
||||
/// Two references under one equipment dedup to a single announcement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_added_uns_reference_announces_its_equipment()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedUnsReferenceVariables = new[]
|
||||
{
|
||||
UnsRef("filling/line-1/station-1/speed", "speed"),
|
||||
UnsRef("filling/line-1/station-1/temp", "temp"),
|
||||
},
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
|
||||
}
|
||||
|
||||
/// <summary>A plan with no additions yields no announcements.</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_empty_add_plan_yields_none()
|
||||
@@ -1557,12 +1582,17 @@ public sealed class AddressSpaceApplierTests
|
||||
/// 2026-08-15 review — the combined plan a backing-tag HISTORIAN edit produces: a surgical raw-tag
|
||||
/// delta AND the coincident UNS-reference delta (the reference record carries the backing historian
|
||||
/// intent, so it goes unequal too). Both are non-rebuild kinds, so ONE apply must update the raw node
|
||||
/// in place AND tear the stale UNS mirror down for the materialise pass to recreate — with NO rebuild.
|
||||
/// Without the coincident delta the UNS mirror kept the OLD historian tagname and HistoryRead through
|
||||
/// the UNS NodeId served a different series than through the raw NodeId.
|
||||
/// in place AND repair the stale UNS mirror — with NO rebuild. Without the coincident delta the UNS
|
||||
/// mirror kept the OLD historian tagname and HistoryRead through the UNS NodeId served a different
|
||||
/// series than through the raw NodeId.
|
||||
/// <para><b>The mirror is repaired IN PLACE (review I2), not by remove+recreate.</b> This delta's
|
||||
/// IDENTITY is unchanged (same NodeId, same EquipmentId, same BackingRawPath) — only the inherited
|
||||
/// Backing* attributes moved — so <c>UpdateTagAttributes</c> in the Uns realm says everything that
|
||||
/// needs saying and the mirror's client MonitoredItems survive. Remove+recreate stays for the deltas
|
||||
/// that genuinely change identity (a re-point or an effective-name edit).</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Backing_historian_edit_updates_raw_in_place_and_removes_the_stale_uns_mirror_without_rebuilding()
|
||||
public void Backing_historian_edit_updates_both_the_raw_node_and_the_uns_mirror_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -1590,21 +1620,26 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
// Raw node: updated in place with the NEW tagname, in the Raw realm.
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
call.Historian.ShouldBe("WW.New");
|
||||
call.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
var raw = sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw);
|
||||
raw.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
raw.Historian.ShouldBe("WW.New");
|
||||
|
||||
// UNS mirror: terminal Bad + in-place remove of the PREVIOUS node (the recreate rides the materialise
|
||||
// pass after Apply returns, which reads the CURRENT record's Backing* values).
|
||||
sink.ValueWrites.ShouldContain((unsBefore.NodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", unsBefore.NodeId, AddressSpaceRealm.Uns));
|
||||
// UNS mirror: the SAME in-place update, in the Uns realm, carrying the new inherited tagname.
|
||||
var uns = sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns);
|
||||
uns.NodeId.ShouldBe(unsAfter.NodeId);
|
||||
uns.Historian.ShouldBe("WW.New");
|
||||
|
||||
// …and NOT torn down: no remove, and no terminal Bad on the mirror's subscribers.
|
||||
sink.RemoveCalls.ShouldBeEmpty();
|
||||
sink.ValueWrites.ShouldNotContain((unsBefore.NodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>The same combined-plan repair for a scalar→ARRAY flip on the backing tag: without it the UNS
|
||||
/// mirror stays scalar AND writable, bypassing the array read-only write gate through the UNS NodeId.</summary>
|
||||
/// mirror stays scalar AND writable, bypassing the array read-only write gate through the UNS NodeId.
|
||||
/// Identity is unchanged, so the mirror is repaired in place too (review I2) — and the in-place call must
|
||||
/// carry the array shape AND the forced read-only, exactly as a recreate would have.</summary>
|
||||
[Fact]
|
||||
public void Backing_shape_flip_removes_the_stale_uns_mirror_without_rebuilding()
|
||||
public void Backing_shape_flip_updates_the_uns_mirror_in_place_without_rebuilding()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -1624,8 +1659,16 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().IsArray.ShouldBeTrue();
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", unsBefore.NodeId, AddressSpaceRealm.Uns));
|
||||
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw).IsArray.ShouldBeTrue();
|
||||
|
||||
var uns = sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns);
|
||||
uns.NodeId.ShouldBe(unsAfter.NodeId);
|
||||
uns.IsArray.ShouldBeTrue();
|
||||
uns.ArrayLength.ShouldBe(8u);
|
||||
// The reference row is authored Writable, but an array node is forced read-only — the same
|
||||
// `Writable && !BackingIsArray` rule MaterialiseUnsReferences applies when it creates the node.
|
||||
uns.Writable.ShouldBeFalse();
|
||||
sink.RemoveCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the
|
||||
|
||||
@@ -190,6 +190,51 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 review I3 — <c>RemoveVariableNode</c> must sever the INVERSE half of the cross-tree
|
||||
/// <c>Organizes</c> edge it holds, not just drop its own node. <c>AddReference</c> wires the edge
|
||||
/// bidirectionally (forward on the UNS reference variable, inverse on the backing RAW node), and the
|
||||
/// raw node SURVIVES a scoped remove — so without this the raw node keeps a browsable, unresolvable
|
||||
/// reference to a node that no longer exists. Rebuilds used to sweep these up; surgical applies made
|
||||
/// rebuilds rare, so they now accumulate one per re-point for the life of the process.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RemoveVariableNode_severs_the_inverse_Organizes_edge_on_the_raw_target()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp",
|
||||
dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
|
||||
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
|
||||
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
|
||||
|
||||
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
|
||||
unsVar.ShouldNotBeNull();
|
||||
// Precondition: the raw node really does hold the inverse half.
|
||||
nm.GetNodeReferences("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw)
|
||||
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && r.IsInverse &&
|
||||
r.TargetId == unsVar.NodeId).ShouldBeTrue();
|
||||
|
||||
nm.RemoveVariableNode("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
// The raw node survives…
|
||||
nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
// …carrying NO reference back to the node that just went away.
|
||||
nm.GetNodeReferences("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw)
|
||||
.Any(r => r.TargetId == unsVar.NodeId).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
|
||||
[Fact]
|
||||
public async Task RebuildAddressSpace_clears_all_registered_variables()
|
||||
|
||||
+113
-11
@@ -609,10 +609,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// 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.
|
||||
// composition. The passes are idempotent, but idempotent is not free. EnsureVariable / EnsureFolder
|
||||
// early-return on a ConcurrentDictionary hit BEFORE taking the node manager's lock, so an already-standing
|
||||
// node costs a dictionary probe, not a lock acquisition — but MaterialiseAlarmCondition DOES take the lock
|
||||
// every time, and the probes are still O(address space) per apply plus the composition walk producing them.
|
||||
// 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
|
||||
@@ -716,11 +719,17 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <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.
|
||||
/// still be applied, 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.
|
||||
/// <para><b>Re-pointed by review I2</b>, which changed HOW this shape is repaired: the delta's identity
|
||||
/// is unchanged (same NodeId, same equipment, same backing RawPath — only the inherited attributes
|
||||
/// moved), so it is now an IN-PLACE <c>UpdateTagAttributes</c> in the Uns realm rather than a
|
||||
/// remove+recreate. The client MonitoredItems on the UNS mirror survive an operator historizing the raw
|
||||
/// tag behind them. The historian-value assertion is unchanged — that is the actual repair being
|
||||
/// pinned; only the mechanism that delivers it moved.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Changed_uns_reference_is_recreated_when_the_backing_tag_flips_historian()
|
||||
public void Changed_uns_reference_is_updated_in_place_when_the_backing_tag_flips_historian()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
@@ -736,15 +745,18 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
historianTagnameForA: "WW.A");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
|
||||
// The mirror now carries the backing tag's historian tagname — the repair itself, unchanged by I2.
|
||||
AwaitAssert(
|
||||
() => sink.VariableDetails.ShouldContain(
|
||||
v => v.NodeId == "filling/line1/station1/a" && v.HistorianTagname == "WW.A"),
|
||||
() => sink.UpdateTagDetails.ShouldContain(
|
||||
u => u.NodeId == "filling/line1/station1/a" && u.HistorianTagname == "WW.A" &&
|
||||
u.Realm == AddressSpaceRealm.Uns),
|
||||
duration: PresenceBudget);
|
||||
Thread.Sleep(200); // let any (unwanted) remove+recreate land before asserting its absence
|
||||
|
||||
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
|
||||
second.ShouldContain("UT:filling/line1/station1/a"); // repaired in place…
|
||||
second.ShouldNotContain("RV:filling/line1/station1/a"); // …NOT torn down (subscriptions survive)
|
||||
}
|
||||
|
||||
/// <summary>An AttributeOnly apply (here a raw tag gaining a historian override, with no UNS reference to
|
||||
@@ -775,6 +787,85 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
second.ShouldContain("UT:Plant/Modbus/dev1/A"); // the in-place attribute update DID happen
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-15 surgical-deploys review C1 — THE headline degraded-apply test. Before the delta
|
||||
/// materialisation the full passes re-ensured EVERY node on EVERY apply, so a swallowed
|
||||
/// <c>EnsureVariable</c> fault silently repaired itself on the next deploy. The delta path removed that
|
||||
/// accidental self-healing: once <c>_lastApplied</c> records a composition, the failed node is never in
|
||||
/// a diff set again and <c>EnsureVariable</c>'s existing-node early-return means it is never re-created
|
||||
/// — the node is lost until a rebuild happens to fire. So a non-rebuild apply that reports
|
||||
/// <c>failedNodes > 0</c> must NOT advance <c>_lastApplied</c>: the next deploy re-diffs the same
|
||||
/// composition and re-ensures what failed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Degraded_non_rebuild_apply_does_not_advance_last_applied()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// The sink faults on exactly one of the two added variables — a PureAdd apply that lands B but
|
||||
// loses A, with no rebuild anywhere in sight.
|
||||
var sink = new RecordingSink { FailEnsureVariableFor = "Plant/Modbus/dev1/A" };
|
||||
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);
|
||||
Thread.Sleep(200);
|
||||
sink.RebuildCalls.ShouldBe(0); // genuinely a non-rebuild (PureAdd) apply
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// The fault clears (a transient sink fault, an SDK hiccup). Re-deploy the SAME composition: because
|
||||
// the degraded apply did not record it as applied, the planner re-diffs A (and B) as ADDED and the
|
||||
// materialise pass re-ensures them. With _lastApplied advanced this second apply would diff to an
|
||||
// EMPTY plan and A would be gone from the served address space forever.
|
||||
sink.FailEnsureVariableFor = null;
|
||||
var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" });
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
|
||||
AwaitAssert(
|
||||
() => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/A"),
|
||||
duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other half of C1's rule: a REBUILD that also reports failed nodes DOES advance
|
||||
/// <c>_lastApplied</c>. A rebuild already re-materialised the whole composition, and the applier's
|
||||
/// one-way rebuild ratchet is the resync mechanism for anything it could not place — holding the
|
||||
/// composition back here would make every subsequent deploy re-diff the entire address space as added.
|
||||
/// Proven by the third apply of the same composition being an empty-plan no-op.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_with_failures_still_advances_last_applied()
|
||||
{
|
||||
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);
|
||||
|
||||
// Renaming the equipment is ChangedEquipment ⇒ the classifier's default-closed Rebuild; the sink
|
||||
// faults on one variable of the full re-materialise that follows.
|
||||
sink.FailEnsureVariableFor = "Plant/Modbus/dev1/b";
|
||||
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);
|
||||
Thread.Sleep(200);
|
||||
var afterSecond = sink.Calls.Count;
|
||||
|
||||
// Re-deploy the SAME composition. _lastApplied advanced despite the failure, so this diffs to an
|
||||
// empty plan and adds no sink calls at all.
|
||||
sink.FailEnsureVariableFor = null;
|
||||
var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3)));
|
||||
Thread.Sleep(300);
|
||||
|
||||
sink.Calls.Count.ShouldBe(afterSecond);
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -894,6 +985,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
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 or sets the node id whose <see cref="EnsureVariable"/> throws (null ⇒ none). The
|
||||
/// applier's <c>SafeEnsureVariable</c> swallows the throw into its failed-node tally, which is exactly
|
||||
/// the degraded-apply shape the C1 no-advance rule exists for. Settable mid-test so a transient fault
|
||||
/// can clear between deploys.</summary>
|
||||
public volatile string? FailEnsureVariableFor;
|
||||
/// <summary>Gets every surgical in-place tag-attribute update with the attributes the applier resolved
|
||||
/// for it (the ordered <see cref="Calls"/> queue only carries node ids).</summary>
|
||||
public ConcurrentQueue<(string NodeId, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> UpdateTagDetails { get; } = new();
|
||||
/// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
|
||||
public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new();
|
||||
/// <summary>Gets the list of recorded surgical folder display-name update calls.</summary>
|
||||
@@ -935,6 +1034,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <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)
|
||||
{
|
||||
if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}");
|
||||
Calls.Enqueue($"EV:{variableNodeId}");
|
||||
VariableDetails.Enqueue((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
|
||||
}
|
||||
@@ -950,6 +1051,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
Calls.Enqueue($"UT:{variableNodeId}");
|
||||
UpdateTagDetails.Enqueue((variableNodeId, writable, historianTagname, realm));
|
||||
return true;
|
||||
}
|
||||
/// <summary>Records a surgical in-place folder display-name update (always succeeds in this recording sink).</summary>
|
||||
|
||||
Reference in New Issue
Block a user