Files
ScadaBridge/archreview/plans/PLAN-R2-02-communication-store-and-forward.md
T
Joseph Doherty 5bbd7689fa docs(archreview): round-2 re-review (2026-07-12) + 8 fix plans (86 tasks)
Re-ran all 8 domain reviews at HEAD 8c888f13 against the b910f5eb baseline:
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.
2026-07-12 23:52:10 -04:00

1341 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 — `SelfIsOldest` delegates 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*.
1. 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):
```csharp
// 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;
}
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter ActiveNodeEvaluatorTests` — expect FAIL (compile error: `ActiveNodeEvaluator` does not exist).
3. Implement `src/ZB.MOM.WW.ScadaBridge.Communication/ClusterState/ActiveNodeEvaluator.cs` (verbatim move of the oldest-Up logic from `ClusterActivityEvaluator.SelfIsOldest`):
```csharp
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&amp;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):
```csharp
/// <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` (`StartAsync` gains 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.
1. Add the additive fixture parameters (`TwoNodeClusterFixture.StartAsync`, currently `StartAsync(string role = "Central", TimeSpan? stableAfter = null)`):
```csharp
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
```
2. Write the failing test:
```csharp
// 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` (`DefaultIsActive` lines 190-198; ctor doc lines 56-61; `HandleSfBufferSnapshot` lines 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.
1. Replace `DefaultIsActive` (`SiteReplicationActor.cs:190-198`) and its doc:
```csharp
/// <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:
```csharp
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");
});
```
3. Host wiring (`AkkaHostedService.RegisterSiteActorsAsync`): hoist the `clusterNodeProvider` resolve (currently at line 878, inside the S&F block) to just above the replication-actor creation at line 775, and pass the SAME delegate:
```csharp
// 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` (`DefaultIsActiveCheck` lines 508-526; ctor doc lines 68-74)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (`siteCommActor` creation lines 822-827 — pass `activeNodeCheck`)
- Modify: `docs/requirements/Component-Communication.md` (any "leader" wording on the heartbeat IsActive stamp), `docs/requirements/Component-StoreAndForward.md` line 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).
1. Replace `DefaultIsActiveCheck` (`SiteCommunicationActor.cs:517-526`):
```csharp
/// <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): "…<c>null</c> 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:
```csharp
var siteCommActor = _actorSystem.ActorOf(
Props.Create(() => new SiteCommunicationActor(
_nodeOptions.SiteId!,
_communicationOptions,
dmProxy,
activeNodeCheck)),
"site-communication");
```
3. Docs: grep both component docs for `leader` in the heartbeat/resync context and align to "oldest-Up member (singleton-host semantics, shared `ActiveNodeEvaluator`)". Do NOT rewrite the full resync paragraph yet (Task 7 owns it — avoid a double edit).
4. Build + regression: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`, then `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteCommunicationActor` (existing tests inject `isActiveCheck` stubs — expect PASS).
5. 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; `HandleRequestSfBufferResync` lines 398-411; new chunker + constants near `MaxResyncRows` line 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).
1. Failing tests (append to `SiteReplicationActorTests.cs`; reuse `NewMessage`/`ResyncTestActor` helpers):
```csharp
// ── 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;` and `internal const int MaxResyncChunkRows = 200;` with the sizing-rationale doc above (cite the 128 000-byte default frame and that `BuildHocon` sets no override).
- Pure chunker (internal static, unit-testable):
```csharp
/// <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 keeps `SendToPeer`/ack bookkeeping actor-safe), then send sequenced chunks to the requester:
```csharp
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:
```csharp
/// <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);
```
```csharp
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):
```csharp
/// <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).
4. Create `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ResyncWireSerializationPinTests.cs` following `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs`: round-trip `SfBufferSnapshotChunk` (fully-populated `StoreAndForwardMessage` incl. `ExecutionId`/`ParentExecutionId`) and `SfBufferResyncAck` through `Sys.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.
5. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "ChunkForRemoting|WithSequencedChunks|ResyncWireSerializationPin"` — expect PASS. Then the full resync group: `--filter SiteReplicationActorTests`.
6. 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).
1. Failing tests:
```csharp
[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));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "AssemblesChunks|DiscardsStalePartialAssembly|IgnoresChunks_NeverAcks"` — expect FAIL (no chunk handler; chunks dead-letter).
3. Implement — actor gains `IWithTimers` (`public ITimerScheduler Timers { get; set; } = null!;`) plus assembly state and the handler:
```csharp
// ── 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.)
```csharp
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 in `HandleSfResyncSnapshotLoaded` + `Receive<SfBufferResyncAck>`), `src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs` (two counters, next to `RecordReplicationFailure` at 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.
1. Failing tests:
```csharp
[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` (alongside `RecordReplicationFailure`, line 108):
```csharp
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 seam `TimeSpan? resyncAckTimeout = null``_resyncAckTimeout` (default 60 s); in `HandleSfResyncSnapshotLoaded` after the chunk loop: `_pendingAckResyncId = resyncId; Timers.StartSingleTimer(ResyncAckTimerKey, new ResyncAckTimedOut(resyncId), _resyncAckTimeout);` plus:
```csharp
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: chunked `SfBufferSnapshotChunk` protocol (byte-budgeted for Akka remoting's 128 KB default frame), standby assembly + atomic `ReplaceAllAsync` + `SfBufferResyncAck` confirmation, 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.
4. Run the filter + `--filter SiteReplicationActorTests` — expect PASS.
5. 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; `TriggerSweep` lines 664-668; `RetryPendingMessagesAsync` lines 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.
1. Failing tests (append; `CreateService(retryTimerInterval)` helper exists at line 53 of the test file):
```csharp
[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
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "DoesNotClobberTheDrainHandle|WaitsForTheRealInFlightSweep"` — expect FAIL (`CurrentSweepTaskForTest` missing; then the pre-fix clobber assertions).
3. Implement in `StoreAndForwardService`:
```csharp
/// <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());
}
```
- `RetryPendingMessagesAsync` keeps 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 the `finally { Interlocked.Exchange(ref _retryInProgress, 0); }`) moves verbatim into `private 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 `_sweepTask` only when the CAS is won — see `KickSweep`").
4. 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: `RetryPendingMessagesAsync` still self-CASes for direct calls).
5. 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).
1. Failing tests (append, following the file's existing valid/invalid pattern):
```csharp
[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);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "SweepBatchLimit_Fails|ZeroSweepBatchLimit|SweepTargetParallelism_Fails"` — expect FAIL (validator passes everything).
3. Implement (append inside `Validate`, after the `DefaultMaxRetries` rule at line 31-33):
```csharp
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.");
```
4. Run the filter + `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter StoreAndForwardOptionsValidator` — expect PASS.
5. 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` (`HandleLiveDelta` lines 347-368; `OnSeedCompleted` line 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.Create` lines 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).
1. Failing tests (append; reuse `SeedStub`/`PublishSink`/`MockSiteAlarmStreamClient` and the existing `CreateAggregator`-style construction):
```csharp
[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
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "DeltaBurst_IsCoalesced|ZeroCoalesce_Preserves"` — expect FAIL (ctor lacks the parameter; then 51 publishes).
3. Implement:
- `CommunicationOptions` (after `LiveAlarmCacheMaxSubscribersPerSite`, line 124):
```csharp
/// <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 in `CommunicationOptionsValidatorTests` (negative fails, zero succeeds).
- `SiteAlarmAggregatorActor`: ctor gains `TimeSpan publishCoalesce` (expression trees reject optional args — every `Props.Create`/test call site passes it explicitly); fields `private readonly TimeSpan _publishCoalesce; private bool _publishPending;` and `private const string PublishTimerKey = "alarm-publish-coalesce";`; `HandleLiveDelta`'s pass-through branch (line 366-367) becomes `if (ApplyDelta(delta, requireStrictlyNewer: false)) SchedulePublish();` with:
```csharp
/// <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).
- `SiteAlarmLiveCacheService` Props (line 228-236): append `_options.LiveAlarmCachePublishCoalesce` as the new last argument.
4. 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 with `TimeSpan.Zero` (legacy behavior preserved for them).
5. 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` (`StartFanout` lines 245-274; `OnSeedCompleted`/`OnSeedFailed`; `OpenGrpcStream` lines 407-433; `GrpcAlarmStreamError` record 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.
1. Failing tests:
```csharp
[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);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "IsQueued_NotDropped|PreviousStreamGeneration_IsIgnored"` — expect FAIL.
3. 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). In `OnSeedCompleted` and `OnSeedFailed`, 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 becomes `ex => self.Tell(new GrpcAlarmStreamError(ex, generation))`; record becomes `internal 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: the `RpcException(Cancelled)` filter at `SiteStreamGrpcClient.cs:256-258` covers the normal path; a genuine socket fault can beat the cancel — N7.2).
4. Run the filter + `--filter SiteAlarmAggregatorActor` — expect PASS.
5. 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.
1. 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.
2. Implement: delete the static properties (lines 61-69); ctor gains `TimeSpan reconnectDelay, TimeSpan stabilityWindow` (explicit — expression trees reject optional args) stored in `private readonly TimeSpan _reconnectDelay; private readonly TimeSpan _stabilityWindow;`; replace the three usages (`OpenGrpcStream` line 419 stability timer, `HandleGrpcError` line 477 reconnect timer). `SiteAlarmLiveCacheService.Props.Create` passes `TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60)` — annotated as the former static defaults. Test harness: remove the ctor static mutations (lines 27-28) and pass `TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(30)` through its `CreateAggregator` helper.
3. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "SiteAlarmAggregatorActor|SiteAlarmLiveCache"` — expect PASS (identical timings, now instance-scoped).
4. 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).
1. Failing test (harness: `ManagementActorTests` DI `ServiceCollection` + `Envelope(cmd, Roles.Administrator)` pattern):
```csharp
[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):
```csharp
// 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).
1. `ISiteAlarmLiveCache.cs` doc (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."
2. `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).
3. 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.
4. 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).
1. Failing test (follow the file's `CreateService(...)` harness — it substitutes `ISiteRepository` and injects a `CountingFactory`):
```csharp
[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`):
```csharp
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);
```
4. Run the filter + `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteAlarmLiveCache` — expect PASS.
5. 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.razor` page-side finding and the sticky-`IsLive` change in `SiteAlarmLiveCacheService.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-01 `TwoNodeClusterFixture` is reused (additively extended) by Task 2. No live round-2 plan owns those files.
- **Contract locks** — the new `SfBufferSnapshotChunk`/`SfBufferResyncAck` messages ride intra-site Akka remoting, NOT ClusterClient, so `ClusterClientContractLockTests` (PLAN-08 T9) needs no update; the equivalent wire pins land in `ResyncWireSerializationPinTests` (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 → 7
- `AkkaHostedService.cs`: 3 → 4
- Live-alarm files (`SiteAlarmAggregatorActor.cs` / `SiteAlarmLiveCacheService.cs`): 10 → 11 → 12 → 15 (**MUTEX with PLAN-R2-07 throughout**)
- `Component-Communication.md`: 4 → 14
- `StoreAndForwardService.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.