fix(management): single-flight large /management bodies at the HTTP edge (409 IMPORT_IN_PROGRESS) and parse the command off the request stream (review 2026-10-21 05/M2)

This commit is contained in:
Joseph Doherty
2026-10-22 09:22:22 -04:00
parent 72f96bdbcb
commit b0c863c6a1
3 changed files with 335 additions and 38 deletions
@@ -1,4 +1,3 @@
using System.Text;
using System.Text.Json;
using Akka.Actor;
using Microsoft.AspNetCore.Builder;
@@ -94,7 +93,25 @@ public static class ManagementEndpoints
/// </summary>
private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024;
private static async Task<IResult> HandleRequest(HttpContext context)
/// <summary>
/// Bodies above this size (or of unknown size) must hold <see cref="LargeRequestGate"/>.
/// Ordinary management commands are a few KB; only Transport bundle import/preview
/// carries a multi-megabyte base64 payload.
/// </summary>
internal const long LargeRequestThresholdBytes = 16L * 1024 * 1024;
/// <summary>
/// Single-flight gate for LARGE management requests at the HTTP edge (review 2026-10-21
/// 05/M2). The actor's <c>TransportGate</c> serialises the bundle handlers, but it is
/// reached only after the endpoint has already parsed the body — so concurrent imports
/// each materialised their payload here and then pinned it while queued at the actor
/// gate. Acquired with <c>Wait(0)</c> BEFORE the body is read and held until the actor
/// replies; a second large request is refused with <c>409 IMPORT_IN_PROGRESS</c> instead
/// of queueing another copy. Per node (static), like <c>TransportGate</c>.
/// </summary>
internal static readonly SemaphoreSlim LargeRequestGate = new(1, 1);
internal static async Task<IResult> HandleRequest(HttpContext context)
{
var logger = context.RequestServices.GetRequiredService<ILogger<ManagementActorHolder>>();
@@ -118,14 +135,36 @@ public static class ManagementEndpoints
var authenticatedUser = auth.User!;
// 4. Parse command from request body
string body;
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
// 4. Large bodies (bundle import/preview) are single-flight per node, decided
// BEFORE the body is read so a refused request never materialises its payload
// (review 2026-10-21 05/M2). Unknown length (chunked) counts as large.
var isLarge = context.Request.ContentLength is not { } length || length > LargeRequestThresholdBytes;
if (isLarge && !LargeRequestGate.Wait(0))
{
body = await reader.ReadToEndAsync();
return Results.Json(new
{
error = "Another large management request (a bundle import or preview) is already in progress on this node. Retry when it completes.",
code = "IMPORT_IN_PROGRESS",
}, statusCode: 409);
}
var parse = ParseCommand(body);
try
{
return await ParseAndDispatchAsync(context, authenticatedUser, logger);
}
finally
{
if (isLarge)
LargeRequestGate.Release();
}
}
private static async Task<IResult> ParseAndDispatchAsync(
HttpContext context, AuthenticatedUser authenticatedUser, ILogger logger)
{
// Parse the command straight off the request stream: one UTF-8 pass into the typed
// command, no ReadToEnd string / JsonDocument / GetRawText copies (review 2026-10-21 05/M2).
var parse = await ParseCommandAsync(context.Request.Body, context.RequestAborted);
if (!parse.Success)
{
return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400);
@@ -193,39 +232,72 @@ public static class ManagementEndpoints
/// <summary>
/// Parses a management request body — a JSON object with a <c>command</c> name and an
/// optional <c>payload</c> — into the strongly-typed command record. The parsed
/// <see cref="JsonDocument"/> is disposed deterministically and the missing-payload
/// case does not allocate a throwaway document.
/// optional <c>payload</c> — into the strongly-typed command record. Shares
/// <see cref="ParseCommandAsync"/>'s single-pass reader, so string and stream callers get
/// identical results.
/// </summary>
/// <param name="body">The raw JSON request body string.</param>
/// <returns>A <see cref="CommandParseResult"/> with the deserialized command on success, or an error on failure.</returns>
public static CommandParseResult ParseCommand(string body)
{
using JsonDocument doc = ParseDocument(body, out var parseError);
if (parseError != null)
return CommandParseResult.Fail(parseError);
try
{
return Complete(JsonSerializer.Deserialize<CommandRequestBody>(body, EnvelopeJsonOptions));
}
catch (Exception ex)
{
return Failure(ex);
}
}
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
/// <summary>
/// Stream variant of <see cref="ParseCommand(string)"/> used by the HTTP endpoint: the body
/// is read once as UTF-8 and the payload is deserialised directly into the command type as
/// soon as <c>command</c> has been seen (the CLI sends <c>command</c> first). A payload that
/// precedes <c>command</c> is buffered as a <see cref="JsonElement"/> and deserialised after.
/// </summary>
/// <param name="body">The request body stream.</param>
/// <param name="cancellationToken">Cancels the read.</param>
/// <returns>A <see cref="CommandParseResult"/> with the deserialized command on success, or an error on failure.</returns>
public static async Task<CommandParseResult> ParseCommandAsync(Stream body, CancellationToken cancellationToken = default)
{
try
{
return Complete(await JsonSerializer.DeserializeAsync<CommandRequestBody>(body, EnvelopeJsonOptions, cancellationToken));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return Failure(ex);
}
}
private static readonly JsonSerializerOptions EnvelopeJsonOptions = new()
{
Converters = { new CommandRequestBodyConverter() },
};
private static CommandParseResult Complete(CommandRequestBody? parsed)
{
if (parsed is null || !parsed.HasCommand)
return CommandParseResult.Fail("Missing 'command' field.");
var commandName = commandNameElement.GetString();
if (string.IsNullOrWhiteSpace(commandName))
if (string.IsNullOrWhiteSpace(parsed.CommandName))
return CommandParseResult.Fail("Empty 'command' field.");
var commandType = ManagementCommandRegistry.Resolve(commandName);
if (commandType == null)
return CommandParseResult.Fail($"Unknown command: '{commandName}'.");
if (parsed.CommandType is null)
return CommandParseResult.Fail($"Unknown command: '{parsed.CommandName}'.");
try
{
// Missing payload: deserialize from the empty-object literal rather than
// allocating (and leaking) a throwaway JsonDocument.
var payloadJson = doc.RootElement.TryGetProperty("payload", out var p)
? p.GetRawText()
: "{}";
var command = JsonSerializer.Deserialize(payloadJson, commandType,
CommandPayloadJsonOptions)!;
return CommandParseResult.Ok(command);
// Payload was either deserialised in-stream, buffered because it preceded the
// command name, or absent (deserialise the empty object).
var command = parsed.Command
?? (parsed.DeferredPayload is { } deferred
? deferred.Deserialize(parsed.CommandType, CommandPayloadJsonOptions)
: JsonSerializer.Deserialize("{}", parsed.CommandType, CommandPayloadJsonOptions));
return CommandParseResult.Ok(command!);
}
catch (Exception ex)
{
@@ -233,17 +305,86 @@ public static class ManagementEndpoints
}
}
private static JsonDocument ParseDocument(string body, out string? error)
private static CommandParseResult Failure(Exception ex) => ex is PayloadDeserializationException payload
? CommandParseResult.Fail($"Failed to deserialize payload: {payload.InnerException?.Message ?? payload.Message}")
: CommandParseResult.Fail("Invalid JSON body.");
/// <summary>The top-level request shape as read by <see cref="CommandRequestBodyConverter"/>.</summary>
private sealed class CommandRequestBody
{
try
public bool HasCommand { get; set; }
public string? CommandName { get; set; }
public Type? CommandType { get; set; }
public object? Command { get; set; }
public JsonElement? DeferredPayload { get; set; }
}
/// <summary>Distinguishes a bad payload from malformed JSON in the parse error.</summary>
private sealed class PayloadDeserializationException(Exception inner)
: Exception(inner.Message, inner);
/// <summary>
/// Reads <c>{"command": "...", "payload": {...}}</c> in one pass. Property names are
/// matched case-sensitively, as the previous <c>JsonDocument.TryGetProperty</c> parse did;
/// unknown top-level properties are skipped.
/// </summary>
private sealed class CommandRequestBodyConverter : System.Text.Json.Serialization.JsonConverter<CommandRequestBody>
{
public override CommandRequestBody Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
error = null;
return JsonDocument.Parse(body);
}
catch
{
error = "Invalid JSON body.";
return JsonDocument.Parse("{}");
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Expected a JSON object.");
var result = new CommandRequestBody();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return result;
if (reader.TokenType != JsonTokenType.PropertyName)
throw new JsonException("Expected a property name.");
var isCommand = reader.ValueTextEquals("command");
var isPayload = !isCommand && reader.ValueTextEquals("payload");
reader.Read();
if (isCommand)
{
result.HasCommand = true;
result.CommandName = reader.TokenType == JsonTokenType.String
? reader.GetString()
: reader.TokenType == JsonTokenType.Null ? null
: throw new JsonException("'command' must be a string.");
result.CommandType = string.IsNullOrWhiteSpace(result.CommandName)
? null
: ManagementCommandRegistry.Resolve(result.CommandName);
}
else if (isPayload && result.CommandType is not null)
{
try
{
result.Command = JsonSerializer.Deserialize(ref reader, result.CommandType, CommandPayloadJsonOptions);
}
catch (Exception ex)
{
throw new PayloadDeserializationException(ex);
}
result.DeferredPayload = null;
}
else if (isPayload)
{
result.DeferredPayload = JsonElement.ParseValue(ref reader);
}
else
{
reader.Skip();
}
}
throw new JsonException("Unterminated JSON object.");
}
public override void Write(Utf8JsonWriter writer, CommandRequestBody value, JsonSerializerOptions options)
=> throw new NotSupportedException();
}
}