186 lines
8.6 KiB
C#
186 lines
8.6 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.Event;
|
|
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.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>
|
|
/// Surgical-deploys Task 7 Part A — the artifact-cache write is I/O (hundreds of chunk inserts
|
|
/// into LocalDb) and must not run on the actor thread.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The ACK was never the thing at risk: <c>HandleArtifactForApply</c> sends it BEFORE it caches, so a
|
|
/// blocking store still acked promptly. What it blocked was <b>everything after</b> — the host
|
|
/// could not dequeue a driver value burst, a diagnostics request or the next dispatch until
|
|
/// SQLite was done. So the load-bearing assertion here is that the actor answers an unrelated
|
|
/// message while the store is still in flight, not that the ACK arrived.
|
|
/// </para>
|
|
/// <para>
|
|
/// Runs at <c>INFO</c> so the completion self-message's log line is observable — the pipe-back
|
|
/// is private, and its visible effect is that line.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverHostActorAsyncCacheWriteTests()
|
|
: RuntimeActorTestBase(AkkaTestHoconAtLevel("INFO"))
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
|
|
[Fact]
|
|
public void AStillRunningCacheStore_DoesNotBlockTheHostMailbox()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var cache = new GatedArtifactCache();
|
|
var deploymentId = SeedDeployment(db, RevA);
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
deploymentArtifactCache: cache));
|
|
|
|
try
|
|
{
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
|
|
// The store was entered and has NOT completed — the gate is still closed.
|
|
AwaitAssert(() => cache.StoresEntered.ShouldBe(1), duration: PresenceBudget);
|
|
cache.StoresCompleted.ShouldBe(0);
|
|
|
|
// THE assertion: with the store still in flight the host is still processing its mailbox.
|
|
// A blocking `.GetAwaiter().GetResult()` parks the actor thread here and this times out.
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
coordinator.ExpectMsg<NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(3));
|
|
|
|
// Completion is observed rather than fire-and-forgotten: the store pipes back to Self and
|
|
// the handler logs it, so an operator can tell a cached apply from an uncached one.
|
|
EventFilter.Info(contains: "cached applied deployment")
|
|
.ExpectOne(TimeSpan.FromSeconds(5), () => cache.Release());
|
|
}
|
|
finally
|
|
{
|
|
// Never leave the fake gated: a parked store would hang ActorSystem shutdown.
|
|
cache.Release();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AFailingCacheStore_WarnsAndLeavesTheApplyApplied()
|
|
{
|
|
// The cache is a boot fallback, not part of the deployment protocol. A failed store must
|
|
// surface as a Warning and nothing else — emphatically NOT as a second, contradictory Failed
|
|
// ACK for a deployment the fleet already believes is live.
|
|
var db = NewInMemoryDbFactory();
|
|
var deploymentId = SeedDeployment(db, RevA);
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
deploymentArtifactCache: new AsyncFailingArtifactCache()));
|
|
|
|
// Matched on the EXCEPTION, not just the prose: the pipe-back carries the Exception itself and
|
|
// the handler logs it as the event's Cause. A SQLite failure is routinely a wrapped inner
|
|
// exception whose outer Message names neither the result code nor the failing frame, so a
|
|
// Reason-string-only carrier would have thrown that away before the log line was written.
|
|
// (Custom rather than EventFilter.Exception<T>, which only ever matches ERROR-level events —
|
|
// a failed cache write is deliberately a Warning, since the apply itself succeeded.)
|
|
EventFilter.Custom<Warning>(e =>
|
|
e.Cause is InvalidOperationException &&
|
|
e.Message?.ToString()?.Contains("could not be cached", StringComparison.Ordinal) == true)
|
|
.ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
{
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
});
|
|
|
|
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
/// <summary>Seeds a sealed, parseable deployment and returns its id.</summary>
|
|
private static DeploymentId SeedDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
|
{
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty<object>() });
|
|
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>
|
|
/// A store that never completes until the test releases it — the fake shape of a slow LocalDb
|
|
/// write. It must not complete synchronously, or it would prove nothing about the mailbox.
|
|
/// </summary>
|
|
private sealed class GatedArtifactCache : IDeploymentArtifactCache
|
|
{
|
|
private readonly TaskCompletionSource _gate =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private int _entered;
|
|
private int _completed;
|
|
|
|
public int StoresEntered => Volatile.Read(ref _entered);
|
|
public int StoresCompleted => Volatile.Read(ref _completed);
|
|
|
|
public void Release() => _gate.TrySetResult();
|
|
|
|
public async Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
|
|
byte[] artifact, CancellationToken ct = default)
|
|
{
|
|
Interlocked.Increment(ref _entered);
|
|
await _gate.Task.ConfigureAwait(false);
|
|
Interlocked.Increment(ref _completed);
|
|
}
|
|
|
|
public Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default)
|
|
=> Task.FromResult<CachedDeploymentArtifact?>(null);
|
|
|
|
public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
|
|
=> Task.FromResult<CachedDeploymentArtifact?>(null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fails the store ASYNCHRONOUSLY (a faulted task), which is how a real LocalDb write fails —
|
|
/// as distinct from <c>DriverHostActorArtifactCacheTests</c>'s synchronously-throwing fake,
|
|
/// which exercises the other half of the guard.
|
|
/// </summary>
|
|
private sealed class AsyncFailingArtifactCache : IDeploymentArtifactCache
|
|
{
|
|
public async Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
|
|
byte[] artifact, CancellationToken ct = default)
|
|
{
|
|
await Task.Yield();
|
|
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);
|
|
}
|
|
}
|