Merge fix/485 driver-side: keep drivers + subscriptions when an artifact reads back empty
v2-ci / build (push) Successful in 4m20s
v2-ci / unit-tests (push) Failing after 9m37s

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-21 02:40:18 -04:00
4 changed files with 275 additions and 3 deletions
@@ -76,3 +76,11 @@ failed. The log excerpt above is now impossible: the `PureRemove` never runs, an
`OpcUaPublishActorRebuildTests.Rebuild_keeps_the_last_known_good_address_space_when_the_artifact_load_fails`,
with a positive control proving a *readable* artifact that genuinely drops an equipment still tears its
subtree down.
The **driver-side half** of the same mistake was fixed alongside it: `DriverHostActor.ReconcileDrivers`
and `PushDesiredSubscriptionsFromArtifact` read the artifact the same way, so empty bytes meant zero
driver specs — `DriverSpawnPlanner` planned every running child for `StopChild` — and then an empty
desired set dropped each surviving driver's live subscription handle. A node lost its entire field I/O
and still ACKed Applied. Both now skip on empty. Covered by `DriverHostActorUnreadableArtifactTests`,
whose absence assertion is calibrated by a control that observes the very same unsubscribe *arriving*
inside the settle window (without it the assertion passed on a race).
@@ -1618,9 +1618,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <summary>
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
/// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) or the
/// configured <see cref="IDriverFactory"/> can't materialise any of the requested
/// types, this is effectively a no-op.
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) the reconcile is
/// SKIPPED outright — see the issue #485 guard below — and when the configured
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op.
/// </summary>
/// <returns>
/// The artifact blob that was reconciled, or <see langword="null"/> when it could not be
@@ -1653,6 +1653,20 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return null;
}
// Issue #485 — no bytes is no answer, not "a configuration with no drivers". Parsing zero bytes
// yields zero specs, and DriverSpawnPlanner then plans EVERY running child for StopChild: a missing
// deployment row (or a half-written one) silently takes this node's entire field I/O down while the
// apply still ACKs Applied. Nothing legitimate produces a zero-length blob — deleting the last
// driver still deploys a JSON document with empty arrays — so treat it exactly like the load
// failure above and leave the running children alone.
if (blob.Length == 0)
{
_log.Warning(
"DriverHost {Node}: artifact for {Id} read back empty; skipping reconcile and keeping the {Count} running driver(s)",
_localNode, deploymentId, _children.Count);
return null;
}
var specs = DeploymentArtifact.ParseDriverInstances(blob, _localNode.Value);
var snapshots = _children.ToDictionary(
kv => kv.Key,
@@ -1814,6 +1828,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </remarks>
private void PushDesiredSubscriptionsFromArtifact(DeploymentId deploymentId, byte[] blob)
{
// Issue #485 — the same "no bytes is no answer" rule as ReconcileDrivers. An empty artifact parses
// to a composition with no raw tags, which hands every child an EMPTY desired set — and an empty set
// drops the live subscription handle rather than re-subscribing. Reachable independently of the
// reconcile guard: this method does its OWN ConfigDb read, so the row can go missing between the two.
if (blob.Length == 0)
{
_log.Warning(
"DriverHost {Node}: artifact for {Id} read back empty; skipping SubscribeBulk and keeping the live subscriptions",
_localNode, deploymentId);
return;
}
AddressSpaceComposition composition;
try
{
@@ -0,0 +1,233 @@
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Issue #485 (driver-side half) — an artifact the host could not obtain must not be mistaken for a
/// configuration that contains nothing.
/// </summary>
/// <remarks>
/// <para>
/// <c>ReconcileDrivers</c> and <c>PushDesiredSubscriptions</c> both read the artifact as
/// <c>…FirstOrDefault() ?? Array.Empty&lt;byte&gt;()</c>. The <b>throw</b> path in each already
/// returns early, but a row that is absent (or carries a zero-length blob) yields empty bytes that
/// flow onwards as a real answer: zero driver specs, so <c>DriverSpawnPlanner</c> plans every
/// running child for <c>StopChild</c>, and then an empty desired-subscription set drops each
/// surviving driver's live handle. A node loses its entire field I/O and still ACKs Applied.
/// </para>
/// <para>
/// The same reasoning as the address-space half applies: nothing legitimate produces a zero-length
/// blob — an operator who really deletes every driver still deploys a JSON document with empty
/// arrays — so "no bytes" can only mean the read did not answer. Seeding a row whose
/// <c>ArtifactBlob</c> is empty reproduces exactly the bytes the missing-row case delivers, without
/// needing to race a delete against the two reads.
/// </para>
/// </remarks>
public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>How long to let post-ACK subscription traffic settle before asserting it did NOT happen.</summary>
private static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
private const string SpeedRef = "Plant/Modbus/dev1/speed";
/// <summary>A dispatch whose artifact reads back as no bytes must leave the running drivers — and their
/// live subscriptions — exactly as they were.</summary>
[Fact]
public void Dispatch_whose_artifact_reads_back_empty_keeps_the_drivers_and_their_subscriptions()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
// Baseline: the child is up and subscribed to its one raw tag.
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1");
// The next dispatch's artifact comes back as no bytes at all.
actor.Tell(new DispatchDeployment(SeedUnreadableDeployment(db, RevB), RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout);
// The ACK precedes the SubscribeBulk pass, and the child's unsubscribe is a further async self-tell,
// so settle before asserting an ABSENCE. SettleWindow is calibrated by
// <see cref="Dropping_a_drivers_last_tag_does_clear_its_subscription"/>, which observes the very same
// unsubscribe ARRIVING well inside it — without that control this assertion would pass on a race.
AskDiagnostics(actor); // ordering barrier through the host's mailbox
Thread.Sleep(SettleWindow);
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // not stopped
factory.UnsubscribeCount.ShouldBe(0); // handle not dropped
factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef);
}
/// <summary>Positive control for the subscription half: a READABLE artifact that keeps the driver but
/// drops its last tag genuinely does clear the live subscription — the child receives an empty desired
/// set and unsubscribes. This proves both that the unsubscribe is observable through this factory and
/// that it lands well within <see cref="SettleWindow"/>, so the absence asserted above is real.</summary>
[Fact]
public void Dropping_a_drivers_last_tag_does_clear_its_subscription()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
factory.UnsubscribeCount.ShouldBe(0);
actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout);
AwaitAssert(() => factory.UnsubscribeCount.ShouldBe(1), duration: SettleWindow);
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // the driver itself stayed
}
/// <summary>Positive control: the guard keys on "the read gave us nothing", not on "the new config has
/// fewer drivers". A READABLE artifact that genuinely drops the driver still stops it — otherwise the
/// test above would pass just as happily against a host that had stopped reconciling altogether.</summary>
[Fact]
public void Dispatch_whose_readable_artifact_genuinely_drops_the_driver_still_stops_it()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
// A real artifact that simply carries no drivers — an operator deleting the last driver.
actor.Tell(new DispatchDeployment(SeedDriverlessDeployment(db, RevB), RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout);
AwaitAssert(
() => AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldNotContain("drv-1"),
duration: Timeout);
}
/// <summary>Spawns the host with the subscribing factory, dispatches <paramref name="deploymentId"/> and
/// waits for the Applied ACK so the child + the initial SubscribeBulk pass are in place.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
{
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: factory,
localRoles: new HashSet<string> { "driver" }));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return (actor, coordinator);
}
private NodeDiagnosticsSnapshot AskDiagnostics(IActorRef actor)
{
var probe = CreateTestProbe();
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), probe.Ref);
return probe.ExpectMsg<NodeDiagnosticsSnapshot>(Timeout);
}
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>).</summary>
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
SeedDeployment(db, rev, JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
}));
/// <summary>Seeds a Sealed deployment whose <c>ArtifactBlob</c> is zero-length — byte-for-byte what the
/// host's <c>?? Array.Empty&lt;byte&gt;()</c> hands downstream when the row cannot be found.</summary>
private static DeploymentId SeedUnreadableDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
SeedDeployment(db, rev, Array.Empty<byte>());
/// <summary>Seeds a Sealed deployment carrying a real, readable artifact that keeps drv-1 (so its child
/// survives the reconcile) but declares no tags for it — the driver's desired set becomes empty.</summary>
private static DeploymentId SeedTaglessDriverDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
SeedDeployment(db, rev, JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = Array.Empty<object>(),
}));
/// <summary>Seeds a Sealed deployment carrying a real, readable artifact that declares no drivers.</summary>
private static DeploymentId SeedDriverlessDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
SeedDeployment(db, rev, JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = Array.Empty<object>(),
DriverInstances = Array.Empty<object>(),
Devices = Array.Empty<object>(),
TagGroups = Array.Empty<object>(),
Tags = Array.Empty<object>(),
}));
private static DeploymentId SeedDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, byte[] artifact)
{
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>Factory producing one shared <see cref="SubscribableStubDriver"/> for the supported type, so a
/// REAL (non-stubbed) <see cref="DriverInstanceActor"/> child spawns and its subscribe/unsubscribe traffic
/// is observable.</summary>
private sealed class SubscribingDriverFactory(string supportedType) : IDriverFactory
{
private readonly SubscribableStubDriver _driver = new();
/// <summary>The reference set passed to the driver's most recent <c>SubscribeAsync</c> call.</summary>
public IReadOnlyList<string>? LastSubscribedRefs => _driver.LastSubscribedRefs;
/// <summary>Number of <c>UnsubscribeAsync</c> calls — non-zero means a live handle was torn down.</summary>
public int UnsubscribeCount => Volatile.Read(ref _driver.UnsubscribeCount);
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
string.Equals(driverType, supportedType, StringComparison.Ordinal) ? _driver : null;
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { supportedType };
}
}
@@ -104,6 +104,10 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
/// <summary>The reference set passed to the most recent <see cref="SubscribeAsync"/> call.</summary>
public IReadOnlyList<string>? LastSubscribedRefs;
/// <summary>Number of times <see cref="UnsubscribeAsync"/> was called — the observable for a live
/// subscription being torn down (an EMPTY desired set drops the handle rather than re-subscribing).</summary>
public int UnsubscribeCount;
/// <summary>When true, <see cref="UnsubscribeAsync"/> genuinely yields (<c>await Task.Yield()</c>)
/// before completing, so a <c>ConfigureAwait(false)</c> continuation in the actor resumes off the
/// Akka ActorContext on a thread-pool thread — reproducing the no-ActorContext race that a
@@ -127,6 +131,7 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
Interlocked.Increment(ref UnsubscribeCount);
if (UnsubscribeYields)
{
// Complete the awaited task from a fresh background thread that has NO Akka actor