From d6bad1738e2ee39e84e6a6fbe5e315595b92a5c4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 06:04:22 -0400 Subject: [PATCH] fix(management): single-flight transport bundle commands + drop redundant export copy (arch-review S4) Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../ManagementActor.cs | 208 ++++++++++-------- .../TransportGateTests.cs | 93 ++++++++ 2 files changed, 215 insertions(+), 86 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 5b820136..131a75e6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -3115,6 +3115,14 @@ public class ManagementActor : ReceiveActor // Transport bundle operations // ======================================================================== + /// Single-flight gate for transport bundle commands: a 200 MB import holds + /// ~4 concurrent whole-artifact copies (base64 string, byte[], MemoryStream, JSON envelope), + /// so two concurrent imports on one central node are a realistic OOM path (arch-review S4). + /// Commands queue here instead of running concurrently on the thread pool. Note the actor + /// pipes these handlers to the sender (HandleEnvelope), so without this gate two envelopes + /// received back-to-back run their handlers concurrently. + private static readonly SemaphoreSlim TransportGate = new(1, 1); + /// /// Resolves the per-type name lists in against the /// repositories, builds an , exports the bundle, @@ -3123,6 +3131,9 @@ public class ManagementActor : ReceiveActor private static async Task HandleExportBundle( IServiceProvider sp, ExportBundleCommand cmd, string username) { + await TransportGate.WaitAsync(); + try + { var templateRepo = sp.GetRequiredService(); var externalRepo = sp.GetRequiredService(); var notifRepo = sp.GetRequiredService(); @@ -3213,10 +3224,19 @@ public class ManagementActor : ReceiveActor selection, user: username, sourceEnvironment: cmd.SourceEnvironment, passphrase: cmd.Passphrase); - using var ms = new MemoryStream(); - await stream.CopyToAsync(ms); - var bytes = ms.ToArray(); - return new ExportBundleResult(Convert.ToBase64String(bytes), bytes.Length); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + // Encode directly off the MemoryStream's backing buffer rather than + // ms.ToArray() — the latter allocates a second whole-artifact copy. + // The long-term shape is streaming multipart to the caller (as the + // audit export path does, AuditEndpoints.cs:183+); deferred for now. + var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length)); + return new ExportBundleResult(base64, (int)ms.Length); + } + finally + { + TransportGate.Release(); + } } /// @@ -3226,33 +3246,41 @@ public class ManagementActor : ReceiveActor private static async Task HandlePreviewBundle( IServiceProvider sp, PreviewBundleCommand cmd) { - var importer = sp.GetRequiredService(); - var bytes = DecodeBundle(cmd.Base64Bundle); - BundleSession session; + await TransportGate.WaitAsync(); try { - await using var stream = new MemoryStream(bytes); - session = await importer.LoadAsync(stream, cmd.Passphrase); - } - catch (ArgumentException ex) - { - throw new ManagementCommandException(ex.Message); - } - catch (System.Security.Cryptography.CryptographicException) - { - throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); - } + var importer = sp.GetRequiredService(); + var bytes = DecodeBundle(cmd.Base64Bundle); + BundleSession session; + try + { + await using var stream = new MemoryStream(bytes); + session = await importer.LoadAsync(stream, cmd.Passphrase); + } + catch (ArgumentException ex) + { + throw new ManagementCommandException(ex.Message); + } + catch (System.Security.Cryptography.CryptographicException) + { + throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); + } - var preview = await importer.PreviewAsync(session.SessionId); - var adds = preview.Items.Count(i => i.Kind == ConflictKind.New); - var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified); - var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical); - var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker); - // Surface the required site/connection mappings so an operator - // (CLI or UI) sees which references need resolving before applying. - return new PreviewBundleResult( - preview.Items, adds, mods, ids, blocks, - preview.RequiredSiteMappings, preview.RequiredConnectionMappings); + var preview = await importer.PreviewAsync(session.SessionId); + var adds = preview.Items.Count(i => i.Kind == ConflictKind.New); + var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified); + var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical); + var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker); + // Surface the required site/connection mappings so an operator + // (CLI or UI) sees which references need resolving before applying. + return new PreviewBundleResult( + preview.Items, adds, mods, ids, blocks, + preview.RequiredSiteMappings, preview.RequiredConnectionMappings); + } + finally + { + TransportGate.Release(); + } } /// @@ -3263,69 +3291,77 @@ public class ManagementActor : ReceiveActor private static async Task HandleImportBundle( IServiceProvider sp, ImportBundleCommand cmd, string username) { - var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy); - var importer = sp.GetRequiredService(); - var bytes = DecodeBundle(cmd.Base64Bundle); - - BundleSession session; + await TransportGate.WaitAsync(); try { - await using var stream = new MemoryStream(bytes); - session = await importer.LoadAsync(stream, cmd.Passphrase); - } - catch (ArgumentException ex) - { - throw new ManagementCommandException(ex.Message); - } - catch (System.Security.Cryptography.CryptographicException) - { - throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); - } + var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy); + var importer = sp.GetRequiredService(); + var bytes = DecodeBundle(cmd.Base64Bundle); - var preview = await importer.PreviewAsync(session.SessionId); - - var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList(); - if (blockers.Count > 0) - { - var details = string.Join("; ", - blockers.Select(b => $"{b.Name}: {b.BlockerReason}")); - throw new ManagementCommandException( - $"Bundle has {blockers.Count} blocker(s); import aborted. {details}"); - } - - // Resolve every required site/connection reference into a - // BundleNameMap. Precedence: an explicit operator spec wins; otherwise - // fall back to the preview's auto-match; otherwise (no match) create-new - // ONLY if the create-missing flag is set, else fail with a clear message. - var nameMap = BuildNameMap(cmd, preview); - - // Dedupe by (EntityType, Name) -- the preview can emit multiple rows per - // entity (e.g. one row per modified member of a template), but ApplyAsync - // requires a unique resolution per key. Last-write-wins matches the - // Central UI's TransportImport.BuildDefaultResolutions behavior. For - // DataConnection rows the preview item Name is already site-qualified - // ({site}/{name}), so keying generically by (EntityType, Name) is - // automatically correct — no bare-connection-name special case needed. - var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); - var resolutionsMap = new Dictionary<(string, string), ImportResolution>(); - foreach (var item in preview.Items) - { - var action = item.Kind switch + BundleSession session; + try { - ConflictKind.New => ResolutionAction.Add, - ConflictKind.Identical => ResolutionAction.Skip, - ConflictKind.Modified => policy, - _ => ResolutionAction.Skip, - }; - var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename) - ? $"{item.Name}-imported-{renameStamp}" - : null; - resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution( - item.EntityType, item.Name, action, renameTo); - } + await using var stream = new MemoryStream(bytes); + session = await importer.LoadAsync(stream, cmd.Passphrase); + } + catch (ArgumentException ex) + { + throw new ManagementCommandException(ex.Message); + } + catch (System.Security.Cryptography.CryptographicException) + { + throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); + } - return await importer.ApplyAsync( - session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap); + var preview = await importer.PreviewAsync(session.SessionId); + + var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList(); + if (blockers.Count > 0) + { + var details = string.Join("; ", + blockers.Select(b => $"{b.Name}: {b.BlockerReason}")); + throw new ManagementCommandException( + $"Bundle has {blockers.Count} blocker(s); import aborted. {details}"); + } + + // Resolve every required site/connection reference into a + // BundleNameMap. Precedence: an explicit operator spec wins; otherwise + // fall back to the preview's auto-match; otherwise (no match) create-new + // ONLY if the create-missing flag is set, else fail with a clear message. + var nameMap = BuildNameMap(cmd, preview); + + // Dedupe by (EntityType, Name) -- the preview can emit multiple rows per + // entity (e.g. one row per modified member of a template), but ApplyAsync + // requires a unique resolution per key. Last-write-wins matches the + // Central UI's TransportImport.BuildDefaultResolutions behavior. For + // DataConnection rows the preview item Name is already site-qualified + // ({site}/{name}), so keying generically by (EntityType, Name) is + // automatically correct — no bare-connection-name special case needed. + var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var resolutionsMap = new Dictionary<(string, string), ImportResolution>(); + foreach (var item in preview.Items) + { + var action = item.Kind switch + { + ConflictKind.New => ResolutionAction.Add, + ConflictKind.Identical => ResolutionAction.Skip, + ConflictKind.Modified => policy, + _ => ResolutionAction.Skip, + }; + var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename) + ? $"{item.Name}-imported-{renameStamp}" + : null; + resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution( + item.EntityType, item.Name, action, renameTo); + } + + return await importer.ApplyAsync( + session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap); + } + finally + { + TransportGate.Release(); + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs new file mode 100644 index 00000000..7b84db27 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs @@ -0,0 +1,93 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +/// +/// Verifies the static TransportGate single-flight guard (arch-review S4): +/// transport bundle commands (export/preview/import) queue rather than run +/// concurrently, because each import holds ~4 concurrent whole-artifact copies of +/// the payload and two concurrent large imports on one central node are a +/// realistic OOM path. The gate is exercised through the real import handler so +/// the test tracks the actual concurrency shape of . +/// +public class TransportGateTests : TestKit, IDisposable +{ + private readonly IBundleImporter _importer; + private readonly ServiceCollection _services; + + public TransportGateTests() + { + _importer = Substitute.For(); + _services = new ServiceCollection(); + _services.AddScoped(_ => _importer); + } + + private IActorRef CreateActor() + { + var sp = _services.BuildServiceProvider(); + return Sys.ActorOf(Props.Create(() => new ManagementActor( + sp, NullLogger.Instance))); + } + + private static ManagementEnvelope Envelope(object command) => + new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty()), + command, Guid.NewGuid().ToString("N")); + + void IDisposable.Dispose() => Shutdown(); + + [Fact] + public async Task ImportBundle_ConcurrentCommands_AreSingleFlighted() + { + // LoadAsync blocks on this TCS so the first import stays inside the gate. + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var loadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var loadCalls = 0; + + var session = new BundleSession { SessionId = Guid.NewGuid() }; + _importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => + { + Interlocked.Increment(ref loadCalls); + loadStarted.TrySetResult(); + return release.Task; + }); + _importer.PreviewAsync(Arg.Any(), Arg.Any()) + .Returns(new ImportPreview(session.SessionId, Array.Empty())); + _importer.ApplyAsync( + Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ImportResult( + Guid.NewGuid(), 0, 0, 0, 0, Array.Empty(), "corr")); + + var actor = CreateActor(); + // A trivially-valid base64 payload; the substitute importer never parses it. + var bundle = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }); + + actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip"))); + actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip"))); + + // The first import must reach LoadAsync... + await loadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + // ...and the second must be parked at the gate: give it ample time to + // (wrongly) proceed, then assert it did not. + await Task.Delay(750); + Assert.Equal(1, Volatile.Read(ref loadCalls)); + + // Release the first; the second should now proceed and both should answer. + release.SetResult(session); + + ExpectMsg(TimeSpan.FromSeconds(5)); + ExpectMsg(TimeSpan.FromSeconds(5)); + AwaitAssert(() => Assert.Equal(2, Volatile.Read(ref loadCalls)), + TimeSpan.FromSeconds(5)); + } +}