diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs index 9edb24b6..b9c0c7e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; @@ -221,11 +222,21 @@ public sealed class AuditWriteMiddleware && opts.PerTargetOverrides.TryGetValue(methodNameForOverride, out var perTarget) && perTarget.SkipBodyCapture; + // A standby that will relay this request to the active peer neither executes nor + // audits it (the serving node owns the single row, see `relayed` below). Skip the + // request/response capture entirely so the body is read once — by the forwarder — + // instead of buffered here, buffered again by the forwarder, and a response copy + // captured only to be discarded (review 2026-10-21 05/M4). If every peer refuses + // and the standby does emit its own failure row, that row simply carries no bodies. + var willRelay = WillRelayToActivePeer(ctx); + var requestBody = (string?)null; var requestTruncated = false; - if (!skipBody && RequestHasBody(ctx.Request)) + if (!skipBody && !willRelay && RequestHasBody(ctx.Request)) { - ctx.Request.EnableBuffering(); + // Bodies are capped at MaxRequestBodyBytes (1 MiB default): keep them in memory + // rather than spilling everything above the 30 KB default threshold to a temp file. + ctx.Request.EnableBuffering((int)Math.Min(maxBytes, int.MaxValue)); try { (requestBody, requestTruncated) = await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false); @@ -243,7 +254,8 @@ public sealed class AuditWriteMiddleware // original Response.Body is restored in the finally block. var originalResponseBody = ctx.Response.Body; using var captureStream = new CapturedResponseStream(originalResponseBody, cap); - ctx.Response.Body = captureStream; + if (!willRelay) + ctx.Response.Body = captureStream; Exception? thrown = null; try @@ -294,6 +306,19 @@ public sealed class AuditWriteMiddleware } } + /// + /// True when this node is a standby with a configured relay and the request has not + /// already been relayed once — i.e. will forward it. + /// Mirrors the filter's own relay decision. + /// + private static bool WillRelayToActivePeer(HttpContext ctx) + { + var gate = ctx.RequestServices?.GetService(); + if (gate is not { IsActiveNode: false }) return false; + if (ctx.Request.Headers.ContainsKey(HttpStandbyRequestForwarder.ForwardedRequestHeader)) return false; + return ctx.RequestServices!.GetService() is { IsConfigured: true }; + } + /// /// Builds and writes the row for the /// request. Wrapped in try/catch so a thrown writer or any other emission diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs index 6f05783c..efd1aa1e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RelayProvenance.cs @@ -64,7 +64,7 @@ internal static class RelayProvenance catch (Exception ex) when (ex is FormatException or JsonException or ArgumentException) { return; } // Authenticate small metadata before reading; bound even non-Kestrel request streams. - context.Request.EnableBuffering(); + context.Request.EnableBuffering((int)Math.Min(options.MaxRequestBodyBytes, int.MaxValue)); var body = await ReadBoundedAsync(context.Request.Body, options.MaxRequestBodyBytes, context.RequestAborted); context.Request.Body.Position = 0; if (!string.Equals(proof.Binding, Binding(context.Request, body), StringComparison.Ordinal)) return; diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index 085ffdc3..598f4a42 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -62,6 +62,36 @@ public class AuditWriteMiddlewareTests if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId); } + /// Verifies that a relaying standby skips request buffering and response capture. + /// A task that represents the asynchronous operation. + [Fact] + public async Task M4_RelayingStandby_DoesNotBufferRequestOrCaptureResponse() + { + // Review 2026-10-21 05/M4: the standby neither executes nor audits a relayed call, so + // it must not buffer the body (the forwarder reads it once) or wrap the response. + var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new StandbyGate()); + services.AddSingleton(new ResultForwarder(Results.Ok())); + using var provider = services.BuildServiceProvider(); + var context = BuildContext("echo", "{}"); + context.RequestServices = provider; + var originalRequest = context.Request.Body; + var originalResponse = new MemoryStream(); + context.Response.Body = originalResponse; + Stream? seenRequest = null, seenResponse = null; + var middleware = CreateMiddleware(ctx => + { + seenRequest = ctx.Request.Body; + seenResponse = ctx.Response.Body; + return Task.CompletedTask; + }, new RecordingAuditWriter()); + + await middleware.InvokeAsync(context); + + Assert.Same(originalRequest, seenRequest); + Assert.Same(originalResponse, seenResponse); + } + private sealed class StandbyGate : IActiveNodeGate { ///