From c83b51fe9cdd42f74c3ac8077e990d7f54c67c2d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:26:43 -0400 Subject: [PATCH] fix(comm): single-gRPC-endpoint sites can go live (flip degenerates to same-node reconnect) (plan R2-02 T15) --- .../SiteAlarmLiveCacheService.cs | 12 ++++- .../SiteAlarmLiveCacheServiceTests.cs | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs index 13ec0c1b..f27d50a7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs @@ -294,7 +294,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache return null; } - if (string.IsNullOrWhiteSpace(site.GrpcNodeAAddress) || string.IsNullOrWhiteSpace(site.GrpcNodeBAddress)) + var nodeA = site.GrpcNodeAAddress; + var nodeB = site.GrpcNodeBAddress; + if (string.IsNullOrWhiteSpace(nodeA) && string.IsNullOrWhiteSpace(nodeB)) { _logger.LogWarning( "Live alarm cache: site {SiteId} ({SiteIdentifier}) has no gRPC node addresses; " + @@ -302,7 +304,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache return null; } - return (site.SiteIdentifier, site.GrpcNodeAAddress!, site.GrpcNodeBAddress!); + // Single-endpoint site (review 02 round 2, N9): reuse the sole configured + // endpoint for both slots — the aggregator's NodeA↔NodeB failover flip + // degenerates to a reconnect against the same node, which is exactly the + // right behavior for a one-node site. + var grpcA = !string.IsNullOrWhiteSpace(nodeA) ? nodeA! : nodeB!; + var grpcB = !string.IsNullOrWhiteSpace(nodeB) ? nodeB! : nodeA!; + return (site.SiteIdentifier, grpcA, grpcB); } // ── Snapshot fan-out (the seed / reconcile) ───────────────────────────────── diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs index 3d7e07ad..eec76ba1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs @@ -258,4 +258,48 @@ public class SiteAlarmLiveCacheServiceTests : TestKit Assert.False(service.IsLive(999)); Assert.Empty(service.GetCurrentAlarms(999)); } + + // ── R2 T15: single-endpoint sites can go live (N9) ── + + [Fact] + public void SiteWithOnlyNodeAEndpoint_StartsAnAggregator_AgainstThatEndpoint() + { + var site = new Site("Three", "site-3") + { + Id = 3, + GrpcNodeAAddress = "http://only-node:8083", + GrpcNodeBAddress = null, // single-endpoint site — pre-fix: never goes live + }; + + var siteRepo = Substitute.For(); + siteRepo.GetSiteByIdAsync(3, Arg.Any()).Returns(site); + + var instanceRepo = Substitute.For(); + instanceRepo.GetInstancesBySiteIdAsync(3, Arg.Any()) + .Returns(new List()); + + var services = new ServiceCollection(); + services.AddScoped(_ => siteRepo); + services.AddScoped(_ => instanceRepo); + var provider = services.BuildServiceProvider(); + + var options = Options.Create(new CommunicationOptions + { + LiveAlarmCacheLinger = TimeSpan.FromSeconds(30), + LiveAlarmCacheReconcileInterval = TimeSpan.FromMinutes(10), + }); + var comm = new CommunicationService(Options.Create(new CommunicationOptions()), + NullLogger.Instance); + var factory = new CountingFactory(); + + var service = new SiteAlarmLiveCacheService( + provider, comm, factory, options, NullLogger.Instance); + service.SetActorSystem(Sys); + + using var sub = service.Subscribe(3, () => { }); + + // Pre-fix: ResolveSiteAsync required BOTH endpoints and bailed → never live, no client. + AwaitCondition(() => service.IsLive(3), TimeSpan.FromSeconds(5)); + Assert.True(factory.GetOrCreateCount >= 1); // a client was created for the single endpoint + } }