Re-ran all 8 domain reviews at HEAD8c888f13against theb910f5ebbaseline: every round-1 finding source-verified (168 fixed, 0 regressions, 0 false claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low), concentrated in post-baseline code (anti-entropy resync, KPI rollup backfill, live alarm stream) and seams the fixes exposed. Headliners: S&F resync predicate inversion can wipe the delivering node's buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame size (02-N2); failover drill kills the one node keep-oldest can't survive (01-N1); unbounded rollup backfill per failover (04-R1); live production API key in untracked test.txt (08-NF1). Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board, P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
88 KiB
PLAN-R2-02 — Communication & Store-and-Forward Round-2 Fix Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Fix every NEW finding in archreview/02-communication-store-and-forward.md (round 2, dated 2026-07-12, on main @ 8c888f13) — the Critical resync predicate inversion that lets a routine rolling restart wipe the delivering node's live S&F buffer (N1, which also closes the round-1 partial: the leader-vs-oldest helper was never swapped into SiteCommunicationActor/SiteReplicationActor), the High frame-size-undeliverable resync snapshot (N2), the _sweepTask clobber that defeats the shutdown drain (N3), live-alarm delta coalescing (N6), and the Low hygiene items (N4, N5, N7, N8, N9).
Architecture: The fixes stay inside the existing seams. A single oldest-Up active-node predicate (ActiveNodeEvaluator, new in Communication, delegated to by the Host's ClusterActivityEvaluator) replaces both surviving leader+Up defaults, and the Host wires the same IClusterNodeProvider.SelfIsPrimary delegate that already gates the S&F delivery sweep into SiteReplicationActor and SiteCommunicationActor — one definition of "active" governs delivery, resync-request, resync-answer, and heartbeat IsActive. The anti-entropy snapshot becomes a chunked, byte-budgeted, ack-confirmed protocol (additive messages; the legacy monolithic SfBufferSnapshot handler is retained for rolling compat). The sweep handoff publishes _sweepTask only when the CAS is won. The live alarm aggregator gains a publish-coalescing timer, a queued re-seed, a stream-generation stamp, and instance-injected test seams; SiteStreamGrpcClientFactory.RemoveSiteAsync gets its production caller on site delete; single-endpoint sites can go live.
Tech Stack: C#/.NET 10, Akka.NET (TestKit.Xunit2, Cluster, ClusterClient), Microsoft.Data.Sqlite, Grpc.Net, xUnit + NSubstitute. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx. Test per-project: dotnet test tests/<project> — targeted --filter runs only, never the full suite mid-plan.
Cross-plan MUTEX (binding)
PLAN-R2-07 also touches SiteAlarmLiveCacheService.cs (sticky IsLive) and AlarmSummary.razor. Serialization rule: Task 10 (delta coalescing), Task 12 (aggregator ctor seams), and Task 15 (single-endpoint resolve) must NOT run concurrently with PLAN-R2-07's sticky-IsLive / AlarmSummary tasks. Tasks 10/12/15 edit SiteAlarmAggregatorActor.cs + the Props.Create call and ResolveSiteAsync inside SiteAlarmLiveCacheService.cs — the exact file R2-07's sticky-IsLive task rewrites, and Task 10 changes the publish cadence that sticky-IsLive semantics observe. Whichever plan starts its live-alarm slice first finishes it (through commit) before the other begins; coordinate via the master tracker. All other tasks in this plan are file-disjoint from R2-07. AlarmSummary.razor is never edited here (page-side fixes belong to PLAN-R2-07).
Findings Coverage
| Report finding | Severity | Task(s) |
|---|---|---|
| N1: Resync authority uses leader predicate while delivery uses oldest-Up — rolling restart of the lower-address node wipes the delivering node's live buffer | Critical | 1, 2, 3, 4 (2 is the mandated failing-first divergence repro; 3 includes the belt-and-braces apply-time re-check) |
Round-1 partial: oldest-Up helper shipped (PLAN-01) but never swapped into SiteCommunicationActor.DefaultIsActiveCheck / SiteReplicationActor.DefaultIsActive |
Low → materially worse | Subsumed by N1's fix — 1 (shared evaluator), 3 (SiteReplicationActor), 4 (SiteCommunicationActor) |
| N2: Resync snapshot rides ONE Akka remoting message — exceeds the 128 000-byte default frame for any realistic backlog, silently undeliverable, no retry, no telemetry | High | 5, 6, 7 (chunked byte-budgeted protocol + standby assembly + ack-based delivery confirmation + telemetry) |
N3: _sweepTask clobbered by every timer tick / TriggerSweep — StopAsync awaits a no-op while the real sweep is mid-delivery |
Medium | 8 |
N4: SweepBatchLimit / SweepTargetParallelism missing from the eager options validator |
Low | 9 |
| N5: Resync snapshot vs in-flight replication op race leaves orphan rows on the standby | Low | 6 (explicit doc comment on the chunk-apply path so nobody "fixes" it into something worse — code change deliberately none, per report) |
| N6: No live-delta coalescing — every alarm delta copies the whole site cache and fans out to every viewer circuit | Medium | 10 (MUTEX with PLAN-R2-07 — see rule above) |
| N7.1: Reconnect re-seed skipped when a reconcile fan-out is already in flight (stale up to 60 s) | Low | 11 |
N7.2: No stream-generation stamp on GrpcAlarmStreamError — a late error from the previous stream burns retry budget |
Low | 11 |
N7.3: ReconnectDelay/StabilityWindow are process-global mutable statics |
Low | 12 |
N8: RemoveSiteAsync has no production caller — deleted site's channels leak until restart |
Low | 13 |
| N8 (second half): "lives only on the active central node" is aspirational — standby browsing starts a second aggregator | Low | 14 (documented acceptance — read-only, bounded, linger-reaped; doc claim corrected rather than gating SetActorSystem, matching the report's "harmless but contradicting the doc" framing) |
| N9: A site with only one configured gRPC endpoint can never go live | Low | 15 |
| Alarm Summary page-side fixes (poll/live interplay, render path) | — | Not this plan — owned by PLAN-R2-07 (referenced here only for the MUTEX rule) |
Task 1: Shared oldest-Up active-node evaluator, reachable from Communication and SiteRuntime
Classification: standard Estimated implement time: ~5 min Parallelizable with: 2, 8, 9, 10, 13, 14 Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Communication/ClusterState/ActiveNodeEvaluator.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Health/ClusterActivityEvaluator.cs(lines 20-32 —SelfIsOldestdelegates to the new evaluator) - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs(create)
Design decision — new home in Communication, Host delegates: PLAN-01's ClusterActivityEvaluator (Host/Health/ClusterActivityEvaluator.cs:14) declares itself "THE single definition of active node", but it lives in Host, which Communication and SiteRuntime cannot reference (Host references them). That is why the two leader+Up defaults were never swapped (round-1 partial). The logic moves to Communication (referenced by both SiteRuntime — see ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj:39 — and Host); Host's evaluator keeps its public API and becomes a one-line delegate, so AkkaClusterNodeProvider.SelfIsPrimary (Host/Health/AkkaClusterNodeProvider.cs:29-38) and every existing Host test keep working against the same single implementation.
- Write the failing test (cluster-provider harness copied from
tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ClusterActivityEvaluatorTests.cs— single-node self-seeding cluster on a free port):
// tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs
using System.Net;
using System.Net.Sockets;
using Akka.Actor;
using Akka.Configuration;
using Xunit;
using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
public class ActiveNodeEvaluatorTests : IAsyncLifetime
{
private ActorSystem? _system;
public async Task InitializeAsync()
{
var port = FreePort();
var config = ConfigurationFactory.ParseString($@"
akka {{
actor.provider = cluster
remote.dot-netty.tcp {{ hostname = ""127.0.0.1"", port = {port} }}
cluster {{
seed-nodes = [""akka.tcp://ane-test@127.0.0.1:{port}""]
roles = [""site-x""]
min-nr-of-members = 1
}}
}}");
_system = ActorSystem.Create("ane-test", config);
var cluster = Akka.Cluster.Cluster.Get(_system);
var deadline = DateTime.UtcNow.AddSeconds(20);
while (cluster.SelfMember.Status != Akka.Cluster.MemberStatus.Up && DateTime.UtcNow < deadline)
await Task.Delay(100);
}
public async Task DisposeAsync() { if (_system != null) await _system.Terminate(); }
[Fact]
public void SelfIsOldestUp_SoleUpMember_ReturnsTrue()
{
var cluster = Akka.Cluster.Cluster.Get(_system!);
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(cluster));
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(cluster, "site-x"));
}
[Fact]
public void SelfIsOldestUp_RoleNotHeld_ReturnsFalse()
{
var cluster = Akka.Cluster.Cluster.Get(_system!);
Assert.False(ActiveNodeEvaluator.SelfIsOldestUp(cluster, "role-nonexistent"));
}
private static int FreePort()
{
using var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
return ((IPEndPoint)l.LocalEndpoint).Port;
}
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter ActiveNodeEvaluatorTests— expect FAIL (compile error:ActiveNodeEvaluatordoes not exist). - Implement
src/ZB.MOM.WW.ScadaBridge.Communication/ClusterState/ActiveNodeEvaluator.cs(verbatim move of the oldest-Up logic fromClusterActivityEvaluator.SelfIsOldest):
using Akka.Cluster;
namespace ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
/// <summary>
/// THE single definition of "active node" (review 01 [High]; review 02 round 2 [Critical] N1):
/// a node is active when it is the OLDEST Up member (optionally within a role scope) — i.e.
/// the member the ClusterSingletonManager places singletons on. Cluster LEADERSHIP (lowest
/// address) is an Akka-internal concept that diverges from singleton placement permanently
/// once the original first node restarts and rejoins; every product-level active/standby
/// decision must use this evaluator, never <c>cluster.State.Leader</c>.
/// <para>
/// Lives in Communication (not Host) so BOTH <c>SiteCommunicationActor</c> and
/// <c>SiteReplicationActor</c> can default to it — Host cannot be referenced from either.
/// The Host's <c>ClusterActivityEvaluator.SelfIsOldest</c> delegates here, so the S&F
/// delivery gate (<c>IClusterNodeProvider.SelfIsPrimary</c>), the resync authority checks,
/// and the heartbeat IsActive stamp all share one implementation.
/// </para>
/// </summary>
public static class ActiveNodeEvaluator
{
/// <summary>True when self is Up and no other Up member (in the role scope) is older.</summary>
/// <param name="cluster">The Akka cluster to evaluate.</param>
/// <param name="role">Optional role scope; when set, only members with this role are considered.</param>
/// <returns><c>true</c> when self is Up and the oldest Up member in the role scope.</returns>
public static bool SelfIsOldestUp(Cluster cluster, string? role = null)
{
var self = cluster.SelfMember;
if (self.Status != MemberStatus.Up)
return false;
if (role != null && !self.HasRole(role))
return false;
return cluster.State.Members
.Where(m => m.Status == MemberStatus.Up)
.Where(m => role == null || m.HasRole(role))
.All(m => m.UniqueAddress.Equals(self.UniqueAddress) || self.IsOlderThan(m));
}
}
And in Host/Health/ClusterActivityEvaluator.cs, replace the SelfIsOldest body (keep OldestUpMember unchanged):
/// <summary>True when self is Up and no other Up member (in the role scope) is older.
/// Delegates to the shared <see cref="Communication.ClusterState.ActiveNodeEvaluator"/> —
/// one implementation for the delivery gate, the resync authority checks, and the
/// heartbeat IsActive stamp (review 02 round 2, N1).</summary>
public static bool SelfIsOldest(Cluster cluster, string? role = null) =>
Communication.ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(cluster, role);
(Full namespace: ZB.MOM.WW.ScadaBridge.Communication.ClusterState — add a using alias if the shorthand does not resolve.)
4. Run the filter — expect PASS. Regression: dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter ClusterActivityEvaluatorTests — expect PASS (delegation preserves behavior).
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.Communication/ClusterState/ActiveNodeEvaluator.cs src/ZB.MOM.WW.ScadaBridge.Host/Health/ClusterActivityEvaluator.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ActiveNodeEvaluatorTests.cs && git commit -m "fix(comm): shared oldest-Up ActiveNodeEvaluator in Communication; Host evaluator delegates (plan R2-02 T1)"
Task 2: Two-node divergence repro — leader≠oldest wipes the delivering node's buffer (failing first)
Classification: high-risk (real two-node cluster; the mandated N1 regression repro) Estimated implement time: ~5 min Parallelizable with: 1, 8, 9, 10, 13, 14, 15 Files:
- Modify:
tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs(StartAsyncgains optional explicit ports — additive) - Test:
tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs(create)
This test is written BEFORE the fix and must FAIL on unfixed code — it reproduces the report's N1 sequence. The report narrates it as a rolling restart of the lower-address node; the essence is older-but-higher-address vs younger-but-lower-address, which the test produces directly (deterministically, no crash/restart needed) by starting the higher-port node first: the first-started (higher-address) node is oldest (owns delivery), the second-started (lower-address) node is leader. Pre-fix, SiteReplicationActor.DefaultIsActive (SiteReplicationActor.cs:190-198, leader+Up) then makes the delivering node request a resync and apply the joiner's stale/empty snapshot via ReplaceAllAsync — the buffer wipe. Post-fix (Task 3), the oldest node answers and the joiner applies: the buffered row survives on the oldest node AND appears on the joiner.
- Add the additive fixture parameters (
TwoNodeClusterFixture.StartAsync, currentlyStartAsync(string role = "Central", TimeSpan? stableAfter = null)):
public static async Task<TwoNodeClusterFixture> StartAsync(
string role = "Central", TimeSpan? stableAfter = null,
int? portA = null, int? portB = null)
{
var f = new TwoNodeClusterFixture();
f.PortA = portA ?? GetFreeTcpPort();
f.PortB = portB ?? GetFreeTcpPort();
// ... rest unchanged
- Write the failing test:
// tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
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.IntegrationTests.Cluster;
/// <summary>
/// N1 regression (review 02 round 2, Critical): the resync authority must use the same
/// oldest-Up predicate as the S&F delivery gate. Divergence scenario = the delivering node
/// is OLDEST but not LEADER (leader = lowest address), the exact state a rolling restart of
/// the lower-address node produces. Pre-fix the delivering node requests a resync from the
/// stale peer and ReplaceAllAsync wipes its live buffer.
/// </summary>
public class SfBufferResyncPredicateTests
{
[Fact]
public async Task OldestButNotLeaderNode_KeepsItsBuffer_AndSeedsTheJoiner()
{
// Two explicit ports, deliberately assigned so the FIRST-started (oldest,
// delivering) node has the HIGHER address → the second node is cluster leader.
var p1 = TwoNodeClusterFixture.GetFreeTcpPort();
var p2 = TwoNodeClusterFixture.GetFreeTcpPort();
var (portHigh, portLow) = p1 > p2 ? (p1, p2) : (p2, p1);
await using var fixture = await TwoNodeClusterFixture.StartAsync(
role: "site-int", portA: portHigh, portB: portLow);
// Real S&F storage + replication actor per node, production default predicate
// (no isActiveOverride) — the exact wiring under test.
var (storageOldest, _) = await CreateReplicationActorAsync(fixture.NodeA, "oldest");
var (storageJoiner, _) = await CreateReplicationActorAsync(fixture.NodeB, "joiner");
// The delivering (oldest) node has a live buffered row the standby never saw.
await storageOldest.EnqueueAsync(NewMessage("live-row"));
// Trigger peer (re)tracking on both sides: each actor got InitialStateAsSnapshot
// in PreStart, but the enqueue raced it — re-deliver via a fresh MemberUp is not
// needed; OnPeerTracked already fired on join. The resync exchange is async:
// wait until the JOINER holds the row (proves the snapshot flowed oldest→joiner,
// the correct direction). Pre-fix this times out (the joiner, as leader, never
// requests) AND the oldest node's row is deleted by the stale wipe.
await AwaitAsync(async () => await storageJoiner.GetMessageByIdAsync("live-row") != null,
TimeSpan.FromSeconds(20),
"joiner never received the resync snapshot (resync ran in the wrong direction)");
// And the delivering node's buffer is untouched — the N1 wipe assertion.
Assert.NotNull(await storageOldest.GetMessageByIdAsync("live-row"));
}
private static async Task<(StoreAndForwardStorage Storage, IActorRef Actor)> CreateReplicationActorAsync(
ActorSystem node, string tag)
{
var sfDb = Path.Combine(Path.GetTempPath(), $"sf-resync-{tag}-{Guid.NewGuid():N}.db");
var siteDb = Path.Combine(Path.GetTempPath(), $"site-resync-{tag}-{Guid.NewGuid():N}.db");
var sfStorage = new StoreAndForwardStorage($"Data Source={sfDb}",
NullLogger<StoreAndForwardStorage>.Instance);
await sfStorage.InitializeAsync();
var siteStorage = new SiteStorageService($"Data Source={siteDb}",
NullLogger<SiteStorageService>.Instance);
var replicationService = new ReplicationService(
new StoreAndForwardOptions(), NullLogger<ReplicationService>.Instance);
// Name MUST be "site-replication" — SendToPeer targets /user/site-replication.
var actor = node.ActorOf(Props.Create(() => new SiteReplicationActor(
siteStorage, sfStorage, replicationService, "site-int",
NullLogger<SiteReplicationActor>.Instance, null, null, null, null)),
"site-replication");
return (sfStorage, actor);
}
private static StoreAndForwardMessage NewMessage(string id) => new()
{
Id = id,
Category = StoreAndForwardCategory.Notification,
Target = "central",
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
MaxRetries = 0,
};
private static async Task AwaitAsync(Func<Task<bool>> condition, TimeSpan timeout, string why)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await condition()) return;
await Task.Delay(250);
}
throw new TimeoutException(why);
}
}
(Adapt member names to the real SiteStorageService/SiteReplicationActor ctor order — see SiteReplicationActor.cs:64-73 and the harness in tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs:48-70. GetFreeTcpPort may need its access raised to public/internal on the fixture — additive.)
3. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.IntegrationTests --filter SfBufferResyncPredicateTests — expect FAIL on unfixed code (either the timeout — resync ran leader-ward — or the final Assert.NotNull after the wipe). This failure is the deliverable of this task; do NOT fix it here.
4. Commit (the red test rides with the fix task's history; commit it now so Task 3's diff shows red→green): git add tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster && git commit -m "test(saf): failing two-node repro — leader-vs-oldest resync divergence wipes delivering node buffer (plan R2-02 T2)"
Task 3: Swap SiteReplicationActor to the shared oldest-Up predicate + Host wires the delivery-gate delegate
Classification: high-risk (resync authority semantics — the N1 fix) Estimated implement time: ~5 min Parallelizable with: 8, 9, 10, 13 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs(DefaultIsActivelines 190-198; ctor doc lines 56-61;HandleSfBufferSnapshotlines 420-444 belt-and-braces) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs(RegisterSiteActorsAsync: replication actor creation lines 775-779; provider resolve lines 878-882 hoisted) - Test:
tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SfBufferResyncPredicateTests.cs(Task 2's red test goes green),tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs(regression)
Depends on Tasks 1 (evaluator) and 2 (red repro). TDD note (mirrors round-1 Task 4's pattern): this task writes no new test — its failing test is Task 2's repro, plus the existing resync suite as regression.
- Replace
DefaultIsActive(SiteReplicationActor.cs:190-198) and its doc:
/// <summary>
/// Repo-standard active-node check: this node is active when it is the OLDEST Up
/// member carrying the site role — the same oldest-Up semantics as the S&F delivery
/// gate (IClusterNodeProvider.SelfIsPrimary → ClusterActivityEvaluator → shared
/// ActiveNodeEvaluator). NEVER the cluster leader: leadership is lowest-address and
/// diverges from singleton/delivery placement permanently after the lower-address
/// node restarts — the divergence that made the delivering node wipe its own live
/// buffer via a wrong-direction resync (review 02 round 2, N1 Critical). Any other
/// state reports standby — safe-by-default.
/// </summary>
private bool DefaultIsActive() =>
Communication.ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(_cluster, _siteRole);
Update the ctor isActiveOverride doc (lines 56-61): the "swap point for plan 01's shared helper" sentence becomes "production wiring passes the Host's IClusterNodeProvider.SelfIsPrimary delegate (the same instance gating the S&F delivery sweep); null falls back to the shared oldest-Up evaluator."
2. Belt-and-braces in HandleSfBufferSnapshot (report's explicit suggestion): re-evaluate the predicate at apply time, inside the async continuation, so an active-flip between receive and apply can never run ReplaceAllAsync on a node that just became the delivering node:
Task.Run(async () =>
{
// Belt-and-braces (N1): re-check at apply time. ReplaceAllAsync discards
// every in-flight row (StoreAndForwardStorage.cs "Never call on an active
// node"); a flip between message receipt and this point must abort.
if (SafeIsActive())
{
_logger.LogWarning(
"Discarding S&F buffer resync snapshot: this node became active before apply");
return;
}
await _sfStorage.ReplaceAllAsync(msg.Messages);
})
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply S&F buffer resync snapshot");
});
- Host wiring (
AkkaHostedService.RegisterSiteActorsAsync): hoist theclusterNodeProviderresolve (currently at line 878, inside the S&F block) to just above the replication-actor creation at line 775, and pass the SAME delegate:
// ONE active-node predicate instance governs the S&F delivery gate, the resync
// authority checks (SiteReplicationActor), and the heartbeat IsActive stamp
// (SiteCommunicationActor, wired below) — review 02 round 2, N1. Null in
// non-clustered test hosts: the actors fall back to the shared oldest-Up
// evaluator, never to a leader check.
var clusterNodeProvider = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.HealthMonitoring.IClusterNodeProvider>();
Func<bool>? activeNodeCheck = clusterNodeProvider != null
? () => clusterNodeProvider.SelfIsPrimary
: null;
var replicationActor = _actorSystem!.ActorOf(
Props.Create(() => new SiteReplicationActor(
storage, sfStorage, replicationService, siteRole, replicationLogger,
deploymentConfigFetcher, activeNodeCheck, siteRuntimeOptionsValue, null)),
"site-replication");
Then at line ~878 delete the duplicate resolve and keep storeAndForwardService.SetDeliveryGate(() => clusterNodeProvider.SelfIsPrimary); guarded by the hoisted variable.
4. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.IntegrationTests --filter SfBufferResyncPredicateTests — Task 2's repro now PASSES (the oldest node keeps live-row and seeds the joiner). Regression: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter SiteReplicationActorTests (the resync tests inject isActiveOverride, so they are predicate-agnostic — expect PASS) and dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter ClusterActivity.
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs && git commit -m "fix(saf): resync authority uses the shared oldest-Up predicate + delivery-gate delegate; apply-time re-check guard (plan R2-02 T3)"
Task 4: Swap SiteCommunicationActor.DefaultIsActiveCheck + Host wiring + doc sync
Classification: small (predicate swap + wiring; divergence coverage is Tasks 1-3) Estimated implement time: ~4 min Parallelizable with: 8, 9, 10, 13, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs(DefaultIsActiveChecklines 508-526; ctor doc lines 68-74) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs(siteCommActorcreation lines 822-827 — passactiveNodeCheck) - Modify:
docs/requirements/Component-Communication.md(any "leader" wording on the heartbeat IsActive stamp),docs/requirements/Component-StoreAndForward.mdline 83 ("repo-standard leader+Up active-node predicate" → oldest-Up; the fuller resync rewrite lands in Task 7)
Depends on Tasks 1 and 3 (shared AkkaHostedService.cs — serialized).
- Replace
DefaultIsActiveCheck(SiteCommunicationActor.cs:517-526):
/// <summary>
/// Default active-node check used when no override is supplied: oldest-Up member
/// semantics via the shared <see cref="ClusterState.ActiveNodeEvaluator"/> — the
/// same predicate as the S&F delivery gate and the replication resync authority
/// (review 02 round 2, N1). Unscoped by role: a site cluster's members all carry
/// the site role, so role scoping is a no-op here; production wiring passes the
/// Host's role-scoped IClusterNodeProvider delegate anyway. Any other state
/// (still joining, leaving) reports standby — safe-by-default.
/// </summary>
private bool DefaultIsActiveCheck() =>
ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(Cluster.Get(Context.System));
Update the ctor doc (lines 68-74): "…null uses the shared oldest-Up evaluator (production wiring passes the Host's singleton-host delegate)…". 2. Host wiring (line 822-827) — pass the Task 3 delegate as the 4th ctor arg:
var siteCommActor = _actorSystem.ActorOf(
Props.Create(() => new SiteCommunicationActor(
_nodeOptions.SiteId!,
_communicationOptions,
dmProxy,
activeNodeCheck)),
"site-communication");
- Docs: grep both component docs for
leaderin the heartbeat/resync context and align to "oldest-Up member (singleton-host semantics, sharedActiveNodeEvaluator)". Do NOT rewrite the full resync paragraph yet (Task 7 owns it — avoid a double edit). - Build + regression:
dotnet build ZB.MOM.WW.ScadaBridge.slnx, thendotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteCommunicationActor(existing tests injectisActiveCheckstubs — expect PASS). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs docs/requirements/Component-Communication.md docs/requirements/Component-StoreAndForward.md && git commit -m "fix(comm): heartbeat IsActive uses the shared oldest-Up predicate; Host wires one delegate everywhere (plan R2-02 T4)"
Task 5: Chunked resync protocol — additive messages, byte-budgeted chunker, active-side chunked answer
Classification: high-risk (cluster protocol; frame-size correctness — the N2 fix, part 1) Estimated implement time: ~5 min Parallelizable with: 8, 9, 10, 13, 14, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs(new records;HandleRequestSfBufferResynclines 398-411; new chunker + constants nearMaxResyncRowsline 40) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs(append),tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ResyncWireSerializationPinTests.cs(create)
Depends on Task 3 (same file). Message-contract rules (additive-only): SfBufferSnapshotChunk and SfBufferResyncAck are NEW records; the legacy SfBufferSnapshot record AND its standby handler are retained so a rolling upgrade keeps working (old active → monolithic snapshot to new standby: handled; new active → chunks to old standby: dead-lettered, divergence self-heals at the next post-upgrade peer-track — same exposure as today's silently-dropped oversized frame, now bounded to the upgrade window and logged). These messages ride intra-site Akka remoting, not ClusterClient, so ClusterClientContractLockTests (PLAN-08 T9, tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs) is deliberately untouched — the wire pins land in a new ResyncWireSerializationPinTests following the ReplicationWireSerializationPinTests pattern instead.
Sizing rationale: Akka remoting's default maximum-frame-size is 128 000 bytes and BuildHocon does not raise it. Budget MaxResyncChunkBytes = 64_000 estimated payload per chunk (≈50% headroom for the JSON envelope, type manifests, and non-payload columns) with a MaxResyncChunkRows = 200 row cap. A single row whose payload alone exceeds the budget ships as its own chunk with a Warning (it may still exceed the frame — pathological, logged, no longer silent for everything else). The active node still materializes at most MaxResyncRows (10 000) rows in memory — unchanged from today and bounded; accepted (report: "reconsider" — the cap already bounds it).
- Failing tests (append to
SiteReplicationActorTests.cs; reuseNewMessage/ResyncTestActorhelpers):
// ── R2 T5: chunked resync answer ──
[Fact]
public void ChunkForRemoting_SplitsByByteBudget_PreservingOrderAndSequence()
{
var rows = Enumerable.Range(0, 10)
.Select(i => NewMessage($"m{i}", payloadJson: new string('x', 20_000)))
.ToList();
var chunks = SiteReplicationActor.ChunkForRemoting(rows, maxChunkBytes: 64_000, maxChunkRows: 200);
Assert.True(chunks.Count > 1); // 10 × 20 KB cannot ride one 64 KB chunk
Assert.Equal(rows.Select(r => r.Id), chunks.SelectMany(c => c).Select(r => r.Id)); // order preserved
Assert.All(chunks, c => Assert.True(
c.Sum(r => r.PayloadJson.Length) <= 64_000 || c.Count == 1)); // budget honored (oversized row isolated)
}
[Fact]
public void ChunkForRemoting_RowCapHonored_AndSingleOversizedRowIsolated()
{
var many = Enumerable.Range(0, 500).Select(i => NewMessage($"s{i}", payloadJson: "{}")).ToList();
Assert.All(SiteReplicationActor.ChunkForRemoting(many, 64_000, 200), c => Assert.True(c.Count <= 200));
var oversized = new List<StoreAndForwardMessage>
{ NewMessage("big", payloadJson: new string('y', 100_000)), NewMessage("small", payloadJson: "{}") };
var chunks = SiteReplicationActor.ChunkForRemoting(oversized, 64_000, 200);
Assert.Equal(2, chunks.Count); // the oversized row rides alone
}
[Fact]
public async Task ActiveNode_AnswersResyncRequest_WithSequencedChunks_SharingOneResyncId()
{
for (var i = 0; i < 3; i++)
await _sfStorage.EnqueueAsync(NewMessage($"c{i}", payloadJson: new string('z', 30_000)));
var actor = CreateResyncActor(isActive: () => true);
actor.Tell(new RequestSfBufferResync(), TestActor);
var first = ExpectMsg<SfBufferSnapshotChunk>(TimeSpan.FromSeconds(5));
var rest = Enumerable.Range(1, first.TotalChunks - 1)
.Select(_ => ExpectMsg<SfBufferSnapshotChunk>(TimeSpan.FromSeconds(5)))
.Prepend(first)
.ToList();
Assert.True(first.TotalChunks > 1);
Assert.All(rest, c => Assert.Equal(first.ResyncId, c.ResyncId));
Assert.Equal(Enumerable.Range(1, first.TotalChunks), rest.Select(c => c.Sequence));
Assert.Equal(3, rest.Sum(c => c.Messages.Count));
}
(NewMessage may need an optional payloadJson parameter — additive to the existing helper. CreateResyncActor = the existing ResyncTestActor construction.)
2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "ChunkForRemoting|WithSequencedChunks" — expect FAIL (compile: chunker/records missing).
3. Implement in SiteReplicationActor.cs:
- Constants next to
MaxResyncRows(line 40):internal const int MaxResyncChunkBytes = 64_000;andinternal const int MaxResyncChunkRows = 200;with the sizing-rationale doc above (cite the 128 000-byte default frame and thatBuildHoconsets no override). - Pure chunker (internal static, unit-testable):
/// <summary>
/// Splits a resync snapshot into chunks that fit Akka remoting's default
/// 128 000-byte frame (review 02 round 2, N2): rows accumulate until the estimated
/// payload budget or the row cap is hit. Estimation is payload-dominated
/// (payload_json length + 512 bytes fixed overhead per row); a single row whose
/// payload exceeds the budget ships alone (Warning at the call site). Order is
/// preserved (oldest-first, matching GetAllMessagesAsync).
/// </summary>
internal static List<List<StoreAndForwardMessage>> ChunkForRemoting(
IReadOnlyList<StoreAndForwardMessage> rows, int maxChunkBytes, int maxChunkRows)
{
var chunks = new List<List<StoreAndForwardMessage>>();
var current = new List<StoreAndForwardMessage>();
var currentBytes = 0;
foreach (var row in rows)
{
var estimate = (row.PayloadJson?.Length ?? 0) + 512;
if (current.Count > 0 && (currentBytes + estimate > maxChunkBytes || current.Count >= maxChunkRows))
{
chunks.Add(current);
current = new List<StoreAndForwardMessage>();
currentBytes = 0;
}
current.Add(row);
currentBytes += estimate;
}
if (current.Count > 0) chunks.Add(current);
return chunks;
}
HandleRequestSfBufferResync(lines 398-411): keep the authority check; pipe the snapshot back to Self (chunking on the actor thread keepsSendToPeer/ack bookkeeping actor-safe), then send sequenced chunks to the requester:
var replyTo = Sender;
_sfStorage.GetAllMessagesAsync(MaxResyncRows).PipeTo(
Self,
failure: ex => new Status.Failure(ex),
success: result => new SfResyncSnapshotLoaded(replyTo, result.Messages, result.Truncated));
with a new internal record + handler:
/// <summary>Internal: the resync snapshot finished loading; chunk and send to the requester.</summary>
internal sealed record SfResyncSnapshotLoaded(
IActorRef ReplyTo, List<StoreAndForwardMessage> Messages, bool Truncated);
private void HandleSfResyncSnapshotLoaded(SfResyncSnapshotLoaded msg)
{
var chunks = ChunkForRemoting(msg.Messages, MaxResyncChunkBytes, MaxResyncChunkRows);
if (chunks.Count == 0) chunks.Add(new List<StoreAndForwardMessage>()); // empty buffer still resyncs (clears the standby)
var resyncId = Guid.NewGuid().ToString("N");
for (var i = 0; i < chunks.Count; i++)
{
if (chunks[i].Count == 1 && (chunks[i][0].PayloadJson?.Length ?? 0) + 512 > MaxResyncChunkBytes)
_logger.LogWarning(
"Resync row {Id} alone exceeds the chunk budget ({Bytes}B payload); sending solo — it may exceed the remoting frame",
chunks[i][0].Id, chunks[i][0].PayloadJson?.Length ?? 0);
msg.ReplyTo.Tell(new SfBufferSnapshotChunk(resyncId, i + 1, chunks.Count, chunks[i], msg.Truncated), Self);
}
_logger.LogInformation(
"Answered S&F resync request with {Rows} row(s) in {Chunks} chunk(s), resyncId={ResyncId}",
msg.Messages.Count, chunks.Count, resyncId);
// Task 7 registers the pending-ack entry here.
}
- New public records at the bottom of the file (next to
SfBufferSnapshot, which is KEPT verbatim for rolling compat):
/// <summary>
/// Active→standby: one sequenced chunk of a full-buffer anti-entropy snapshot
/// (review 02 round 2, N2 — the monolithic <see cref="SfBufferSnapshot"/> exceeds Akka
/// remoting's default 128 000-byte frame for any realistic backlog). All chunks of one
/// resync share <paramref name="ResyncId"/>; <paramref name="Sequence"/> is 1-based up to
/// <paramref name="TotalChunks"/>. Additive message — the legacy monolithic snapshot
/// handler is retained for rolling upgrades. Crosses intra-site Akka remoting (NOT
/// ClusterClient — ClusterClientContractLockTests is intentionally not involved).
/// </summary>
public sealed record SfBufferSnapshotChunk(
string ResyncId, int Sequence, int TotalChunks,
List<StoreAndForwardMessage> Messages, bool Truncated);
/// <summary>
/// Standby→active: delivery confirmation — the standby assembled all chunks of
/// <paramref name="ResyncId"/> and applied them atomically (<paramref name="RowCount"/>
/// rows installed). Absence within the ack window is surfaced by the active node
/// (Warning + counter) — the silent-loss mode N2 flagged.
/// </summary>
public sealed record SfBufferResyncAck(string ResyncId, int RowCount);
- Register
Receive<SfResyncSnapshotLoaded>(HandleSfResyncSnapshotLoaded);in the ctor next to the other resync receives (line 111-113).
- Create
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ResyncWireSerializationPinTests.csfollowingtests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs: round-tripSfBufferSnapshotChunk(fully-populatedStoreAndForwardMessageincl.ExecutionId/ParentExecutionId) andSfBufferResyncAckthroughSys.Serialization, plus type-identity pins (ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.SfBufferSnapshotChunk,...SfBufferResyncAck) — a rename/move is an intra-site wire break during rolling upgrades. - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "ChunkForRemoting|WithSequencedChunks|ResyncWireSerializationPin"— expect PASS. Then the full resync group:--filter SiteReplicationActorTests. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests && git commit -m "fix(saf): chunk the resync snapshot to fit Akka remoting frames (additive protocol, byte-budgeted) (plan R2-02 T5)"
Task 6: Standby-side chunk assembly, atomic apply, ack + the N5 race comment
Classification: high-risk (cluster protocol; buffer-replace correctness — the N2 fix, part 2) Estimated implement time: ~5 min Parallelizable with: 8, 9, 10, 13, 14, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs(new assembly state +Receive<SfBufferSnapshotChunk>; N5 doc comment on the apply path) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs(append)
Depends on Task 5 (same file, chunk records).
- Failing tests:
[Fact]
public async Task StandbyNode_AssemblesChunks_AppliesOnce_AndAcks()
{
await _sfStorage.EnqueueAsync(NewMessage("stale"));
var actor = CreateResyncActor(isActive: () => false);
var resyncId = "r1";
actor.Tell(new SfBufferSnapshotChunk(resyncId, 1, 2,
new List<StoreAndForwardMessage> { NewMessage("f1") }, false), TestActor);
actor.Tell(new SfBufferSnapshotChunk(resyncId, 2, 2,
new List<StoreAndForwardMessage> { NewMessage("f2") }, false), TestActor);
var ack = ExpectMsg<SfBufferResyncAck>(TimeSpan.FromSeconds(5));
Assert.Equal(resyncId, ack.ResyncId);
Assert.Equal(2, ack.RowCount);
await AwaitAssertAsync(async () =>
{
Assert.Null(await _sfStorage.GetMessageByIdAsync("stale")); // replaced wholesale
Assert.NotNull(await _sfStorage.GetMessageByIdAsync("f1"));
Assert.NotNull(await _sfStorage.GetMessageByIdAsync("f2"));
});
}
[Fact]
public async Task StandbyNode_NewResyncId_DiscardsStalePartialAssembly()
{
var actor = CreateResyncActor(isActive: () => false);
actor.Tell(new SfBufferSnapshotChunk("old", 1, 2,
new List<StoreAndForwardMessage> { NewMessage("orphan") }, false), TestActor);
actor.Tell(new SfBufferSnapshotChunk("new", 1, 1,
new List<StoreAndForwardMessage> { NewMessage("fresh") }, false), TestActor);
ExpectMsg<SfBufferResyncAck>(TimeSpan.FromSeconds(5)); // "new" completed
await AwaitAssertAsync(async () =>
{
Assert.NotNull(await _sfStorage.GetMessageByIdAsync("fresh"));
Assert.Null(await _sfStorage.GetMessageByIdAsync("orphan")); // stale partial never applied
});
}
[Fact]
public void ActiveNode_IgnoresChunks_NeverAcks()
{
var actor = CreateResyncActor(isActive: () => true);
actor.Tell(new SfBufferSnapshotChunk("r", 1, 1,
new List<StoreAndForwardMessage> { NewMessage("x") }, false), TestActor);
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "AssemblesChunks|DiscardsStalePartialAssembly|IgnoresChunks_NeverAcks"— expect FAIL (no chunk handler; chunks dead-letter). - Implement — actor gains
IWithTimers(public ITimerScheduler Timers { get; set; } = null!;) plus assembly state and the handler:
// ── Chunked-resync assembly (standby side; actor-thread only) ──
private string? _assemblingResyncId;
private int _assemblingTotalChunks;
private bool _assemblingTruncated;
private readonly Dictionary<int, List<StoreAndForwardMessage>> _assemblingChunks = new();
private const string ResyncAssemblyTimerKey = "sf-resync-assembly-timeout";
/// <summary>How long a partial chunk assembly may wait for its missing chunks before
/// being discarded (a lost chunk = lost resync; the next peer-track retries). Ctor
/// test seam; production default 30 s.</summary>
private readonly TimeSpan _resyncAssemblyTimeout;
(ctor gains TimeSpan? resyncAssemblyTimeout = null → _resyncAssemblyTimeout = resyncAssemblyTimeout ?? TimeSpan.FromSeconds(30); — additive optional parameter, existing callers unchanged.)
private void HandleSfBufferSnapshotChunk(SfBufferSnapshotChunk msg)
{
if (SafeIsActive())
{
_logger.LogDebug("Ignoring S&F resync chunk — this node is active");
return;
}
if (_assemblingResyncId != msg.ResyncId)
{
if (_assemblingResyncId != null)
_logger.LogWarning(
"Discarding partial S&F resync assembly {Old} ({Have}/{Want} chunks): a new resync {New} superseded it",
_assemblingResyncId, _assemblingChunks.Count, _assemblingTotalChunks, msg.ResyncId);
_assemblingResyncId = msg.ResyncId;
_assemblingTotalChunks = msg.TotalChunks;
_assemblingTruncated = msg.Truncated;
_assemblingChunks.Clear();
}
_assemblingChunks[msg.Sequence] = msg.Messages;
Timers.StartSingleTimer(ResyncAssemblyTimerKey, new ResyncAssemblyTimedOut(msg.ResyncId), _resyncAssemblyTimeout);
if (_assemblingChunks.Count < _assemblingTotalChunks)
return;
// Complete: assemble in sequence order and apply atomically.
var assembled = Enumerable.Range(1, _assemblingTotalChunks)
.SelectMany(seq => _assemblingChunks[seq])
.ToList();
var resyncId = _assemblingResyncId!;
var truncated = _assemblingTruncated;
_assemblingResyncId = null;
_assemblingChunks.Clear();
Timers.Cancel(ResyncAssemblyTimerKey);
if (truncated)
_logger.LogWarning(
"S&F buffer resync snapshot truncated at {Cap} rows; divergence beyond the cap drains naturally",
MaxResyncRows);
_logger.LogInformation(
"Applying chunked S&F resync {ResyncId} ({Count} rows), replacing local buffer", resyncId, assembled.Count);
// KNOWN, ACCEPTED race (review 02 round 2, N5 — do NOT "fix" this into something
// worse): a replicated Remove sent after the active node read its snapshot but
// before the snapshot's chunks is ordered BEFORE them on the wire (same
// sender/receiver pair), so this apply can re-add the removed row → an orphan
// Pending row that a later failover re-delivers ONCE. Bounded, self-correcting at
// the next resync, and inherent to no-ack replication; a delivered-side dedup or
// op-sequencing scheme would cost far more than one rare duplicate.
var replyTo = Sender;
Task.Run(async () =>
{
if (SafeIsActive()) // belt-and-braces, mirrors the monolithic path (T3)
{
_logger.LogWarning("Discarding chunked S&F resync {ResyncId}: node became active before apply", resyncId);
return;
}
await _sfStorage.ReplaceAllAsync(assembled);
replyTo.Tell(new SfBufferResyncAck(resyncId, assembled.Count));
})
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply chunked S&F resync {ResyncId}", resyncId);
});
}
private void HandleResyncAssemblyTimedOut(ResyncAssemblyTimedOut msg)
{
if (_assemblingResyncId != msg.ResyncId) return; // superseded already
_logger.LogWarning(
"S&F resync assembly {ResyncId} timed out with {Have}/{Want} chunks — discarding partial (a lost chunk; the next peer-track retries)",
msg.ResyncId, _assemblingChunks.Count, _assemblingTotalChunks);
ScadaBridgeTelemetry.RecordReplicationFailure();
_assemblingResyncId = null;
_assemblingChunks.Clear();
}
Plus internal sealed record ResyncAssemblyTimedOut(string ResyncId); and ctor registrations Receive<SfBufferSnapshotChunk>(HandleSfBufferSnapshotChunk); / Receive<ResyncAssemblyTimedOut>(HandleResyncAssemblyTimedOut); next to the legacy Receive<SfBufferSnapshot> (line 113 — which stays, for rolling compat). Sender inside HandleSfBufferSnapshotChunk is the active node's replication actor (chunks are Tell'd with Self as sender in T5), so the ack lands where Task 7 listens.
4. Run the filter — expect PASS. Full group: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter SiteReplicationActorTests, and re-run the Task 2 integration repro (--filter SfBufferResyncPredicateTests) — it must still pass with the exchange now chunked.
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs && git commit -m "fix(saf): standby assembles chunked resync, applies atomically with re-check, acks; N5 race documented (plan R2-02 T6)"
Task 7: Resync delivery confirmation on the active node + telemetry + doc rewrite
Classification: standard Estimated implement time: ~5 min Parallelizable with: 8, 9, 10, 13, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs(ack tracking inHandleSfResyncSnapshotLoaded+Receive<SfBufferResyncAck>),src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs(two counters, next toRecordReplicationFailureat line 108) - Modify:
docs/requirements/Component-StoreAndForward.md(line 83 — full resync-paragraph rewrite) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs(append)
Depends on Task 6 (same file). N2's "no telemetry counts it / nothing retries until the next peer-track" — the ack closes the silent-loss mode: a resync that was answered but never applied is now a Warning + counter instead of nothing.
- Failing tests:
[Fact]
public async Task ActiveNode_ReceivingAck_CountsResyncCompleted()
{
await _sfStorage.EnqueueAsync(NewMessage("m1"));
var actor = CreateResyncActor(isActive: () => true, ackTimeout: TimeSpan.FromSeconds(30));
actor.Tell(new RequestSfBufferResync(), TestActor);
var chunk = ExpectMsg<SfBufferSnapshotChunk>(TimeSpan.FromSeconds(5));
actor.Tell(new SfBufferResyncAck(chunk.ResyncId, 1), TestActor);
// Observable: the ack-timeout no longer fires (next test proves the inverse);
// completing must not warn. EventFilter pins it:
// (arrange the EventFilter *around* the ack Tell)
}
[Fact]
public async Task ActiveNode_MissingAck_WarnsAfterAckTimeout()
{
await _sfStorage.EnqueueAsync(NewMessage("m1"));
var actor = CreateResyncActor(isActive: () => true, ackTimeout: TimeSpan.FromMilliseconds(200));
await EventFilter.Warning(contains: "was never acknowledged").ExpectOneAsync(async () =>
{
actor.Tell(new RequestSfBufferResync(), TestActor);
ExpectMsg<SfBufferSnapshotChunk>(TimeSpan.FromSeconds(5));
await Task.Delay(500); // let the ack timer fire
});
}
(CreateResyncActor gains an ackTimeout pass-through to the new ctor seam.)
2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "CountsResyncCompleted|WarnsAfterAckTimeout" — expect FAIL (no ack handling; ctor param missing).
3. Implement:
ScadaBridgeTelemetry(alongsideRecordReplicationFailure, line 108):
private static readonly Counter<long> _sfResyncCompleted =
Meter.CreateCounter<long>("scadabridge.store_and_forward.resync.completed", unit: "1",
description: "S&F anti-entropy resyncs the standby acknowledged as applied");
/// <summary>Records one acknowledged (applied) S&F buffer resync.</summary>
public static void RecordSfResyncCompleted() => _sfResyncCompleted.Add(1);
private static readonly Counter<long> _sfResyncAckMissing =
Meter.CreateCounter<long>("scadabridge.store_and_forward.resync.ack_missing", unit: "1",
description: "S&F resyncs answered by the active node but never acknowledged by the standby within the ack window (lost chunks / dead peer)");
/// <summary>Records one resync whose ack window expired.</summary>
public static void RecordSfResyncAckMissing() => _sfResyncAckMissing.Add(1);
SiteReplicationActor: ctor seamTimeSpan? resyncAckTimeout = null→_resyncAckTimeout(default 60 s); inHandleSfResyncSnapshotLoadedafter the chunk loop:_pendingAckResyncId = resyncId; Timers.StartSingleTimer(ResyncAckTimerKey, new ResyncAckTimedOut(resyncId), _resyncAckTimeout);plus:
Receive<SfBufferResyncAck>(msg =>
{
if (msg.ResyncId == _pendingAckResyncId)
{
_pendingAckResyncId = null;
Timers.Cancel(ResyncAckTimerKey);
}
ScadaBridgeTelemetry.RecordSfResyncCompleted();
_logger.LogInformation("S&F resync {ResyncId} acknowledged by standby: {Rows} row(s) applied",
msg.ResyncId, msg.RowCount);
});
Receive<ResyncAckTimedOut>(msg =>
{
if (msg.ResyncId != _pendingAckResyncId) return;
_pendingAckResyncId = null;
ScadaBridgeTelemetry.RecordSfResyncAckMissing();
_logger.LogWarning(
"S&F resync {ResyncId} was never acknowledged within {Window} — snapshot chunks may have been lost (frame drop / dead peer); the next peer-track retries",
msg.ResyncId, _resyncAckTimeout);
});
(single-outstanding-resync bookkeeping is sufficient: a new request supersedes — overwrite _pendingAckResyncId and restart the timer.)
- Doc rewrite —
Component-StoreAndForward.md:83, replace the resync paragraph: chunkedSfBufferSnapshotChunkprotocol (byte-budgeted for Akka remoting's 128 KB default frame), standby assembly + atomicReplaceAllAsync+SfBufferResyncAckconfirmation, ack-missing Warning + counters, the shared oldest-Up predicate on both roles (same predicate as the delivery gate — cite N1), the retained legacy monolithic handler for rolling upgrades, and the accepted N5 orphan-row race.
- Run the filter +
--filter SiteReplicationActorTests— expect PASS. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs docs/requirements/Component-StoreAndForward.md tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs && git commit -m "feat(saf): resync ack confirmation + completed/ack-missing telemetry; doc resync rewrite (plan R2-02 T7)"
Task 8: Publish _sweepTask only when the sweep CAS is won (unclobber the shutdown drain)
Classification: high-risk (shutdown/concurrency semantics) Estimated implement time: ~5 min Parallelizable with: 1, 2, 3, 4, 5, 6, 7, 9, 10, 13, 14, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs(timer callback line 396-400;TriggerSweeplines 664-668;RetryPendingMessagesAsynclines 674-744 split) - Test:
tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs(append)
N3: both the timer callback and TriggerSweep unconditionally Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync()); while a sweep is in flight the new call no-ops via the _retryInProgress CAS and returns a completed task that overwrites the drain handle — StopAsync (line 422-431) then awaits the no-op and shuts down under the real sweep. Fix: the caller performs the CAS and publishes the task only on a win.
- Failing tests (append;
CreateService(retryTimerInterval)helper exists at line 53 of the test file):
[Fact]
public async Task TriggerSweep_WhileSweepInFlight_DoesNotClobberTheDrainHandle()
{
var service = CreateService(retryTimerInterval: TimeSpan.FromHours(1));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async _ =>
{
entered.TrySetResult();
await release.Task;
return true;
});
await service.StartAsync();
try
{
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
service.TriggerSweep(); // real sweep, blocked in the handler
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
service.TriggerSweep(); // redundant kick — pre-fix clobbers _sweepTask
var handle = service.CurrentSweepTaskForTest;
Assert.NotNull(handle);
Assert.False(handle!.IsCompleted); // pre-fix: true (a completed no-op replaced the real sweep)
}
finally
{
release.TrySetResult();
await service.StopAsync();
}
}
[Fact]
public async Task StopAsync_WaitsForTheRealInFlightSweep_EvenAfterARedundantTrigger()
{
var service = CreateService(retryTimerInterval: TimeSpan.FromHours(1));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async _ =>
{
entered.TrySetResult();
await release.Task;
return true;
});
await service.StartAsync();
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
service.TriggerSweep();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
service.TriggerSweep(); // the clobbering kick
var stop = service.StopAsync();
await Task.Delay(300);
Assert.False(stop.IsCompleted); // pre-fix: StopAsync already returned (awaited the no-op)
release.TrySetResult();
await stop.WaitAsync(TimeSpan.FromSeconds(5)); // drains the real sweep promptly once released
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "DoesNotClobberTheDrainHandle|WaitsForTheRealInFlightSweep"— expect FAIL (CurrentSweepTaskForTestmissing; then the pre-fix clobber assertions). - Implement in
StoreAndForwardService:
/// <summary>The current drain handle — test seam for the N3 clobber regression.</summary>
internal Task? CurrentSweepTaskForTest => Volatile.Read(ref _sweepTask);
/// <summary>
/// Starts a sweep IF none is in flight, publishing the new task into
/// <see cref="_sweepTask"/> only when this call wins the <see cref="_retryInProgress"/>
/// CAS. A kick that loses the CAS returns without touching <see cref="_sweepTask"/> —
/// pre-fix it overwrote the drain handle with an instantly-completed no-op, so
/// <see cref="StopAsync"/> proceeded with disposal under a still-running sweep
/// (review 02 round 2, N3) — defeating the drain exactly when a sweep outlives a tick.
/// </summary>
private void KickSweep()
{
if (Interlocked.CompareExchange(ref _retryInProgress, 1, 0) != 0)
return;
Volatile.Write(ref _sweepTask, RunSweepOwnedAsync());
}
RetryPendingMessagesAsynckeeps its signature for the existing direct-call tests, but becomes the CAS wrapper:internal async Task RetryPendingMessagesAsync() { if (Interlocked.CompareExchange(ref _retryInProgress, 1, 0) != 0) return; await RunSweepOwnedAsync(); }— and the existing body (lines 680-743, including the delivery gate, lanes, catch, and thefinally { Interlocked.Exchange(ref _retryInProgress, 0); }) moves verbatim intoprivate async Task RunSweepOwnedAsync()with the entry CAS removed (ownership is the caller's).- Timer callback (line 397):
_ => KickSweep(). TriggerSweep(line 664-668):if (_retryTimer == null) return; KickSweep();(doc comment updated: "publishes into_sweepTaskonly when the CAS is won — seeKickSweep").
- Run the filter, then the whole project's sweep group:
dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "RetrySweep_|Enqueue_DeferToSweep|StopAsync"— expect PASS (existing sweep/defer tests unchanged:RetryPendingMessagesAsyncstill self-CASes for direct calls). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs && git commit -m "fix(saf): publish _sweepTask only on CAS win so StopAsync drains the real in-flight sweep (plan R2-02 T8)"
Task 9: Eager validation for SweepBatchLimit / SweepTargetParallelism
Classification: trivial Estimated implement time: ~3 min Parallelizable with: 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 13, 14, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardOptionsValidator.cs(lines 17-34) - Test:
tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs(append)
N4: a negative SweepBatchLimit silently means "unlimited (legacy)" (StoreAndForwardStorage.cs:393 checks > 0); a non-positive SweepTargetParallelism is silently clamped to 1 (StoreAndForwardService.cs:715). Both get fail-fast messages per the §1.5 eager-validation convention (PLAN-08).
- Failing tests (append, following the file's existing valid/invalid pattern):
[Theory]
[InlineData(-1)]
[InlineData(-500)]
public void Validate_NegativeSweepBatchLimit_Fails(int limit)
{
var options = ValidOptions();
options.SweepBatchLimit = limit;
var result = Validate(options);
Assert.True(result.Failed);
Assert.Contains("SweepBatchLimit", result.FailureMessage);
}
[Fact]
public void Validate_ZeroSweepBatchLimit_IsValidUnlimitedLegacy()
{
var options = ValidOptions();
options.SweepBatchLimit = 0;
Assert.True(Validate(options).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(-4)]
public void Validate_NonPositiveSweepTargetParallelism_Fails(int parallelism)
{
var options = ValidOptions();
options.SweepTargetParallelism = parallelism;
var result = Validate(options);
Assert.True(result.Failed);
Assert.Contains("SweepTargetParallelism", result.FailureMessage);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "SweepBatchLimit_Fails|ZeroSweepBatchLimit|SweepTargetParallelism_Fails"— expect FAIL (validator passes everything). - Implement (append inside
Validate, after theDefaultMaxRetriesrule at line 31-33):
builder.RequireThat(options.SweepBatchLimit >= 0,
$"ScadaBridge:StoreAndForward:SweepBatchLimit must be >= 0 " +
$"(was {options.SweepBatchLimit}); it bounds due rows per retry sweep — 0 means unlimited (legacy).");
builder.RequireThat(options.SweepTargetParallelism >= 1,
$"ScadaBridge:StoreAndForward:SweepTargetParallelism must be >= 1 " +
$"(was {options.SweepTargetParallelism}); it caps concurrent (category,target) sweep lanes — 1 means serial.");
- Run the filter +
dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter StoreAndForwardOptionsValidator— expect PASS. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardOptionsValidator.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs && git commit -m "chore(saf): eagerly validate SweepBatchLimit/SweepTargetParallelism (plan R2-02 T9)"
Task 10: Coalesce live-alarm delta publishes (bound the per-circuit fan-out)
Classification: standard (perf; correctness unchanged — last write wins). MUTEX with PLAN-R2-07's sticky-IsLive task — see the rule at the top; do not start while R2-07's live-alarm slice is in flight. Estimated implement time: ~5 min Parallelizable with: 1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 14 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs(HandleLiveDeltalines 347-368;OnSeedCompletedline 303; ctor + new timer),src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs(after line 124),src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs(after line 87),src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs(Props.Createlines 228-236 — pass the new option) - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs(append),tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs(append)
N6: HandleLiveDelta → Publish() materializes _cache.Values.ToList() per delta and OnPublish fans out to every viewer circuit per delta — O(cacheSize) copies and a render per circuit per transition during an alarm storm. Fix: dirty-flag + single-shot coalescing timer (default 250 ms; TimeSpan.Zero restores per-delta publish). Seed/reconcile completion still publishes immediately (already batched).
- Failing tests (append; reuse
SeedStub/PublishSink/MockSiteAlarmStreamClientand the existingCreateAggregator-style construction):
[Fact]
public void DeltaBurst_IsCoalesced_IntoFewPublishes_ContainingEveryRow()
{
var (actor, seed, sink, client) = CreateAggregator(publishCoalesce: TimeSpan.FromMilliseconds(150));
seed.CompleteNext(); // empty initial seed
AwaitAssert(() => Assert.Equal(1, sink.Count)); // seed publish, immediate
var sub = client.Subs.Single();
for (var i = 0; i < 50; i++)
sub.OnAlarm(Alarm($"A{i}", DateTimeOffset.UtcNow)); // 50 distinct keys, one burst
AwaitAssert(() =>
{
Assert.Equal(50, sink.Latest!.Count); // nothing lost
Assert.InRange(sink.Count, 2, 4); // pre-fix: 51 publishes (1 seed + 1 per delta)
});
}
[Fact]
public void ZeroCoalesce_PreservesLegacyPerDeltaPublish()
{
var (actor, seed, sink, client) = CreateAggregator(publishCoalesce: TimeSpan.Zero);
seed.CompleteNext();
AwaitAssert(() => Assert.Equal(1, sink.Count));
var sub = client.Subs.Single();
sub.OnAlarm(Alarm("A1", DateTimeOffset.UtcNow));
sub.OnAlarm(Alarm("A2", DateTimeOffset.UtcNow));
AwaitAssert(() => Assert.Equal(3, sink.Count)); // 1 seed + 1 per delta
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "DeltaBurst_IsCoalesced|ZeroCoalesce_Preserves"— expect FAIL (ctor lacks the parameter; then 51 publishes). - Implement:
CommunicationOptions(afterLiveAlarmCacheMaxSubscribersPerSite, line 124):
/// <summary>
/// Publish-coalescing window for live alarm deltas: an applied delta marks the cache
/// dirty and one publish (fresh snapshot + per-viewer onChanged fan-out) fires after
/// this window, batching an alarm storm into ~4 publishes/second instead of one per
/// transition (review 02 round 2, N6). Zero = publish per delta (legacy). Seed and
/// reconcile publishes are always immediate. Default 250 ms.
/// </summary>
public TimeSpan LiveAlarmCachePublishCoalesce { get; set; } = TimeSpan.FromMilliseconds(250);
CommunicationOptionsValidator(after line 87):builder.RequireThat(options.LiveAlarmCachePublishCoalesce >= TimeSpan.Zero, $"Communication:LiveAlarmCachePublishCoalesce must be zero or a positive duration (was {options.LiveAlarmCachePublishCoalesce}).");— plus a validator test inCommunicationOptionsValidatorTests(negative fails, zero succeeds).SiteAlarmAggregatorActor: ctor gainsTimeSpan publishCoalesce(expression trees reject optional args — everyProps.Create/test call site passes it explicitly); fieldsprivate readonly TimeSpan _publishCoalesce; private bool _publishPending;andprivate const string PublishTimerKey = "alarm-publish-coalesce";;HandleLiveDelta's pass-through branch (line 366-367) becomesif (ApplyDelta(delta, requireStrictlyNewer: false)) SchedulePublish();with:
/// <summary>
/// Coalesced publish: with a positive window, the first dirtying delta arms a
/// single-shot timer and further deltas ride the same tick — one snapshot copy and
/// one viewer fan-out per window instead of per transition (N6). Zero = legacy
/// immediate publish. Last write wins, so batching never changes final state.
/// </summary>
private void SchedulePublish()
{
if (_publishCoalesce <= TimeSpan.Zero) { Publish(); return; }
if (_publishPending) return;
_publishPending = true;
Timers.StartSingleTimer(PublishTimerKey, new PublishCoalesced(), _publishCoalesce);
}
ctor registration Receive<PublishCoalesced>(_ => { _publishPending = false; if (!_stopped) Publish(); }); and record internal sealed record PublishCoalesced;. In OnSeedCompleted (and OnSeedFailed's publish branch), before the immediate Publish(): Timers.Cancel(PublishTimerKey); _publishPending = false; (the seed snapshot already carries the buffered deltas).
SiteAlarmLiveCacheServiceProps (line 228-236): append_options.LiveAlarmCachePublishCoalesceas the new last argument.
- Run the filter, then the aggregator + service groups:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "SiteAlarmAggregatorActor|SiteAlarmLiveCache|CommunicationOptionsValidator"— fix any existing per-delta-publish assertions by constructing withTimeSpan.Zero(legacy behavior preserved for them). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Communication tests/ZB.MOM.WW.ScadaBridge.Communication.Tests && git commit -m "perf(comm): coalesce live-alarm delta publishes (250ms window, 0 = legacy) (plan R2-02 T10)"
Task 11: Aggregator lifecycle — queued re-seed after reconnect + stream-generation stamp
Classification: standard Estimated implement time: ~5 min Parallelizable with: 1, 2, 5, 6, 7, 8, 9, 13, 14 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs(StartFanoutlines 245-274;OnSeedCompleted/OnSeedFailed;OpenGrpcStreamlines 407-433;GrpcAlarmStreamErrorrecord line 524 + handler lines 164-168) - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs(append)
Depends on Task 10 (same files). N7.1: the post-failover StartFanout(isInitial: false) (line 472) no-ops if a reconcile fan-out is in flight — deltas missed between stream death and that snapshot's read-time stay stale up to 60 s despite the "never silently serve stale" comment. N7.2: a late error raced out of the previous cancelled stream is indistinguishable from a current-stream failure and burns retry budget / double-flips.
- Failing tests:
[Fact]
public void ReseedRequestedDuringInFlightFanout_IsQueued_NotDropped()
{
var (actor, seed, sink, client) = CreateAggregator(publishCoalesce: TimeSpan.Zero);
seed.CompleteNext(); // initial seed done (CallCount 1)
AwaitAssert(() => Assert.Equal(1, sink.Count));
actor.Tell(new RunReconcile()); // reconcile fan-out now in flight (CallCount 2)
AwaitAssert(() => Assert.Equal(2, seed.CallCount));
// Stream error while the reconcile is in flight → the failover re-seed must not
// be silently skipped. Pre-fix: StartFanout no-ops and CallCount stays 2 until
// the next 60s reconcile tick.
client.Subs.Last().OnError(new Exception("stream fault"));
seed.CompleteNext(); // finish the in-flight reconcile
AwaitAssert(() => Assert.Equal(3, seed.CallCount)); // queued re-seed ran immediately after
}
[Fact]
public void LateErrorFromAPreviousStreamGeneration_IsIgnored()
{
var (actor, seed, sink, client) = CreateAggregator(publishCoalesce: TimeSpan.Zero);
seed.CompleteNext();
var firstSub = client.Subs.Single();
firstSub.OnError(new Exception("real fault")); // gen 1 error → flip, reconnect
AwaitAssert(() => Assert.Equal(2, client.Subs.Count)); // gen 2 stream open
firstSub.OnError(new Exception("late zombie fault")); // stale gen-1 error races in
// Pre-fix this burns retry budget and opens a THIRD stream; post-fix it is ignored.
Task.Delay(400).Wait();
Assert.Equal(2, client.Subs.Count);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "IsQueued_NotDropped|PreviousStreamGeneration_IsIgnored"— expect FAIL. - Implement:
- Queued re-seed: field
private bool _reseedQueued;.StartFanout:if (_fanoutInFlight) { if (!isInitial) _reseedQueued = true; return; }(comment: a failover re-seed requested mid-fan-out must run right after — the in-flight snapshot's read-time predates the stream death, N7.1). InOnSeedCompletedandOnSeedFailed, after_fanoutInFlight = false;(and after the flush/publish):if (_reseedQueued) { _reseedQueued = false; StartFanout(isInitial: false); }. - Generation stamp: field
private int _streamGeneration;.OpenGrpcStream:var generation = ++_streamGeneration;and the error callback becomesex => self.Tell(new GrpcAlarmStreamError(ex, generation)); record becomesinternal sealed record GrpcAlarmStreamError(Exception Exception, int Generation);; the handler (line 164-168) prepends:if (msg.Generation != _streamGeneration) { _log.Debug("Ignoring stale gRPC error from stream generation {0} (current {1})", msg.Generation, _streamGeneration); return; }(comment: theRpcException(Cancelled)filter atSiteStreamGrpcClient.cs:256-258covers the normal path; a genuine socket fault can beat the cancel — N7.2).
- Queued re-seed: field
- Run the filter +
--filter SiteAlarmAggregatorActor— expect PASS. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs && git commit -m "fix(comm): queue failover re-seed behind in-flight fan-out; stamp stream generation on gRPC errors (plan R2-02 T11)"
Task 12: Instance-injected ReconnectDelay/StabilityWindow (kill the process-global test seams)
Classification: small (mechanical; N7.3) Estimated implement time: ~4 min Parallelizable with: 1, 2, 5, 6, 7, 8, 9, 13, 14 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteAlarmAggregatorActor.cs(lines 61-69 statics → ctor-injected instance fields),src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs(Props.Create— pass production defaults explicitly) - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs(ctor at lines 27-28 — construct with explicit values instead of mutating statics)
Depends on Task 11 (same files). The internal static mutable ReconnectDelay/StabilityWindow are process-global state parallel test classes can trample; every other tuning knob in this file is ctor-injected.
- This is a refactor with existing coverage: the aggregator suite exercises both knobs (fast reconnect at 50 ms, long stability at 30 s). No new test — the "failing test" is the compile break that proves the statics are gone.
- Implement: delete the static properties (lines 61-69); ctor gains
TimeSpan reconnectDelay, TimeSpan stabilityWindow(explicit — expression trees reject optional args) stored inprivate readonly TimeSpan _reconnectDelay; private readonly TimeSpan _stabilityWindow;; replace the three usages (OpenGrpcStreamline 419 stability timer,HandleGrpcErrorline 477 reconnect timer).SiteAlarmLiveCacheService.Props.CreatepassesTimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60)— annotated as the former static defaults. Test harness: remove the ctor static mutations (lines 27-28) and passTimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(30)through itsCreateAggregatorhelper. - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "SiteAlarmAggregatorActor|SiteAlarmLiveCache"— expect PASS (identical timings, now instance-scoped). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Communication tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteAlarmAggregatorActorTests.cs && git commit -m "chore(comm): ctor-inject aggregator reconnect/stability tuning, remove process-global statics (plan R2-02 T12)"
Task 13: Production caller for RemoveSiteAsync — dispose a deleted site's gRPC channels
Classification: small Estimated implement time: ~4 min Parallelizable with: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs(HandleDeleteSite, lines 1574-1588) - Test:
tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs(append)
N8 first half: SiteStreamGrpcClientFactory.RemoveSiteAsync (SiteStreamGrpcClientFactory.cs:95-102) is documented as "call it when a site record is deleted" and has no production caller — a deleted site's channels persist until process restart. ManagementActor.HandleDeleteSite is the single site-deletion path (the CLI and Central UI both dispatch DeleteSiteCommand through it).
- Failing test (harness:
ManagementActorTestsDIServiceCollection+Envelope(cmd, Roles.Administrator)pattern):
[Fact]
public async Task DeleteSite_DisposesTheSitesCachedGrpcChannels()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetSiteByIdAsync(7).Returns(new Site
{ Id = 7, SiteIdentifier = "site-7", Name = "Seven" });
siteRepo.GetInstancesBySiteIdAsync(7).Returns(new List<Instance>());
_services.AddScoped(_ => siteRepo);
var factory = new TrackingGrpcFactory(); // subclass overriding CreateClient, as in SiteStreamGrpcClientFactoryTests
var cached = (TrackingClient)factory.GetOrCreate("site-7", "http://node-a:8083");
_services.AddSingleton<SiteStreamGrpcClientFactory>(factory);
var actor = CreateActor();
actor.Tell(Envelope(new DeleteSiteCommand(7), Roles.Administrator));
ExpectMsg<ManagementResponse>(r => Assert.True(r.Success));
await AwaitAssertAsync(() =>
{
Assert.True(cached.Disposed);
Assert.Null(factory.TryGet("site-7", "http://node-a:8083"));
});
}
(Copy the TrackingGrpcFactory/TrackingClient doubles from tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs — or reference them if visible.)
2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DeleteSite_DisposesTheSitesCachedGrpcChannels — expect FAIL (channels untouched).
3. Implement in HandleDeleteSite (after commService?.RefreshSiteAddresses();, line 1585):
// Dispose the deleted site's cached gRPC channels — RemoveSiteAsync is the
// factory's designed site-deletion disposal path and previously had no
// production caller (arch review 02 round 2, N8): a deleted site's channels
// (both node endpoints) otherwise persist until process restart. Null-safe:
// test/composition roots without the factory skip it. Any live-alarm
// aggregator for the site reconciles to an empty snapshot and is reaped by
// the viewer linger (documented acceptance — see Component-Communication.md).
var grpcFactory = sp.GetService<SiteStreamGrpcClientFactory>();
if (grpcFactory is not null && site is not null)
await grpcFactory.RemoveSiteAsync(site.SiteIdentifier);
(using ZB.MOM.WW.ScadaBridge.Communication.Grpc; — ManagementService already references Communication.)
4. Run the filter + dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ManagementActorTests — expect PASS.
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs && git commit -m "fix(comm): site delete disposes the site's cached gRPC channels via RemoveSiteAsync (plan R2-02 T13)"
Task 14: Documented acceptance — standby-node aggregators + deleted-site viewer behavior
Classification: trivial (docs only) Estimated implement time: ~3 min Parallelizable with: 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs(doc lines 12-17),docs/requirements/Component-Communication.md(live-alarm-stream section)
Depends on Task 4 (shared Component-Communication.md — serialized). N8 second half: SetActorSystem is wired on every central node (AkkaHostedService.cs:429-430), so browsing the standby directly (port 9002, documented for diagnostics) starts a second, fully-functional aggregator + gRPC stream. The report calls it "harmless (read-only) but contradicting the [PERM] doc claim" — the fix is honest documentation, not gating (gating SetActorSystem behind the active check would break the diagnostic-browse path and buy nothing: the aggregator is read-only and linger-reaped).
ISiteAlarmLiveCache.csdoc (lines 12-17): replace "…lives only on the active central node…" with: "…purely in-memory, per-node, no EF entity/table/migration. In routine operation only the active node (behind Traefik) hosts viewers, so only it runs aggregators; browsing the standby node directly (diagnostic ports) starts an independent read-only aggregator there — accepted: bounded (one stream/site, viewer-capped), read-only, and torn down by the viewer linger. On a NodeA↔NodeB failover the new active node re-seeds from scratch."Component-Communication.md, live-alarm-stream section — add an explicit "Accepted limitations (arch review 02 round 2, N8)" note covering (a) the standby-browse aggregator above, and (b) a site deleted while an Alarm Summary viewer is open: the viewer's aggregator reconciles to an empty snapshot until its viewers leave, then the linger stop reaps it; the site's gRPC channels are disposed at deletion (Task 13).- Verify:
dotnet build ZB.MOM.WW.ScadaBridge.slnx(doc-comment change compiles) and grep —grep -rn "lives only on the active central node" src/returns nothing. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs docs/requirements/Component-Communication.md && git commit -m "docs(comm): accept + document standby aggregators and deleted-site viewer behavior (plan R2-02 T14)"
Task 15: Single-endpoint sites can go live
Classification: small. MUTEX with PLAN-R2-07 (same SiteAlarmLiveCacheService.cs) — see the rule at the top.
Estimated implement time: ~4 min
Parallelizable with: 2, 4, 5, 6, 7, 8, 9, 13, 14
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs(ResolveSiteAsync, lines 294-302) - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs(append)
Depends on Task 12 (file-serialized chain 10 → 12 → 15 on the live-alarm files). N9: ResolveSiteAsync requires both GrpcNodeAAddress and GrpcNodeBAddress; a single-node site silently never starts an aggregator and polls forever. The aggregator works fine flipping between one address — accept a single endpoint by reusing it for both slots (the NodeA↔NodeB flip degenerates to a reconnect against the same node).
- Failing test (follow the file's
CreateService(...)harness — it substitutesISiteRepositoryand injects aCountingFactory):
[Fact]
public async Task SiteWithOnlyNodeAEndpoint_StartsAnAggregator_AgainstThatEndpoint()
{
var service = CreateService(TimeSpan.FromSeconds(30), out var factory,
site: new Site
{
Id = 3, SiteIdentifier = "site-3",
GrpcNodeAAddress = "http://only-node:8083",
GrpcNodeBAddress = null, // single-endpoint site — pre-fix: never goes live
});
using var sub = service.Subscribe(3, onChanged: () => { });
await AwaitAssertAsync(() =>
{
Assert.Equal(1, factory.CreatedCount); // pre-fix: 0 — ResolveSiteAsync bailed
Assert.NotNull(factory.TryGet("site-3", "http://only-node:8083"));
});
}
(Adapt the harness call to CreateService's real signature; the essential arrangement is a site row with exactly one gRPC address.)
2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteWithOnlyNodeAEndpoint — expect FAIL (no client ever created; the service logged "has no gRPC node addresses").
3. Implement — replace the both-required check (SiteAlarmLiveCacheService.cs:294-300):
var nodeA = site.GrpcNodeAAddress;
var nodeB = site.GrpcNodeBAddress;
if (string.IsNullOrWhiteSpace(nodeA) && string.IsNullOrWhiteSpace(nodeB))
{
_logger.LogWarning(
"Live alarm cache: site {SiteId} ({SiteIdentifier}) has no gRPC node addresses; " +
"not starting an aggregator", siteId, site.SiteIdentifier);
return null;
}
// Single-endpoint site (review 02 round 2, N9): reuse the sole configured
// endpoint for both slots — the aggregator's NodeA↔NodeB failover flip
// degenerates to a reconnect against the same node, which is exactly the
// right behavior for a one-node site.
var grpcA = !string.IsNullOrWhiteSpace(nodeA) ? nodeA! : nodeB!;
var grpcB = !string.IsNullOrWhiteSpace(nodeB) ? nodeB! : nodeA!;
return (site.SiteIdentifier, grpcA, grpcB);
- Run the filter +
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteAlarmLiveCache— expect PASS. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs && git commit -m "fix(comm): single-gRPC-endpoint sites can go live (flip degenerates to same-node reconnect) (plan R2-02 T15)"
Dependencies on other plans
- PLAN-R2-07 (UI/Management/Security round 2) — owns every
AlarmSummary.razorpage-side finding and the sticky-IsLivechange inSiteAlarmLiveCacheService.cs. Binding MUTEX (see top of plan): Tasks 10, 12, 15 must not run concurrently with R2-07's live-alarm slice. Everything else here is file-disjoint from R2-07. - PLAN-01 heritage — Task 1 relocates (by delegation, API-preserving) the oldest-Up logic PLAN-01 shipped in
Host/Health/ClusterActivityEvaluator.cs; the PLAN-01TwoNodeClusterFixtureis reused (additively extended) by Task 2. No live round-2 plan owns those files. - Contract locks — the new
SfBufferSnapshotChunk/SfBufferResyncAckmessages ride intra-site Akka remoting, NOT ClusterClient, soClusterClientContractLockTests(PLAN-08 T9) needs no update; the equivalent wire pins land inResyncWireSerializationPinTests(Task 5). If a later change routes any resync message over ClusterClient, it MUST be added to the frozen contract-lock matrix.
Execution order
P0 (the Critical, strictly ordered): 1 → 2 (red repro) → 3 (repro goes green) → 4. Do not ship a build between 2 and 3.
Critical path (file-serialized chains):
SiteReplicationActor.cs: 3 → 5 → 6 → 7AkkaHostedService.cs: 3 → 4- Live-alarm files (
SiteAlarmAggregatorActor.cs/SiteAlarmLiveCacheService.cs): 10 → 11 → 12 → 15 (MUTEX with PLAN-R2-07 throughout) Component-Communication.md: 4 → 14StoreAndForwardService.cs: 8 standalone; validator: 9 standalone;ManagementActor.cs: 13 standalone
P1 (the High): 5 → 6 → 7 immediately after P0. P2: 8, 9, 10, 11, 12, 13, 14, 15 in any dependency-respecting order; 8/9/13/14 are safe to run in parallel with the P0/P1 chains (file-disjoint).
After each task: build the touched projects + the named per-project --filter command only. At plan end: dotnet build ZB.MOM.WW.ScadaBridge.slnx, then dotnet test ZB.MOM.WW.ScadaBridge.slnx (the slnx now includes CLI.Tests), and rebuild the docker cluster (bash docker/deploy.sh) since Host/actor runtime changed.