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; /// /// Perf roadmap Task 26 — the Direct-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. /// /// /// /// The FetchAndCache path has always piped its artifact fetch back to Self for /// exactly this reason. Direct 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. /// /// /// The load-bearing assertion is therefore that the host answers an unrelated 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. /// /// 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 { "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(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(TimeSpan.FromSeconds(3)); // Releasing the read completes the apply exactly as before — same Applied ACK, same revision. db.Open(); coordinator.ExpectMsg(TimeSpan.FromSeconds(10)).Outcome.ShouldBe(ApplyAckOutcome.Applied); actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).CurrentRevision.ShouldBe(RevA); } finally { // Never leave the gate closed: a parked read would hang ActorSystem shutdown. db.Open(); } } /// Seeds a sealed, parseable deployment and returns its id. private static DeploymentId SeedDeployment(IDbContextFactory db, RevisionHash rev) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty() }); 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; } /// /// 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. /// private sealed class GatedConfigDbFactory(IDbContextFactory inner) : IDbContextFactory { private readonly ManualResetEventSlim _gate = new(initialState: true); private int _allowance = int.MaxValue; private int _blocked; private int _unblocked; /// How many callers have reached the closed gate. public int Blocked => Volatile.Read(ref _blocked); /// How many of those have been let through again. public int Unblocked => Volatile.Read(ref _unblocked); /// Closes the gate, after letting more contexts past. public void CloseAfter(int allowThrough) { Volatile.Write(ref _allowance, allowThrough); _gate.Reset(); } /// Opens the gate for good — also the test-teardown safety net. 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(); } } }