Skip to content

Commit c0ba6d4

Browse files
xperiandriCopilotCopilotclaude
authored
Adopt pending/incremental/completed delivery format (#609)
Adopted the pending/incremental/completed delivery format of incremental delivery v0.2 Incremental delivery over graphql-transport-ws now uses the pending/incremental/completed/hasNext wire format of the incremental delivery specification v0.2, the one graphql-js 17 and Apollo Client's GraphQL17Alpha9Handler consume. Every deferred or streamed field is announced once in a `pending` entry and identified afterwards by a short id: a deferred field is announced at the object containing it and delivered as an object map of that field, a streamed field is announced at its own path and its items are delivered in list order, batched when the engine produces them together. A field whose announcement never reached the client is not completed, `errors` is omitted when there are none, and the final payload reports `hasNext: false`. Directives and validation: - `@defer` and `@stream` declare `if: Boolean = true` and `label: String`, `@stream` also `initialCount: Int = 0`. `@defer` is allowed on fields, fragment spreads and inline fragments, `@stream` on fields only. `if: false` executes the field inline (a literal is decided while planning, a variable at execution), `initialCount` delivers the first items with the initial payload and streams the rest through the same enumerator, and a stream's label travels in its announcement. - New validation rules: `@stream` only on list fields; no `@defer`/`@stream` in a subscription operation or on a mutation root field unless disabled with `if: false`; labels are string literals, unique per operation over the fragments it spreads. Engine: - The announcements of nested deferred and streamed fields are carried as data by a private `AnnouncedEvents` wrapper, so a containing payload replays them by concatenation instead of capturing them under a lock at subscription time; `ResolverResult` keeps its signature. - `ofAsyncEnumerableResolved` delivers results through a single-reader channel with one emitter, which releases each concurrency slot after emitting; the `SemaphoreSlim` bounding `maxConcurrency` is the only synchronization primitive left. - `DeferredPending` carries `InitialCount`. Middleware, rewritten around single-owner loops and `System.Threading.Channels`: - Each connection runs a reader loop (sole reader of the socket), a control loop (sole owner of the subscription registry) and a sender loop (sole writer of the socket, including its close), plus one worker per subscription whose observer only queues events. No lock, no `SemaphoreSlim` gate, no `.Wait()` and no start-up barrier remain; the connection-init timeout is a `Task.WhenAny`, and shutdown closes the socket gracefully. - The former closure nest is split into WebSocketErrors, WebSocketMessaging, WebSocketTransport, SubscriptionPayloads, SubscriptionWorker and WebSocketConnection. `GraphQLTransportWS.SubProtocol` names the sub-protocol once. Breaking changes: - `SubscriptionExecutionResult.Data` and `IncrementalResult.Data` are `Skippable<obj voption>`, `SubscriptionExecutionResult` gains `Pending`, `Incremental`, `Completed` and `HasNext` and loses `Path`, `IncrementalResult` gains `SubPath`. - `GQLDeferredResponseContent.DeferredPending` gains `InitialCount`. - `@defer`/`@stream` gain the arguments above and drop the `FRAGMENT_DEFINITION` location. - `SubscriptionsDict`, `SubscriptionUnsubscriber` and `OnUnsubscribeAction` are removed from `FSharp.Data.GraphQL.Shared.WebSockets`, together with `GraphQLSubscriptionsManagement` and the internal `Observable.withCompletionMarker`. Tests measure the engine, the translator and the serialization against the specification (end-to-end payload sequences with protocol invariants, defer inside stream, error bubbling inside a deferred payload, null parents, root and mutation defers, `initialCount`, labels, validation rules) and add real-socket tests of the middleware hosted through `WebApplicationFactory`; the end-to-end tests tolerate a loaded CI runner. Release notes, the type-system docs and the project instructions (XML comment rules, primary constructor documentation) are updated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 54cc2be commit c0ba6d4

44 files changed

Lines changed: 5123 additions & 1386 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎RELEASE_NOTES.md‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@
288288

289289
* **Breaking Change** Migrated to .NET 10
290290
* **Breaking Change** Made Relay `Edge` a read-only struct
291-
* **Breaking Change** `SubscriptionExecutionResult.Data` is now `obj voption Skippable`, and the record has new `Path` and `HasNext` fields for incremental delivery
291+
* **Breaking Change** `SubscriptionExecutionResult.Data` is now `Skippable<obj voption>` (absent, `null`, or an object), `SubscriptionExecutionResult.Errors` is now `GQLProblemDetails list Skippable`, `SubscriptionExecutionResult.Path` was removed, and the record has new `Pending`, `Incremental`, `Completed` and `HasNext` fields for incremental delivery; `IncrementalResult.Data` follows the same `Skippable<obj voption>` contract and `IncrementalResult` gained `SubPath`
292+
* **Breaking Change** Removed `SubscriptionsDict`, `SubscriptionUnsubscriber` and `OnUnsubscribeAction` from `FSharp.Data.GraphQL.Shared.WebSockets`: the `graphql-transport-ws` middleware keeps its subscriptions in a per-connection registry owned by a single loop
293+
* **Breaking Change** `@defer` and `@stream` now declare the arguments the incremental delivery specification requires: `if: Boolean = true` and `label: String` on both, `initialCount: Int = 0` on `@stream`. `@stream` is allowed on `FIELD` only and `@defer` on `FIELD`, `FRAGMENT_SPREAD` and `INLINE_FRAGMENT`, no longer on `FRAGMENT_DEFINITION`. `if: false`, literal or through a variable, executes the field inline; `initialCount` delivers the first items with the initial payload and streams the rest; `label` is carried by the `pending` entry announcing the field
294+
* **Breaking Change** `GQLDeferredResponseContent.DeferredPending` gained `InitialCount`, the number of items of a streamed field delivered with the initial payload, so that the `graphql-transport-ws` translator expects the streamed items from that index
292295
* **Breaking Change** `BufferedStreamOptions.Interval` and `BufferedStreamOptions.PreferredBatchSize` are now `int voption`
293296
* **Breaking Change** `ServerMessage.Error` and `ServerRawPayload.ErrorMessages` now carry `GQLProblemDetails list` instead of `NameValueLookup list`, so an `error` message's `payload` is a standard GraphQL error array as the `graphql-transport-ws` protocol requires
294297
* **Breaking Change** A query or mutation whose non-null root field fails during execution now produces a `Direct` (execution) result with `null` data instead of a `RequestError`, which is now only ever produced for a request rejected before execution (validation, planning, variable or inline argument coercion, a middleware, or the executor itself failing); HTTP and `graphql-transport-ws` responses for such a failure now carry `data: null` as the spec requires, instead of omitting `data` entirely. This also changes the public `GQLResponse.Data`, `GQLResponseContent.Direct.Data`, `DeferredErrors.Data`, and `SubscriptionErrors.Data` signatures to use `voption`
@@ -301,16 +304,22 @@
301304
* Added `StreamBatching` to group streamed items of a `Define.TaskSeqField` into batches of a fixed size or of a size computed from the sequence, such as a page size kept with a paged SDK sequence. The `preferredBatchSize` argument of `@stream` takes precedence, and the batching function itself is evaluated lazily, only for a `@stream` query that does not supply its own `preferredBatchSize`
302305
* Added `Microsoft.Bcl.AsyncInterfaces` dependency of `FSharp.Data.GraphQL.Shared` for `netstandard2.0`
303306
* Added `Human.friendsStream` field to the Star Wars sample to demonstrate `@stream`
304-
* Fixed a query or mutation whose root field has an invalid inline (literal) argument, such as a custom input object validator failing, being reported as a `Direct` result with `null` data instead of a `RequestError`; inline argument coercion is now checked for every root field before any of them execute, the same as variable coercion, so a mutation no longer executes earlier root fields before rejecting the request over a later one's invalid argument
305-
* Fixed `Define.TaskSeqField` streaming retaining a task for every item already delivered until the sequence ends
306-
* Fixed `graphql-transport-ws` delivery of `@defer` and `@stream` results, which are now sent as soon as they are produced with `path` and `hasNext` instead of after a fixed 5 second delay, followed by a final payload with `hasNext: false`
307+
* Added the validation rules of incremental delivery: `@stream` only on list fields, no `@defer` or `@stream` in a subscription operation or on a mutation root field unless disabled with `if: false`, and labels must be string literals unique within each operation, counting the fragments it spreads
308+
* Added `GraphQLTransportWS.SubProtocol`, the `graphql-transport-ws` sub-protocol name
309+
* Fixed `graphql-transport-ws` delivery of `@defer` and `@stream` results, which are now sent as soon as they are produced instead of after a fixed 5 second delay, followed by a final payload with `hasNext: false`
310+
* Changed `graphql-transport-ws` incremental delivery of `@defer` and `@stream` results to the `pending`/`incremental`/`completed`/`hasNext` wire format used by graphql-js 17 and Apollo Client's `GraphQL17Alpha9Handler`, superseding the previous `data`/`path`/`hasNext` shape. Every deferred or streamed field is announced once, in a `pending` entry, and identified afterwards by a short id instead of its path. A deferred field is announced in the same payload as its own value, while a streamed field is announced as soon as the payload exposing its containing data is sent. A `@stream` field's items are always delivered to the client in list order, buffering an item that arrives out of turn until the item before it fills the gap, and a batch of items (grouped by `preferredBatchSize` or `StreamBatching`) is delivered as the `items` of a single `incremental` entry addressed by that id, rather than one payload per item
311+
* Added a completion signal to the engine's deferred/streamed event stream (`DeferredCompleted`), fired once after a `@defer` field's own payload and once after all of a `@stream` field's items, whether they succeeded or the source failed; used to build the `completed` entries of the new wire format
312+
* Changed `graphql-transport-ws` delivery of a deferred field to the addressing the specification and graphql-js/Apollo clients expect: the `pending` entry names the object containing the field and the `incremental` entry carries an object map of that one field; a field whose announcement the client never received, because the payload that should have exposed it resolved to `null` there, is no longer completed
313+
* Changed the `graphql-transport-ws` middleware to single-owner loops communicating through `System.Threading.Channels`: one reader of the socket, one control loop owning the subscription registry, one sender owning every write and the close of the socket, and one worker per subscription. No lock, no thread blocked on a send, a graceful close on application shutdown; a slow client's messages now queue in memory for the life of the connection instead of blocking the subscription's source
314+
* Changed the execution engine to work without locks: the announcements of nested deferred and streamed fields are carried as data instead of being captured under a lock at subscription time, and a streamed `Define.TaskSeqField` delivers its results through a single-reader channel, with `SemaphoreSlim` only bounding `maxConcurrency`
315+
* Changed `graphql-transport-ws` result payloads to omit `errors` when there are none, as the GraphQL response format requires, instead of sending an empty array
307316
* Fixed `graphql-transport-ws` failure on deferred and streamed results that are not objects, such as streamed list items and scalars
308317
* Fixed `graphql-transport-ws` dropping errors of the initial payload of a deferred result together with all its deferred results
309318
* Fixed `graphql-transport-ws` discarding the partial `data` of a subscription result that also had field errors, sending `null` instead
310319
* Fixed `graphql-transport-ws` discarding the field errors of a `Direct` (non-subscription) result, sending an empty error list instead
311320
* Fixed `graphql-transport-ws` stranding a subscription id forever when its deferred result completed synchronously, before it was registered
312321
* Fixed `graphql-transport-ws` leaving a subscription id occupied when subscribing to its result failed synchronously
313-
* Fixed `graphql-transport-ws` addressing a batch of streamed items (grouped by `preferredBatchSize` or `StreamBatching`) with a `path` ending in the list of the batch's own indices, such as `["numbers", [0, 1]]`, which no client can merge into the response tree; a batch is now sent as one independently addressed payload per item instead, in the batch's own order
314322
* Fixed `graphql-transport-ws` never sending `complete` after the `next` of a query or mutation result, as the protocol requires
315323
* Fixed `graphql-transport-ws` sending a request error (rejected before execution: validation, planning, variable coercion, a middleware, or the executor itself failing) as a `next` result followed by `complete`, instead of the terminal `error` message the protocol requires for it; a query or mutation whose non-null root field fails during execution still gets `next` + `complete`, since it is a result, not a request error
316324
* Fixed `graphql-transport-ws` throwing while serializing an `error` message or a `pong` carrying a payload, since neither was written under the `payload` property name `Utf8JsonWriter` requires
325+
* Removed the internal `Observable.withCompletionMarker`

‎docs/type-system.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ How the sequence is delivered depends on the query:
9898
- With `@defer` on a `Nullable (ListOf ...)` field the complete list is delivered in one deferred payload.
9999
- With `@stream` every item is delivered as soon as the sequence produces it and its fields are resolved. The enumeration is cancelled when the client unsubscribes.
100100

101+
Over `graphql-transport-ws`, a `@defer` or `@stream` field is delivered using the `pending`/`incremental`/`completed`/`hasNext` format used by graphql-js 17 and Apollo Client's `GraphQL17Alpha9Handler`. Each field is announced once, in a `pending` entry, and identified afterwards by a short id rather than its path. A deferred field is announced at the path of the object containing it, in the same payload as its own value, and that value is delivered as an object map of the one field, which the client merges into the announced object; a streamed field is announced at its own path as soon as the payload exposing its containing data is sent, and its items are delivered as the `items` of `incremental` entries, always in list order, a batch of items in one entry. The `label` of `@defer` or `@stream` surfaces as `pending.label`. Both directives take `if: Boolean = true`, which executes the field inline when false, and `@stream` takes `initialCount: Int = 0`, the number of items delivered with the initial payload before the rest is streamed. A payload that carries only GraphQL errors omits the top-level `data` property instead of sending `data: null`, matching the existing request-error contract used elsewhere in the transport.
102+
101103
Streamed items can be grouped into batches. The `preferredBatchSize` argument of `@stream`, available with `SchemaConfig.DefaultWithBufferedStream`, has priority. Otherwise the `batching` parameter of the field applies. It is either a fixed size or a function that reads the size from the source, such as the page size of a paged SDK sequence. The function is evaluated lazily: only for a `@stream` query that does not itself specify `preferredBatchSize`, so it never runs for an ordinary or `@defer` query.
102104

103105
```fsharp

‎src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
<Compile Include="Helpers.fs" />
2020
<Compile Include="RequestExecutionContext.fs" />
2121
<Compile Include="GraphQLOptions.fs" />
22-
<Compile Include="GraphQLSubscriptionsManagement.fs" />
22+
<Compile Include="IncrementalDelivery.fs" />
23+
<Compile Include="WebSocketErrors.fs" />
24+
<Compile Include="WebSocketMessaging.fs" />
25+
<Compile Include="WebSocketTransport.fs" />
26+
<Compile Include="SubscriptionPayloads.fs" />
27+
<Compile Include="SubscriptionWorker.fs" />
28+
<Compile Include="WebSocketConnection.fs" />
2329
<Compile Include="GraphQLWebsocketMiddleware.fs" />
2430
<Compile Include="Parser.fs" />
2531
<Compile Include="HttpContext.fs" />

‎src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLRequestHandler.fs‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,24 @@ and [<AbstractClass>] GraphQLRequestHandler<'Root>
6464
if logger.IsEnabled LogLevel.Debug then
6565
deferred
6666
|> Observable.add (function
67+
| DeferredPending (path, label, isStream, _) ->
68+
let fieldKind = if isStream then "streamed" else "deferred"
69+
logger.LogDebug ("Announced GraphQL deferred field at path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join)
70+
match label with
71+
| ValueSome label -> logger.LogDebug ("Deferred field label: {label}; kind: {kind}", label, fieldKind)
72+
| ValueNone -> logger.LogDebug ("Deferred field kind: {kind}", fieldKind)
6773
| DeferredResult (data, path) ->
6874
logger.LogDebug ("Produced GraphQL deferred result for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join)
6975

7076
if logger.IsEnabled LogLevel.Trace then
7177
logger.LogTrace ("GraphQL deferred data:\n{data}", serializeIndented data)
72-
| DeferredErrors (ValueNone, errors, path) ->
78+
| DeferredErrors (data, errors, path) ->
7379
logger.LogDebug ("Produced GraphQL deferred errors for path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join)
7480

7581
if logger.IsEnabled LogLevel.Trace then
76-
logger.LogTrace ("GraphQL deferred errors:\n{errors}", errors)
77-
| DeferredErrors (ValueSome data, errors, path) ->
78-
logger.LogDebug (
79-
"Produced GraphQL deferred result with errors for path: {path}",
80-
path |> Seq.map string |> Seq.toArray |> Path.Join
81-
)
82-
83-
if logger.IsEnabled LogLevel.Trace then
84-
logger.LogTrace ("GraphQL deferred errors:\n{errors}\nGraphQL deferred data:\n{data}", errors, serializeIndented data))
82+
logger.LogTrace ("GraphQL deferred errors:\n{errors}\nGraphQL deferred data:\n{data}", errors, serializeIndented data)
83+
| DeferredCompleted path ->
84+
logger.LogDebug ("Completed GraphQL deferred field at path: {path}", path |> Seq.map string |> Seq.toArray |> Path.Join))
8585

8686
GQLResponse.Direct (documentId, data, errs)
8787

‎src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLSubscriptionsManagement.fs‎

Lines changed: 0 additions & 57 deletions
This file was deleted.

0 commit comments

Comments
 (0)