1633 lines
100 KiB
Markdown
1633 lines
100 KiB
Markdown
# Communication & Store-and-Forward Fix Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Fix every finding in `archreview/02-communication-store-and-forward.md` — the ClusterClient recreate crash-loop, the ungated standby S&F delivery pipeline, the shared-gRPC-channel disposal, the collapsing retry sweep, replication ordering/upsert/observability, notification parking/blocking semantics, corrupt-payload handling, and the SQLite/serializer/observability hygiene items.
|
|
|
|
**Architecture:** The fixes stay inside the existing seams: `CentralCommunicationActor`/`DefaultSiteClientFactory` get collision-free generation-suffixed actor names; `StoreAndForwardService` gets an injectable active-node delivery gate (a `Func<bool>` seam that plan 01's shared singleton-host helper will later plug into), a bounded/short-circuiting/parallel-lane sweep, and a defer-to-sweep enqueue path so `Notify.Send` never blocks a script thread; `SiteStreamGrpcClientFactory` is re-keyed by `(site, endpoint)` so per-session failover cannot dispose shared channels; `ReplicationService` dispatches inline (ordered) with Warning-level observability and upsert-based standby applies, plus a peer-join buffer resync as anti-entropy.
|
|
|
|
**Tech Stack:** C#/.NET 10, Akka.NET (TestKit.Xunit2, ClusterClient, DistributedPubSub), Microsoft.Data.Sqlite, Grpc.Net, xUnit + NSubstitute. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Test per-project: `dotnet test tests/<project>`.
|
|
|
|
## Findings Coverage
|
|
|
|
| Report finding | Severity | Task(s) |
|
|
|---|---|---|
|
|
| ClusterClient recreate-on-address-change → name collision → permanent restart loop | Critical | 1, 2 |
|
|
| `siteId` interpolated unvalidated into actor name (secondary, same lines) | Critical (sub) | 1 |
|
|
| Standby site node runs full S&F delivery pipeline (systematic duplicate delivery) | High | 3, 4 |
|
|
| Debug-session gRPC failover disposes shared channel under other sessions; `CleanupGrpc` creates channels during teardown | High | 5, 6 |
|
|
| Retry sweep serial, unbounded, single-laned — collapses under backlog | High (perf) | 7, 8, 9 |
|
|
| Replication operations applied out of order (`Task.Run` fire-and-forget) | Medium | 10 |
|
|
| Replication failures logged at Debug — dead channel invisible | Medium | 10 |
|
|
| Park/Requeue replication is a blind UPDATE — lost on standby missing the row | Medium | 11 |
|
|
| Notifications park after ~25 min of central unreachability; `maxRetries: 0` escape hatch unused | Medium | 13 |
|
|
| Corrupt buffered notification discarded and reported as delivered | Medium | 14 |
|
|
| Spec-vs-code drift: standby-passive model; `Notify.Send` blocks script up to 30 s | Medium | 4 (doc), 12, 13 |
|
|
| No explicit Akka serializer — wire format unpinned | Medium (perf) | 15 (round-trip pin tests). Serializer *swap* Deferred: changing the wire format breaks rolling cross-version central↔site compat; needs its own migration plan (flagged for a follow-up design session) |
|
|
| Central heartbeat state not replicated to peer central node | Low | 16 |
|
|
| `SubscribeInstance` concurrency-limit check-then-act (comment only) | Low | 22 |
|
|
| S&F SQLite: no WAL, no busy_timeout | Low | 17 |
|
|
| Due-check does per-row `julianday()` string parsing | Low | 18 |
|
|
| Audit-observer notification awaited inline in the sweep | Low | 19 |
|
|
| `StreamRelayActor.WriteToChannel` dead branch + silent DropOldest loss | Low | 22 |
|
|
| Duplicated Ask-timeout constants between the two ingest transports | Low | 22 |
|
|
| Heartbeat `IsActive` leader-check vs oldest-node singleton placement | Low | **Deferred → plan 01** (owns the shared active-node/singleton-host helper; Task 3's `Func<bool>` seam is designed to accept it) |
|
|
| `TransportHeartbeatInterval` double duty (transport FD + app heartbeat) | Low | 22 |
|
|
| U1: Dual transports for the same ingest operations | Underdev | **Won't-fix here** — consolidation spans the central ingest actors (another reviewer's domain); Task 22's shared timeout constant removes the copy-paste drift; consolidation flagged as a scheduled follow-up in the Task 22 doc note |
|
|
| U2: No real-collaborator test for ClusterClient cache lifecycle | Underdev | 2 |
|
|
| U3: No test/design treatment of standby-node sweep behavior | Underdev | 3, 4 (unit-level); real two-node kill rig → plan 01 dependency |
|
|
| U4: Replication has no anti-entropy/resync | Underdev | 20, 21 |
|
|
| U5: Parked rows have unbounded retention, no aging signal | Underdev | 23 |
|
|
| U6: Reconciliation cursor edge (`>` vs `>=`) unpinned | Underdev | 24 |
|
|
| U7: `DebugStreamService._sessions` lost silently on central restart | Underdev | 22 (documented as accepted limitation in `Component-Communication.md`) |
|
|
|
|
---
|
|
|
|
### Task 1: Generation-suffixed, sanitized ClusterClient actor names
|
|
|
|
**Classification:** high-risk (actor lifecycle/naming)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 3, 5, 10, 14, 15, 24
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs` (lines 33-41, `DefaultSiteClientFactory`)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DefaultSiteClientFactoryTests.cs` (create)
|
|
|
|
**Design decision — generation suffix over await-termination:** `Context.Stop` is async and the name stays reserved until the old ClusterClient fully terminates. Watching `Terminated` and deferring recreation requires a pending-recreate state machine in the actor and *lengthens* the routing gap (envelopes for that site are dropped until the new client exists either way). A per-incarnation generation suffix makes creation collision-free by construction, is synchronous, and mirrors the existing repo precedent (`SiteStreamGrpcServer._actorCounter`, `SiteStreamGrpcServer.cs:36,259`). Sanitization handles validity; the counter guarantees uniqueness even if two site ids sanitize identically.
|
|
|
|
1. Write the failing test:
|
|
|
|
```csharp
|
|
// tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DefaultSiteClientFactoryTests.cs
|
|
using System.Collections.Immutable;
|
|
using Akka.Actor;
|
|
using Akka.Cluster.Tools.Client;
|
|
using Akka.Configuration;
|
|
using Akka.TestKit.Xunit2;
|
|
using Xunit;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
|
|
|
public class DefaultSiteClientFactoryTests : TestKit
|
|
{
|
|
private static readonly Config TestConfig = ConfigurationFactory.ParseString(@"
|
|
akka.actor.provider = cluster
|
|
akka.remote.dot-netty.tcp.port = 0
|
|
akka.remote.dot-netty.tcp.hostname = localhost")
|
|
.WithFallback(ClusterClientReceptionist.DefaultConfig());
|
|
|
|
public DefaultSiteClientFactoryTests() : base(TestConfig) { }
|
|
|
|
private static ImmutableHashSet<ActorPath> Contacts() =>
|
|
ImmutableHashSet.Create(ActorPath.Parse("akka.tcp://other@localhost:2552/system/receptionist"));
|
|
|
|
[Fact]
|
|
public void Create_TwiceForSameSite_DoesNotCollide()
|
|
{
|
|
var factory = new DefaultSiteClientFactory();
|
|
var first = factory.Create(Sys, "site-a", Contacts());
|
|
var second = factory.Create(Sys, "site-a", Contacts()); // pre-fix: InvalidActorNameException
|
|
Assert.NotEqual(first.Path, second.Path);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("site/with/slashes")]
|
|
[InlineData("site with spaces")]
|
|
[InlineData("näme#!")]
|
|
public void Create_WithUnsafeSiteId_SanitizesAndSucceeds(string siteId)
|
|
{
|
|
var factory = new DefaultSiteClientFactory();
|
|
var client = factory.Create(Sys, siteId, Contacts()); // pre-fix: InvalidActorNameException
|
|
Assert.NotNull(client);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("plant-01", "plant-01")]
|
|
[InlineData("a/b c", "a_b_c")]
|
|
[InlineData("", "site")]
|
|
public void SanitizeForActorName_ProducesValidPathElement(string input, string expected)
|
|
{
|
|
Assert.Equal(expected, DefaultSiteClientFactory.SanitizeForActorName(input));
|
|
Assert.True(ActorPath.IsValidPathElement(
|
|
DefaultSiteClientFactory.SanitizeForActorName(input)));
|
|
}
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter DefaultSiteClientFactoryTests` — expect FAIL (collision throws / sanitize method missing).
|
|
3. Implement in `CentralCommunicationActor.cs`, replacing the `DefaultSiteClientFactory` body:
|
|
|
|
```csharp
|
|
public class DefaultSiteClientFactory : ISiteClientFactory
|
|
{
|
|
/// <summary>
|
|
/// Per-incarnation generation counter. Context.Stop of the previous client is
|
|
/// asynchronous — its actor name stays reserved until termination completes —
|
|
/// so a same-named recreate in the same message handling throws
|
|
/// InvalidActorNameException. A generation suffix makes every incarnation's
|
|
/// name unique by construction (mirrors SiteStreamGrpcServer._actorCounter).
|
|
/// </summary>
|
|
private long _generation;
|
|
|
|
/// <inheritdoc />
|
|
public IActorRef Create(ActorSystem system, string siteId, ImmutableHashSet<ActorPath> contacts)
|
|
{
|
|
var settings = ClusterClientSettings.Create(system).WithInitialContacts(contacts);
|
|
var name = $"site-client-{SanitizeForActorName(siteId)}-{Interlocked.Increment(ref _generation)}";
|
|
return system.ActorOf(ClusterClient.Props(settings), name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps an arbitrary SiteIdentifier onto a valid Akka actor-path element:
|
|
/// letters/digits/'-'/'_' pass through, everything else becomes '_'. Uniqueness
|
|
/// is NOT required here (the generation suffix guarantees it); only validity is.
|
|
/// </summary>
|
|
internal static string SanitizeForActorName(string siteId)
|
|
{
|
|
if (string.IsNullOrEmpty(siteId)) return "site";
|
|
var sanitized = new string(siteId
|
|
.Select(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' ? c : '_')
|
|
.ToArray());
|
|
return ActorPath.IsValidPathElement(sanitized) ? sanitized : "site";
|
|
}
|
|
}
|
|
```
|
|
|
|
(`internal` is visible to the test project via the existing `InternalsVisibleTo` in the csproj.)
|
|
4. Run the filter again — expect PASS. Also run the full project: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests`.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/DefaultSiteClientFactoryTests.cs && git commit -m "fix(communication): generation-suffixed, sanitized ClusterClient actor names to prevent recreate name collision"`
|
|
|
|
### Task 2: Guard client creation in the refresh loop + real-factory address-edit lifecycle test
|
|
|
|
**Classification:** high-risk (actor restart semantics)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 3, 5, 7, 10, 14
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs` (`HandleSiteAddressCacheLoaded`, lines 507-526)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorClientLifecycleTests.cs` (create)
|
|
|
|
Depends on Task 1 (same file, uses the fixed factory).
|
|
|
|
1. Write the failing test — the address-edit scenario with the **real** `DefaultSiteClientFactory` (this is the exact production path the report says no test exercises; follow the DI-stub pattern from `CentralCommunicationActorTests` and the TestKit config from Task 1):
|
|
|
|
```csharp
|
|
// tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorClientLifecycleTests.cs
|
|
public class CentralCommunicationActorClientLifecycleTests : TestKit
|
|
{
|
|
// same TestConfig as DefaultSiteClientFactoryTests
|
|
|
|
private static SiteAddressCacheLoaded Load(string siteId, params string[] addrs) =>
|
|
new(new Dictionary<string, IReadOnlyList<string>>
|
|
{ [siteId] = addrs.ToList().AsReadOnly() });
|
|
|
|
[Fact]
|
|
public void AddressEdit_RecreatesClient_WithoutRestartLoop()
|
|
{
|
|
var sp = new ServiceCollection().BuildServiceProvider(); // no repo needed: we inject cache loads directly
|
|
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
|
|
sp, new DefaultSiteClientFactory(), null)));
|
|
|
|
// First load creates the client; second load (edited NodeA address) stops
|
|
// the old one and creates a replacement in the same message handling.
|
|
// Pre-fix this throws InvalidActorNameException and restarts the actor.
|
|
EventFilter.Exception<InvalidActorNameException>().Expect(0, () =>
|
|
{
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081"));
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a-edited:8081"));
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")); // and back — third generation
|
|
});
|
|
|
|
// The actor must still be alive and routing (not crash-looping):
|
|
// an envelope for an unknown site produces the "No ClusterClient" warning,
|
|
// proving the Receive pipeline is healthy.
|
|
EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() =>
|
|
actor.Tell(new SiteEnvelope("unknown-site", new object())));
|
|
}
|
|
|
|
[Fact]
|
|
public void FactoryThrow_SkipsSite_DoesNotCrashActor()
|
|
{
|
|
var throwingFactory = Substitute.For<ISiteClientFactory>();
|
|
throwingFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>())
|
|
.Returns(_ => throw new InvalidOperationException("boom"));
|
|
var sp = new ServiceCollection().BuildServiceProvider();
|
|
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
|
|
sp, throwingFactory, null)));
|
|
|
|
EventFilter.Error(contains: "Failed to create ClusterClient").ExpectOne(() =>
|
|
actor.Tell(Load("site-a", "akka.tcp://scadabridge@node-a:8081")));
|
|
|
|
// Actor survived — a subsequent load with a healthy factory-free path still processes.
|
|
EventFilter.Warning(contains: "No ClusterClient for site").ExpectOne(() =>
|
|
actor.Tell(new SiteEnvelope("site-a", new object())));
|
|
}
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter CentralCommunicationActorClientLifecycleTests` — expect FAIL (`FactoryThrow` case escapes the handler and restarts the actor; pre-Task-1 the first case also fails).
|
|
3. Implement — in `HandleSiteAddressCacheLoaded`, wrap the create (currently line 520) so a failure logs, cleans the entry, and skips the site instead of crashing the actor:
|
|
|
|
```csharp
|
|
// Stop old client if addresses changed
|
|
if (_siteClients.ContainsKey(siteId))
|
|
{
|
|
_log.Info("Updating ClusterClient for site {0} (addresses changed)", siteId);
|
|
Context.Stop(_siteClients[siteId].Client);
|
|
// Remove now: if the replacement create below fails, a stale entry
|
|
// would route envelopes to a stopping actor.
|
|
_siteClients.Remove(siteId);
|
|
}
|
|
|
|
IActorRef client;
|
|
try
|
|
{
|
|
client = _siteClientFactory.Create(Context.System, siteId, contactPaths);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Error(ex,
|
|
"Failed to create ClusterClient for site {0}; site is unroutable until the next refresh",
|
|
siteId);
|
|
continue;
|
|
}
|
|
_siteClients[siteId] = (client, contactStrings);
|
|
```
|
|
|
|
4. Run the filter — expect PASS. Run full project tests.
|
|
5. Commit: `git add -A src/ZB.MOM.WW.ScadaBridge.Communication tests/ZB.MOM.WW.ScadaBridge.Communication.Tests && git commit -m "fix(communication): guard per-site ClusterClient creation; add real-factory address-edit lifecycle test"`
|
|
|
|
### Task 3: Active-node delivery gate on the S&F retry sweep
|
|
|
|
**Classification:** high-risk (failover semantics)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 1, 2, 5, 14, 15, 16, 24
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` (fields near line 85; `RetryPendingMessagesAsync`, lines 563-589)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs` (append)
|
|
|
|
The gate is a `Func<bool>` seam re-evaluated on **every sweep tick** — failover resumes delivery within one `RetryTimerInterval` (10 s) with no re-wiring. Plan 01 owns the shared active-node/singleton-host helper; this seam is where it plugs in (Task 4 wires the interim leader-check).
|
|
|
|
1. Write the failing tests (append to `StoreAndForwardServiceTests.cs`, reusing its temp-SQLite construction pattern):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task RetrySweep_SkipsDelivery_WhenDeliveryGateReportsStandby()
|
|
{
|
|
var service = CreateService(); // existing helper pattern: storage + options + service
|
|
var delivered = 0;
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => { delivered++; return Task.FromResult(true); });
|
|
service.SetDeliveryGate(() => false); // standby
|
|
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "target-x", "{}",
|
|
attemptImmediateDelivery: false);
|
|
await service.RetryPendingMessagesAsync();
|
|
|
|
Assert.Equal(0, delivered);
|
|
Assert.NotNull(await service.GetMessageByIdAsync(
|
|
(await GetOnlyPendingId(service)))); // row untouched, still Pending
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrySweep_ResumesDelivery_WhenGateFlipsActive()
|
|
{
|
|
var service = CreateService();
|
|
var delivered = 0;
|
|
var active = false;
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => { delivered++; return Task.FromResult(true); });
|
|
service.SetDeliveryGate(() => active);
|
|
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "target-x", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
|
|
await service.RetryPendingMessagesAsync();
|
|
Assert.Equal(0, delivered);
|
|
|
|
active = true; // failover: this node became active
|
|
await service.RetryPendingMessagesAsync();
|
|
Assert.Equal(1, delivered);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrySweep_TreatsThrowingGateAsStandby()
|
|
{
|
|
var service = CreateService();
|
|
var delivered = 0;
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => { delivered++; return Task.FromResult(true); });
|
|
service.SetDeliveryGate(() => throw new InvalidOperationException("cluster not ready"));
|
|
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
await service.RetryPendingMessagesAsync(); // must not throw
|
|
Assert.Equal(0, delivered);
|
|
}
|
|
```
|
|
|
|
Note: `attemptImmediateDelivery: false` sets `LastAttemptAt = now`, so pass `retryInterval: TimeSpan.Zero` (or use the storage `retry_interval_ms = 0` always-due path) to make the row due immediately.
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter RetrySweep_` — expect FAIL (`SetDeliveryGate` missing).
|
|
3. Implement in `StoreAndForwardService`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Active-node delivery gate. When set, the retry sweep runs ONLY when the
|
|
/// gate reports true — the standby node applies replicated buffer operations
|
|
/// but must never deliver (Component-StoreAndForward.md "standby-passive"
|
|
/// model). Re-evaluated on every sweep tick so a failover resumes delivery
|
|
/// within one RetryTimerInterval without re-wiring. Null (tests, central
|
|
/// hosts) preserves the ungated legacy behaviour. A throwing gate is treated
|
|
/// as standby — safe-by-default, mirroring SiteCommunicationActor's
|
|
/// DefaultIsActiveCheck fallback.
|
|
/// </summary>
|
|
private Func<bool>? _deliveryGate;
|
|
|
|
/// <summary>Installs the active-node delivery gate (see <see cref="_deliveryGate"/>).</summary>
|
|
public void SetDeliveryGate(Func<bool> gate) => _deliveryGate = gate;
|
|
```
|
|
|
|
And at the top of `RetryPendingMessagesAsync`, immediately after the `_retryInProgress` CAS succeeds (inside the `try`):
|
|
|
|
```csharp
|
|
var gate = _deliveryGate;
|
|
if (gate != null)
|
|
{
|
|
bool isActive;
|
|
try { isActive = gate(); }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"S&F delivery gate threw; treating this node as standby for this sweep");
|
|
isActive = false;
|
|
}
|
|
if (!isActive)
|
|
{
|
|
_logger.LogDebug("S&F retry sweep skipped: this node is not the active site node");
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
4. Run the filter — expect PASS. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests`.
|
|
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(store-and-forward): gate the retry sweep behind an active-node delivery gate (standby must be passive)"`
|
|
|
|
### Task 4: Wire the delivery gate in the Host + align the S&F design doc
|
|
|
|
**Classification:** small (wiring + docs; behavior covered by Task 3's tests)
|
|
**Estimated implement time:** ~3 min
|
|
**Parallelizable with:** 5, 6, 10, 11, 14
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` (in `RegisterSiteActorsAsync`, immediately after `await storeAndForwardService.StartAsync();` at line 981)
|
|
- Modify: `docs/requirements/Component-StoreAndForward.md` (Persistence section, the failover paragraphs at lines ~77-80)
|
|
|
|
Depends on Task 3. **Dependency on plan 01:** plan 01 delivers the shared active-node/singleton-host helper (and the leader-vs-oldest correction). Until it lands, wire the same leader+Up check the repo already uses (`ActiveNodeGate`, `SiteCommunicationActor.DefaultIsActiveCheck`); the lambda body is the single swap point.
|
|
|
|
1. Implement the wiring in `AkkaHostedService.RegisterSiteActorsAsync`:
|
|
|
|
```csharp
|
|
await storeAndForwardService.StartAsync();
|
|
|
|
// Standby must be passive: only the active site node runs the S&F
|
|
// delivery sweep. Without this gate BOTH nodes deliver the same
|
|
// replicated Pending rows — systematic duplicate external calls and
|
|
// DB writes (arch review 02, Stability #2). Re-evaluated per sweep
|
|
// tick so failover resumes delivery within one RetryTimerInterval.
|
|
// NOTE: leader+Up mirrors ActiveNodeGate / ActiveNodeHealthCheck /
|
|
// SiteCommunicationActor.DefaultIsActiveCheck. When the shared
|
|
// singleton-host helper from the cluster-infrastructure fix plan
|
|
// lands, replace this lambda body with it.
|
|
var gateSystem = _actorSystem!;
|
|
storeAndForwardService.SetDeliveryGate(() =>
|
|
{
|
|
var cluster = Akka.Cluster.Cluster.Get(gateSystem);
|
|
var self = cluster.SelfMember;
|
|
return self.Status == Akka.Cluster.MemberStatus.Up
|
|
&& cluster.State.Leader != null
|
|
&& cluster.State.Leader == self.Address;
|
|
});
|
|
```
|
|
|
|
2. Update `docs/requirements/Component-StoreAndForward.md` Persistence/failover text: replace the sentence "On failover, the new active node resumes delivery from its local copy." with an explicit statement that (a) the delivery sweep is **gated to the active node** and the standby only applies replicated operations, and (b) duplicate deliveries are confined to the failover window (an in-flight delivery whose `Remove` was not yet replicated), not steady-state operation.
|
|
3. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect success. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests` (regression only).
|
|
4. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs docs/requirements/Component-StoreAndForward.md && git commit -m "fix(host): wire S&F active-node delivery gate on site nodes; align S&F doc standby-passive wording"`
|
|
|
|
### Task 5: Key the gRPC client cache by (site, endpoint)
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 1, 2, 3, 10, 14, 15
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs` (whole cache surface, lines 14, 52-137)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs` (modify — the dispose-on-endpoint-change assertions invert), `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryDisposeTests.cs` (modify)
|
|
|
|
Both node channels coexist per site; per-session failover never disposes shared state. Site *removal* (`RemoveSiteAsync`) disposes all of a site's entries — that remains the only shared-disposal path. Trade-off (documented in the class doc): an edited gRPC address leaves the old endpoint's idle channel cached until site removal or process shutdown — bounded at a handful of entries per site (2 nodes + rare edits), each an idle HTTP/2 channel.
|
|
|
|
1. Modify the existing factory tests (they use the `protected virtual CreateClient` tracking-client override): the test that asserts "requesting a different endpoint disposes the cached client" becomes:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void GetOrCreate_DifferentEndpointSameSite_KeepsBothClientsAlive()
|
|
{
|
|
var factory = new TrackingFactory(LoggerFactory);
|
|
var a = factory.GetOrCreate("site-1", "http://node-a:8083");
|
|
var b = factory.GetOrCreate("site-1", "http://node-b:8083");
|
|
|
|
Assert.NotSame(a, b);
|
|
Assert.False(((TrackingClient)a).Disposed); // pre-fix: a is disposed here
|
|
Assert.Same(a, factory.GetOrCreate("site-1", "http://node-a:8083")); // still cached
|
|
}
|
|
|
|
[Fact]
|
|
public void TryGet_ReturnsCachedClientOrNull_WithoutCreating()
|
|
{
|
|
var factory = new TrackingFactory(LoggerFactory);
|
|
Assert.Null(factory.TryGet("site-1", "http://node-a:8083"));
|
|
var a = factory.GetOrCreate("site-1", "http://node-a:8083");
|
|
Assert.Same(a, factory.TryGet("site-1", "http://node-a:8083"));
|
|
Assert.Null(factory.TryGet("site-1", "http://node-b:8083"));
|
|
Assert.Equal(1, factory.CreatedCount); // TryGet never creates
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveSiteAsync_DisposesAllEndpointsForTheSite_OnlyThatSite()
|
|
{
|
|
var factory = new TrackingFactory(LoggerFactory);
|
|
var a = (TrackingClient)factory.GetOrCreate("site-1", "http://node-a:8083");
|
|
var b = (TrackingClient)factory.GetOrCreate("site-1", "http://node-b:8083");
|
|
var other = (TrackingClient)factory.GetOrCreate("site-2", "http://node-a:8083");
|
|
|
|
await factory.RemoveSiteAsync("site-1");
|
|
Assert.True(a.Disposed);
|
|
Assert.True(b.Disposed);
|
|
Assert.False(other.Disposed);
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteStreamGrpcClientFactory` — expect FAIL.
|
|
3. Implement:
|
|
|
|
```csharp
|
|
private readonly ConcurrentDictionary<(string Site, string Endpoint), SiteStreamGrpcClient> _clients = new();
|
|
|
|
public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) =>
|
|
_clients.GetOrAdd((siteIdentifier, grpcEndpoint), _ => CreateClient(grpcEndpoint));
|
|
|
|
/// <summary>
|
|
/// Returns the cached client for (site, endpoint), or null — never creates.
|
|
/// Teardown paths (DebugStreamBridgeActor.CleanupGrpc / failed-stream
|
|
/// unsubscribe) use this so cleanup can never open a fresh channel.
|
|
/// </summary>
|
|
public virtual SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) =>
|
|
_clients.TryGetValue((siteIdentifier, grpcEndpoint), out var client) ? client : null;
|
|
|
|
public async Task RemoveSiteAsync(string siteIdentifier)
|
|
{
|
|
foreach (var key in _clients.Keys.Where(k => k.Site == siteIdentifier).ToList())
|
|
{
|
|
if (_clients.TryRemove(key, out var client))
|
|
await client.DisposeAsync();
|
|
}
|
|
}
|
|
```
|
|
|
|
`DisposeAsync`/`Dispose` iterate `_clients.Values` unchanged. Update the class `<summary>` (lines 40-48) — GetOrCreate no longer disposes on endpoint mismatch; both node channels coexist; site removal is the disposal path; note the idle-stale-channel trade-off. Fix any `RemoveSiteAsync` caller expectations (grep: only tests today).
|
|
4. Run the filter — expect PASS. Run the whole project (DebugStreamBridgeActorTests may still pass pre-Task-6 since GetOrCreate keeps working).
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc && git commit -m "fix(communication): key gRPC client cache by (site, endpoint) so per-session failover cannot dispose shared channels"`
|
|
|
|
### Task 6: Endpoint-safe unsubscribe in DebugStreamBridgeActor + concurrent-session regression test
|
|
|
|
**Classification:** high-risk (actor/stream lifecycle)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 3, 4, 7, 10, 14
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs` (`HandleGrpcError` lines 463-470, `CleanupGrpc` lines 486-495)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs` (append)
|
|
|
|
Depends on Task 5 (`TryGet`).
|
|
|
|
1. Write the failing test (use the existing tracking-factory pattern in `DebugStreamBridgeActorTests`):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void SessionFailover_DoesNotDisposeOrTouch_OtherEndpointClient()
|
|
{
|
|
var factory = new TrackingFactory(LoggerFactory);
|
|
// Session A on node-a; session B's healthy client on node-b already cached.
|
|
var bClient = (TrackingClient)factory.GetOrCreate("site-1", "http://node-b:8083");
|
|
|
|
var bridge = CreateBridge(factory, nodeA: "http://node-a:8083", nodeB: "http://node-b:8083");
|
|
bridge.Tell(new GrpcStreamError(new Exception("stream fault"))); // A flips a->b
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
Assert.False(bClient.Disposed); // pre-Task-5 fix: disposed
|
|
Assert.Equal(0, bClient.UnsubscribeCallsForOtherCorrelations); // untouched
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void CleanupGrpc_NeverCreatesAChannel()
|
|
{
|
|
var factory = new TrackingFactory(LoggerFactory);
|
|
var bridge = CreateBridge(factory, nodeA: "http://node-a:8083", nodeB: "http://node-b:8083");
|
|
var createdBefore = factory.CreatedCount; // 1: PreStart opened node-a stream
|
|
|
|
bridge.Tell(new StopDebugStream()); // teardown path
|
|
AwaitAssert(() => Assert.Equal(createdBefore, factory.CreatedCount)); // pre-fix: GetOrCreate in CleanupGrpc creates
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "SessionFailover_DoesNotDispose|CleanupGrpc_NeverCreates"` — expect FAIL on the create-count assertion.
|
|
3. Implement — replace both `GetOrCreate` teardown uses with `TryGet`:
|
|
|
|
```csharp
|
|
// HandleGrpcError (was lines 468-470):
|
|
var previousEndpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
|
|
// TryGet, not GetOrCreate: unsubscribing a failed stream must never open a
|
|
// fresh channel (and, pre-(site,endpoint)-keying, must never dispose another
|
|
// session's healthy channel). Absent client => the channel is already gone
|
|
// and the site-side relay will be reaped by keepalive/session-lifetime.
|
|
_grpcFactory.TryGet(_siteIdentifier, previousEndpoint)?.Unsubscribe(_correlationId);
|
|
```
|
|
|
|
```csharp
|
|
private void CleanupGrpc()
|
|
{
|
|
_grpcCts?.Cancel();
|
|
_grpcCts?.Dispose();
|
|
_grpcCts = null;
|
|
|
|
var endpoint = _useNodeA ? _grpcNodeAAddress : _grpcNodeBAddress;
|
|
_grpcFactory.TryGet(_siteIdentifier, endpoint)?.Unsubscribe(_correlationId);
|
|
}
|
|
```
|
|
|
|
4. Run the filter, then the full project — expect PASS.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs && git commit -m "fix(communication): debug-stream teardown/failover unsubscribes via TryGet, never creates or disposes shared channels"`
|
|
|
|
### Task 7: Bound the retry sweep with a batch LIMIT
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 1, 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs` (`GetMessagesForRetryAsync`, lines 183-203), `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardOptions.cs`, `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` (line 571)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs` (append), `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsTests.cs` (append)
|
|
|
|
1. Failing tests:
|
|
|
|
```csharp
|
|
// StoreAndForwardStorageTests
|
|
[Fact]
|
|
public async Task GetMessagesForRetry_HonorsLimit_OldestFirst()
|
|
{
|
|
for (var i = 0; i < 5; i++)
|
|
await _storage.EnqueueAsync(NewPendingMessage($"m{i}", createdAt: Base.AddSeconds(i)));
|
|
|
|
var page = await _storage.GetMessagesForRetryAsync(limit: 3);
|
|
Assert.Equal(3, page.Count);
|
|
Assert.Equal(new[] { "m0", "m1", "m2" }, page.Select(m => m.Id));
|
|
}
|
|
|
|
// StoreAndForwardOptionsTests
|
|
[Fact]
|
|
public void SweepBatchLimit_Defaults_To_500() =>
|
|
Assert.Equal(500, new StoreAndForwardOptions().SweepBatchLimit);
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "GetMessagesForRetry_HonorsLimit|SweepBatchLimit_Defaults"` — expect FAIL (no `limit` parameter / no option).
|
|
3. Implement:
|
|
- `StoreAndForwardOptions`: `/// <summary>Maximum due rows loaded per retry sweep. Bounds sweep memory and duration under backlog; the remainder is picked up next tick (10 s). 0 = unlimited (legacy).</summary> public int SweepBatchLimit { get; set; } = 500;`
|
|
- `GetMessagesForRetryAsync(int limit = 0)`: append `LIMIT @limit` when `limit > 0` (`cmd.CommandText += "\n LIMIT @limit"; cmd.Parameters.AddWithValue("@limit", limit);`). Keep `ORDER BY created_at ASC` (oldest-first fairness).
|
|
- `RetryPendingMessagesAsync` line 571: `var messages = await _storage.GetMessagesForRetryAsync(_options.SweepBatchLimit);`
|
|
4. Run the filter, then the full project — expect PASS.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests && git commit -m "perf(store-and-forward): bound retry sweep with SweepBatchLimit (default 500) on the due-rows query"`
|
|
|
|
### Task 8: Per-target short-circuit in the retry sweep
|
|
|
|
**Classification:** high-risk (retry-count semantics)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` (`RetryPendingMessagesAsync` lines 563-589, `RetryMessageAsync` signature/returns, lines 591-720)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs` (append)
|
|
|
|
Depends on Task 7 (same methods). After the first *transient* failure to a `(category, target)` in a sweep, remaining messages for that pair are skipped — N messages to a dead target cost one timeout per sweep, not N. Skipped messages are NOT counted as retry attempts (their `RetryCount`/`LastAttemptAt` are untouched), so the park horizon reflects real attempts.
|
|
|
|
1. Failing test:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task RetrySweep_ShortCircuitsTarget_AfterFirstTransientFailure()
|
|
{
|
|
var service = CreateService();
|
|
var attemptsByTarget = new Dictionary<string, int>();
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, msg =>
|
|
{
|
|
attemptsByTarget[msg.Target] = attemptsByTarget.GetValueOrDefault(msg.Target) + 1;
|
|
if (msg.Target == "dead-target") throw new TimeoutException("down");
|
|
return Task.FromResult(true);
|
|
});
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "dead-target", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "healthy-target", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
|
|
await service.RetryPendingMessagesAsync();
|
|
|
|
Assert.Equal(1, attemptsByTarget["dead-target"]); // pre-fix: 3
|
|
Assert.Equal(1, attemptsByTarget["healthy-target"]); // healthy lane unaffected
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrySweep_SkippedMessages_DoNotAccrueRetryCount()
|
|
{
|
|
var service = CreateService();
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => throw new TimeoutException("down"));
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "dead", "{}", maxRetries: 5,
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
var skippedId = (await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "dead", "{}",
|
|
maxRetries: 5, attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero)).MessageId;
|
|
|
|
await service.RetryPendingMessagesAsync();
|
|
var skipped = await service.GetMessageByIdAsync(skippedId);
|
|
Assert.Equal(0, skipped!.RetryCount); // only the attempted message accrued a retry
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "ShortCircuitsTarget|DoNotAccrueRetryCount"` — expect FAIL.
|
|
3. Implement:
|
|
- Add `internal enum RetryOutcome { Delivered, Parked, TransientFailure, Skipped }` and change `RetryMessageAsync` to `private async Task<RetryOutcome> RetryMessageAsync(StoreAndForwardMessage message)` returning `Delivered` on success, `Parked` on both park branches, `TransientFailure` on the non-park transient branch, `Skipped` on no-handler/CAS-skip paths.
|
|
- In `RetryPendingMessagesAsync`:
|
|
|
|
```csharp
|
|
var failedTargets = new HashSet<(StoreAndForwardCategory, string)>();
|
|
foreach (var message in messages)
|
|
{
|
|
// One transient failure per (category, target) per sweep: the target
|
|
// is down — burning a full timeout per remaining message serializes
|
|
// the sweep into hours under backlog (arch review 02, Performance #1).
|
|
// Skipped rows keep their RetryCount/LastAttemptAt untouched.
|
|
if (failedTargets.Contains((message.Category, message.Target)))
|
|
continue;
|
|
|
|
var outcome = await RetryMessageAsync(message);
|
|
if (outcome == RetryOutcome.TransientFailure)
|
|
failedTargets.Add((message.Category, message.Target));
|
|
}
|
|
```
|
|
|
|
4. Run the filter + full project — expect PASS.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs && git commit -m "perf(store-and-forward): short-circuit a (category,target) lane after its first transient failure per sweep"`
|
|
|
|
### Task 9: Parallel per-target lanes with a small concurrency cap
|
|
|
|
**Classification:** high-risk (concurrency)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` (`RetryPendingMessagesAsync`), `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardOptions.cs`
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs` (append)
|
|
|
|
Depends on Task 8. Removes head-of-line blocking *across* targets (one dead external system no longer delays notification forwarding to a healthy central). Ordering **within** a `(category, target)` lane stays strictly sequential — per-target FIFO is preserved.
|
|
|
|
1. Failing test:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task RetrySweep_SlowTarget_DoesNotBlockOtherTargets()
|
|
{
|
|
var service = CreateService(); // options.SweepTargetParallelism default 4
|
|
var slowGate = new TaskCompletionSource();
|
|
var healthyDelivered = new TaskCompletionSource();
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, async msg =>
|
|
{
|
|
if (msg.Target == "slow") { await slowGate.Task; return true; }
|
|
healthyDelivered.TrySetResult();
|
|
return true;
|
|
});
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "slow", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "healthy", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero);
|
|
|
|
var sweep = service.RetryPendingMessagesAsync();
|
|
// Healthy lane completes while the slow lane is still blocked — pre-fix this
|
|
// times out because delivery is strictly serial across targets.
|
|
await healthyDelivered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
slowGate.SetResult();
|
|
await sweep;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetrySweep_WithinTargetLane_StaysSequentialOldestFirst()
|
|
{
|
|
var service = CreateService();
|
|
var order = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, msg =>
|
|
{
|
|
order.Enqueue(msg.Id); return Task.FromResult(true);
|
|
});
|
|
for (var i = 0; i < 3; i++)
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero, messageId: $"m{i}");
|
|
await service.RetryPendingMessagesAsync();
|
|
Assert.Equal(new[] { "m0", "m1", "m2" }, order.ToArray());
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "SlowTarget_DoesNotBlock|StaysSequentialOldestFirst"` — expect FAIL (first test).
|
|
3. Implement:
|
|
- Option: `/// <summary>Max (category,target) lanes delivered concurrently per sweep. 1 = legacy serial. Within a lane delivery stays sequential (per-target FIFO).</summary> public int SweepTargetParallelism { get; set; } = 4;`
|
|
- In `RetryPendingMessagesAsync`, replace the flat loop (keeping Task 8's short-circuit *inside* each lane):
|
|
|
|
```csharp
|
|
var lanes = messages
|
|
.GroupBy(m => (m.Category, m.Target))
|
|
.Select(g => g.ToList())
|
|
.ToList();
|
|
|
|
using var laneCap = new SemaphoreSlim(Math.Max(1, _options.SweepTargetParallelism));
|
|
var laneTasks = lanes.Select(async lane =>
|
|
{
|
|
await laneCap.WaitAsync();
|
|
try
|
|
{
|
|
foreach (var message in lane)
|
|
{
|
|
var outcome = await RetryMessageAsync(message);
|
|
if (outcome == RetryOutcome.TransientFailure)
|
|
return; // short-circuit the rest of this lane this sweep
|
|
}
|
|
}
|
|
finally { laneCap.Release(); }
|
|
}).ToList();
|
|
await Task.WhenAll(laneTasks);
|
|
```
|
|
|
|
(`_bufferedCount` uses `Interlocked` and each storage call opens its own connection, so lanes are safe to run concurrently; the `failedTargets` set from Task 8 collapses into the per-lane `return`.)
|
|
4. Run the filter + full StoreAndForward.Tests project (watch for timing-sensitive existing sweep tests).
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests && git commit -m "perf(store-and-forward): parallel per-target sweep lanes (cap 4), sequential within lane"`
|
|
|
|
### Task 10: Ordered, observable replication dispatch
|
|
|
|
**Classification:** high-risk (replication ordering)
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 1, 2, 5, 6, 7, 14
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ReplicationService.cs` (`FireAndForget`, lines 134-149), `src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs` (add counter), `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs` (`SendToPeer`, lines 139-149)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs` (append)
|
|
|
|
`Task.Run` bought nothing (the handler is a non-blocking Akka `Tell` returning `Task.CompletedTask`) and cost ordering: Add/Remove pairs invert on the thread pool. Invoking inline preserves ordering by construction; Akka then guarantees per-sender/receiver order.
|
|
|
|
1. Failing test:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void ReplicationOperations_AreDispatchedInIssueOrder()
|
|
{
|
|
var seen = new List<(ReplicationOperationType, string)>();
|
|
var service = new ReplicationService(new StoreAndForwardOptions(), NullLogger<ReplicationService>.Instance);
|
|
service.SetReplicationHandler(op =>
|
|
{
|
|
seen.Add((op.OperationType, op.MessageId));
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
service.ReplicateEnqueue(new StoreAndForwardMessage { Id = $"m{i}" });
|
|
service.ReplicateRemove($"m{i}");
|
|
}
|
|
|
|
// Inline dispatch: by the time the calls return, every op was handed to the
|
|
// handler, Add strictly before Remove per id. Pre-fix (Task.Run) this is
|
|
// both racy-in-count and racy-in-order.
|
|
Assert.Equal(400, seen.Count);
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
Assert.Equal((ReplicationOperationType.Add, $"m{i}"), seen[2 * i]);
|
|
Assert.Equal((ReplicationOperationType.Remove, $"m{i}"), seen[2 * i + 1]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ReplicationHandlerThrow_IsSwallowed_AndLoggedAtWarning()
|
|
{
|
|
var logger = new TestLogger<ReplicationService>(); // xunit-friendly capture, or use a Meter listener
|
|
var service = new ReplicationService(new StoreAndForwardOptions(), logger);
|
|
service.SetReplicationHandler(_ => throw new InvalidOperationException("peer gone"));
|
|
service.ReplicateRemove("m1"); // must not throw
|
|
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
|
}
|
|
```
|
|
|
|
(If no `TestLogger` helper exists in the test project, add a minimal `List<(LogLevel, string)>`-capturing `ILogger<T>` to the test file.)
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "ReplicationOperations_AreDispatchedInIssueOrder|ReplicationHandlerThrow"` — expect FAIL.
|
|
3. Implement:
|
|
- `ScadaBridgeTelemetry`: add alongside the existing counters:
|
|
|
|
```csharp
|
|
private static readonly Counter<long> _replicationFailures =
|
|
Meter.CreateCounter<long>("scadabridge.store_and_forward.replication.failures", unit: "1",
|
|
description: "S&F buffer replication operations that failed to dispatch/deliver to the peer node");
|
|
/// <summary>Records one failed S&F replication dispatch.</summary>
|
|
public static void RecordReplicationFailure() => _replicationFailures.Add(1);
|
|
```
|
|
|
|
- `ReplicationService.FireAndForget` (rename to `DispatchInline`, update the three callers or keep the name with a corrected doc comment):
|
|
|
|
```csharp
|
|
private void FireAndForget(ReplicationOperation operation)
|
|
{
|
|
// Invoked inline, NOT via Task.Run: the handler is a non-blocking Akka
|
|
// Tell, and thread-pool hand-off destroyed Add/Remove ordering for the
|
|
// same message id. Inline invocation preserves issue order; Akka's
|
|
// per-sender/receiver guarantee preserves it on the wire.
|
|
try
|
|
{
|
|
var task = _replicationHandler!.Invoke(operation);
|
|
if (!task.IsCompletedSuccessfully)
|
|
{
|
|
task.ContinueWith(t =>
|
|
{
|
|
ScadaBridgeTelemetry.RecordReplicationFailure();
|
|
_logger.LogWarning(t.Exception,
|
|
"Replication of {OpType} for message {MessageId} failed (best-effort); standby buffer may be diverging",
|
|
operation.OperationType, operation.MessageId);
|
|
},
|
|
TaskContinuationOptions.OnlyOnFaulted);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ScadaBridgeTelemetry.RecordReplicationFailure();
|
|
_logger.LogWarning(ex,
|
|
"Replication of {OpType} for message {MessageId} failed (best-effort); standby buffer may be diverging",
|
|
operation.OperationType, operation.MessageId);
|
|
}
|
|
}
|
|
```
|
|
|
|
- `SiteReplicationActor.SendToPeer` no-peer drop: raise `LogDebug` → `LogWarning` and call `ScadaBridgeTelemetry.RecordReplicationFailure()` (a dropped op is a lost delta; when the peer is legitimately absent — single-node dev — the warning is rate-tolerable at one per op; acceptable per review).
|
|
4. Run the filter + `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests` + `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter SiteReplication`.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ReplicationService.cs src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationServiceTests.cs && git commit -m "fix(store-and-forward): inline ordered replication dispatch; Warning + counter on replication failures"`
|
|
|
|
### Task 11: Upsert-based standby applies for Add/Park/Requeue
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs` (add `UpsertMessageAsync`), `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ReplicationService.cs` (`ApplyReplicatedOperationAsync`, lines 107-132)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardReplicationTests.cs` (append)
|
|
|
|
Depends on Task 10 (same `ReplicationService.cs`) and Task 7 (same storage file). A Park/Requeue whose original Add was lost self-heals (the full message rides in the operation); a duplicate Add after Task 21's resync no longer violates the PK.
|
|
|
|
1. Failing tests:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task ApplyReplicatedPark_WhenAddWasLost_MaterializesTheParkedRow()
|
|
{
|
|
var service = new ReplicationService(OptionsWithReplication(), NullLogger<ReplicationService>.Instance);
|
|
var msg = NewMessage("lost-add-1"); // never Added on this (standby) storage
|
|
msg.Status = StoreAndForwardMessageStatus.Parked;
|
|
|
|
await service.ApplyReplicatedOperationAsync(
|
|
new ReplicationOperation(ReplicationOperationType.Park, msg.Id, msg), _storage);
|
|
|
|
var row = await _storage.GetMessageByIdAsync("lost-add-1");
|
|
Assert.NotNull(row); // pre-fix: blind UPDATE affected 0 rows, row is gone forever
|
|
Assert.Equal(StoreAndForwardMessageStatus.Parked, row!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyReplicatedAdd_Twice_IsIdempotent_NewestWins()
|
|
{
|
|
var service = new ReplicationService(OptionsWithReplication(), NullLogger<ReplicationService>.Instance);
|
|
var msg = NewMessage("dup-add");
|
|
await service.ApplyReplicatedOperationAsync(
|
|
new ReplicationOperation(ReplicationOperationType.Add, msg.Id, msg), _storage);
|
|
msg.RetryCount = 3;
|
|
await service.ApplyReplicatedOperationAsync( // pre-fix: SqliteException PK violation
|
|
new ReplicationOperation(ReplicationOperationType.Add, msg.Id, msg), _storage);
|
|
|
|
Assert.Equal(3, (await _storage.GetMessageByIdAsync("dup-add"))!.RetryCount);
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "ApplyReplicatedPark_WhenAddWasLost|ApplyReplicatedAdd_Twice"` — expect FAIL.
|
|
3. Implement:
|
|
- `StoreAndForwardStorage.UpsertMessageAsync(StoreAndForwardMessage message)`: same column list/parameters as `EnqueueAsync` but `INSERT INTO sf_messages (...) VALUES (...) ON CONFLICT(id) DO UPDATE SET category=excluded.category, target=excluded.target, payload_json=excluded.payload_json, retry_count=excluded.retry_count, max_retries=excluded.max_retries, retry_interval_ms=excluded.retry_interval_ms, last_attempt_at=excluded.last_attempt_at, status=excluded.status, last_error=excluded.last_error, origin_instance=excluded.origin_instance, execution_id=excluded.execution_id, source_script=excluded.source_script, parent_execution_id=excluded.parent_execution_id` (keep the original `created_at` — do not overwrite it, it orders the sweep).
|
|
- `ApplyReplicatedOperationAsync`: `Add`, `Park`, and `Requeue` all call `storage.UpsertMessageAsync(operation.Message)` (after the existing Status/RetryCount mutations for Park/Requeue). `Remove` unchanged. Update the doc comment: replicated applies are upserts so a lost Add self-heals from any later full-message operation.
|
|
4. Run the filter + full project.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests && git commit -m "fix(store-and-forward): standby applies Add/Park/Requeue as upserts so a lost Add self-heals"`
|
|
|
|
### Task 12: `deferToSweep` enqueue mode + immediate sweep kick
|
|
|
|
**Classification:** high-risk (delivery-path semantics)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` (`EnqueueAsync` signature + tail, lines 467-546; new `TriggerSweep`)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs` (append)
|
|
|
|
Depends on Tasks 8/9 (same file). Groundwork for Task 13: `Notify.Send` must never run the 30 s central Ask inline on the script's await. `deferToSweep: true` buffers without invoking the handler and **without** stamping `LastAttemptAt` (row is due immediately), then kicks a background sweep so the healthy-path latency is milliseconds, not one timer interval.
|
|
|
|
1. Failing tests:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task Enqueue_DeferToSweep_DoesNotInvokeHandlerInline_AndRowIsDueImmediately()
|
|
{
|
|
var service = CreateService();
|
|
var inlineInvoked = false;
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
|
|
_ => { inlineInvoked = true; return Task.FromResult(true); });
|
|
|
|
var result = await service.EnqueueAsync(StoreAndForwardCategory.Notification, "ops", "{}",
|
|
deferToSweep: true, messageId: "n1");
|
|
|
|
Assert.False(inlineInvoked);
|
|
Assert.True(result.WasBuffered);
|
|
var row = await service.GetMessageByIdAsync("n1");
|
|
Assert.Null(row!.LastAttemptAt); // due on the very next sweep, not after RetryInterval
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Enqueue_DeferToSweep_AfterStart_KicksAnImmediateSweep()
|
|
{
|
|
var service = CreateService(retryTimerInterval: TimeSpan.FromHours(1)); // timer will never fire in-test
|
|
var delivered = new TaskCompletionSource();
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
|
|
_ => { delivered.TrySetResult(); return Task.FromResult(true); });
|
|
await service.StartAsync();
|
|
try
|
|
{
|
|
await service.EnqueueAsync(StoreAndForwardCategory.Notification, "ops", "{}", deferToSweep: true);
|
|
await delivered.Task.WaitAsync(TimeSpan.FromSeconds(5)); // pre-fix: times out
|
|
}
|
|
finally { await service.StopAsync(); }
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "Enqueue_DeferToSweep"` — expect FAIL (no parameter).
|
|
3. Implement:
|
|
- Add parameter `bool deferToSweep = false` to `EnqueueAsync` (after `attemptImmediateDelivery`), XML-doc: *"When true, the handler is never invoked inline — the message is buffered due-immediately (`LastAttemptAt` stays null) and a background sweep is kicked. Use for callers on latency-sensitive threads (script dispatchers) whose delivery involves a long Ask timeout. Unlike `attemptImmediateDelivery: false`, no delivery attempt is presumed to have been made."*
|
|
- Guard: `if (deferToSweep) { await BufferAsync(message); RaiseActivity("Queued", category, $"Deferred to sweep: {target}"); TriggerSweep(); return new StoreAndForwardResult(true, message.Id, true); }` placed **before** the immediate-delivery block. (`LastAttemptAt` intentionally not set.)
|
|
- Add:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Kicks a background retry sweep now (fire-and-forget). No-op before
|
|
/// StartAsync (storage may be uninitialized). Overlap-safe: the sweep's
|
|
/// _retryInProgress CAS makes a concurrent kick a cheap no-op.
|
|
/// </summary>
|
|
public void TriggerSweep()
|
|
{
|
|
if (_retryTimer == null) return;
|
|
Volatile.Write(ref _sweepTask, RetryPendingMessagesAsync());
|
|
}
|
|
```
|
|
|
|
- `CreateService` helper: add optional `retryTimerInterval` parameter feeding `StoreAndForwardOptions.RetryTimerInterval` if not already supported.
|
|
4. Run the filter + full project.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardServiceTests.cs && git commit -m "feat(store-and-forward): deferToSweep enqueue mode with immediate sweep kick for latency-sensitive callers"`
|
|
|
|
### Task 13: Unstrand and unblock `Notify.Send` (maxRetries: 0 + deferToSweep) + docs
|
|
|
|
**Classification:** high-risk (script-facing semantics)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (the `EnqueueAsync` call at lines ~2197-2203)
|
|
- Modify: `docs/requirements/Component-StoreAndForward.md` (notification-delivery paragraph, ~line 52 region), `CLAUDE.md` (the "`Notify.Send` is async — returns … immediately" bullet: note it is now enqueue-only + swept, unbounded retries)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs` (append)
|
|
|
|
Depends on Task 12. Two changes at the single notification producer: `maxRetries: 0` (the documented no-limit escape hatch — a central maintenance window can no longer strand notifications into per-message operator unparking) and `deferToSweep: true` (a `Notify.Send` during a central outage returns in milliseconds instead of blocking a scarce script thread for the 30 s `NotificationForwardTimeout`).
|
|
|
|
1. Failing test (the `NotifyHelperTests` harness constructs a real `StoreAndForwardService` at line 45):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task NotifySend_BuffersUnbounded_AndNeverInvokesForwarderInline()
|
|
{
|
|
var inlineInvoked = false;
|
|
_saf.RegisterDeliveryHandler(StoreAndForwardCategory.Notification,
|
|
_ => { inlineInvoked = true; throw new TimeoutException("central down"); });
|
|
|
|
var notificationId = await SendViaNotifyHelper("ops-list", "hello"); // existing helper pattern
|
|
|
|
Assert.False(inlineInvoked); // pre-fix: Send ran the forwarder Ask inline
|
|
var row = await _saf.GetMessageByIdAsync(notificationId);
|
|
Assert.NotNull(row);
|
|
Assert.Equal(0, row!.MaxRetries); // pre-fix: 50 → parked after ~25 min of outage
|
|
Assert.Equal(StoreAndForwardMessageStatus.Pending, row.Status);
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter NotifySend_BuffersUnbounded` — expect FAIL.
|
|
3. Implement — the `ScriptRuntimeContext` call becomes:
|
|
|
|
```csharp
|
|
await _storeAndForward.EnqueueAsync(
|
|
StoreAndForwardCategory.Notification,
|
|
target: _listName,
|
|
payloadJson: payloadJson,
|
|
originInstanceName: _instanceName,
|
|
// 0 = the documented "no limit" escape hatch (StoreAndForward-015):
|
|
// notifications are retried until central acks and are never parked
|
|
// for retry exhaustion — a long central outage must not strand them
|
|
// behind per-message operator unparking.
|
|
maxRetries: 0,
|
|
// Never run the forwarder's 30s central Ask inline on the script
|
|
// thread: buffer due-immediately and kick the sweep. Send returns
|
|
// in milliseconds whether central is up or down.
|
|
deferToSweep: true,
|
|
messageId: notificationId);
|
|
```
|
|
|
|
4. Update the docs: `Component-StoreAndForward.md` — rewrite the "bounded by DefaultMaxRetries … will park the buffered notification" passage: `Notify.Send` now enqueues with `maxRetries: 0`; notifications never park for retry exhaustion (corrupt payloads still park — Task 14); `Notify.Send` is enqueue-only (no inline forward attempt) with an immediate sweep kick, so its worst-case latency is the local SQLite insert. `CLAUDE.md`: adjust the `Notify.Send` bullet accordingly.
|
|
5. Run the filter + `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter Notify` — expect PASS (fix any existing Notify tests that asserted the inline-attempt behavior).
|
|
6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs docs/requirements/Component-StoreAndForward.md CLAUDE.md tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs && git commit -m "fix(notifications): Notify.Send enqueues unbounded (maxRetries 0) and defers to sweep — no 30s script-thread block, no stranding"`
|
|
|
|
### Task 14: Park (don't silently drop) corrupt buffered notifications
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 1, 2, 3, 5, 7, 10, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/NotificationForwarder.cs` (lines 77-106)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs` (modify lines 201-255 — the two StoreAndForward-018 regression tests invert)
|
|
- Modify: `docs/requirements/Component-StoreAndForward.md` (note superseding StoreAndForward-018)
|
|
|
|
**Deliberate reversal of StoreAndForward-018.** That decision discarded corrupt payloads to honour "notifications do not park". The doc itself now frames "do not park" as happy-path intent, and Task 13 makes retry-exhaustion parking impossible (`maxRetries: 0`) — so parking is exactly the designed home for an *undeliverable-but-preserved* payload: the operator gets a parked row with the payload for forensics instead of a silent site-log-only vanish that central reports as delivered.
|
|
|
|
1. Rewrite the two tests (rename; flip assertions):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task Deliver_CorruptJsonPayload_ReturnsFalse_ParksForForensics_AndDoesNotForward()
|
|
{
|
|
// Supersedes StoreAndForward-018 (discard-on-corrupt). Returning false is the
|
|
// handler contract's permanent-failure signal: the engine parks the row, which
|
|
// preserves the payload for operator forensics. Retrying a corrupt payload is
|
|
// pointless; deleting it silently (and reporting Delivered) loses data with no
|
|
// parked row, no central Notifications row, and no audit trail.
|
|
var centralProbe = CreateTestProbe();
|
|
var forwarder = new NotificationForwarder(centralProbe.Ref, "site-7", ForwardTimeout);
|
|
var corrupt = new StoreAndForwardMessage
|
|
{
|
|
Id = "msg-corrupt", Category = StoreAndForwardCategory.Notification,
|
|
Target = "Operators", PayloadJson = "{not-valid-json", OriginInstanceName = "Plant.Pump3",
|
|
};
|
|
|
|
Assert.False(await forwarder.DeliverAsync(corrupt));
|
|
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
```
|
|
|
|
(Mirror for the `"null"` payload test.)
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "Deliver_CorruptJsonPayload|Deliver_NullDeserializedPayload"` — expect FAIL.
|
|
3. Implement in `DeliverAsync`:
|
|
|
|
```csharp
|
|
if (!TryBuildSubmit(message, out var submit))
|
|
{
|
|
_logger.LogWarning(
|
|
"Parking corrupt buffered notification {NotificationId} (payload is not deserialisable as NotificationSubmit); " +
|
|
"the row is preserved for operator forensics. Payload preview: {PayloadPreview}",
|
|
message.Id,
|
|
PreviewPayload(message.PayloadJson));
|
|
// false = permanent failure by the delivery-handler contract → the
|
|
// engine parks the row (payload preserved, operator can inspect or
|
|
// discard). Supersedes StoreAndForward-018's silent discard, which
|
|
// reported the notification as delivered while losing it entirely.
|
|
return false;
|
|
}
|
|
```
|
|
|
|
Update the class XML doc list (`true`/throws/`false` outcomes) to include the park case.
|
|
4. Update `Component-StoreAndForward.md`: corrupt buffered notifications park (StoreAndForward-018 superseded); note the two parking causes for notifications are now *corrupt payload only* (retry exhaustion is off via `maxRetries: 0`).
|
|
5. Run the filter + full project.
|
|
6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/NotificationForwarder.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs docs/requirements/Component-StoreAndForward.md && git commit -m "fix(notifications): park corrupt buffered notification payloads instead of silently discarding as delivered (supersedes StoreAndForward-018)"`
|
|
|
|
### Task 15: Wire-format pin tests for cross-cluster contracts
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 1, 2, 3, 5, 7, 10, 14
|
|
**Files:**
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs` (create), `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs` (create)
|
|
|
|
No production code change. The Akka default reflective-JSON wire format couples compatibility to *type and namespace identity*; nothing tests it (`MessageContractTests` asserts correlation-ids only). These tests pin (a) round-trip fidelity and (b) the fully-qualified type identity of the highest-traffic cross-cluster contracts, so a rename/move that would break rolling central↔site compat fails a test instead of production. The serializer *swap* (proto/explicit bindings) is **deferred** — it changes the wire format and needs a coordinated rolling-upgrade plan.
|
|
|
|
1. Write the tests (they pass immediately if the wire behavior is sane — the value is the pin; expect PASS on first run, and that's fine: the "failing" phase here is meaningless, this is characterization):
|
|
|
|
```csharp
|
|
// tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs
|
|
public class WireSerializationPinTests : TestKit
|
|
{
|
|
private T RoundTrip<T>(T message)
|
|
{
|
|
var serialization = Sys.Serialization;
|
|
var serializer = serialization.FindSerializerFor(message);
|
|
var bytes = serializer.ToBinary(message);
|
|
return (T)serialization.Deserialize(bytes, serializer.Identifier,
|
|
message!.GetType().TypeQualifiedName());
|
|
}
|
|
|
|
[Fact]
|
|
public void HeartbeatMessage_RoundTripsOnTheWire()
|
|
{
|
|
var original = new HeartbeatMessage("site-1", "host-a", true, DateTimeOffset.UtcNow);
|
|
var back = RoundTrip(original);
|
|
Assert.Equal(original, back);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationSubmit_RoundTripsOnTheWire() { /* construct with all fields non-default; assert field equality */ }
|
|
|
|
[Fact]
|
|
public void IngestAuditEventsCommand_RoundTripsOnTheWire() { /* one fully-populated AuditEvent entity */ }
|
|
|
|
[Fact]
|
|
public void SiteHealthReport_RoundTripsOnTheWire() { /* minimal + one dictionary entry per collection */ }
|
|
|
|
// Type-identity pins: the wire embeds CLR type manifests; a rename/move of any
|
|
// of these types breaks rolling cross-version central↔site communication.
|
|
// If one of these assertions fails, you are making a wire-breaking change —
|
|
// coordinate a full-fleet upgrade or add an explicit serialization binding.
|
|
[Theory]
|
|
[InlineData(typeof(HeartbeatMessage), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.HeartbeatMessage")]
|
|
[InlineData(typeof(NotificationSubmit), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification.NotificationSubmit")]
|
|
[InlineData(typeof(IngestAuditEventsCommand), "ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit.IngestAuditEventsCommand")]
|
|
public void CrossClusterContract_TypeIdentity_IsPinned(Type type, string expectedFullName) =>
|
|
Assert.Equal(expectedFullName, type.FullName);
|
|
}
|
|
```
|
|
|
|
And in `ReplicationWireSerializationPinTests.cs` (StoreAndForward.Tests, which references the S&F assembly): round-trip `ReplicationOperation` carrying a fully-populated `StoreAndForwardMessage` (every property non-default, including `ExecutionId`/`ParentExecutionId`) and pin its type identity.
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter WireSerializationPinTests` and `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter ReplicationWireSerializationPinTests` — expect PASS. If any round-trip FAILS, that is a live wire-fidelity bug: fix the message type (missing ctor for deserialization, get-only property), not the test.
|
|
3. Commit: `git add tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/WireSerializationPinTests.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/ReplicationWireSerializationPinTests.cs && git commit -m "test(communication): pin wire round-trip + type identity of cross-cluster message contracts"`
|
|
|
|
### Task 16: Replicate heartbeat marks to the peer central node
|
|
|
|
**Classification:** small
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 3, 5, 7, 10, 14
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs` (`HandleHeartbeat` lines 349-353; new record + Receive)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTests.cs` (append)
|
|
|
|
Depends on Task 2 (same file). Mirrors the existing `SiteHealthReportReplica` pattern exactly; `MarkHeartbeat` is idempotent (timestamp overwrite), so self-delivery via pub-sub is harmless.
|
|
|
|
1. Failing test (follow the existing DI-stub pattern that substitutes `ICentralHealthAggregator`):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void HeartbeatReplica_MarksLocalAggregator_WithoutRebroadcast()
|
|
{
|
|
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
|
var actor = CreateActorWith(aggregator); // existing helper pattern
|
|
var ts = DateTimeOffset.UtcNow;
|
|
|
|
actor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage("site-1", "host-a", true, ts)));
|
|
|
|
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", ts));
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter HeartbeatReplica_MarksLocalAggregator` — expect FAIL (record missing).
|
|
3. Implement:
|
|
- Record (bottom of file, next to the other registration records): `/// <summary>Peer-replication envelope for a site heartbeat, fanned out over the same DistributedPubSub topic as SiteHealthReportReplica so both central aggregators mark heartbeats regardless of which node the site's ClusterClient delivered to.</summary> public sealed record SiteHeartbeatReplica(HeartbeatMessage Heartbeat);`
|
|
- Constructor: `Receive<SiteHeartbeatReplica>(r => MarkHeartbeatLocally(r.Heartbeat));`
|
|
- `HandleHeartbeat`: extract `MarkHeartbeatLocally(heartbeat)` (the existing two lines), then publish `new Publish(HealthReportTopic, new SiteHeartbeatReplica(heartbeat))` inside the same try/catch-no-op-for-TestKit pattern used by `HandleSiteHealthReport`.
|
|
4. Run the filter + full project.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CentralCommunicationActorTests.cs && git commit -m "fix(communication): replicate heartbeat marks to the peer central node (closes post-failover offline blind window)"`
|
|
|
|
### Task 17: S&F SQLite hygiene — WAL + busy_timeout
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 2, 5, 6, 13, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs` (`InitializeAsync` line 32-79; new `OpenConnectionAsync` helper; replace every `new SqliteConnection` + `OpenAsync` pair — ~12 sites)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs` (append)
|
|
|
|
Depends on Task 11 (same file — includes the new `UpsertMessageAsync`).
|
|
|
|
1. Failing test:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task Initialize_EnablesWalJournalMode_OnFileDatabase()
|
|
{
|
|
var path = Path.Combine(_tempDir, "wal-test.db");
|
|
var storage = new StoreAndForwardStorage($"Data Source={path}", NullLogger<StoreAndForwardStorage>.Instance);
|
|
await storage.InitializeAsync();
|
|
|
|
await using var conn = new SqliteConnection($"Data Source={path}");
|
|
await conn.OpenAsync();
|
|
await using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "PRAGMA journal_mode";
|
|
Assert.Equal("wal", (string)(await cmd.ExecuteScalarAsync())!);
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter Initialize_EnablesWalJournalMode` — expect FAIL (`delete`).
|
|
3. Implement:
|
|
- `InitializeAsync`, right after `OpenAsync()`: execute `PRAGMA journal_mode=WAL;` (persistent, file-scoped; in-memory DBs return `memory` — harmless, don't assert).
|
|
- Add and use everywhere:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Opens a connection with a 5s busy_timeout. Concurrent writers exist by
|
|
/// design (script enqueues, the sweep's lanes, standby replication applies,
|
|
/// central pull queries); with connection-per-operation the pragma must be
|
|
/// set per open (it is per-connection, and Microsoft.Data.Sqlite pooling
|
|
/// makes the extra statement cheap on a pooled physical connection).
|
|
/// </summary>
|
|
private async Task<SqliteConnection> OpenConnectionAsync()
|
|
{
|
|
var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync();
|
|
await using var pragma = connection.CreateCommand();
|
|
pragma.CommandText = "PRAGMA busy_timeout = 5000";
|
|
await pragma.ExecuteNonQueryAsync();
|
|
return connection;
|
|
}
|
|
```
|
|
|
|
- Mechanical replace in every method: `await using var connection = new SqliteConnection(_connectionString); await connection.OpenAsync();` → `await using var connection = await OpenConnectionAsync();`.
|
|
4. Run the filter + full project.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs && git commit -m "perf(store-and-forward): enable WAL + per-connection busy_timeout on the S&F SQLite store"`
|
|
|
|
### Task 18: Epoch-ms due-check (replace per-row julianday parsing)
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 13, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs` (schema migration in `InitializeAsync`; writers `EnqueueAsync`/`UpdateMessageAsync`/`UpdateMessageIfStatusAsync`/`UpsertMessageAsync`/`RetryParkedMessageAsync`; due query lines 189-198)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs` (append)
|
|
|
|
Depends on Task 17 (same file). Additive column, existing `AddColumnIfMissingAsync` pattern; the ISO-8601 text column stays authoritative for reads/back-compat, the ms column drives the due predicate.
|
|
|
|
1. Failing tests:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task GetMessagesForRetry_UsesEpochMs_RespectsInterval()
|
|
{
|
|
var msg = NewPendingMessage("due-check", retryIntervalMs: 60_000);
|
|
msg.LastAttemptAt = DateTimeOffset.UtcNow.AddSeconds(-30); // not yet due
|
|
await _storage.EnqueueAsync(msg);
|
|
Assert.Empty(await _storage.GetMessagesForRetryAsync());
|
|
|
|
msg.LastAttemptAt = DateTimeOffset.UtcNow.AddSeconds(-90); // due
|
|
await _storage.UpdateMessageAsync(msg);
|
|
Assert.Single(await _storage.GetMessagesForRetryAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_BackfillsEpochMs_ForLegacyRows()
|
|
{
|
|
await _storage.InitializeAsync();
|
|
// Simulate a legacy row: text timestamp present, ms column NULL.
|
|
await ExecRaw("INSERT INTO sf_messages (id, category, target, payload_json, created_at, last_attempt_at, status) " +
|
|
"VALUES ('legacy', 0, 't', '{}', @c, @l, 0)",
|
|
("@c", DateTimeOffset.UtcNow.ToString("O")),
|
|
("@l", DateTimeOffset.UtcNow.AddHours(-1).ToString("O")));
|
|
await _storage.InitializeAsync(); // second init runs the backfill
|
|
var ms = await ScalarRaw<long?>("SELECT last_attempt_at_ms FROM sf_messages WHERE id='legacy'");
|
|
Assert.NotNull(ms);
|
|
Assert.InRange(ms!.Value, DateTimeOffset.UtcNow.AddHours(-1).AddMinutes(-1).ToUnixTimeMilliseconds(),
|
|
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "GetMessagesForRetry_UsesEpochMs|Initialize_BackfillsEpochMs"` — expect FAIL (column missing).
|
|
3. Implement:
|
|
- `InitializeAsync`: `await AddColumnIfMissingAsync(connection, "last_attempt_at_ms", "INTEGER");` then a one-time backfill: `UPDATE sf_messages SET last_attempt_at_ms = CAST((julianday(last_attempt_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_attempt_at IS NOT NULL AND last_attempt_at_ms IS NULL` (the only remaining julianday use — runs once per legacy DB, then never matches). Add `CREATE INDEX IF NOT EXISTS idx_sf_messages_status_due ON sf_messages(status, last_attempt_at_ms);`.
|
|
- Every writer that sets `@lastAttempt` also sets `@lastAttemptMs` (`message.LastAttemptAt?.ToUnixTimeMilliseconds()` or DBNull); `RetryParkedMessageAsync` sets `last_attempt_at_ms = NULL`.
|
|
- Due query: `WHERE status = @pending AND (last_attempt_at_ms IS NULL OR retry_interval_ms = 0 OR (@nowMs - last_attempt_at_ms) >= retry_interval_ms)` with `cmd.Parameters.AddWithValue("@nowMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())`.
|
|
4. Run the filter + full project (existing due-check tests must still pass).
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs && git commit -m "perf(store-and-forward): epoch-ms due-check with additive column, backfill, and (status, due) index"`
|
|
|
|
### Task 19: Decouple audit-observer notification from the sweep (ordered channel)
|
|
|
|
**Classification:** high-risk (concurrency; audit-event ordering)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs` (`StartAsync`/`StopAsync`; the three `NotifyCachedCallObserverAsync` await sites at lines ~620-626, 688-694, 711-717 and the transient site ~711)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs` (append)
|
|
|
|
Depends on Task 12 (same file). A slow observer (SQLite audit write) must not stretch the sweep; a single-reader unbounded channel preserves per-operation event order.
|
|
|
|
1. Failing test:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task SlowObserver_DoesNotBlockTheSweep_AndEventsArriveInOrder()
|
|
{
|
|
var gate = new TaskCompletionSource();
|
|
var observed = new List<CachedCallAttemptOutcome>();
|
|
var observer = new DelegatingObserver(async ctx =>
|
|
{
|
|
await gate.Task; // first call blocks until released
|
|
lock (observed) observed.Add(ctx.Outcome);
|
|
});
|
|
var service = CreateServiceWithObserver(observer); // existing harness pattern
|
|
await service.StartAsync();
|
|
try
|
|
{
|
|
service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ => Task.FromResult(true));
|
|
await service.EnqueueAsync(StoreAndForwardCategory.ExternalSystem, "t", "{}",
|
|
attemptImmediateDelivery: false, retryInterval: TimeSpan.Zero,
|
|
messageId: TrackedOperationId.New().ToString());
|
|
|
|
var sweep = service.RetryPendingMessagesAsync();
|
|
await sweep.WaitAsync(TimeSpan.FromSeconds(5)); // pre-fix: blocks on gate → times out
|
|
|
|
gate.SetResult();
|
|
await AssertEventually(() => { lock (observed) return observed.Count == 1; });
|
|
}
|
|
finally { await service.StopAsync(); }
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter SlowObserver_DoesNotBlockTheSweep` — expect FAIL (timeout).
|
|
3. Implement:
|
|
- Fields: `private readonly Channel<Func<Task>> _observerQueue = Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions { SingleReader = true }); private Task? _observerPump;`
|
|
- `StartAsync`: `_observerPump = Task.Run(async () => { await foreach (var work in _observerQueue.Reader.ReadAllAsync()) { try { await work(); } catch (Exception ex) { _logger.LogWarning(ex, "Cached-call audit observer threw; ignored (best-effort per alog.md §7)"); } } });`
|
|
- The three sweep call sites become non-awaiting posts: `PostObserverNotification(message, outcome, lastError, httpStatus, attemptStartUtc, durationMs);` where `PostObserverNotification` snapshots nothing extra (each branch finishes mutating `message` before posting; the sweep re-reads fresh row instances next tick) and does `_observerQueue.Writer.TryWrite(() => NotifyCachedCallObserverAsync(...));`.
|
|
- `StopAsync` (before clearing the gauge): `_observerQueue.Writer.TryComplete(); if (_observerPump is not null) { try { await _observerPump.WaitAsync(SweepShutdownWaitTimeout); } catch (TimeoutException) { _logger.LogWarning("Audit-observer pump did not drain within {Timeout}", SweepShutdownWaitTimeout); } }` — note: after TryComplete a restarted service needs a fresh channel; make the channel a non-readonly field re-created in `StartAsync` if existing tests restart the same instance.
|
|
- XML-doc on `NotifyCachedCallObserverAsync`: now invoked from the single-reader pump — latency-isolated from the sweep, order preserved per posting sequence.
|
|
4. Run the filter + full project (especially `CachedCallAttemptEmissionTests` — they may need `StartAsync`/`StopAsync` bracketing or an explicit pump-drain helper; keep their ordering assertions intact).
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs && git commit -m "perf(store-and-forward): post audit-observer notifications to an ordered single-reader channel, latency-isolating the sweep"`
|
|
|
|
### Task 20: Buffer resync storage primitives (`GetAllMessagesAsync` / `ReplaceAllAsync`)
|
|
|
|
**Classification:** standard
|
|
**Estimated implement time:** ~4 min
|
|
**Parallelizable with:** 2, 5, 6, 13, 14, 16, 19
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs`
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs` (append)
|
|
|
|
Depends on Task 18 (same file). Anti-entropy groundwork (report Underdeveloped #4).
|
|
|
|
1. Failing tests:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task GetAllMessages_ReturnsEveryStatus_OldestFirst_UpToLimit()
|
|
{
|
|
await _storage.EnqueueAsync(NewMessage("p1", status: StoreAndForwardMessageStatus.Pending, createdAt: Base));
|
|
await _storage.EnqueueAsync(NewMessage("k1", status: StoreAndForwardMessageStatus.Parked, createdAt: Base.AddSeconds(1)));
|
|
await _storage.EnqueueAsync(NewMessage("p2", status: StoreAndForwardMessageStatus.Pending, createdAt: Base.AddSeconds(2)));
|
|
|
|
var (all, truncated) = await _storage.GetAllMessagesAsync(limit: 2);
|
|
Assert.Equal(new[] { "p1", "k1" }, all.Select(m => m.Id));
|
|
Assert.True(truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplaceAll_SwapsTheEntireBuffer_Atomically()
|
|
{
|
|
await _storage.EnqueueAsync(NewMessage("stale"));
|
|
await _storage.ReplaceAllAsync(new[] { NewMessage("fresh-1"), NewMessage("fresh-2") });
|
|
|
|
Assert.Null(await _storage.GetMessageByIdAsync("stale"));
|
|
Assert.NotNull(await _storage.GetMessageByIdAsync("fresh-1"));
|
|
Assert.NotNull(await _storage.GetMessageByIdAsync("fresh-2"));
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "GetAllMessages_ReturnsEveryStatus|ReplaceAll_SwapsTheEntireBuffer"` — expect FAIL.
|
|
3. Implement:
|
|
- `GetAllMessagesAsync(int limit)`: the standard SELECT column list, no status filter, `ORDER BY created_at ASC LIMIT @limitPlusOne` — fetch `limit + 1`, return `(rows.Take(limit).ToList(), rows.Count > limit)`.
|
|
- `ReplaceAllAsync(IReadOnlyList<StoreAndForwardMessage> messages)`: one transaction — `DELETE FROM sf_messages`, then insert each row (extract the parameter-binding block shared with `EnqueueAsync` into a private `BindMessageParameters(SqliteCommand, StoreAndForwardMessage)` helper), commit. XML-doc: standby-side anti-entropy apply; never call on an active node.
|
|
4. Run the filter + full project.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs && git commit -m "feat(store-and-forward): GetAllMessagesAsync/ReplaceAllAsync storage primitives for standby buffer resync"`
|
|
|
|
### Task 21: Peer-join S&F buffer resync (anti-entropy)
|
|
|
|
**Classification:** high-risk (cluster protocol)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 14, 16
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs` (new messages, handlers, trigger in `TryTrackPeer`; ctor gains `Func<bool>? isActiveOverride = null` seam)
|
|
- Modify: `docs/requirements/Component-StoreAndForward.md` (Persistence section — document the resync)
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReplicationActorTests.cs` (append; follow the existing `SendToPeer`-override subclass pattern)
|
|
|
|
Depends on Tasks 10 (same file) and 20 (primitives). Closes "a standby down for an hour rejoins and diverges forever": on tracking a peer, a **standby** requests a full-buffer snapshot; the **active** node answers with up to `MaxResyncRows` (10 000) rows; the standby replaces its buffer wholesale. Combined with Task 11's upserts, later replicated ops land cleanly on the resynced state.
|
|
|
|
1. Failing tests:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void StandbyTrackingPeer_SendsResyncRequest()
|
|
{
|
|
var actor = CreateActor(isActive: () => false, sendToPeerCapture: out var sent);
|
|
SimulatePeerUp(actor); // existing MemberUp-injection pattern
|
|
AwaitAssert(() => Assert.Contains(sent, m => m is RequestSfBufferResync));
|
|
}
|
|
|
|
[Fact]
|
|
public void ActiveTrackingPeer_DoesNotRequestResync()
|
|
{
|
|
var actor = CreateActor(isActive: () => true, sendToPeerCapture: out var sent);
|
|
SimulatePeerUp(actor);
|
|
ExpectNoMsg(200.Milliseconds());
|
|
Assert.DoesNotContain(sent, m => m is RequestSfBufferResync);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ActiveNode_AnswersResyncRequest_WithFullBufferSnapshot()
|
|
{
|
|
await _sfStorage.EnqueueAsync(NewMessage("m1"));
|
|
var actor = CreateActor(isActive: () => true, sendToPeerCapture: out _);
|
|
actor.Tell(new RequestSfBufferResync(), TestActor);
|
|
var snapshot = ExpectMsg<SfBufferSnapshot>();
|
|
Assert.Single(snapshot.Messages);
|
|
Assert.False(snapshot.Truncated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StandbyNode_AppliesSnapshot_ReplacingItsBuffer()
|
|
{
|
|
await _sfStorage.EnqueueAsync(NewMessage("stale"));
|
|
var actor = CreateActor(isActive: () => false, sendToPeerCapture: out _);
|
|
actor.Tell(new SfBufferSnapshot(new List<StoreAndForwardMessage> { NewMessage("fresh") }, false));
|
|
await AwaitAssertAsync(async () =>
|
|
{
|
|
Assert.Null(await _sfStorage.GetMessageByIdAsync("stale"));
|
|
Assert.NotNull(await _sfStorage.GetMessageByIdAsync("fresh"));
|
|
});
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "ResyncRequest|ResyncRequest_WithFullBufferSnapshot|AppliesSnapshot_Replacing"` — expect FAIL (messages missing).
|
|
3. Implement in `SiteReplicationActor.cs`:
|
|
- Messages (same file, public — they cross Akka remoting; POCO payload rides the default serializer, pinned by Task 15's pattern):
|
|
|
|
```csharp
|
|
/// <summary>Standby→active: request a full S&F buffer snapshot for anti-entropy resync (sent when a standby (re)tracks a peer).</summary>
|
|
public sealed record RequestSfBufferResync;
|
|
|
|
/// <summary>Active→standby: full-buffer snapshot; Truncated=true when the buffer exceeded MaxResyncRows (standby logs a Warning — divergence beyond the cap drains naturally).</summary>
|
|
public sealed record SfBufferSnapshot(List<StoreAndForwardMessage> Messages, bool Truncated);
|
|
```
|
|
|
|
- Ctor: `Func<bool>? isActiveOverride = null` stored as `_isActive = isActiveOverride ?? DefaultIsActive;` where `DefaultIsActive` is the repo-standard leader+Up check via `_cluster` (same body as `SiteCommunicationActor.DefaultIsActiveCheck`; swap point for plan 01's helper).
|
|
- `TryTrackPeer`: after setting `_peerAddress`, `if (!SafeIsActive()) SendToPeer(new RequestSfBufferResync());` (`SafeIsActive` wraps `_isActive()` in try/catch→false).
|
|
- `Receive<RequestSfBufferResync>`: only when `SafeIsActive()`; capture `var replyTo = Sender;` then pipe `_sfStorage.GetAllMessagesAsync(MaxResyncRows)` (const `int MaxResyncRows = 10_000`) into `replyTo.Tell(new SfBufferSnapshot(rows, truncated))` via `Task.Run(...).PipeTo(replyTo, success: t => new SfBufferSnapshot(t.Rows, t.Truncated))`. Standby receiving a request ignores it (Debug log).
|
|
- `Receive<SfBufferSnapshot>`: only when `!SafeIsActive()`; fire-and-forget `_sfStorage.ReplaceAllAsync(msg.Messages)` with a `ContinueWith` error log (matches `HandleApplyStoreAndForward`'s pattern); Warning when `msg.Truncated`.
|
|
4. Update `Component-StoreAndForward.md` Persistence: document the peer-join resync (trigger, direction, 10k cap, replace-all semantics, why upserts make trailing ops safe).
|
|
5. Run the filter + `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests`.
|
|
6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/SiteReplicationActor.cs docs/requirements/Component-StoreAndForward.md tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests && git commit -m "feat(store-and-forward): peer-join full-buffer resync — standby anti-entropy for the S&F buffer"`
|
|
|
|
### Task 22: Conventions & observability cleanup batch (Lows)
|
|
|
|
**Classification:** small (batched Lows, concrete edits)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 4, 13, 17, 18, 20 (blocked only by 16 — shared `CentralCommunicationActor.cs`)
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs` (lines 243-258), `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs` (lines 105-111), `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs` (lines 114-124), `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs` (line 425), `docs/requirements/Component-Communication.md`
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsTests.cs` (append)
|
|
|
|
Five concrete edits:
|
|
|
|
1. **[Low: check-then-act]** `SiteStreamGrpcServer.cs:243-245` — add a comment above the `_activeStreams.Count >= _maxConcurrentStreams` check: `// Deliberately check-then-act: two concurrent subscribes at the boundary can both pass, overshooting the cap by at most (concurrent subscribers - 1). Bounded and benign — not worth a lock on this path.`
|
|
2. **[Low: DropOldest observability]** `SiteStreamGrpcServer.cs:256-257` — count evictions via the `itemDropped` overload:
|
|
|
|
```csharp
|
|
long dropped = 0;
|
|
var correlationId = request.CorrelationId;
|
|
var channel = Channel.CreateBounded<SiteStreamEvent>(
|
|
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest },
|
|
_ =>
|
|
{
|
|
// Lossy-under-backpressure is the debug-view spec; make the real
|
|
// loss visible: first eviction + every 500th thereafter.
|
|
var n = Interlocked.Increment(ref dropped);
|
|
if (n == 1 || n % 500 == 0)
|
|
_logger.LogWarning(
|
|
"Debug stream {CorrelationId} backpressure: {Dropped} oldest event(s) evicted so far",
|
|
correlationId, n);
|
|
});
|
|
```
|
|
|
|
And in `StreamRelayActor.WriteToChannel` (lines 105-111), replace the dead warning: `// TryWrite on a DropOldest bounded channel never returns false — eviction of the oldest item is counted by the channel's itemDropped callback (SiteStreamGrpcServer). This branch guards only a completed/closed channel.` and demote the log to Debug ("channel closed, dropping event").
|
|
3. **[Low: duplicated timeout constants]** `SiteStreamGrpcServer.cs:45` — change `private static readonly TimeSpan AuditIngestAskTimeout` to `internal static readonly` with a doc note that `CentralCommunicationActor` shares it; `CentralCommunicationActor.cs:114-124` — delete `DefaultAuditIngestAskTimeout` and initialize `_auditIngestAskTimeout = auditIngestAskTimeout ?? Grpc.SiteStreamGrpcServer.AuditIngestAskTimeout;` (same assembly). Keep the fault-semantics divergence comment but append: `// Consolidating the two ingest transports (gRPC vs ClusterClient) is a scheduled follow-up — see arch review 02, Underdeveloped #1.`
|
|
4. **[Low: interval double duty]** `CommunicationOptions.cs` — add `/// <summary>Application-level site→central heartbeat cadence. Distinct from TransportHeartbeatInterval (the Akka.Remote failure-detector setting) — tuning the transport FD must not silently retune the health heartbeat.</summary> public TimeSpan ApplicationHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);` and switch `SiteCommunicationActor.PreStart` (line 425) to `_options.ApplicationHeartbeatInterval`. Default equals the old effective value — no behavior change. Test: `[Fact] public void ApplicationHeartbeatInterval_DefaultsTo5s_IndependentOfTransport() { var o = new CommunicationOptions(); Assert.Equal(TimeSpan.FromSeconds(5), o.ApplicationHeartbeatInterval); o.TransportHeartbeatInterval = TimeSpan.FromSeconds(30); Assert.Equal(TimeSpan.FromSeconds(5), o.ApplicationHeartbeatInterval); }`
|
|
5. **[U7 doc]** `Component-Communication.md` (debug-stream section): add an explicit accepted-limitation note — debug sessions are process-local on the central node; a central restart/failover drops them with no `OnStreamTerminated` signal; the engineer re-establishes the session.
|
|
|
|
Steps: write the `CommunicationOptionsTests` test first → `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter ApplicationHeartbeatInterval` (FAIL) → apply all five edits → run full `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests` (PASS) → commit: `git add src/ZB.MOM.WW.ScadaBridge.Communication docs/requirements/Component-Communication.md tests/ZB.MOM.WW.ScadaBridge.Communication.Tests && git commit -m "chore(communication): low-severity cleanup — eviction counting, shared ingest timeout, split app heartbeat interval, doc notes"`
|
|
|
|
### Task 23: Oldest-parked-age health metric (parked-retention aging signal)
|
|
|
|
**Classification:** standard (additive message contract)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 2, 5, 6, 13, 16, 21
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs` (add `GetOldestParkedCreatedAtAsync`), `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs` (additive field), `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs` (lines ~105-118, next to the parked-count block) + the `ISiteHealthCollector` interface/implementation it calls
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardStorageTests.cs` (append)
|
|
|
|
Depends on Task 20 (same storage file). Parked rows persist until operator action by design; this gives operators the aging signal the review flagged as absent (a forgotten site's parked backlog now shows its age, not just its count). Central UI tile rendering is a display-only follow-up once the field flows.
|
|
|
|
1. Failing test:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task GetOldestParkedCreatedAt_ReturnsOldestParkedRow_OrNull()
|
|
{
|
|
Assert.Null(await _storage.GetOldestParkedCreatedAtAsync());
|
|
|
|
var old = NewMessage("old-parked", status: StoreAndForwardMessageStatus.Parked,
|
|
createdAt: DateTimeOffset.UtcNow.AddDays(-3));
|
|
var recent = NewMessage("new-parked", status: StoreAndForwardMessageStatus.Parked,
|
|
createdAt: DateTimeOffset.UtcNow.AddHours(-1));
|
|
var pending = NewMessage("pending", status: StoreAndForwardMessageStatus.Pending,
|
|
createdAt: DateTimeOffset.UtcNow.AddDays(-9)); // must not count
|
|
await _storage.EnqueueAsync(old);
|
|
await _storage.EnqueueAsync(recent);
|
|
await _storage.EnqueueAsync(pending);
|
|
|
|
var oldest = await _storage.GetOldestParkedCreatedAtAsync();
|
|
Assert.Equal(old.CreatedAt, oldest!.Value, TimeSpan.FromSeconds(1));
|
|
}
|
|
```
|
|
|
|
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter GetOldestParkedCreatedAt` — expect FAIL.
|
|
3. Implement:
|
|
- Storage: `SELECT MIN(created_at) FROM sf_messages WHERE status = @parked` → parse to `DateTimeOffset?` (null when no rows).
|
|
- `SiteHealthReport`: append additive default parameter `double? OldestParkedMessageAgeSeconds = null` with a doc comment (age computed at report time on the site; null = no parked rows; additive-only contract evolution).
|
|
- `ISiteHealthCollector`: add `SetOldestParkedMessageAgeSeconds(double? ageSeconds);` implement in the collector (store; include in the built report).
|
|
- `HealthReportSender`: inside the existing parked-count `try` block: `var oldest = await _sfStorage.GetOldestParkedCreatedAtAsync(); _collector.SetOldestParkedMessageAgeSeconds(oldest.HasValue ? (DateTimeOffset.UtcNow - oldest.Value).TotalSeconds : null);`
|
|
4. Build the solution (`dotnet build ZB.MOM.WW.ScadaBridge.slnx` — the additive default param must not break any constructor caller) and run `dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests` + `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests`.
|
|
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Health/SiteHealthReport.cs src/ZB.MOM.WW.ScadaBridge.HealthMonitoring tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests && git commit -m "feat(health): oldest-parked-message age on the site health report (parked-retention aging signal)"`
|
|
|
|
### Task 24: Pin the reconciliation cursor boundary contract (`>=` at the site read)
|
|
|
|
**Classification:** standard (test-first; possible one-operator fix)
|
|
**Estimated implement time:** ~5 min
|
|
**Parallelizable with:** 1, 3, 5, 7, 10, 14
|
|
**Files:**
|
|
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullAuditEventsTests.cs` (append; mirror in `SiteStreamPullSiteCallsTests.cs`)
|
|
- Possibly modify: the site-side since-cursor query invoked by `SiteStreamGrpcServer.PullAuditEvents` (`SiteStreamGrpcServer.cs:478-484` — follow the Ask/store call into the site audit store in `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/`; same for `PullSiteCalls` at `:563-572` into the SiteRuntime tracking store)
|
|
|
|
Central advances `since_utc` from the last returned row's timestamp; rows *sharing* that boundary timestamp are only safe if the site-side read is **inclusive** (`>=`) — duplicates are absorbed by central's EventId dedup / upsert-on-newer, but an *exclusive* read silently loses same-timestamp rows forever.
|
|
|
|
1. Locate the site-side query behind `PullAuditEvents` (grep `since` in the store the gRPC handler Asks) and note the current operator.
|
|
2. Write the failing/pinning test in `SiteStreamPullAuditEventsTests` (its harness already stands up the server with a real/fake store — follow it):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task PullAuditEvents_BoundaryTimestamp_IsInclusive_EventIdDedupAbsorbsDuplicates()
|
|
{
|
|
// Two rows share one OccurredAtUtc; a pull whose since_utc equals that
|
|
// timestamp MUST return both (inclusive read). Central dedups on EventId,
|
|
// so duplication is safe; exclusion is silent permanent loss.
|
|
var boundary = DateTime.UtcNow;
|
|
await SeedAuditRow(eventId: _e1, occurredAtUtc: boundary);
|
|
await SeedAuditRow(eventId: _e2, occurredAtUtc: boundary);
|
|
|
|
var reply = await PullSince(boundary);
|
|
Assert.Equal(2, reply.Events.Count);
|
|
}
|
|
```
|
|
|
|
3. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter PullAuditEvents_BoundaryTimestamp` — if the current operator is `>=` it PASSES (the pin is the deliverable); if it FAILS, change the site-side query operator from `>` to `>=` (one-character fix + doc comment: *"inclusive: central's cursor is the last returned timestamp; EventId idempotency absorbs the re-sent boundary rows, exclusivity would lose same-timestamp rows"*), rerun, expect PASS.
|
|
4. Mirror the same boundary test for `PullSiteCalls` (upsert-on-newer absorbs duplicates there).
|
|
5. Run both filters + full project.
|
|
6. Commit: `git add -A tests/ZB.MOM.WW.ScadaBridge.Communication.Tests src/ZB.MOM.WW.ScadaBridge.AuditLog src/ZB.MOM.WW.ScadaBridge.SiteRuntime && git commit -m "test(communication): pin inclusive (>=) reconciliation cursor reads at the site; EventId/upsert dedup absorbs boundary duplicates"`
|
|
|
|
---
|
|
|
|
## Dependencies on other plans
|
|
|
|
- **Plan 01 (Cluster/Host/Failover)** — owns the general active-node/singleton-host facility and the leader-vs-oldest correction. Task 3's gate is a `Func<bool>` seam and Task 4/Task 21 wire the interim repo-standard leader+Up check with an explicit swap-point comment; when plan 01's shared helper lands, replace those two lambda bodies (and `SiteCommunicationActor.DefaultIsActiveCheck`, which this plan defers to plan 01). Plan 01 also owns the **real two-node failover test rig**, which is the integration-level proof for Tasks 1-4 (kill -9 a node → assert single-node S&F delivery and ClusterClient survival); this plan's unit tests are the fast-feedback layer.
|
|
- **Plan 04 (Data/Audit backbone)** — owns central-side ingest idempotency and data-layer indexes; Task 24 only pins the *site-side* read contract and relies on plan-04-owned central dedup staying as-is.
|
|
- No other plan touches `src/ZB.MOM.WW.ScadaBridge.StoreAndForward` or `src/ZB.MOM.WW.ScadaBridge.Communication`; merge conflicts are expected only in `AkkaHostedService.cs` (plan 01 edits `BuildHocon`; Task 4 edits `RegisterSiteActorsAsync` — different regions).
|
|
|
|
## Execution order
|
|
|
|
**P0 (fix before the next production deployment):** Tasks 1 → 2 (Critical crash-loop), 3 → 4 (standby gate), 5 → 6 (shared-channel disposal), 7 → 8 (sweep collapse). These four pairs are mutually independent — run them as four parallel tracks.
|
|
|
|
**Critical path (file-serialized chains):**
|
|
- `StoreAndForwardService.cs`: 3 → 7 → 8 → 9 → 12 → 13 → 19
|
|
- `StoreAndForwardStorage.cs`: 7 → 11 → 17 → 18 → 20 → 23
|
|
- `ReplicationService.cs` / `SiteReplicationActor.cs`: 10 → 11, 10+20 → 21
|
|
- `CentralCommunicationActor.cs`: 1 → 2 → 16 → 22
|
|
|
|
**P1:** 10, 11, 12, 13, 14, 15, 16. **P2 (hardening/underdeveloped):** 9, 17, 18, 19, 20, 21, 22, 23, 24.
|
|
|
|
After each task: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` + the named per-project test command. At plan end: full `dotnet test ZB.MOM.WW.ScadaBridge.slnx` **plus** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (not in the slnx), and rebuild the docker cluster (`bash docker/deploy.sh`) since Host/actor runtime changed.
|