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
|
||||
// ========================================================================
|
||||
|
||||
/// <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>
|
||||
/// Resolves the per-type name lists in <paramref name="cmd"/> against the
|
||||
/// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
|
||||
@@ -3123,6 +3131,9 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleExportBundle(
|
||||
IServiceProvider sp, ExportBundleCommand cmd, string username)
|
||||
{
|
||||
await TransportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
var notifRepo = sp.GetRequiredService<INotificationRepository>();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3226,33 +3246,41 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandlePreviewBundle(
|
||||
IServiceProvider sp, PreviewBundleCommand cmd)
|
||||
{
|
||||
var importer = sp.GetRequiredService<IBundleImporter>();
|
||||
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<IBundleImporter>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3263,69 +3291,77 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleImportBundle(
|
||||
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
||||
{
|
||||
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
|
||||
var importer = sp.GetRequiredService<IBundleImporter>();
|
||||
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<IBundleImporter>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user