feat(runtime): DriverHost spawns + subscribes only its own ClusterId's drivers

This commit is contained in:
Joseph Doherty
2026-06-07 03:19:22 -04:00
parent 4fca4e1aca
commit 1b7f995aea
2 changed files with 82 additions and 2 deletions
@@ -364,7 +364,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return;
}
var specs = DeploymentArtifact.ParseDriverInstances(blob);
var specs = DeploymentArtifact.ParseDriverInstances(blob, _localNode.Value);
var snapshots = _children.ToDictionary(
kv => kv.Key,
kv => new DriverChildSnapshot(kv.Value.DriverType, kv.Value.LastConfigJson),
@@ -429,7 +429,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Phase7CompositionResult composition;
try
{
composition = DeploymentArtifact.ParseComposition(blob);
composition = DeploymentArtifact.ParseComposition(blob, _localNode.Value);
}
catch (Exception ex)
{
@@ -132,6 +132,86 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
snap.Drivers.Count.ShouldBe(1);
}
/// <summary>
/// Verifies per-ClusterId scoping at the actor level: a 2-cluster artifact (MAIN + SITE-A,
/// one driver each) dispatched to a node whose ClusterNode row puts it in SITE-A spawns ONLY
/// the SITE-A driver — and the node still reaches Applied (the ack fires unconditionally even
/// when a node's cluster slice is empty).
/// </summary>
[Fact]
public void DriverHostActor_spawns_only_its_clusters_drivers()
{
var db = NewInMemoryDbFactory();
var factory = new CountingDriverFactory("Modbus");
// Both drivers are Modbus so the factory could create either — scoping, not type support,
// must be what excludes the MAIN driver.
var deploymentId = SeedMultiClusterDeployment(db, RevA,
("main-modbus", "Modbus", "{}", true, "MAIN"),
("sa-modbus", "Modbus", "{}", true, "SITE-A"));
// This node belongs to SITE-A per the Nodes (ClusterNode) rows.
var siteANode = NodeId.Parse("site-a-1:4053");
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, siteANode, coordinator.Ref,
driverFactory: factory,
localRoles: new HashSet<string> { "driver" }));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
// The node still reaches Applied even though it hosts only its own cluster's slice.
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// Only the SITE-A driver was constructed — the MAIN driver was filtered out by scoping.
AwaitAssert(() => factory.CreateCount.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
snap.Drivers.Count.ShouldBe(1);
snap.Drivers[0].Name.ShouldBe("sa-modbus");
}
private static DeploymentId SeedMultiClusterDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> db,
RevisionHash rev,
params (string Id, string Type, string Config, bool Enabled, string ClusterId)[] drivers)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
// >1 cluster + matching Nodes rows triggers ScopeTo (single-cluster would resolve to None).
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
Nodes = new[]
{
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
},
DriverInstances = drivers.Select(d => new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = d.Id,
Name = d.Id,
DriverType = d.Type,
Enabled = d.Enabled,
DriverConfig = d.Config,
ClusterId = d.ClusterId,
}).ToArray(),
});
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;
}
private static DeploymentId SeedDeploymentWithDrivers(
IDbContextFactory<OtOpcUaConfigDbContext> db,
RevisionHash rev,