fix(management): single-flight transport bundle commands + drop redundant export copy (arch-review S4)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -3115,6 +3115,14 @@ public class ManagementActor : ReceiveActor
|
|||||||
// Transport bundle operations
|
// Transport bundle operations
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private static readonly SemaphoreSlim TransportGate = new(1, 1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the per-type name lists in <paramref name="cmd"/> against the
|
/// Resolves the per-type name lists in <paramref name="cmd"/> against the
|
||||||
/// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
|
/// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
|
||||||
@@ -3122,6 +3130,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<object?> HandleExportBundle(
|
private static async Task<object?> HandleExportBundle(
|
||||||
IServiceProvider sp, ExportBundleCommand cmd, string username)
|
IServiceProvider sp, ExportBundleCommand cmd, string username)
|
||||||
|
{
|
||||||
|
await TransportGate.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
|
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||||
@@ -3215,8 +3226,17 @@ public class ManagementActor : ReceiveActor
|
|||||||
|
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
await stream.CopyToAsync(ms);
|
await stream.CopyToAsync(ms);
|
||||||
var bytes = ms.ToArray();
|
// Encode directly off the MemoryStream's backing buffer rather than
|
||||||
return new ExportBundleResult(Convert.ToBase64String(bytes), bytes.Length);
|
// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3225,6 +3245,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<object?> HandlePreviewBundle(
|
private static async Task<object?> HandlePreviewBundle(
|
||||||
IServiceProvider sp, PreviewBundleCommand cmd)
|
IServiceProvider sp, PreviewBundleCommand cmd)
|
||||||
|
{
|
||||||
|
await TransportGate.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var importer = sp.GetRequiredService<IBundleImporter>();
|
var importer = sp.GetRequiredService<IBundleImporter>();
|
||||||
var bytes = DecodeBundle(cmd.Base64Bundle);
|
var bytes = DecodeBundle(cmd.Base64Bundle);
|
||||||
@@ -3254,6 +3277,11 @@ public class ManagementActor : ReceiveActor
|
|||||||
preview.Items, adds, mods, ids, blocks,
|
preview.Items, adds, mods, ids, blocks,
|
||||||
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TransportGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-shot import: load + preview + apply with a single global conflict
|
/// One-shot import: load + preview + apply with a single global conflict
|
||||||
@@ -3262,6 +3290,9 @@ public class ManagementActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<object?> HandleImportBundle(
|
private static async Task<object?> HandleImportBundle(
|
||||||
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
||||||
|
{
|
||||||
|
await TransportGate.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
|
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
|
||||||
var importer = sp.GetRequiredService<IBundleImporter>();
|
var importer = sp.GetRequiredService<IBundleImporter>();
|
||||||
@@ -3327,6 +3358,11 @@ public class ManagementActor : ReceiveActor
|
|||||||
return await importer.ApplyAsync(
|
return await importer.ApplyAsync(
|
||||||
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TransportGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
|
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the static <c>TransportGate</c> 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 <see cref="IBundleImporter"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class TransportGateTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IBundleImporter _importer;
|
||||||
|
private readonly ServiceCollection _services;
|
||||||
|
|
||||||
|
public TransportGateTests()
|
||||||
|
{
|
||||||
|
_importer = Substitute.For<IBundleImporter>();
|
||||||
|
_services = new ServiceCollection();
|
||||||
|
_services.AddScoped(_ => _importer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActorRef CreateActor()
|
||||||
|
{
|
||||||
|
var sp = _services.BuildServiceProvider();
|
||||||
|
return Sys.ActorOf(Props.Create(() => new ManagementActor(
|
||||||
|
sp, NullLogger<ManagementActor>.Instance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManagementEnvelope Envelope(object command) =>
|
||||||
|
new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()),
|
||||||
|
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<BundleSession>(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var loadStarted = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var loadCalls = 0;
|
||||||
|
|
||||||
|
var session = new BundleSession { SessionId = Guid.NewGuid() };
|
||||||
|
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(_ =>
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref loadCalls);
|
||||||
|
loadStarted.TrySetResult();
|
||||||
|
return release.Task;
|
||||||
|
});
|
||||||
|
_importer.PreviewAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ImportPreview(session.SessionId, Array.Empty<ImportPreviewItem>()));
|
||||||
|
_importer.ApplyAsync(
|
||||||
|
Arg.Any<Guid>(), Arg.Any<IReadOnlyList<ImportResolution>>(),
|
||||||
|
Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<BundleNameMap?>())
|
||||||
|
.Returns(new ImportResult(
|
||||||
|
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), "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<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
AwaitAssert(() => Assert.Equal(2, Volatile.Read(ref loadCalls)),
|
||||||
|
TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user