Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorArtifactCacheTests.cs
T
Joseph Doherty ef41a43102 fix(drivers): fail the apply when the artifact could not be read (#486)
ApplyAndAck advanced _currentRevision and recorded NodeDeploymentState=Applied
immediately after ReconcileDrivers, which returns null when the artifact could
not be read. So a node that applied NOTHING reported success, claimed a
revision whose configuration it never applied, and — because
HandleDispatchFromSteady short-circuits on a revision match — could never be
healed by re-dispatching that revision. Only a later, different revision
recovered it.

Now a null blob fails the apply: the revision is left where it was,
NodeDeploymentState records Failed with the reason, and the coordinator gets a
Failed ACK. Leaving the revision alone is what makes the retry actually land
instead of being waved through.

This is about telling the truth, not about tearing anything down — the node
keeps serving its last-known-good address space, drivers and subscriptions
throughout (#485).

Tests, RED-first (both verified failing with "should be Failed but was
Applied"): the ACK/revision assertion, plus the one that matters — after a
failed apply, re-dispatching the SAME revision now genuinely applies. Its
recovered artifact adds a second driver precisely so a short-circuited ACK
(which has no side effects) cannot satisfy it.

Four existing tests changed, and both changes are deliberate:

- DriverHostActorTests.SeedDeployment never set ArtifactBlob at all. Its three
  consumers are about the apply/ACK/state machine, not the artifact, and now
  need a genuine apply — so the helper seeds a well-formed artifact declaring
  no drivers. That is what the composer emits for an empty configuration, and
  is precisely what "bytes we could not read" is NOT.
- EmptyArtifact_IsNotCached asserted "the apply still succeeds — an empty
  artifact is a legitimate no-op deployment". That premise is the bug. Flipped
  to Failed; the test's actual subject (nothing is cached) is untouched and now
  holds for two independent reasons.

Closes #486

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 03:22:40 -04:00

276 lines
12 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
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.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// LocalDb Phase 1 (Task 7) — the write half of the deployment-artifact cache.
/// </summary>
/// <remarks>
/// What matters here is not that a store happens, but the conditions under which it must NOT:
/// a failed apply, and — less obviously — a "successful" apply whose artifact never actually
/// loaded. See <c>ReconcileDrivers</c>: it swallows its own DB failures, so apply success does
/// not imply a real configuration was applied.
/// </remarks>
public sealed class DriverHostActorArtifactCacheTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
[Fact]
public void SuccessfulApply_StoresTheArtifactInTheCache()
{
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var (deploymentId, artifact) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
var store = cache.Stores[0];
store.DeploymentId.ShouldBe(deploymentId.ToString());
store.RevisionHash.ShouldBe(RevA.Value);
store.Artifact.ShouldBe(artifact);
}
[Fact]
public void ApplyOfAClusterScopedArtifact_StoresUnderThatClusterId()
{
// The ClusterId is not config and not on IClusterRoleInfo — it is carried in the artifact's
// Nodes[] rows. Getting this wrong keys the pair's two nodes to different cache rows, and
// they would silently stop sharing a cache entry.
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: "SITE-A");
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
cache.Stores[0].ClusterId.ShouldBe("SITE-A");
}
[Fact]
public void UnscopedArtifact_StoresUnderTheSingleClusterSentinel()
{
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));
AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
cache.Stores[0].ClusterId.ShouldBe("__single");
}
[Fact]
public void EmptyArtifact_IsNotCached()
{
// THE case that makes this cache safe to boot from. ReconcileDrivers catches its own DB
// failures, logs a warning and returns without rethrowing — so an apply can reach its
// success path, ACK Applied, and have started zero drivers. Persisting that as
// last-known-good would boot the node into an empty address space during the next outage:
// strictly worse than having no cache at all. An empty blob is the observable form of it.
var db = NewInMemoryDbFactory();
var cache = new RecordingArtifactCache();
var deploymentId = DeploymentId.NewId();
using (var ctx = db.CreateDbContext())
{
ctx.Deployments.Add(new Deployment
{
DeploymentId = deploymentId.Value,
RevisionHash = RevA.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = Array.Empty<byte>(),
});
ctx.SaveChanges();
}
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
deploymentArtifactCache: cache));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
// Since #486 the apply FAILS rather than reporting a success it did not achieve: empty bytes are
// what an unreadable artifact looks like, not a legitimate no-op deployment. (A configuration that
// genuinely declares nothing still serialises to a JSON document with empty arrays.)
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed);
// The point of this test is unchanged, and now holds for two independent reasons: nothing is cached.
Thread.Sleep(500);
cache.Stores.ShouldBeEmpty();
}
[Fact]
public void AThrowingCache_DoesNotFailTheApply()
{
// By the time the cache runs, an Applied ACK has already gone to the coordinator. If a cache
// failure escaped, ApplyAndAck's catch would send a SECOND, contradictory Failed ACK for a
// deployment the fleet already believes is live.
var db = NewInMemoryDbFactory();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
deploymentArtifactCache: new ThrowingArtifactCache()));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// Exactly one ACK, and it says Applied. A second Failed ACK arriving here would mean the
// cache failure had corrupted the deployment protocol.
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void NoCacheConfigured_AppliesNormally()
{
// Admin-only graphs and most test harnesses pass null. Caching is skipped, nothing else changes.
var db = NewInMemoryDbFactory();
var (deploymentId, _) = SeedDeployment(db, RevA, clusterId: null);
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" }));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
}
/// <summary>
/// Seeds a sealed deployment whose artifact optionally scopes <see cref="TestNode"/> to a
/// cluster, and returns the exact bytes stored so a round-trip can be asserted.
/// </summary>
private static (DeploymentId Id, byte[] Artifact) SeedDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string? clusterId)
{
object payload = clusterId is null
? new
{
DriverInstances = Array.Empty<object>(),
}
: new
{
DriverInstances = Array.Empty<object>(),
// ResolveClusterScope only filters when the artifact declares MORE than one cluster;
// a single-cluster artifact is treated as unscoped.
Clusters = new object[]
{
new { ClusterId = clusterId },
new { ClusterId = "OTHER" },
},
Nodes = new object[]
{
new { NodeId = TestNode.Value, ClusterId = clusterId },
},
};
var artifact = JsonSerializer.SerializeToUtf8Bytes(payload);
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, artifact);
}
/// <summary>Records every store so the test can assert on what was cached.</summary>
private sealed class RecordingArtifactCache : IDeploymentArtifactCache
{
private readonly Lock _gate = new();
private readonly List<StoreCall> _stores = [];
public IReadOnlyList<StoreCall> Stores
{
get { lock (_gate) { return _stores.ToArray(); } }
}
public Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
byte[] artifact, CancellationToken ct = default)
{
lock (_gate)
{
_stores.Add(new StoreCall(clusterId, deploymentId, revisionHash, artifact));
}
return Task.CompletedTask;
}
public Task<CachedDeploymentArtifact?> GetCurrentAsync(
string clusterId, CancellationToken ct = default)
=> Task.FromResult<CachedDeploymentArtifact?>(null);
public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
=> Task.FromResult<CachedDeploymentArtifact?>(null);
internal sealed record StoreCall(
string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact);
}
/// <summary>Fails every store, to prove a cache fault cannot reach the deployment protocol.</summary>
private sealed class ThrowingArtifactCache : IDeploymentArtifactCache
{
public Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
byte[] artifact, CancellationToken ct = default)
=> throw new InvalidOperationException("disk full");
public Task<CachedDeploymentArtifact?> GetCurrentAsync(
string clusterId, CancellationToken ct = default)
=> Task.FromResult<CachedDeploymentArtifact?>(null);
public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
=> Task.FromResult<CachedDeploymentArtifact?>(null);
}
}