5bbd7689fa
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.
1341 lines
88 KiB
Markdown
1341 lines
88 KiB
Markdown
# 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&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.
|