Skip to content

Added Define.TaskSeqField with streaming of IAsyncEnumerable results - #598

Open
xperiandri wants to merge 3 commits into
devfrom
task-seq-field
Open

xperiandri wants to merge 3 commits into
devfrom
task-seq-field

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds native support for list fields resolved from IAsyncEnumerable<'T>, such as taskSeq { }, C# async iterators or Azure SDK AsyncPageable<T>, and makes @defer/@stream results usable over graphql-transport-ws.

Define.TaskSeqField

Define.TaskSeqField ("orders", ListOf OrderType, (fun _ customer -> getOrders customer.Id), batching = StreamBatching.Fixed 50)
Query Behavior
no directive The sequence is enumerated into a regular list
@defer The whole list is delivered in one deferred payload (requires the Nullable (ListOf …) overloads, which are included)
@stream Every item is delivered as soon as the sequence produces it and its fields are resolved
  • The sequence is pulled lazily, and the enumeration is cancelled when the subscriber disposes.
  • An exception thrown mid-enumeration:
    • without directives behaves like a lazy seq today: nullable field → null + field error, non-nullable → error propagates;
    • with @stream it is delivered as DeferredErrors for the field after the items already produced, so buffered items and sibling deferred streams are not lost.
  • Existing @stream on ordinary lists keeps its behavior.

Batching of streamed items

StreamBatching<'Item> is set on the field:

  • StreamBatching.Fixed size
  • StreamBatching.FromSource (IAsyncEnumerable<'Item> -> int voption) computes the size from the resolved sequence instance.

The preferredBatchSize argument of @stream takes precedence over the field-level policy.

Note: Azure AsyncPageable<T> does not expose a page size (it is only a hint passed to AsPages), so FromSource can batch by pages only when the application keeps the hint, for example in a subclass of AsyncPageable<T>. Tests cover both a plain AsyncPageable<T> and one that keeps the hint.

WebSocket (graphql-transport-ws) delivery

  • Deferred and streamed payloads are sent immediately instead of after a hard-coded 5 second delay.
  • Incremental payloads carry path and hasNext; the initial payload has hasNext: true, and a final {"hasNext": false} payload precedes complete.
  • Payloads are no longer cast to Dictionary<string, obj>, which failed for streamed items and scalars.
  • Errors of the initial deferred payload are no longer dropped together with all deferred results.

Example with the Star Wars sample (Human.friendsStream was added to demonstrate @stream):

{"type":"next","id":"1","payload":{"data":{"hero":{"name":"Luke Skywalker","friendsStream":[]}},"errors":[],"hasNext":true}}
{"type":"next","id":"1","payload":{"data":[{"name":"Han Solo"}],"errors":[],"path":["hero","friendsStream",0],"hasNext":true}}
…
{"type":"next","id":"1","payload":{"data":[{"name":"R2-D2"}],"errors":[],"path":["hero","friendsStream",3],"hasNext":true}}
{"type":"next","id":"1","payload":{"errors":[],"hasNext":false}}
{"type":"complete","id":"1"}

Breaking changes

  • SubscriptionExecutionResult.Data is now obj Skippable, and the record has new Path and HasNext fields. Use the Create, CreateErrors, CreateInitial, CreateIncremental and CreateCompleted factory members.
  • BufferedStreamOptions.Interval and BufferedStreamOptions.PreferredBatchSize are now int voption. Construct the options with ValueSome/ValueNone, for example SchemaConfig.DefaultWithBufferedStream { Interval = ValueSome 2000; PreferredBatchSize = ValueNone }.

Dependencies

  • FSharp.Data.GraphQL.Shared references Microsoft.Bcl.AsyncInterfaces for netstandard2.0 only.
  • Tests reference FSharp.Control.TaskSeq and Azure.Core explicitly.

SDK

dev now pins SDK 10.0.303 instead of 10.0.401. The F# compiler in SDK 10.0.4xx (verified on 10.0.400 and 10.0.401) compiles resumable state machines incorrectly in Debug, so a taskSeq { } that awaits returns no further items: dotnet/fsharp#20466, fixed in main by dotnet/fsharp#20469 but not shipped yet (also fsprojects/FSharp.Control.TaskSeq#473). 10.0.303 is the latest SDK without the regression. The --always-inline+ workaround and Optimize=true alone did not help on 10.0.401.

The code in this PR still builds with SDK 10.0.4xx. Passing a voption to a struct optional parameter through ?name = value is supported only by the 10.0.4xx compiler, so that call is written as a match.

Known limitations

  • Resolvers are captured as quotations, so a taskSeq { } block using let! or yield! must live in a separate function called from the resolver. This is documented.
  • WithResolveMiddleware is not supported for TaskSeqField and throws NotSupportedException.
  • Consumers building with SDK 10.0.4xx in Debug hit the compiler regression above in their own taskSeq { } blocks. Tests that need a real await use a hand-written suspending IAsyncEnumerable, so they pass on any SDK.
  • The HTTP handler still returns only the initial payload of a deferred result; incremental delivery over HTTP is out of scope.
  • Integration test introspection snapshots were not regenerated for the new sample field.

Testing

  • New TaskSeqFieldTests: draining, @defer, @stream ordering and early delivery, query/fixed/source batching and precedence, enumeration errors (nullable, non-nullable, streamed), cancellation, null sequence, Azure AsyncPageable<T>.
  • ObservableExtensionsTests for ofAsyncEnumerable and withCompletionMarker; SerializationTests for the new WebSocket payload shape.
  • On SDK 10.0.303: unit tests 623 passed, 0 failed, 5 skipped (already skipped on dev); Release builds of all library projects succeed with warnings as errors; the Star Wars sample builds.
  • FSharp.Data.GraphQL.Shared also builds in Release on SDK 10.0.401.
  • Manually verified WebSocket streaming against the Star Wars sample.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Test Results

    3 files      3 suites   11m 50s ⏱️
  628 tests   623 ✅  5 💤 0 ❌
1 884 runs  1 869 ✅ 15 💤 0 ❌

Results for commit b0d54ed.

♻️ This comment has been updated with latest results.

A field resolved from `IAsyncEnumerable<'T>` is enumerated into a list without directives, delivered as a
whole with `@defer`, and streamed item by item with `@stream`. Streaming pulls the sequence lazily, cancels
the enumeration when the subscriber disposes, and reports an enumeration error as a deferred error after
the items already produced, so buffered items and sibling deferred streams are not lost.

`StreamBatching` groups streamed items into batches of a fixed size or of a size computed from the
sequence. The `preferredBatchSize` argument of `@stream` takes precedence. Azure `AsyncPageable<T>` does not
expose its page size, so tests cover both a plain pageable and one that keeps the page size hint.

The `graphql-transport-ws` middleware now sends deferred and streamed payloads immediately with `path` and
`hasNext`, followed by a final `hasNext: false` payload, instead of after a fixed 5 second delay. It no
longer casts payloads to a dictionary and no longer drops initial payload errors.
`SubscriptionExecutionResult.Data` became `obj Skippable` and the record got `Path` and `HasNext`.

`FSharp.Data.GraphQL.Shared` references `Microsoft.Bcl.AsyncInterfaces` for `netstandard2.0`.
The Star Wars sample got a `Human.friendsStream` field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Stream failures can overtake prior items, batching callbacks run for non-streamed queries, and item resolution concurrency is unbounded.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds native IAsyncEnumerable field resolution, streaming/batching support, and incremental WebSocket delivery.

Changes:

  • Introduces Define.TaskSeqField and configurable stream batching.
  • Adds async-enumerable execution, cancellation, and error handling.
  • Updates WebSocket payloads, documentation, samples, and tests.
File summaries
File Description
TaskSeqFieldTests.fs Tests async sequence fields and streaming.
Helpers.fs Adds a suspending async-enumerable test helper.
ObservableExtensionsTests.fs Tests async-enumerable observables.
FSharp.Data.GraphQL.Tests.fsproj Adds test dependencies and source file.
SerializationTests.fs Tests incremental payload serialization.
WebSockets.fs Expands WebSocket execution payloads.
TypeSystem.fs Adds async-sequence resolver and batching types.
SchemaDefinitionsExtensions.fs Rejects unsupported resolver middleware.
SchemaDefinitions.fs Adds TaskSeqField overloads.
FSharp.Data.GraphQL.Shared.fsproj Adds async-interface compatibility dependency.
ObservableExtensions.fs Adds async-enumerable observable adapters.
Execution.fs Executes and streams async sequence items.
ErrorMessages.fs Updates enumerable type error text.
GraphQLWebsocketMiddleware.fs Sends incremental payloads immediately.
star-wars-api.fsproj Adds TaskSeq dependency.
Schema.fs Demonstrates streamed friends.
RELEASE_NOTES.md Documents features and breaking changes.
Packages.props Centrally versions new dependencies.
docs/type-system.md Documents asynchronous sequence fields.
Review details
  • Files reviewed: 19/19 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +366 to +369
| System.Reactive.NotificationKind.OnError -> Observable.singleton (StreamFailure notification.Exception)
| _ -> Observable.empty)
// Each item is emitted as soon as its own fields are resolved
|> Observable.mergeInner
Comment on lines +368 to +370
// Each item is emitted as soon as its own fields are resolved
|> Observable.mergeInner
|> buffer
Comment on lines +2439 to +2441
match applyBatching policy boxedSource with
| ValueSome size -> AsyncEnumerableFieldValue<'U> (source, preferredBatchSize = size) |> box
| ValueNone -> AsyncEnumerableFieldValue<'U> (source) |> box
xperiandri and others added 2 commits September 15, 2026 02:06
`BufferedStreamOptions.Interval` and `BufferedStreamOptions.PreferredBatchSize` are now `int voption`,
so the batch size computed for a `Define.TaskSeqField` sequence is used without converting between
`option` and `voption`. The `@stream` planning and buffering code and the stream event filtering follow.
`Define.Input` still takes the default value of a `Nullable IntType` argument as `int option`.

The optional callbacks of the `TestObserver` and `SuspendingAsyncEnumerable` test helpers are struct
optional parameters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tests checking that disposing a subscription stops the enumeration blocked the test thread with
`Thread.Sleep` and `ManualResetEventSlim.Wait`. They now return `Task`, await `TaskCompletionSource`
signals through the new `waitForTask` helper, which fails the test with a message on timeout, and wait
with `Task.Delay`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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