Skip to content

Production-ready subscriptions (SSE + graphql-ws) and a single UTF-8 envelope parser - #115

Merged
joadan merged 6 commits into
Linq2GraphQL:mainfrom
ricred:feature/subscription-production-readiness
Oct 16, 2026
Merged

joadan merged 6 commits into
Linq2GraphQL:mainfrom
ricred:feature/subscription-production-readiness

Conversation

@ricred

@ricred ricred commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes the subscription transports (SSE and graphql-ws) production-ready and moves every response path onto a single, allocation-light envelope parser.

No public signature is changed or removed. Several behaviour changes are listed below; please read those first. Full details are in the new CHANGELOG.md ([Unreleased]).

Size note: 35+ files and ~5,000 added lines, ~60% of which are tests. Happy to split this into two PRs (response parser, then subscriptions) if that is easier to review.

Behaviour changes (please review)

  • GraphClient.SerializerOptions is a shared, frozen instance: mutating it after construction throws InvalidOperationException.
  • __AdditionalProperties is null until first used (no eager dictionary per entity).
  • WebSocket: StartAsync no longer awaits the handshake. Connection/ack failures and per-message parse errors surface as OnError on the stream; unknown message types are ignored.
  • Empty response body / empty string in ProcessResponseFull(string) / ProcessResponse(string) returns Data = default with no errors instead of throwing JsonException.
  • ProcessResponseFull(JsonElement) with a non-object root throws JsonException instead of InvalidOperationException.
  • WebSocket close codes follow the graphql-ws reference client (4408 is now retryable). Reconnect is owned by the subscription, with a bounded retry budget (SubscriptionRetryAttempts, default 5), exponential backoff and jitter.

Added

  • GraphSubscriptionExecute.StartWithErrorsAsync(): IObservable<GraphResult<T>>, so field errors no longer end the subscription.
  • GraphClientOptions: SubscriptionMaxBufferedEvents, SubscriptionOverflowPolicy (Fault / DropOldest / KeepLatest), SubscriptionIdleTimeout (SSE), SubscriptionRetryAttempts (WS).
  • QueryExecutor.ProcessResponse(JsonElement, ...) and ProcessResponse(byte[], ...) overloads.

Fixed

  • A server complete frame now completes the typed subscription observable.
  • A Fault overflow in one subscriber no longer starves the other subscribers.
  • SSEClient race between Dispose and connect (possible NRE / spurious failure).
  • WSConnectionInitPayload default returns a completed task instead of null.

Single envelope parser: GraphEnvelopeReader

All response paths now parse data / errors / extensions with one internal Utf8JsonReader-based reader (Common/GraphEnvelopeReader): typed queries, GraphClient.ExecuteAsync (GraphQL text, including the non-2xx "is this a GraphQL body?" decision), every ProcessResponse / ProcessResponseFull overload, and subscription payloads.

  • CLAUDE.md said QueryExecutor.ProcessResponseFull is "the single place both go through". That rule is kept but now names GraphEnvelopeReader, since that is where the single path lives after this change. @joadan, you wrote that file, so please check the wording.
  • EnvelopeAndResponseTests.AllOverloads_ProduceTheSameResult checks that the overloads cannot drift apart again.

Benchmark (BenchmarkDotNet 0.15.8, .NET 10.0.402, in-process, comparing the previous JsonDocument path with the new reader; results checked equal):

Payload Before (mean / alloc) After (mean / alloc)
Small 4.04 µs / 3.11 KB 3.51 µs / 2.45 KB
Large (500 items) 8.83 ms / 3,498 KB (Gen2: 156) 8.48 ms / 1,756 KB (Gen2: 0)
Requested field + 2 large sibling root fields 10.21 ms / 6,325 KB 4.97 ms / 19.9 KB

The Large timing is noisy (SD 1.4 ms); the allocation drop is the robust result. Not measured: copying the HTTP body into the buffer.

Testing

  • dotnet restore Linq2GraphQL.CI.slnf --locked-mode → build --no-restore (0 warnings, 0 errors) → test --no-build: 446/446 passed
    • on Windows (4 consecutive full runs);
    • on Linux in mcr.microsoft.com/dotnet/sdk:10.0 (a clean clone, same steps as CI's ubuntu job).
  • ./scripts/regenerate-test-clients.ps1 -Check: checked-in test clients match the generator.
  • New test suites cover SSE framing, idle timeout, WS protocol/close codes/reconnect (in-process graphql-ws and SSE test servers), buffer overflow policies, envelope parsing, and regressions from review.
  • Some subscription tests depend on timing. Fixed delays were replaced with waits on the actual condition, and the idle-timeout tests were checked to still fail when the timer re-arm is removed. If one flakes on CI, please tell me.

Notes

  • CHANGELOG.md is new in this repo (Keep a Changelog format). Happy to move the notes elsewhere if you prefer.
  • Versioning is untouched (Nerdbank.GitVersioning).

Subscriptions:
- SSE and WebSocket transports with per-subscriber buffers and overflow policy
  (Fault, DropOldest, KeepLatest); each Subscribe() gets its own connection.
- Server 'complete' ends the typed observable; overflow in one subscriber no longer
  starves others.
- StartWithErrorsAsync(): a next event with GraphQL errors no longer terminates the
  subscription; data and errors are delivered together. StartAsync() unchanged.
- Bounded WebSocket reconnect (SubscriptionRetryAttempts, default 5) with exponential
  backoff and jitter; every attempt opens a fresh socket; budget reset on connection_ack;
  fails once with inner cause. A 30 s no-message watchdog detects silent connections.
- WebSocket fatal close codes follow the graphql-ws reference client; fatal closes
  cancel library reconnection.
- Unknown WS message types are ignored; SSE idle timeout (SubscriptionIdleTimeout).

Client:
- Responses parsed from UTF-8 bytes (GraphEnvelopeReader): no JsonDocument, no UTF-16
  round trip, only the requested root field is materialized.
- One shared frozen JsonSerializerOptions per process.
- __AdditionalProperties is null until first used.
- Empty HTTP 200 body yields Data = default instead of throwing.

Behaviour changes are listed in CHANGELOG.md under [Unreleased].

Verification: 0 build warnings, 289/289 tests, generated test clients match the generator.
Restore the escaped errors/next code spans, split two fused bullets, and add the missing trailing newline to GraphClientOptions.cs.
GraphEnvelopeReader is now the single GraphQL envelope parser:
- ProcessResponseFull(string) transcodes to UTF-8 and uses the reader
  (empty string now returns Data = default instead of throwing).
- ProcessResponseFull(JsonElement) hands its raw UTF-8 text to the reader.
- GraphClient.ExecuteAsync (ExecuteDocumentAsync) uses new ReadDocument,
  which also decides whether a non-2xx body is a GraphQL result.

Tests pin that all overloads agree, the empty-string behaviour, and the
text-query transport rules. CHANGELOG documents the behaviour change;
CLAUDE.md names GraphEnvelopeReader as the single parser.
… load

- Buffer overflow tests wait until event 1 is delivered (processor stalled)
  instead of a fixed 50 ms delay; stall raised to 1 s.
- Idle-timeout tests use a wider per-gap margin (10-15x) while the total
  duration still exceeds the timeout (re-arm still required; mutation-checked).
- Dispose test waits for the event instead of a fixed delay.
@ricred

ricred commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@joadan could you take this one? I can't assign or request reviewers from a fork (no triage rights), so a mention will have to do. Happy to split it into smaller PRs if that makes review easier.

@joadan
joadan merged commit 5ba4b65 into Linq2GraphQL:main Oct 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants