diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 9f057d10..71e692e4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -1583,6 +1583,18 @@ public class ManagementActor : ReceiveActor await repo.SaveChangesAsync(); var commService = sp.GetService(); commService?.RefreshSiteAddresses(); + + // Dispose the deleted site's cached gRPC channels — RemoveSiteAsync is the + // factory's designed site-deletion disposal path and previously had no + // production caller (arch review 02 round 2, N8): a deleted site's channels + // (both node endpoints) otherwise persist until process restart. Null-safe: + // test/composition roots without the factory skip it. Any live-alarm + // aggregator for the site reconciles to an empty snapshot and is reaped by + // the viewer linger (documented acceptance — see Component-Communication.md). + var grpcFactory = sp.GetService(); + if (grpcFactory is not null && site is not null) + await grpcFactory.RemoveSiteAsync(site.SiteIdentifier); + await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null); return true; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index afde74b3..b52d42e5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -5,8 +5,10 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using NSubstitute.ExceptionExtensions; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas; using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; @@ -3599,6 +3601,51 @@ public class ManagementActorTests : TestKit, IDisposable ExpectMsg(TimeSpan.FromSeconds(5)); } + // ── R2 T13: site delete disposes the site's cached gRPC channels (N8) ── + + [Fact] + public void DeleteSite_DisposesTheSitesCachedGrpcChannels() + { + var siteRepo = Substitute.For(); + siteRepo.GetSiteByIdAsync(7, Arg.Any()) + .Returns(new Site("Seven", "site-7") { Id = 7 }); + siteRepo.GetInstancesBySiteIdAsync(7, Arg.Any()) + .Returns((IReadOnlyList)new List()); + _services.AddScoped(_ => siteRepo); + + var factory = new TrackingGrpcFactory(); + var cached = (TrackingClient)factory.GetOrCreate("site-7", "http://node-a:8083"); + _services.AddSingleton(factory); + + var actor = CreateActor(); + actor.Tell(Envelope(new DeleteSiteCommand(7), "Administrator")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + AwaitAssert(() => + { + Assert.True(cached.Disposed); + Assert.Null(factory.TryGet("site-7", "http://node-a:8083")); + }, TimeSpan.FromSeconds(5)); + } + + private sealed class TrackingClient : SiteStreamGrpcClient + { + public TrackingClient(string endpoint) : base(endpoint) { } + public bool Disposed { get; private set; } + public override void Dispose() => Disposed = true; + public override ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + } + + private sealed class TrackingGrpcFactory : SiteStreamGrpcClientFactory + { + public TrackingGrpcFactory() : base(NullLoggerFactory.Instance) { } + protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint) => new TrackingClient(grpcEndpoint); + } + /// /// Records remote-query relays for the actor-dispatch tests. The browse / /// search / verify / cert-trust methods on