Skip to content

[Fusion] Add an experimental YARP-style subgraph transport - #129

Open
michaelstaib wants to merge 3 commits into
mainfrom
perf/fusion-sockets-transport
Open

michaelstaib wants to merge 3 commits into
mainfrom
perf/fusion-sockets-transport

Conversation

@michaelstaib

Copy link
Copy Markdown
Owner

With the HotChocolate.Fusion.Experimental.SocketsHttpTransport AppContext switch, Fusion sends source schema requests through one SocketsHttpHandler per source schema that it owns, with YARP's forwarding defaults (no cookies, no redirects, no decompression, 15 s connect timeout, pooled connections closed after 2 minutes instead of handler rotation), and copies the gateway request headers named in an AppContext data entry onto source schema requests itself instead of through ASP.NET Core header propagation. A schema reload keeps the handler of every source schema whose settings did not change, and requests without an execution deadline keep HttpClient's 100 s bound. This builds on upstream 4adcdf2 (the diff includes ChilliCream#10711 and ChilliCream#10712 until the fork's main catches up), so it measures only what goes beyond the invoker route of ChilliCream#10712: on the official benchmark with both inline settings, the transport keeps throughput, latency, CPU and RSS within run-to-run noise (about 1% req/s, 2% CPU) and removes about 0.7 KB of allocations per request. The switch keeps the default path unchanged, so this can land before a public configuration API exists.

Metric Before Optimization After Optimization
Constant-latency req/s, AOT 3,342 3,354
Constant-latency p50 / p95, AOT 13.91 / 19.43 ms 13.87 / 19.30 ms
Constant-latency req/s, JIT 3,398 3,375
Constant-latency p50 / p95, JIT 13.70 / 19.10 ms 13.77 / 19.32 ms
Constant req/s, AOT 4,775 4,786
Constant req/s, JIT 4,984 4,984
Gateway CPU per request, constant-latency, AOT / JIT 0.790 / 0.679 ms 0.802 / 0.692 ms
Allocated B/request, JIT, constant-latency / constant 26,014 / 26,360 25,318 / 25,677
Idle RSS, AOT / JIT 50.5 / 94.4 MB 50.3 / 94.3 MB
Peak RSS, constant-latency, AOT / JIT 102.5 / 173.8 MB 102.2 / 171.7 MB
AOT binary / startup 42.63 MB / 76 ms 42.65 MB / 74 ms

Official 16.7.0 subgraphs, alias-batching gateway configuration, k6 deep-recursion with 50 VUs, Kestrel UnsafePreferInlineScheduling and DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS=1 on for both sides. Medians of 4 (AOT constant-latency), 3 (AOT constant) and 2 (JIT) rounds; the first AOT variant rounds used a binary that still set AutomaticDecompression explicitly, which differs only in linked code. The benchmark gateway opts in with BENCH_FUSION_SOCKETS_TRANSPORT=1.

Where the remaining transport cost is

JIT profile at constant latency (thread-time samples scaled to the measured 0.68 ms CPU per request, allocations from gc-verbose):

  • IHttpClientFactory and HttpClient layers: about 0 µs and 0.7 KB per request in the base (the header propagation middleware and RedirectHandler). The transport removes all of it.
  • Inside SocketsHttpHandler: about 28% of CPU samples, including send and receive syscalls, and 6.9 KB per request (27% of all gateway allocations). That splits into header collections 2.4 KB, HttpConnection 2.1 KB, pool and handler continuations 1.4 KB, and request/response messages with content 1.1 KB.
  • Fusion request building and response parsing: about 6% of CPU and 8.1 KB per request (32%).
  • A microbenchmark (per subgraph call, HttpMessageInvoker with these handler settings vs a raw-socket HTTP/1.1 floor) puts the most a Fusion-owned HTTP/1.1 client could still save at 3.7 to 3.8 µs per call on JIT and 5.5 to 6.7 µs per call on AOT, plus 1.8 KB per call. With 4 subgraph calls per request here, that is about 15 µs (JIT) or 23 µs (AOT) per request, 2 to 3% of gateway CPU, and about 7 KB of allocations per request.

Configuration needs found by the prototype

The prototype ignores every AddHttpClient("fusion") setting, so each of these needs a home in the configuration API:

  • Per source schema handler settings: MaxConnectionsPerServer (the benchmark used 256), ConnectTimeout, PooledConnectionLifetime (DNS refresh, replaces handler rotation), PooledConnectionIdleTimeout, EnableMultipleHttp2Connections, HTTP/2 keep-alive pings. These settings also decide whether a schema reload keeps a handler.
  • TLS: client certificates for mTLS, a custom CA or certificate validation callback, SslProtocols.
  • HTTP version: request Version and VersionPolicy (HTTP/2 or h2c to source schemas). Today these come from HttpClient.DefaultRequestVersion, which needs ConfigureHttpClient and that turns off client sharing.
  • Delegating handlers: token injection for source schema auth, AddRequestDeduplication, resilience handlers, logging. The prototype has no handler pipeline.
  • Header forwarding: which gateway request headers go to which source schemas, with renames and value factories (what AddHeaderPropagation offers). The prototype reads a comma-separated list from AppContext.
  • Timeouts: requests without an execution deadline (deferred fragments, subscriptions) keep HttpClient's 100 s time-to-headers bound, which is not configurable per source schema; a per source schema request timeout is missing.
  • Proxy: UseProxy stays at its default, so HTTP_PROXY, HTTPS_PROXY and ALL_PROXY still reroute source schema calls. YARP turns it off and configures a proxy per cluster.
  • Decompression: off. Anyone who relies on compressed source schema responses needs AutomaticDecompression as an opt-in. Setting it, even to None, makes NativeAOT link the Brotli and Zstandard decoders (1.1 MB).
  • Test and in-process hooks: tests route source schema calls to TestServer by replacing IHttpClientFactory. The transport needs a way to supply the handler (like YARP's IForwarderHttpClientFactory), and ConnectCallback would cover Unix domain sockets to sidecars.
  • Telemetry: ActivityHeadersPropagator, and the System.Net.Http.HttpClient.<name> log categories of IHttpClientFactory, which disappear.

Open questions

  • UseProxy: keep the default (environment proxies apply) or turn it off like YARP.
  • Replaced handlers are not disposed, so in-flight requests finish and idle connections close through the idle timeout (YARP does the same). Is that acceptable for frequent reloads?
  • [Fusion] Build subgraph request bodies with fewer allocations #80 (SendJsonAsync) adds a second send path, which would also need to call the header forwarder.

The HotChocolate.Fusion.Experimental.SocketsHttpTransport AppContext switch makes
Fusion send source schema requests through one SocketsHttpHandler per source schema
that it owns, instead of through IHttpClientFactory. The handler does not store
cookies or follow redirects, leaves automatic decompression at its default (off),
uses a 15 s connect timeout and allows multiple HTTP/2 connections, and closes
pooled connections after 2 minutes instead of rotating handlers. Shared clients
never expire, and a schema reload keeps the handler of every source schema whose
settings did not change.

Gateway request headers named in the
HotChocolate.Fusion.Experimental.SocketsHttpTransport.ForwardedHeaders AppContext
data entry are copied from the HttpContext onto source schema requests, which
replaces ASP.NET Core header propagation when the transport is on.

The benchmark gateway opts in with BENCH_FUSION_SOCKETS_TRANSPORT=1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant