fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)

H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 11:30:13 -04:00
parent 77c39bf02d
commit 2e0743ad25
34 changed files with 616 additions and 265 deletions
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -56,7 +57,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var asker = CreateTestProbe();
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
@@ -79,7 +80,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
@@ -110,7 +111,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
CorrelationId.NewId()));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -129,7 +130,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -149,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
localRoles: new HashSet<string> { "driver" }));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
result.Success.ShouldBeFalse();
@@ -157,6 +158,46 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
result.Reason!.ShouldContain("stale");
}
/// <summary>
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
/// </summary>
[Fact]
public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm()
{
var db = NewInMemoryDbFactory();
var factory = new PerInstanceRecordingDriverFactory("Modbus");
var deploymentId = SeedCollisionDeployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, factory);
// Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D.
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref);
asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
// Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W.
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref);
asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
// drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222).
var w1 = factory.WritesFor("drv-1");
var w2 = factory.WritesFor("drv-2");
w1.Count.ShouldBe(1);
w1[0].FullReference.ShouldBe("A/B/C/D");
w1[0].Value.ShouldBe(111.0);
w2.Count.ShouldBe(1);
w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id
w2[0].Value.ShouldBe(222.0);
}, duration: Timeout);
}
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
@@ -214,6 +255,52 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
return id;
}
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[]
{
new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" },
new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[]
{
new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" },
new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } },
// Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2.
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } },
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed,
CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
/// into the Stale path.</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
@@ -223,6 +310,41 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
throw new InvalidOperationException("config DB unreachable (test stub)");
}
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
private readonly object _lock = new();
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the given driver instance received.</summary>
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
{
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
}
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
lock (_lock)
{
if (!_byId.TryGetValue(driverInstanceId, out var d))
{
d = new RecordingDriver();
d.Bind(driverInstanceId, driverType);
_byId[driverInstanceId] = d;
}
return d;
}
}
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
private sealed class RecordingDriverFactory : IDriverFactory
{