Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
12002df
Added `Define.TaskSeqField` with streaming of `IAsyncEnumerable` results
xperiandri Sep 14, 2026
1be63d3
Changed `BufferedStreamOptions` and stream helpers to use `voption`
xperiandri Sep 14, 2026
8d3ac42
Rewrote subscription disposal tests as asynchronous `task` tests
xperiandri Sep 15, 2026
18479a9
Addressed Copilot review: ordered stream failures, bounded concurrenc…
xperiandri Sep 15, 2026
940686b
Addressed second Copilot review: enumerator lifecycle, WS partial dat…
xperiandri Sep 15, 2026
5628275
Moved the shared TaskSeq test sources into Helpers
xperiandri Sep 15, 2026
5520935
Addressed third Copilot review: in-flight tracking, slot release on f…
xperiandri Sep 15, 2026
5970b3b
Fixed CI: the observer-throws regression test deadlocked on System.Re…
xperiandri Sep 15, 2026
9c5c73d
Rechecked stop conditions after acquiring a concurrency slot
xperiandri Sep 15, 2026
43f363f
Addressed sixth Copilot review: recheck after MoveNextAsync, clarifie…
xperiandri Sep 16, 2026
3b5d8ca
Addressed seventh Copilot review: addressable batch payloads, complet…
xperiandri Sep 16, 2026
156e149
Addressed eighth Copilot review: WS error message, internal helper, c…
xperiandri Sep 16, 2026
7aaa5d1
Addressed ninth Copilot review: a failed non-null root field is an ex…
xperiandri Sep 16, 2026
aa97285
Fix ninth-round regression: inline argument coercion must stay a Requ…
xperiandri Sep 16, 2026
be2b892
Add regression coverage for a mixed success/error stream batch
xperiandri Sep 16, 2026
46809e6
Streamline streaming execution: struct tuples, backgroundTask, rename…
xperiandri Sep 17, 2026
25fbbbc
PR review fix
xperiandri Sep 18, 2026
9a926ff
Address PR review feedback
Copilot Sep 18, 2026
2693a4c
Cancel MoveNextAsync on resolution failure
Copilot Sep 18, 2026
cb67b78
Prefer resolution failure over cancellation
Copilot Sep 18, 2026
34023fb
Use `CanceledIndependently` active pattern
xperiandri Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
<PackageReference Update="FsToolkit.ErrorHandling" Version="$(FsToolkitVersion)" />
<PackageReference Update="FsToolkit.ErrorHandling.TaskResult" Version="$(FsToolkitVersion)" />
<PackageReference Update="Giraffe" Version="7.*" />
<PackageReference Update="IcedTasks" Version="0.11.*" />
<PackageReference Update="Microsoft.Bcl.AsyncInterfaces" Version="$(SystemVersion)" />
<PackageReference Update="Microsoft.Extensions.Http" Version="$(MicrosoftExtensionsVersion)" />
<PackageReference Update="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsVersion)" />
<PackageReference Update="Microsoft.NETCore.Platforms" Version="$(SystemVersion)" />
Expand Down Expand Up @@ -67,9 +69,11 @@
<PackageReference Update="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup Label="Tests and Samples">
<PackageReference Update="Azure.Core" Version="1.*" />
<PackageReference Update="CommandLineParser" Version="2.9.*" />
<PackageReference Update="Donald" Version="10.1.0" />
<PackageReference Update="EntityFramework" Version="1.*" />
<PackageReference Update="FSharp.Control.TaskSeq" Version="1.*" />
<PackageReference Update="FSharp.Data.TypeProviders" Version="1.*" />
<PackageReference Update="GraphQL.Server.Ui.Altair" Version="8.*" />
<PackageReference Update="GraphQL.Server.Ui.GraphiQL" Version="8.*" />
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ This boilerplate code can be easily reduced with a built-in implementation:

```fsharp
let streamOptions =
{ Interval = Some 2000; PreferredBatchSize = None }
{ Interval = ValueSome 2000; PreferredBatchSize = ValueNone }
let schemaConfig =
SchemaConfig.DefaultWithBufferedStream(streamOptions)
```
Expand Down
23 changes: 23 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,29 @@

* **Breaking Change** Migrated to .NET 10
* **Breaking Change** Made Relay `Edge` a read-only struct
* **Breaking Change** `SubscriptionExecutionResult.Data` is now `obj Skippable`, and the record has new `Path` and `HasNext` fields for incremental delivery
* **Breaking Change** `BufferedStreamOptions.Interval` and `BufferedStreamOptions.PreferredBatchSize` are now `int voption`
* **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
* **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
* Added case-insensitive string comparison support to `ObjectListFilter`, including comparer-aware filter cases and GraphQL filter suffix handling
* Improved Relay XML documentation comments
* Changed query planning to throw `MalformedGQLQueryException` for invalid queries, `NotSupportedException` for unsupported type definition implementations and `InvalidOperationException` for internal planning errors instead of `System.Exception`, with messages naming the affected field, type and execution kind
* Added `Define.TaskSeqField` for list fields resolved from `IAsyncEnumerable<'T>`, such as `taskSeq { }` or Azure SDK `AsyncPageable<T>`. Without directives the sequence is enumerated into a list, `@defer` delivers the whole list, and `@stream` delivers every item as soon as it is produced and its fields are resolved
* Added cancellation of a streamed `Define.TaskSeqField` enumeration when the client unsubscribes, and delivery of a failure raised acquiring the sequence's enumerator, while enumerating, or disposing it, as a deferred error for the field, after every item already pulled has been resolved and delivered, so a slower item can never be overtaken by the error that follows it; an item resolution that throws stops the enumeration and is delivered the same way, while an item whose own fields fail is delivered as that item's deferred errors and streaming continues, exactly as for `@stream` on an ordinary list; a concurrency slot is never leaked even if delivering an item's result fails
* Added `maxConcurrency` to `Define.TaskSeqField`, bounding how many items of a streamed sequence are pulled and resolved at the same time; defaults to `Environment.ProcessorCount`
* 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`
* Added `Microsoft.Bcl.AsyncInterfaces` dependency of `FSharp.Data.GraphQL.Shared` for `netstandard2.0`
* Added `Human.friendsStream` field to the Star Wars sample to demonstrate `@stream`
* 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`
* Fixed `graphql-transport-ws` failure on deferred and streamed results that are not objects, such as streamed list items and scalars
* Fixed `graphql-transport-ws` dropping errors of the initial payload of a deferred result together with all its deferred results
* Fixed `graphql-transport-ws` discarding the partial `data` of a subscription result that also had field errors, sending `null` instead
* Fixed `graphql-transport-ws` discarding the field errors of a `Direct` (non-subscription) result, sending an empty error list instead
* Fixed `graphql-transport-ws` stranding a subscription id forever when its deferred result completed synchronously, before it was registered
* Fixed `Define.TaskSeqField` streaming retaining a task for every item already delivered until the sequence ends
* Fixed `graphql-transport-ws` leaving a subscription id occupied when subscribing to its result failed synchronously
* 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
* Fixed `graphql-transport-ws` never sending `complete` after the `next` of a query or mutation result, as the protocol requires
* 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
* 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
* 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
46 changes: 46 additions & 0 deletions docs/type-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,52 @@ let rec Person = Define.Object(name = "Person", fieldsFn = fun () -> [

As you may see, we defined Person object definition using *rec* keyword and instead of defining fields as a list and we used a lazily evaluated function instead.

### Defining fields backed by asynchronous sequences

When a list comes from an asynchronous source, such as a database cursor or a paged SDK client, use `Define.TaskSeqField`. Its resolver returns `IAsyncEnumerable<'T>`, which is what the `taskSeq { }` computation expression from [FSharp.Control.TaskSeq](https://github.com/fsprojects/FSharp.Control.TaskSeq) and C# async iterators produce.

```fsharp
let getOrders (customerId : int) = taskSeq {
for page in 0 .. 10 do
let! orders = db.GetOrdersPageAsync (customerId, page)
yield! orders
}

Define.TaskSeqField("orders", ListOf Order, fun _ customer -> getOrders customer.Id)
```

How the sequence is delivered depends on the query:

- Without directives the sequence is enumerated completely and returned as a regular list.
- With `@defer` on a `Nullable (ListOf ...)` field the complete list is delivered in one deferred payload.
- 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.

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.

```fsharp
Define.TaskSeqField("orders", ListOf Order, (fun _ customer -> getOrders customer.Id), batching = StreamBatching.Fixed 50)

Define.TaskSeqField(
"blobs",
ListOf Blob,
(fun _ container -> listBlobs container),
batching = StreamBatching.FromSource (function
| :? PagedSequence<BlobItem> as paged -> ValueSome paged.PageSize
| _ -> ValueNone))
```

Azure SDK `AsyncPageable<T>` does not expose its page size, because the size is only a hint passed to `AsPages`. To batch its items by pages, keep the hint in your own type, for example a subclass of `AsyncPageable<T>` or a wrapper, and read it in `StreamBatching.FromSource`.

With `@stream`, at most `maxConcurrency` items are pulled from the sequence and resolved at the same time; enumeration waits for one of them to complete before pulling the next, so a fast or infinite source cannot outrun resolution. It defaults to `Environment.ProcessorCount`.

```fsharp
Define.TaskSeqField("orders", ListOf Order, (fun _ customer -> getOrders customer.Id), maxConcurrency = 4)
```

An error raised while enumerating the source is delivered after every item already pulled has been resolved and delivered, so a slow item can never be overtaken by a failure that follows it. An item whose own fields fail is delivered as that item's deferred errors, and the following items are still streamed, exactly as for `@stream` on an ordinary list; only an exception that escapes the item's resolution, or the source itself, ends the stream.

Resolvers are captured as F# quotations. A `taskSeq { }` block that uses `let!` or `yield!` cannot be written inline in the resolver lambda, so define it in a separate function as shown above. Fields defined this way do not support `WithResolveMiddleware`.

## Defining an Interface

GraphQL interfaces are so called abstract types (along with unions). This means, that they can be used as part of the query, however query materialization must always be bound to some concrete Object type definition.
Expand Down
19 changes: 19 additions & 0 deletions samples/star-wars-api/Schema.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ namespace FSharp.Data.GraphQL.Samples.StarWarsApi

open System.Linq
open System.Text.Json.Serialization
open System.Threading.Tasks
open Microsoft.FSharp.Reflection
open FSharp.Control
open FSharp.Data.GraphQL
open FSharp.Data.GraphQL.Types
open FSharp.Data.GraphQL.Server.Relay
Expand Down Expand Up @@ -141,6 +143,17 @@ module Schema =

let getCharacter id = characters |> List.tryFind (matchesId id)

/// Produces friends one by one with a delay, which demonstrates the @stream directive.
/// TaskSeq functions are used instead of a taskSeq block, because a taskSeq block compiled
/// without optimizations does not resume correctly after an await.
let getFriendsStream (friendIds : string list) =
friendIds
|> TaskSeq.ofList
|> TaskSeq.chooseAsync (fun id -> task {
do! Task.Delay 500
return getCharacter id
})

let EpisodeType =
Define.Enum (
name = "Episode",
Expand Down Expand Up @@ -226,6 +239,12 @@ module Schema =
con
)
Define.Field ("appearsIn", ListOf EpisodeType, "Which movies they appear in.", (fun _ (h : Human) -> h.AppearsIn))
Define.TaskSeqField (
"friendsStream",
ListOf CharacterType,
"The friends of the human produced one by one. Request the field with @stream to receive each friend as soon as it is available.",
fun _ (h : Human) -> getFriendsStream h.Friends
)
Define.Field ("homePlanet", Nullable StringType, "The home planet of the human, or null if unknown.", (fun _ h -> h.HomePlanet))
]
)
Expand Down
1 change: 1 addition & 0 deletions samples/star-wars-api/star-wars-api.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup Label="PackageReferences">
<PackageReference Include="FSharp.Control.TaskSeq" />
<PackageReference Include="FsToolkit.ErrorHandling.TaskResult" />
<PackageReference Include="GraphQL.Server.Ui.Altair" />
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup Label="InternalsVisibleTo">
<InternalsVisibleTo Include="FSharp.Data.GraphQL.Tests" />
</ItemGroup>

<ItemGroup>
<Compile Include="Helpers.fs" />
<Compile Include="RequestExecutionContext.fs" />
Expand Down
Loading
Loading