test(otopcua): cover discovered-node rebind drop + clarify re-apply scope
This commit is contained in:
@@ -1245,11 +1245,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
_lastComposition = composition;
|
_lastComposition = composition;
|
||||||
|
|
||||||
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
|
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
|
||||||
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, every redeploy /
|
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
|
||||||
// bootstrap-restore would drop the injected FixedTree routes + materialised nodes until the driver
|
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
|
||||||
// happens to reconnect and re-discover. Re-resolve each cached driver's equipment from the CURRENT
|
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
|
||||||
// composition; drop the cache entry if the driver/equipment no longer resolves to exactly one (a rebind
|
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
|
||||||
// or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
|
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from Task 6's
|
||||||
|
// post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's equipment from the
|
||||||
|
// CURRENT composition; drop the cache entry if the driver/equipment no longer resolves to exactly one
|
||||||
|
// (a rebind or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
|
||||||
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
|
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
|
||||||
{
|
{
|
||||||
var plan = _discoveredByDriver[driverId];
|
var plan = _discoveredByDriver[driverId];
|
||||||
@@ -1267,6 +1270,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
var equipmentId = equipmentIds[0];
|
var equipmentId = equipmentIds[0];
|
||||||
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
|
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
|
||||||
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
|
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
|
||||||
|
// KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a
|
||||||
|
// CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's
|
||||||
|
// EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery —
|
||||||
|
// ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is
|
||||||
|
// never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment
|
||||||
|
// until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger
|
||||||
|
// logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the
|
||||||
|
// safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse).
|
||||||
var planEquipmentConsistent = plan.Variables.Count > 0
|
var planEquipmentConsistent = plan.Variables.Count > 0
|
||||||
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
|
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
|
||||||
if (!planEquipmentConsistent)
|
if (!planEquipmentConsistent)
|
||||||
|
|||||||
@@ -341,6 +341,50 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the
|
||||||
|
/// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly
|
||||||
|
/// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD
|
||||||
|
/// equipment (EQ-1), so the <c>StartsWith(equipmentId + "/")</c> guard sees they no longer match EQ-2 and
|
||||||
|
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
|
||||||
|
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
|
||||||
|
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new SubscribingDriverFactory("Modbus");
|
||||||
|
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||||
|
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||||
|
|
||||||
|
var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||||
|
{
|
||||||
|
new DiscoveredNode(
|
||||||
|
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
|
||||||
|
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
|
||||||
|
Writable: false, IsHistorized: false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1).
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
|
||||||
|
|
||||||
|
// Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still
|
||||||
|
// resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is
|
||||||
|
// unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the
|
||||||
|
// guard's known-limitation comment describes.
|
||||||
|
var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
|
||||||
|
(Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||||
|
actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
|
// After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached
|
||||||
|
// EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/").
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
|
||||||
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the
|
/// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the
|
||||||
/// deployment, and waits for the Applied ACK so the apply (and thus <c>_lastComposition</c> + the live
|
/// deployment, and waits for the Applied ACK so the apply (and thus <c>_lastComposition</c> + the live
|
||||||
/// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A
|
/// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A
|
||||||
|
|||||||
Reference in New Issue
Block a user