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:
@@ -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