feat(store-and-forward): peer-join full-buffer resync — standby anti-entropy for the S&F buffer

This commit is contained in:
Joseph Doherty
2026-07-08 21:43:11 -04:00
parent e8996f859b
commit 0321ad0c20
3 changed files with 244 additions and 1 deletions
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -184,6 +185,107 @@ akka {
Assert.Equal("tok-abc", applied.FetchToken);
}
// ── Task 21: peer-join S&F buffer resync (anti-entropy) ──
[Fact]
public void StandbyTrackingPeer_SendsResyncRequest()
{
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => false)));
actor.Tell(new TriggerPeerTracked()); // stands in for TryTrackPeer's MemberUp path
probe.ExpectMsg<RequestSfBufferResync>(TimeSpan.FromSeconds(3));
}
[Fact]
public void ActiveTrackingPeer_DoesNotRequestResync()
{
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => true)));
actor.Tell(new TriggerPeerTracked());
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // active node never requests a resync
}
[Fact]
public async Task ActiveNode_AnswersResyncRequest_WithFullBufferSnapshot()
{
await _sfStorage.EnqueueAsync(NewSfMessage("m1"));
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => true)));
actor.Tell(new RequestSfBufferResync(), TestActor);
var snapshot = ExpectMsg<SfBufferSnapshot>(TimeSpan.FromSeconds(3));
Assert.Single(snapshot.Messages);
Assert.False(snapshot.Truncated);
}
[Fact]
public async Task StandbyNode_AppliesSnapshot_ReplacingItsBuffer()
{
await _sfStorage.EnqueueAsync(NewSfMessage("stale"));
var probe = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new ResyncTestActor(
_storage, _sfStorage, _replicationService, SiteRole,
NullLogger<SiteReplicationActor>.Instance, probe.Ref, () => false)));
actor.Tell(new SfBufferSnapshot(new List<StoreAndForwardMessage> { NewSfMessage("fresh") }, false));
await AwaitAssertAsync(async () =>
{
Assert.Null(await _sfStorage.GetMessageByIdAsync("stale"));
Assert.NotNull(await _sfStorage.GetMessageByIdAsync("fresh"));
}, TimeSpan.FromSeconds(5));
}
private static StoreAndForwardMessage NewSfMessage(string id) => new()
{
Id = id,
Category = StoreAndForwardCategory.ExternalSystem,
Target = "t",
PayloadJson = "{}",
RetryCount = 0,
MaxRetries = 50,
RetryIntervalMs = 30000,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
};
/// <summary>Test message: drives <see cref="SiteReplicationActor.OnPeerTracked"/> directly,
/// standing in for the MemberUp→TryTrackPeer path (a single-node TestKit cannot form a real peer).</summary>
private sealed record TriggerPeerTracked;
/// <summary>
/// Test subclass for the resync tests: captures peer sends to a probe, injects the
/// active-node check, and exposes <see cref="OnPeerTracked"/> via a test message.
/// </summary>
private sealed class ResyncTestActor : SiteReplicationActor
{
private readonly IActorRef _peerProbe;
public ResyncTestActor(
SiteStorageService storage, StoreAndForwardStorage sfStorage,
ReplicationService replicationService, string siteRole,
ILogger<SiteReplicationActor> logger, IActorRef peerProbe, Func<bool> isActive)
: base(storage, sfStorage, replicationService, siteRole, logger,
configFetcher: null, isActiveOverride: isActive)
{
_peerProbe = peerProbe;
Receive<TriggerPeerTracked>(_ => OnPeerTracked());
}
protected override void SendToPeer(object message) => _peerProbe.Tell(message, Self);
}
/// <summary>
/// Test subclass exposing the peer send: <see cref="SiteReplicationActor.SendToPeer"/> is
/// overridden to forward to a probe so the outbound mapping can be asserted without a real