153 lines
6.8 KiB
C#
153 lines
6.8 KiB
C#
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.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Perf roadmap Task 26 — the <c>Direct</c>-mode artifact read is I/O (an EF read of a potentially
|
|
/// multi-MB gzip blob out of central SQL) and must not run on the actor thread.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The <c>FetchAndCache</c> path has always piped its artifact fetch back to <c>Self</c> for
|
|
/// exactly this reason. <c>Direct</c> read the blob INLINE inside the receive, so every value
|
|
/// batch, diagnostics request and driver command queued behind central SQL for the whole read —
|
|
/// on a WAN link or a loaded server, for as long as that read takes.
|
|
/// </para>
|
|
/// <para>
|
|
/// The load-bearing assertion is therefore that the host answers an <b>unrelated</b> message
|
|
/// while the read is still in flight, not that the ACK eventually arrives: a blocking read still
|
|
/// acks, just late and behind everything else.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverHostActorDirectApplyOffMailboxTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
|
|
[Fact]
|
|
public void A_slow_direct_artifact_read_does_not_block_the_host_mailbox()
|
|
{
|
|
var inner = NewInMemoryDbFactory();
|
|
var db = new GatedConfigDbFactory(inner);
|
|
var deploymentId = SeedDeployment(inner, RevA);
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
try
|
|
{
|
|
// Barrier: an answered GetDiagnostics proves PreStart (and its bootstrap ConfigDb read) is
|
|
// done, so the allowance below counts only the apply's own contexts.
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
coordinator.ExpectMsg<NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(5));
|
|
|
|
// Let ONE more context through — the apply's Applying-row upsert, which is a deliberate
|
|
// on-thread write (bounded, one row) — and block the next, which is the artifact read.
|
|
// Both misconfigurations of this number fail LOUDLY rather than passing vacuously: too
|
|
// small blocks the upsert and the assertion below times out even on a fixed build; too
|
|
// large never engages the gate and the presence assertion on Blocked fails.
|
|
db.CloseAfter(allowThrough: 1);
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
|
|
// The read was entered and has NOT completed — the gate is still closed.
|
|
AwaitAssert(() => db.Blocked.ShouldBe(1), duration: PresenceBudget);
|
|
db.Unblocked.ShouldBe(0);
|
|
|
|
// THE assertion: with the artifact read still in flight the host is still processing its
|
|
// mailbox. Read inline on the actor thread, this times out.
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
coordinator.ExpectMsg<NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(3));
|
|
|
|
// Releasing the read completes the apply exactly as before — same Applied ACK, same revision.
|
|
db.Open();
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(10)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
coordinator.ExpectMsg<NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(5)).CurrentRevision.ShouldBe(RevA);
|
|
}
|
|
finally
|
|
{
|
|
// Never leave the gate closed: a parked read would hang ActorSystem shutdown.
|
|
db.Open();
|
|
}
|
|
}
|
|
|
|
/// <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 ConfigDb factory that parks the caller on a gate — the fake shape of a slow central-SQL
|
|
/// read. It blocks the calling THREAD (not a task), because that is what an EF read does and it
|
|
/// is the only shape that can prove which thread the read runs on.
|
|
/// </summary>
|
|
private sealed class GatedConfigDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
|
|
: IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
private readonly ManualResetEventSlim _gate = new(initialState: true);
|
|
private int _allowance = int.MaxValue;
|
|
private int _blocked;
|
|
private int _unblocked;
|
|
|
|
/// <summary>How many callers have reached the closed gate.</summary>
|
|
public int Blocked => Volatile.Read(ref _blocked);
|
|
|
|
/// <summary>How many of those have been let through again.</summary>
|
|
public int Unblocked => Volatile.Read(ref _unblocked);
|
|
|
|
/// <summary>Closes the gate, after letting <paramref name="allowThrough"/> more contexts past.</summary>
|
|
public void CloseAfter(int allowThrough)
|
|
{
|
|
Volatile.Write(ref _allowance, allowThrough);
|
|
_gate.Reset();
|
|
}
|
|
|
|
/// <summary>Opens the gate for good — also the test-teardown safety net.</summary>
|
|
public void Open() => _gate.Set();
|
|
|
|
public OtOpcUaConfigDbContext CreateDbContext()
|
|
{
|
|
if (Interlocked.Decrement(ref _allowance) < 0 && !_gate.IsSet)
|
|
{
|
|
Interlocked.Increment(ref _blocked);
|
|
// Bounded so a mis-wired test fails rather than hanging the whole assembly.
|
|
_gate.Wait(TimeSpan.FromSeconds(30));
|
|
Interlocked.Increment(ref _unblocked);
|
|
}
|
|
|
|
return inner.CreateDbContext();
|
|
}
|
|
}
|
|
}
|