ef41a43102
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
276 lines
12 KiB
C#
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);
|
|
}
|
|
}
|