Eliminate passive-health async frame from the default proxy pipeline - #3040
Eliminate passive-health async frame from the default proxy pipeline#3040artl93 wants to merge 4 commits into
Conversation
… when disabled PassiveHealthCheckMiddleware.Invoke was an `async` method that always awaited `_next`, so it heap-allocated an async state machine (~112 B) on every proxied request through the default MapReverseProxy pipeline -- even though passive health checks are disabled by default and there is no post-processing to do. Short-circuit to `return _next(context)` when passive health is not engaged for the request's cluster (read at entry, mirroring SessionAffinityMiddleware and LimitsMiddleware). The outcome recording still re-reads the cluster/destination after `_next`, preserving ReassignProxyRequest semantics for the documented "reassign first" pattern and for enabled clusters. Measured (Apple M-series arm64, .NET 9.0.2): - In-process pipeline A/B: -112 B/req on every default-pipeline shape (1/100/1000 routes, 8/64 destinations, round-robin/p2c, affinity hit/miss, healthy filter); passive-health-enabled path unchanged; synchronous path unchanged. - E2E (real Kestrel proxy -> backend): -112 B/req (HTTP/1.1) / -106 B/req (HTTP/2) with no throughput, latency, or working-set regression. Adds differential tests locking in reassignment/ordering semantics and a durable benchmark (perf-hunt/) that reproduces the A/B. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1144d71-c639-4408-84cf-97c6a8f8355c
Fold default-pipeline passive health recording into ForwarderMiddleware's existing async frame instead of using an entry-time fast path. This retains final proxy feature and custom pipeline ordering semantics, including downstream transforms and request reassignment, while preserving the 112 B/request allocation reduction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1144d71-c639-4408-84cf-97c6a8f8355c
Cover the default pipeline's final recording point for successful and failed forwarding, thrown exceptions, disabled recording, and downstream cluster reassignment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1144d71-c639-4408-84cf-97c6a8f8355c
Exact-SHA real-Kestrel raw resultsAll 40 H1/H2 direct/proxy measurements completed with E2E command log SHA-256: All 40 structured RESULT linesThe complete local evidence manifest contains 122 hashed generated files. The benchmark harness archive is SHA-256 |
|
Still draft — please treat this as an evidence/semantic review request, not a ready-for-merge signal. The body now links the valid Linux/Windows/macOS exact-binary allocation artifacts; |
Exercise both MapReverseProxy overloads through the full TestServer pipeline for asynchronous success, forwarding errors, thrown exceptions, request cancellation, and missing destinations. Verify passive-health recording order and exact-once behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1144d71-c639-4408-84cf-97c6a8f8355c
Motivation
The parameterless
MapReverseProxy()pipeline always passed throughPassiveHealthCheckMiddleware.Invoke. Because that method awaited the forwarding pipeline, an asynchronously completed request allocated its state machine/continuation even when passive health was disabled (the default).This removes that ubiquitous frame: 112 B/request in the complete routing/load-balancing pipeline measured below.
Change
The default pipeline records passive-health outcomes in
ForwarderMiddleware, which already has an unavoidable async frame. Recording still happens:ForwarderErrorresults, but not thrown exceptions, matching the previous post-awaitbehavior.The custom
MapReverseProxy(configureApp)overload keeps publicUsePassiveHealthChecks()middleware semantics and does not double-record.BenchmarkDotNet
Exact binary comparison: baseline
49b23da6d8versus implementation4e8c0103f2; PR head71d1ad10adds tests only. Runtime SHA-256 guards fail each benchmark process if it loads the wrong product DLL.Cross-platform
BenchmarkDotNet 0.15.8short apples-to-apples run on .NET 10.0.10 (1 launch, 1 warmup, 3 measurement iterations), with a reusable incompleteIValueTaskSourceforwarder:The exact allocation table reproduced on Linux x64, Windows x64, and macOS arm64: workflow and raw artifacts. Product DLL SHA-256: baseline
0c813f18...bd9f4, candidated1e6a57f...ac190.YARP is not configured in EgorBot's supported polling repositories; its current source names
dotnet/runtimeandEgorBot/Benchmarks, notdotnet/yarp(configuration). The cross-platform matrix above is the campaign-policy substitute, not an EgorBot result.Validation
The focused full-pipeline theories pass 20/20 across net8/net9. The repository command passes the build and Application, ReverseProxy unit/functional, and Kubernetes suites on supported net8/net9 targets (0 warnings, 0 errors).
The new
TestServercoverage exercises bothMapReverseProxyoverloads and proves exact-once post-forwarding recording for asynchronous success and normalForwarderError, no recording for asynchronous throws, propagated request cancellation, or missing destinations, unchanged exceptions/status codes, and no forwarder invocation on the no-destination path. Existing focused tests cover disabled/integrated gating, counter cleanup, and cluster/destination reassignment.A fresh exact-SHA real-Kestrel control used direct and proxy H1/H2, 32 connections, 5 s warmup + 12 s measurement, and a 100-byte response. All eight runs completed with zero errors, but machine conditions also moved the direct controls, so it is only a no-regression/allocation-direction check. Five earlier alternating paired rounds gave direct-adjusted allocation deltas of -108.6 +/- 102.7 B/request (H1) and -150.8 +/- 135.9 B/request (H2).
Risks / limits
731432ef7f) made an unsafe entry-time passive-health decision. The final implementation (4e8c0103f2) deliberately records after forwarding in the existing frame, preserving middleware ordering and reassignment semantics.TestServerwith a controlledIHttpForwarder; real Kestrel controls transport integration but do not inject every fault shape.AI disclosure
GitHub Copilot assisted with profiling, implementation, tests, benchmark automation, and drafting. I reviewed the code, benchmark identity checks, results, and semantic coverage.