648f173b18
Phase 3 Task 5. Under ConfigSource:Mode=FetchAndCache, a DispatchDeployment whose revision differs from _currentRevision kicks off a gRPC artifact fetch that PipeTo's its result back to Self as FetchedForApply (never a blocking await in a receive — that would freeze the mailbox for the fetch deadline). HandleFetchedForApply then applies synchronously on the actor thread, mirroring ApplyAndAck: null bytes = apply FAILURE (keep last-known-good, do not advance the revision, ack Failed — #485); non-null bytes reconcile drivers, rebuild the address space and push subscriptions FROM the bytes in hand (OpcUaPublishActor never reads central SQL), then cache them. A faulted fetch task maps to null bytes so it can't escape as an unhandled message. Extracted ReconcileDriversFromBlob from ReconcileDrivers so Direct-read, FetchAndCache and boot-from-cache share one reconcile body (the #485 empty guard moves into it); ApplyCachedArtifact now reuses it instead of duplicating the spawn-plan logic. DI: DriverHostActor.Props gains fetchAndCacheMode + artifactFetcher (LAST, per the positional-forwarding warning); Runtime resolves them from IOptions<ConfigSourceOptions> (fetcher only in FetchAndCache mode); Host registers GrpcDeploymentArtifactFetcher under hasDriver. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
154 lines
6.7 KiB
C#
154 lines
6.7 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.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>
|
|
/// Per-cluster mesh Phase 3 (Task 5) — the <c>FetchAndCache</c> apply path: on dispatch the node
|
|
/// fetches the artifact bytes over gRPC, applies them from bytes in hand, and caches them —
|
|
/// reading NO config from central SQL. A null fetch is an apply failure that keeps last-known-good
|
|
/// and does not advance the revision (#485).
|
|
/// </summary>
|
|
public sealed class DriverHostActorFetchAndCacheTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
|
|
/// <summary>Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in.</summary>
|
|
private static byte[] ArtifactBytes() =>
|
|
JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty<object>() });
|
|
|
|
[Fact]
|
|
public void FetchReturningGoodBytes_AppliesAndCaches_WithoutReadingCentralSql()
|
|
{
|
|
// No Deployment row is seeded: if the apply path read ArtifactBlob from SQL it would get empty
|
|
// bytes and FAIL (#485). Reaching Applied proves the bytes came from the fetcher, not SQL.
|
|
var db = NewInMemoryDbFactory();
|
|
var cache = new RecordingArtifactCache();
|
|
var fetcher = new RecordingFetcher(ArtifactBytes());
|
|
var deploymentId = DeploymentId.NewId();
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
deploymentArtifactCache: cache,
|
|
fetchAndCacheMode: true,
|
|
artifactFetcher: fetcher));
|
|
|
|
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));
|
|
cache.Stores[0].RevisionHash.ShouldBe(RevA.Value);
|
|
fetcher.Calls.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void FetchReturningNull_FailsTheApply_KeepsRevisionUnchanged_AndCachesNothing()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var cache = new RecordingArtifactCache();
|
|
var fetcher = new RecordingFetcher(bytes: null); // all endpoints down / NotFound / SHA mismatch
|
|
var deploymentId = DeploymentId.NewId();
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
deploymentArtifactCache: cache,
|
|
fetchAndCacheMode: true,
|
|
artifactFetcher: fetcher));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed);
|
|
|
|
Thread.Sleep(300);
|
|
cache.Stores.ShouldBeEmpty();
|
|
|
|
// The revision was NOT advanced: a re-dispatch of the SAME revision fetches AGAIN rather than
|
|
// short-circuiting as "already current". (Two fetch attempts prove #485's keep-last-known-good.)
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Failed);
|
|
fetcher.Calls.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void RedispatchOfTheAppliedRevision_DoesNotFetchAgain()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var cache = new RecordingArtifactCache();
|
|
var fetcher = new RecordingFetcher(ArtifactBytes());
|
|
var deploymentId = DeploymentId.NewId();
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
deploymentArtifactCache: cache,
|
|
fetchAndCacheMode: true,
|
|
artifactFetcher: fetcher));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
|
|
// Re-dispatch the now-current revision: the _currentRevision guard short-circuits to an
|
|
// immediate ACK without a second fetch.
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
|
|
Thread.Sleep(200);
|
|
fetcher.Calls.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Records fetch calls and returns canned bytes (or null for "no answer").</summary>
|
|
private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher
|
|
{
|
|
private int _calls;
|
|
public int Calls => Volatile.Read(ref _calls);
|
|
|
|
public Task<byte[]?> FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref _calls);
|
|
return Task.FromResult(bytes);
|
|
}
|
|
}
|
|
|
|
/// <summary>Records every store; GetCurrent* return null (no idempotency shortcut in these tests).</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);
|
|
}
|
|
}
|