158 lines
7.0 KiB
C#
158 lines
7.0 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
|
|
|
/// <summary>
|
|
/// The 60 s site-address refresh (review 2026-10-21 03/M-1): at most one DB load in flight, a
|
|
/// per-load deadline so a wedged read cannot pin the refresh forever, and a monotonic sequence so a
|
|
/// late (older) result never overwrites a newer one.
|
|
/// </summary>
|
|
public class CentralCommunicationActorAddressRefreshTests : TestKit
|
|
{
|
|
/// <summary>Initializes the test kit with a quieter Akka log level.</summary>
|
|
public CentralCommunicationActorAddressRefreshTests() : base(@"akka.loglevel = WARNING") { }
|
|
|
|
private static Site GrpcSite(string id) =>
|
|
new("Test " + id, id) { GrpcNodeAAddress = "http://a:8083" };
|
|
|
|
private (IActorRef Actor, ISiteCommandTransport Transport) CreateActor(
|
|
ISiteRepository repo, TimeSpan? loadTimeout = null)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => repo);
|
|
if (loadTimeout is { } t)
|
|
{
|
|
services.AddSingleton(Options.Create(new CommunicationOptions { QueryTimeout = t }));
|
|
}
|
|
var sp = services.BuildServiceProvider();
|
|
var transport = Substitute.For<ISiteCommandTransport>();
|
|
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport)));
|
|
return (actor, transport);
|
|
}
|
|
|
|
/// <summary>Verifies that refresh ticks arriving while a load is in flight do not start more loads, and coalesce into one re-run.</summary>
|
|
[Fact]
|
|
public void RefreshWhileLoadInFlight_DoesNotStackLoads_AndCoalescesIntoOneRerun()
|
|
{
|
|
var gate = new TaskCompletionSource<IReadOnlyList<Site>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var calls = 0;
|
|
var repo = Substitute.For<ISiteRepository>();
|
|
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(_ =>
|
|
Interlocked.Increment(ref calls) == 1
|
|
? gate.Task
|
|
: Task.FromResult<IReadOnlyList<Site>>(new List<Site> { GrpcSite("site-b") }));
|
|
|
|
var (actor, transport) = CreateActor(repo);
|
|
AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3));
|
|
|
|
for (var i = 0; i < 5; i++) actor.Tell(new RefreshSiteAddresses());
|
|
Thread.Sleep(300);
|
|
Assert.Equal(1, Volatile.Read(ref calls));
|
|
|
|
gate.SetResult(new List<Site> { GrpcSite("site-a") });
|
|
|
|
// The wedged load completes, then exactly ONE coalesced re-run picks up the newer state.
|
|
AwaitAssert(() => transport.Received().ReconcileSites(
|
|
Arg.Is<SiteAddressCacheLoaded>(c => c.GrpcContacts.ContainsKey("site-b"))),
|
|
TimeSpan.FromSeconds(3));
|
|
Thread.Sleep(300);
|
|
Assert.Equal(2, Volatile.Read(ref calls));
|
|
}
|
|
|
|
/// <summary>Verifies that a load which never completes is cancelled at its deadline so the next refresh can run.</summary>
|
|
[Fact]
|
|
public void HungLoad_IsCancelledAtItsDeadline_AndTheNextRefreshRuns()
|
|
{
|
|
var calls = 0;
|
|
var repo = Substitute.For<ISiteRepository>();
|
|
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(async ci =>
|
|
{
|
|
if (Interlocked.Increment(ref calls) == 1)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, ci.Arg<CancellationToken>());
|
|
}
|
|
return (IReadOnlyList<Site>)new List<Site> { GrpcSite("site-a") };
|
|
});
|
|
|
|
var (actor, transport) = CreateActor(repo, loadTimeout: TimeSpan.FromMilliseconds(300));
|
|
AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3));
|
|
|
|
// Let the deadline expire, then a fresh tick must be able to load.
|
|
Thread.Sleep(600);
|
|
actor.Tell(new RefreshSiteAddresses());
|
|
|
|
AwaitAssert(() => transport.Received().ReconcileSites(
|
|
Arg.Is<SiteAddressCacheLoaded>(c => c.GrpcContacts.ContainsKey("site-a"))),
|
|
TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Verifies that a result older than the last applied one is dropped rather than reconciled.</summary>
|
|
[Fact]
|
|
public void OlderResult_ArrivingAfterANewerOne_IsDropped()
|
|
{
|
|
var repo = Substitute.For<ISiteRepository>();
|
|
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>());
|
|
var (actor, transport) = CreateActor(repo);
|
|
|
|
static SiteAddressCacheLoaded Loaded(string site, long seq) => new(
|
|
new[] { site },
|
|
new Dictionary<string, SiteGrpcEndpoints> { [site] = new("http://" + site + ":8083", null) },
|
|
seq);
|
|
|
|
actor.Tell(Loaded("site-new", long.MaxValue - 1));
|
|
AwaitAssert(() => transport.Received().ReconcileSites(
|
|
Arg.Is<SiteAddressCacheLoaded>(c => c.GrpcContacts.ContainsKey("site-new"))),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(Loaded("site-old", long.MaxValue - 2));
|
|
Thread.Sleep(300);
|
|
transport.DidNotReceive().ReconcileSites(
|
|
Arg.Is<SiteAddressCacheLoaded>(c => c.GrpcContacts.ContainsKey("site-old")));
|
|
}
|
|
|
|
/// <summary>Verifies that an unexpected Status.Failure releases the overlap guard so later refreshes still load.</summary>
|
|
[Fact]
|
|
public void UnexpectedStatusFailure_ReleasesTheOverlapGuard()
|
|
{
|
|
var calls = 0;
|
|
var repo = Substitute.For<ISiteRepository>();
|
|
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(_ =>
|
|
{
|
|
Interlocked.Increment(ref calls);
|
|
return new TaskCompletionSource<IReadOnlyList<Site>>().Task; // never completes
|
|
});
|
|
|
|
var (actor, _) = CreateActor(repo);
|
|
AwaitCondition(() => Volatile.Read(ref calls) == 1, TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(new Status.Failure(new InvalidOperationException("unexpected pipe fault")));
|
|
actor.Tell(new RefreshSiteAddresses());
|
|
|
|
AwaitCondition(() => Volatile.Read(ref calls) == 2, TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Verifies that a load cancelled by the actor lifecycle (stop/restart) is not logged as a Warning.</summary>
|
|
[Fact]
|
|
public void LoadCancelledByActorLifecycle_IsNotAWarning()
|
|
{
|
|
var repo = Substitute.For<ISiteRepository>();
|
|
repo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>());
|
|
var (actor, _) = CreateActor(repo);
|
|
|
|
// A previous incarnation's load, cancelled by its PostStop, lands on the restarted actor.
|
|
EventFilter.Warning(contains: "site addresses").Expect(0, TimeSpan.FromSeconds(1), () =>
|
|
actor.Tell(new SiteAddressLoadFailed(1, new OperationCanceledException(), null, ActorStopping: true)));
|
|
|
|
// A genuine DB failure still warns.
|
|
EventFilter.Warning(contains: "site addresses").ExpectOne(TimeSpan.FromSeconds(3), () =>
|
|
actor.Tell(new SiteAddressLoadFailed(2, new InvalidOperationException("db down"), null)));
|
|
}
|
|
}
|