Skip to content

Added Define.TaskSeqField for lazy async collection streaming support (IAsyncEnumerable) - #607

Merged
xperiandri merged 21 commits into
devfrom
task-seq-field-core
Sep 18, 2026
Merged

xperiandri merged 21 commits into
devfrom
task-seq-field-core

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the base implementation split out of the original #598. It adds native Define.TaskSeqField support for IAsyncEnumerable<'T> and the server-side streaming execution pipeline.

Included

  • Define.TaskSeqField for async-enumerable list fields.
  • Lazy enumeration, batching, bounded concurrency, cancellation, and stream error propagation.
  • @stream execution support and related response handling.
  • Unit/regression coverage, documentation, and the Star Wars sample field.

Review scope

Please review the TaskSeqField and core streaming execution changes in this PR. WebSocket lifecycle, incremental WebSocket payload formatting, and WebSocket error cleanup are intentionally excluded and are covered by the stacked follow-up PR.

Stacked follow-up PR: #608
Original superseded PR: #598

xperiandri and others added 16 commits September 18, 2026 15:50
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>
`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>
…y, lazy batching

Fixes the three review comments on PR #598 (commit 764cc07):

- An enumeration failure of a streamed TaskSeqField could overtake an earlier item that was still
  resolving asynchronously, because the failure was merged as an immediately-completing observable
  alongside still-running item resolutions. `Observable.ofAsyncEnumerableResolved` now awaits every
  resolution started before the failure before emitting it, so it always arrives last.
- The same function bounds how many items are pulled from the source and resolved at the same time
  to `maxConcurrency`, a new optional parameter on `Define.TaskSeqField` (default
  `Environment.ProcessorCount`), so a fast or infinite source can no longer accumulate unbounded
  resolver work while streaming.
- `StreamBatching.FromSource`'s callback ran whenever a TaskSeqField resolver was wrapped, so it also
  ran for ordinary and `@defer` queries. `IAsyncEnumerableFieldValue.GetPreferredBatchSize` now
  computes it lazily, only when `streamed` needs it: for a `@stream` query that does not itself supply
  `preferredBatchSize`.

`Resolve.TaskSeq` now carries a `TaskSeqStreamingOptions` record (batching policy + max concurrency)
instead of a bare `StreamBatchingPolicy`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a, subscription race

Fixes the five review comments on PR #598 (commit faaacb9):

- `ofAsyncEnumerable`, `ofAsyncEnumerableResolved` and `AsyncEnumerableExtensions.toArrayAsync` acquired their
  enumerator before the try block, so a source throwing from `GetAsyncEnumerator` bypassed the failure handling
  and faulted the returned Task directly. For `ofAsyncEnumerableResolved` that meant `OnError` on the merged
  deferred observable of the whole query instead of `DeferredErrors` for just this field, which can drop sibling
  deferred results and the final `hasNext: false` payload. Acquisition now happens inside the try, and a shared
  `disposeEnumerator` helper also routes a throwing `DisposeAsync` through the same failure path, preferring an
  earlier enumeration failure if there was one.
- `sendSubscriptionResponseOutput` discarded the partial data the executor can return alongside `SubscriptionErrors`
  and sent `data: null`; it now forwards both. `applyPlanExecutionResult`'s `Direct` branch dropped the execution
  errors the HTTP handler forwards; it now sends them too, with a warning log matching the other branches.
- `addClientSubscription` subscribed before registering the subscription id, so a deferred observable completing
  synchronously ran its removal callback while the id was still absent; the helper then added the already-completed
  subscription, stranding the id permanently (a later `Subscribe` with the same id was rejected as already taken).
  A `SingleAssignmentDisposable` is now registered first and assigned after subscribing, so synchronous completion
  can find and remove it; assigning `Disposable` on an already-disposed instance disposes the assigned value too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TaskSeqFieldTests.fs and ObservableExtensionsTests.fs had each grown their own
copies of the same async-enumerable test sources: ThrowingAsyncEnumerable, an
"endless numbers" source recording pulls and disposal, "one item then the
enumeration fails", "one item then DisposeAsync throws", the synchronous
asyncItems/asyncRange sequence, and a delay helper differing only in argument
order. Moved all of them into the shared, auto-opened Helpers module next to
the existing SuspendingAsyncEnumerable and waitForTask, and updated both test
files to use the shared versions instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ailure, placeholder cleanup

ObservableExtensions.fs: ofAsyncEnumerableResolved kept every asynchronously
resolved item's Task in a ResizeArray until the whole source ended, so a long
or infinite @stream source retained one task per delivered item despite
maxConcurrency. It also released a resolution's concurrency slot only after
both awaiting it and emitting succeeded, so a failed resolution or an
observer throwing while a result was delivered left the slot held forever;
with maxConcurrency = 1 this deadlocked the enumeration. Replaced the task
list with an in-flight counter plus a TaskCompletionSource signalled once
enumeration has ended and every started resolution has settled, and moved
the slot release into a finally so it always runs. A resolution failure now
stops pulling further items and is delivered through onFailure the same way
an enumeration failure is, after every resolution already started settles.

GraphQLWebsocketMiddleware.fs: addClientSubscription registered its
SingleAssignmentDisposable placeholder before calling Subscribe, so a stream
throwing synchronously from Subscribe left the placeholder registered
forever, permanently occupying the subscription id. Subscribe is now
wrapped so a synchronous failure removes the placeholder (a no-op if a
synchronous completion already did) before rethrowing for the existing
per-message error handling to report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…active's own subscription teardown

The previous commit's new test subscribed a raw IObserver<'T> whose OnNext
throws to reproduce the concurrency-slot leak Copilot flagged. That part of
the fix is correct (confirmed by an isolated repro run 300 times without
System.Reactive: the semaphore slot is always released via the finally
block). But through the real System.Reactive Subscribe(IObserver<'T>) call
used by the operator, and confirmed with another isolated repro against the
actual System.Reactive package, an observer's OnNext throwing makes Rx tear
the subscription down itself: it disposes the subscription (cancelling the
enumeration's token) before rethrowing. ofAsyncEnumerableResolved correctly
treats that as "nobody is listening anymore" and skips both the onFailure
delivery and OnCompleted, exactly as it does when disposed for any other
reason. The test's assertion that OnCompleted still fires and onFailure
still gets delivered was therefore wrong, and it hung for the test's full
timeout on CI (Timeout waiting for OnCompleted), failing the build on all
three OS runners.

Replaced it with a test that doesn't depend on OnCompleted: it uses a source
that signals a TaskCompletionSource from DisposeAsync, and waits (bounded)
for that instead, which still proves the enumeration reaches disposal
without hanging on the concurrency slot. Corrected the doc comment and
RELEASE_NOTES.md, which both overstated that onFailure is delivered in this
case. Also split the RELEASE_NOTES.md bullet about graphql-transport-ws
discarding partial data into its two separate, more precise fixes
(subscription data vs. Direct-result errors), per Copilot's fourth review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ofAsyncEnumerableResolved only checked failed()/cancellation at the top of
the while loop. With maxConcurrency = 1 the loop parks in slots.WaitAsync
while the single in-flight resolution runs; when that resolution fails, its
finally releases the slot and the loop resumes straight into MoveNextAsync,
so one more item is pulled and, if it resolves synchronously, emitted
before the failure that already happened - contradicting the doc comment's
claim that a failed resolution "stops the enumeration". Rechecked both
conditions right after acquiring the slot, releasing it and stopping
without pulling when either is set.

Confirmed the race and the fix with an isolated fsi repro of the operator
(100/100 runs emitted [2; -1] before this change, [-1] after), since the
project's test suite can't be run standalone here (see the third-review
commit's message on the AspNetCore-only compiler bug being addressed on
struct-optional-params).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d item-error semantics

ofAsyncEnumerableResolved only rechecked the stop conditions right after
acquiring a concurrency slot, not after the following MoveNextAsync. A
background resolution can fail while that move is still suspended; once it
completes, the code went straight to resolving the item it produced, so
with maxConcurrency > 1 an item pulled after a failure could still be
resolved and, if synchronous, emitted before it. Factored the two checks
into one `stopped ()` predicate and used it after both awaits.

Confirmed the race and the fix with the same isolated fsi repro approach as
the fifth review's fix (100/100 runs emitted the extra item before this
change, 0/100 after).

Also addressed the review's other thread: it read "a failed item resolution
stops the enumeration" (from the doc comment, RELEASE_NOTES.md and the PR
description) as meaning any per-item GraphQL error should end the stream,
since resolveStreamedItem wraps every ResolverResult, including Error, into
a plain StreamedItem. That wording described the AsyncVal computation
itself throwing (a bug in the resolution plumbing, treated like a source
failure), not an ordinary resolver error, which executeResolvers already
turns into a normal, non-throwing ResolverResult.Error value - the same
value @stream on an ordinary list turns into that item's DeferredErrors
while continuing to stream the rest (see DeferredTests."Resolver list
error"). Kept that behavior, since diverging from ordinary lists here would
be surprising and isn't what GraphQL's per-field error semantics call for,
and reworded the doc comment, RELEASE_NOTES.md and docs/type-system.md to
make the distinction explicit. Added an execution-level regression test
that pins the intended behavior: an item's own field error is delivered on
its own path and the following items keep streaming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e after Direct/RequestError

GraphQLWebsocketMiddleware.fs sent a batch of streamed items (grouped by the
field's batching policy or the query's preferredBatchSize) as one payload
addressed by a path ending in the list of the batch's own indices, such as
["numbers", [0, 1]]. No graphql-transport-ws client can merge that into the
response tree: a batch isn't addressable by any single index, only its
individual items are. Added IncrementalPayloadSplitting, a small pure module
that recognises such a path and splits the batch into one payload per item,
addressed the same way a field that streams one item at a time already is
(a one-element data array at a path ending in that item's own index), in
the batch's own order. Each item's own errors are attributed by checking
which item's path they start with, since every error the engine attaches to
a batch already carries the full path of the specific item it came from -
no cross-message state is needed. This keeps the engine's batching (still
one buffered/merged event upstream) while making every item addressable on
the wire. Verified the splitting logic with an isolated fsi repro of the
algorithm (out-of-order batch, and a batch with one item's own field error)
before adding the xUnit test.

Also fixed graphql-transport-ws never sending complete after the single
next of a Direct (query/mutation) or RequestError result, which the
protocol requires ("Server dispatches the Complete message indicating that
the execution has completed" after "at most one Next message").

A newer incremental-delivery wire format (pending/incremental/completed,
matching graphql-js 17 and Apollo Client's GraphQL17Alpha9Handler) was
considered and is worth adopting, but requires a per-field completion
signal threaded through the engine's whole merged deferred/streamed/live
observable, which touches the ~25 pre-existing, TaskSeqField-unrelated
tests in DeferredTests.fs (exact payload positions and counts) that this PR
otherwise leaves alone. Tracked as follow-up work on a separate branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onventions

Request (validation) errors were sent over graphql-transport-ws as a Next
followed by Complete, which the protocol reserves for results; a client
reading it would see a successful, null-data result instead of the
operation being terminated by an error. Routed RequestError through the
terminal Error message instead, with no Complete after it (Direct still
gets Next + Complete: it is a real, if partly erroneous, result).

That required ServerMessage.Error and ServerRawPayload.ErrorMessages to
carry GQLProblemDetails list instead of NameValueLookup list, so the error
payload is a standard GraphQL error array as the protocol's ExecutionError
requires, rather than an arbitrary object. Replaced the one other Error call
site (the subscription catch-all's hand-built NameValueLookup, which had no
message field and so was not a valid GraphQL error either) with
GQLProblemDetails.Create.

Found while wiring this up: RawServerMessageConverter.Write serialized the
ErrorMessages and CustomResponse payloads without a preceding
WritePropertyName ("payload"), unlike the ExecutionResult branch.
Utf8JsonWriter throws when a value is written where a property name is
expected, so every "error" message, and every "pong" carrying a payload,
failed to serialize - nothing covered those two write paths. Fixed, and
added two regression tests; verified the throw and the fix against a bare
Utf8JsonWriter first.

Made IncrementalPayloadSplitting internal instead of public - it was public
only so the test project could reach it, which committed obj list paths and
tuple results to the package's supported surface for no reason. Exposed it
to the test assembly the same way Shared and Server already do
(InternalsVisibleTo), rather than the transport implementation detail. Also
replaced the one list-append (@) this PR introduced with a list expression,
per the project's collection conventions.

Added IcedTasks to the test project only (no transitive dependency for
consumers) and rewrote SuspendingAsyncEnumerable.MoveNextAsync as a
valueTask CE instead of manually wrapping a Task in a ValueTask, per the
async conventions; verified the valueTask CE against the real IcedTasks
package before wiring it in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ecution result, not a request error

GQLResponseContent.RequestError was produced both before execution
(validation, planning, variable coercion, a middleware, or the executor
itself failing) and after it, whenever executeOperation's non-null root
field failed and the error propagated to the root. The previous commit
routed every RequestError through graphql-transport-ws's terminal Error
message, which is wrong for the second case: per the spec, a response with
a failed non-null root field is still an execution result (data is null,
but present), not a request rejected before execution, so it must be sent
as Next + Complete like any other result. The HTTP handler had the same
conflation the other way: GQLResponse.RequestError omits data for both,
so such a response was missing "data" entirely instead of carrying null.

Represented the root failure as Direct (null, errs) instead of adding a new
case: RequestError now means pre-execution only, and both transports
already handle Direct correctly. Updated the six tests that asserted
RequestError for this case (three in ExecutionTests.fs, one each in
LazyEnumerationExceptionTests.fs and TaskSeqFieldTests.fs) to assert Direct
with null data instead; the other 21 ensureRequestError call sites are
genuine pre-execution validation/coercion/middleware failures and are
unchanged. Documented the distinction on the two GQLResponseContent cases
and reworded the middleware's comments and log messages to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…estError

executeQueryOrMutation's final Error branch (added when the ninth review's
fix made a failed non-null root field produce Direct(null, errs)) was also
being reached by executeRootOperation's own getArgumentValues failure for
inline (literal) root field arguments, since both errors flowed into the
same collectFields-aggregated Result. That reclassified inline argument and
input object coercion/validation failures as execution results with null
data instead of RequestError, breaking InputObjectValidatorTests's
"Execute handles validation of invalid inline input records with all
fields" on CI.

Inline argument coercion is now checked for every root field up front,
mirroring Executor.eval's coerceVariables step for variables: if any root
field's arguments fail to coerce, the whole request is rejected as
RequestError before any resolver runs. Only a genuine resolver failure on a
non-null root field now reaches the Direct(null, errs) branch. As a side
effect, a mutation with an invalid literal argument on a later root field no
longer executes the earlier root fields' resolvers first.

Verified with a full solution build and the complete unit test suite
locally (dotnet build FSharp.Data.GraphQL.slnx + dotnet test), matching
CI's approach, instead of the previous partial per-project builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The tenth Copilot review claimed Execution.collectItems' chunk branch
omits a failed item's index from `indicies` while still reserving its
slot in `data`, so GraphQLWebsocketMiddleware.splitBatch's List.map2
would throw on a batch mixing a successful and a failed item.

It does not: both arms of collectItems' `merge` prepend the item's
index (`box index :: indicies`), and only the Ok arm additionally
writes into `data` - so `indicies` and `data` always end up exactly
`chunk.Length` long, with a failed item's slot left null. List.map2
never sees mismatched lengths.

Added two regression tests pinning this shape rather than changing
behavior: one drives the real engine through a @stream query with
Fixed batching where item 0's own field resolution fails and item 1
succeeds, asserting the single DeferredErrors event this produces
(TaskSeqFieldTests.fs); the other exercises splitBatch directly with a
null data slot (IncrementalPayloadSplittingTests.fs). Both pass.

Also addressed the same review's suppressed comment: the "emits each
item as soon as its fields are resolved" test's ordering assertion
depended on the default maxConcurrency (Environment.ProcessorCount),
which could fail on a single-CPU runner; maxConcurrency is now set
explicitly on that test.

Verified with a full solution build and the complete unit test suite
locally, matching CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d AsyncEnumerable module

Avoid allocating reference tuples in the per-item and per-batch stream
collection paths in Execution.fs by switching to struct tuples. In
ObservableExtensions.fs, switch every enumeration/resolution loop from
task to backgroundTask so continuations never resume on a subscriber's
or caller's synchronization context, rename AsyncEnumerableExtensions to
AsyncEnumerable, and replace the ExceptionDispatchInfo round-trip with
ex.Reraise().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@xperiandri xperiandri changed the title Add TaskSeqField streaming support Added TaskSeqField streaming support Sep 18, 2026
@xperiandri xperiandri changed the title Added TaskSeqField streaming support Added Define.TaskSeqField for lazy async collection streaming support (IAsyncEnumerable) Sep 18, 2026
@xperiandri
xperiandri requested a balanced review from Copilot September 18, 2026 13:57
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Test Results

    9 files      9 suites   13m 4s ⏱️
  765 tests   760 ✅  5 💤 0 ❌
2 295 runs  2 280 ✅ 15 💤 0 ❌

Results for commit 34023fb.

♻️ This comment has been updated with latest results.

Copilot stopped reviewing on behalf of xperiandri due to an error September 18, 2026 14:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Copilot was unable to run its full agentic suite in this review.

Pull request overview

This PR adds first-class support for list fields resolved from IAsyncEnumerable<'T> (via Define.TaskSeqField) and updates incremental delivery over graphql-transport-ws to properly stream/defer with path + hasNext, while aligning error classification (request rejection vs execution result) and switching buffered stream options to voption.

Changes:

  • Introduces Define.TaskSeqField + streaming options (StreamBatching, maxConcurrency) and supporting execution/planning logic.
  • Fixes graphql-transport-ws incremental delivery: immediate incremental payloads, payload splitting for batched paths, correct request-error vs result handling, and serialization fixes.
  • Expands test coverage across execution, observables, websocket middleware, and serialization; updates docs and release notes.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/FSharp.Data.GraphQL.Tests/TaskSeqFieldTests.fs New end-to-end tests for Define.TaskSeqField, batching, max concurrency, and error ordering.
tests/FSharp.Data.GraphQL.Tests/LazyEnumerationExceptionTests.fs Aligns expectations for non-null root failures as Direct with data = null.
tests/FSharp.Data.GraphQL.Tests/Helpers.fs Adds async-enumerable test helpers and switches observer callback optional arg to voption.
tests/FSharp.Data.GraphQL.Tests/Helpers and Extensions/ObservableExtensionsTests.fs Adds tests for ofAsyncEnumerable / ofAsyncEnumerableResolved disposal, ordering, and failure behavior.
tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj Adds packages and compiles new tests.
tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs Updates root-error expectations and adds inline argument coercion guard regression coverage.
tests/FSharp.Data.GraphQL.Tests/DeferredTests.fs Updates buffered stream options initialization to voption.
tests/FSharp.Data.GraphQL.Tests/AspNetCore/SerializationTests.fs Adds coverage for incremental payload JSON shape and error/pong payload serialization regressions.
tests/FSharp.Data.GraphQL.Tests/AspNetCore/IncrementalPayloadSplittingTests.fs New tests for batch path detection and batch splitting semantics.
src/FSharp.Data.GraphQL.Shared/WebSockets.fs Expands subscription payload model to support incremental delivery (path, hasNext) and standardizes error payloads.
src/FSharp.Data.GraphQL.Shared/TypeSystem.fs Adds Resolve.TaskSeq, batching/concurrency options, and IAsyncEnumerableFieldValue wrapper.
src/FSharp.Data.GraphQL.Shared/Serialization/JsonConverters.fs Fixes missing "payload" property name for error and pong messages.
src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs Disallows resolve middleware for TaskSeqField.
src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs Adds Define.TaskSeqField overloads with batching and concurrency options.
src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj Adds Microsoft.Bcl.AsyncInterfaces dependency for netstandard2.0.
src/FSharp.Data.GraphQL.Server/Schema.fs Converts buffered stream option defaults to option where required by directive arg definitions.
src/FSharp.Data.GraphQL.Server/Planning.fs Switches stream directive parsing to voption path.
src/FSharp.Data.GraphQL.Server/ObservableExtensions.fs Adds async-enumerable observable helpers and async enumerable draining utilities.
src/FSharp.Data.GraphQL.Server/IO.fs Clarifies RequestError vs Direct semantics via comments.
src/FSharp.Data.GraphQL.Server/Execution.fs Implements IAsyncEnumerable execution for direct + streamed results, batching, and pre-coercion gating for root fields.
src/FSharp.Data.GraphQL.Server/ErrorMessages.fs Updates enumerable error message to mention async enumerables.
src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs Implements incremental delivery (path/hasNext), correct request error routing, and batch splitting for client mergeability.
src/FSharp.Data.GraphQL.Server.AspNetCore/FSharp.Data.GraphQL.Server.AspNetCore.fsproj Exposes internals to tests for incremental splitting test coverage.
samples/star-wars-api/star-wars-api.fsproj Adds FSharp.Control.TaskSeq to support streaming sample.
samples/star-wars-api/Schema.fs Adds friendsStream TaskSeqField demonstrating @stream.
docs/type-system.md Documents Define.TaskSeqField, batching, and concurrency behavior.
README.md Updates stream options snippet to voption.
Packages.props Adds versions for Azure.Core, TaskSeq, IcedTasks, and Bcl.AsyncInterfaces.
RELEASE_NOTES.md Documents breaking changes and new streaming/incremental delivery capabilities.

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

Comment thread src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs Outdated
Comment thread src/FSharp.Data.GraphQL.Server/ObservableExtensions.fs Outdated
Comment thread src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs
Comment thread src/FSharp.Data.GraphQL.Server/ErrorMessages.fs Outdated
Comment thread src/FSharp.Data.GraphQL.Server/Execution.fs Outdated
Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

A pending source move can hang after resolution failure, stream defaults are mishandled, and the diff includes work reserved for the stacked follow-up.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

src/FSharp.Data.GraphQL.Server/Execution.fs:303

  • ValueNone here means only that preferredBatchSize was omitted from the query AST, not that the schema has no configured default. DefaultWithBufferedStream stores defaults on the directive definition, but getStreamBufferMode reads only explicitly supplied AST arguments. Consequently an omitted argument lets the field-level batching policy override the schema default, and configured default intervals are also dropped. Resolve the directive's effective argument values, including defaults, during planning before applying the field fallback.
    src/FSharp.Data.GraphQL.Server/Execution.fs:548
  • This changes the response contract for every query and mutation, not only TaskSeqField: inline argument coercion is moved ahead of all root execution, and a non-null root failure becomes Direct(null, errors). The PR description assigns response-contract changes to stacked PR #608, so move this behavior and its tests/release notes there, or revise the documented base/follow-up scope to match the actual stack.
    src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs:256
  • The incremental WebSocket transport work here (path/hasNext, batch splitting, and related delivery changes) is explicitly excluded from this PR's review scope and assigned to stacked PR #608. This file also includes the follow-up's subscription-registration lifecycle fix. Please restack these WebSocket changes and their shared contracts/tests/release notes into #608, or update the PR scope and stack so reviewers are not evaluating the same work twice.
  • Files reviewed: 29/29 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/FSharp.Data.GraphQL.Server/ObservableExtensions.fs
Copilot AI and others added 2 commits September 18, 2026 15:39
Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>
Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cleanup can mask resolution failures, and middleware coercion errors remain misclassified.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

src/FSharp.Data.GraphQL.Server/IO.fs:80

  • The new contract says middleware rejection is a RequestError, but ObjectListFilterMiddleware.reportMiddleware still returns GQLExecutionResult.Direct(..., null, errors) when its pre-execution inline-filter coercion fails (src/FSharp.Data.GraphQL.Server.Middleware/MiddlewareDefinitions.fs:133-137). Over WebSocket this is therefore sent as next plus complete, not the terminal error used for other pre-execution failures. Return GQLExecutionResult.Error from that middleware path and add coverage for this request-error case.
    src/FSharp.Data.GraphQL.Server/ObservableExtensions.fs:231
  • If an item resolution fails and DisposeAsync then throws while the stream is stopping, disposeEnumerator puts the disposal exception into failure. The fallback on line 231 selects that exception before resolutionFailure, so clients receive "Boom disposing" instead of the resolution error that actually stopped the stream. Track the pre-disposal enumeration failure separately: preserve a genuine enumeration failure, otherwise prefer resolutionFailure over an exception raised only during cleanup.

src/FSharp.Data.GraphQL.Server.AspNetCore/GraphQLWebsocketMiddleware.fs:218

  • The stated review scope assigns WebSocket lifecycle, incremental payload, and response-contract work to stacked follow-up #608, but this hunk implements the synchronous-completion registration race fix and this PR also contains the related WebSocket delivery changes. Move the follow-up-only implementation, tests, and release notes to #608, or update the PR description and stack so this behavior is explicitly in scope for review.
        // Registered before subscribing, so a stream that completes synchronously (from inside Subscribe) still
        // finds the id when its onCompleted callback above runs; only then is it safe to remove and dispose it.
        // Assigning Disposable on an already-disposed SingleAssignmentDisposable disposes the assigned value too.
        let placeholder = new System.Reactive.Disposables.SingleAssignmentDisposable ()

        subscriptions
        |> GraphQLSubscriptionsManagement.addSubscription (id, placeholder, (fun _ -> ()))

        try
            placeholder.Disposable <- streamSource.Subscribe (observer)
        with _ ->
            // Nothing will ever complete this subscription now, so the id is freed here instead; a no-op if the
            // synchronous completion above already removed it. Rethrown for the caller to report the failure.
            subscriptions |> GraphQLSubscriptionsManagement.removeSubscription id
            reraise ()
  • Files reviewed: 29/29 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@xperiandri
xperiandri merged commit 771d526 into dev Sep 18, 2026
5 checks passed
@xperiandri
xperiandri deleted the task-seq-field-core branch September 18, 2026 16:42
xperiandri added a commit that referenced this pull request Sep 18, 2026
…rt (`IAsyncEnumerable`) (#607)

* Added `Define.TaskSeqField` with streaming of `IAsyncEnumerable` 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>

* Changed `BufferedStreamOptions` and stream helpers to use `voption`

`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>

* Rewrote subscription disposal tests as asynchronous `task` tests

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>

* Addressed Copilot review: ordered stream failures, bounded concurrency, lazy batching

Fixes the three review comments on PR #598 (commit 764cc07):

- An enumeration failure of a streamed TaskSeqField could overtake an earlier item that was still
  resolving asynchronously, because the failure was merged as an immediately-completing observable
  alongside still-running item resolutions. `Observable.ofAsyncEnumerableResolved` now awaits every
  resolution started before the failure before emitting it, so it always arrives last.
- The same function bounds how many items are pulled from the source and resolved at the same time
  to `maxConcurrency`, a new optional parameter on `Define.TaskSeqField` (default
  `Environment.ProcessorCount`), so a fast or infinite source can no longer accumulate unbounded
  resolver work while streaming.
- `StreamBatching.FromSource`'s callback ran whenever a TaskSeqField resolver was wrapped, so it also
  ran for ordinary and `@defer` queries. `IAsyncEnumerableFieldValue.GetPreferredBatchSize` now
  computes it lazily, only when `streamed` needs it: for a `@stream` query that does not itself supply
  `preferredBatchSize`.

`Resolve.TaskSeq` now carries a `TaskSeqStreamingOptions` record (batching policy + max concurrency)
instead of a bare `StreamBatchingPolicy`.

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

* Addressed second Copilot review: enumerator lifecycle, WS partial data, subscription race

Fixes the five review comments on PR #598 (commit faaacb9):

- `ofAsyncEnumerable`, `ofAsyncEnumerableResolved` and `AsyncEnumerableExtensions.toArrayAsync` acquired their
  enumerator before the try block, so a source throwing from `GetAsyncEnumerator` bypassed the failure handling
  and faulted the returned Task directly. For `ofAsyncEnumerableResolved` that meant `OnError` on the merged
  deferred observable of the whole query instead of `DeferredErrors` for just this field, which can drop sibling
  deferred results and the final `hasNext: false` payload. Acquisition now happens inside the try, and a shared
  `disposeEnumerator` helper also routes a throwing `DisposeAsync` through the same failure path, preferring an
  earlier enumeration failure if there was one.
- `sendSubscriptionResponseOutput` discarded the partial data the executor can return alongside `SubscriptionErrors`
  and sent `data: null`; it now forwards both. `applyPlanExecutionResult`'s `Direct` branch dropped the execution
  errors the HTTP handler forwards; it now sends them too, with a warning log matching the other branches.
- `addClientSubscription` subscribed before registering the subscription id, so a deferred observable completing
  synchronously ran its removal callback while the id was still absent; the helper then added the already-completed
  subscription, stranding the id permanently (a later `Subscribe` with the same id was rejected as already taken).
  A `SingleAssignmentDisposable` is now registered first and assigned after subscribing, so synchronous completion
  can find and remove it; assigning `Disposable` on an already-disposed instance disposes the assigned value too.

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

* Moved the shared TaskSeq test sources into Helpers

TaskSeqFieldTests.fs and ObservableExtensionsTests.fs had each grown their own
copies of the same async-enumerable test sources: ThrowingAsyncEnumerable, an
"endless numbers" source recording pulls and disposal, "one item then the
enumeration fails", "one item then DisposeAsync throws", the synchronous
asyncItems/asyncRange sequence, and a delay helper differing only in argument
order. Moved all of them into the shared, auto-opened Helpers module next to
the existing SuspendingAsyncEnumerable and waitForTask, and updated both test
files to use the shared versions instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Addressed third Copilot review: in-flight tracking, slot release on failure, placeholder cleanup

ObservableExtensions.fs: ofAsyncEnumerableResolved kept every asynchronously
resolved item's Task in a ResizeArray until the whole source ended, so a long
or infinite @stream source retained one task per delivered item despite
maxConcurrency. It also released a resolution's concurrency slot only after
both awaiting it and emitting succeeded, so a failed resolution or an
observer throwing while a result was delivered left the slot held forever;
with maxConcurrency = 1 this deadlocked the enumeration. Replaced the task
list with an in-flight counter plus a TaskCompletionSource signalled once
enumeration has ended and every started resolution has settled, and moved
the slot release into a finally so it always runs. A resolution failure now
stops pulling further items and is delivered through onFailure the same way
an enumeration failure is, after every resolution already started settles.

GraphQLWebsocketMiddleware.fs: addClientSubscription registered its
SingleAssignmentDisposable placeholder before calling Subscribe, so a stream
throwing synchronously from Subscribe left the placeholder registered
forever, permanently occupying the subscription id. Subscribe is now
wrapped so a synchronous failure removes the placeholder (a no-op if a
synchronous completion already did) before rethrowing for the existing
per-message error handling to report.

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

* Fixed CI: the observer-throws regression test deadlocked on System.Reactive's own subscription teardown

The previous commit's new test subscribed a raw IObserver<'T> whose OnNext
throws to reproduce the concurrency-slot leak Copilot flagged. That part of
the fix is correct (confirmed by an isolated repro run 300 times without
System.Reactive: the semaphore slot is always released via the finally
block). But through the real System.Reactive Subscribe(IObserver<'T>) call
used by the operator, and confirmed with another isolated repro against the
actual System.Reactive package, an observer's OnNext throwing makes Rx tear
the subscription down itself: it disposes the subscription (cancelling the
enumeration's token) before rethrowing. ofAsyncEnumerableResolved correctly
treats that as "nobody is listening anymore" and skips both the onFailure
delivery and OnCompleted, exactly as it does when disposed for any other
reason. The test's assertion that OnCompleted still fires and onFailure
still gets delivered was therefore wrong, and it hung for the test's full
timeout on CI (Timeout waiting for OnCompleted), failing the build on all
three OS runners.

Replaced it with a test that doesn't depend on OnCompleted: it uses a source
that signals a TaskCompletionSource from DisposeAsync, and waits (bounded)
for that instead, which still proves the enumeration reaches disposal
without hanging on the concurrency slot. Corrected the doc comment and
RELEASE_NOTES.md, which both overstated that onFailure is delivered in this
case. Also split the RELEASE_NOTES.md bullet about graphql-transport-ws
discarding partial data into its two separate, more precise fixes
(subscription data vs. Direct-result errors), per Copilot's fourth review.

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

* Rechecked stop conditions after acquiring a concurrency slot

ofAsyncEnumerableResolved only checked failed()/cancellation at the top of
the while loop. With maxConcurrency = 1 the loop parks in slots.WaitAsync
while the single in-flight resolution runs; when that resolution fails, its
finally releases the slot and the loop resumes straight into MoveNextAsync,
so one more item is pulled and, if it resolves synchronously, emitted
before the failure that already happened - contradicting the doc comment's
claim that a failed resolution "stops the enumeration". Rechecked both
conditions right after acquiring the slot, releasing it and stopping
without pulling when either is set.

Confirmed the race and the fix with an isolated fsi repro of the operator
(100/100 runs emitted [2; -1] before this change, [-1] after), since the
project's test suite can't be run standalone here (see the third-review
commit's message on the AspNetCore-only compiler bug being addressed on
struct-optional-params).

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

* Addressed sixth Copilot review: recheck after MoveNextAsync, clarified item-error semantics

ofAsyncEnumerableResolved only rechecked the stop conditions right after
acquiring a concurrency slot, not after the following MoveNextAsync. A
background resolution can fail while that move is still suspended; once it
completes, the code went straight to resolving the item it produced, so
with maxConcurrency > 1 an item pulled after a failure could still be
resolved and, if synchronous, emitted before it. Factored the two checks
into one `stopped ()` predicate and used it after both awaits.

Confirmed the race and the fix with the same isolated fsi repro approach as
the fifth review's fix (100/100 runs emitted the extra item before this
change, 0/100 after).

Also addressed the review's other thread: it read "a failed item resolution
stops the enumeration" (from the doc comment, RELEASE_NOTES.md and the PR
description) as meaning any per-item GraphQL error should end the stream,
since resolveStreamedItem wraps every ResolverResult, including Error, into
a plain StreamedItem. That wording described the AsyncVal computation
itself throwing (a bug in the resolution plumbing, treated like a source
failure), not an ordinary resolver error, which executeResolvers already
turns into a normal, non-throwing ResolverResult.Error value - the same
value @stream on an ordinary list turns into that item's DeferredErrors
while continuing to stream the rest (see DeferredTests."Resolver list
error"). Kept that behavior, since diverging from ordinary lists here would
be surprising and isn't what GraphQL's per-field error semantics call for,
and reworded the doc comment, RELEASE_NOTES.md and docs/type-system.md to
make the distinction explicit. Added an execution-level regression test
that pins the intended behavior: an item's own field error is delivered on
its own path and the following items keep streaming.

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

* Addressed seventh Copilot review: addressable batch payloads, complete after Direct/RequestError

GraphQLWebsocketMiddleware.fs sent a batch of streamed items (grouped by the
field's batching policy or the query's preferredBatchSize) as one payload
addressed by a path ending in the list of the batch's own indices, such as
["numbers", [0, 1]]. No graphql-transport-ws client can merge that into the
response tree: a batch isn't addressable by any single index, only its
individual items are. Added IncrementalPayloadSplitting, a small pure module
that recognises such a path and splits the batch into one payload per item,
addressed the same way a field that streams one item at a time already is
(a one-element data array at a path ending in that item's own index), in
the batch's own order. Each item's own errors are attributed by checking
which item's path they start with, since every error the engine attaches to
a batch already carries the full path of the specific item it came from -
no cross-message state is needed. This keeps the engine's batching (still
one buffered/merged event upstream) while making every item addressable on
the wire. Verified the splitting logic with an isolated fsi repro of the
algorithm (out-of-order batch, and a batch with one item's own field error)
before adding the xUnit test.

Also fixed graphql-transport-ws never sending complete after the single
next of a Direct (query/mutation) or RequestError result, which the
protocol requires ("Server dispatches the Complete message indicating that
the execution has completed" after "at most one Next message").

A newer incremental-delivery wire format (pending/incremental/completed,
matching graphql-js 17 and Apollo Client's GraphQL17Alpha9Handler) was
considered and is worth adopting, but requires a per-field completion
signal threaded through the engine's whole merged deferred/streamed/live
observable, which touches the ~25 pre-existing, TaskSeqField-unrelated
tests in DeferredTests.fs (exact payload positions and counts) that this PR
otherwise leaves alone. Tracked as follow-up work on a separate branch.

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

* Addressed eighth Copilot review: WS error message, internal helper, conventions

Request (validation) errors were sent over graphql-transport-ws as a Next
followed by Complete, which the protocol reserves for results; a client
reading it would see a successful, null-data result instead of the
operation being terminated by an error. Routed RequestError through the
terminal Error message instead, with no Complete after it (Direct still
gets Next + Complete: it is a real, if partly erroneous, result).

That required ServerMessage.Error and ServerRawPayload.ErrorMessages to
carry GQLProblemDetails list instead of NameValueLookup list, so the error
payload is a standard GraphQL error array as the protocol's ExecutionError
requires, rather than an arbitrary object. Replaced the one other Error call
site (the subscription catch-all's hand-built NameValueLookup, which had no
message field and so was not a valid GraphQL error either) with
GQLProblemDetails.Create.

Found while wiring this up: RawServerMessageConverter.Write serialized the
ErrorMessages and CustomResponse payloads without a preceding
WritePropertyName ("payload"), unlike the ExecutionResult branch.
Utf8JsonWriter throws when a value is written where a property name is
expected, so every "error" message, and every "pong" carrying a payload,
failed to serialize - nothing covered those two write paths. Fixed, and
added two regression tests; verified the throw and the fix against a bare
Utf8JsonWriter first.

Made IncrementalPayloadSplitting internal instead of public - it was public
only so the test project could reach it, which committed obj list paths and
tuple results to the package's supported surface for no reason. Exposed it
to the test assembly the same way Shared and Server already do
(InternalsVisibleTo), rather than the transport implementation detail. Also
replaced the one list-append (@) this PR introduced with a list expression,
per the project's collection conventions.

Added IcedTasks to the test project only (no transitive dependency for
consumers) and rewrote SuspendingAsyncEnumerable.MoveNextAsync as a
valueTask CE instead of manually wrapping a Task in a ValueTask, per the
async conventions; verified the valueTask CE against the real IcedTasks
package before wiring it in.

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

* Addressed ninth Copilot review: a failed non-null root field is an execution result, not a request error

GQLResponseContent.RequestError was produced both before execution
(validation, planning, variable coercion, a middleware, or the executor
itself failing) and after it, whenever executeOperation's non-null root
field failed and the error propagated to the root. The previous commit
routed every RequestError through graphql-transport-ws's terminal Error
message, which is wrong for the second case: per the spec, a response with
a failed non-null root field is still an execution result (data is null,
but present), not a request rejected before execution, so it must be sent
as Next + Complete like any other result. The HTTP handler had the same
conflation the other way: GQLResponse.RequestError omits data for both,
so such a response was missing "data" entirely instead of carrying null.

Represented the root failure as Direct (null, errs) instead of adding a new
case: RequestError now means pre-execution only, and both transports
already handle Direct correctly. Updated the six tests that asserted
RequestError for this case (three in ExecutionTests.fs, one each in
LazyEnumerationExceptionTests.fs and TaskSeqFieldTests.fs) to assert Direct
with null data instead; the other 21 ensureRequestError call sites are
genuine pre-execution validation/coercion/middleware failures and are
unchanged. Documented the distinction on the two GQLResponseContent cases
and reworded the middleware's comments and log messages to match.

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

* Fix ninth-round regression: inline argument coercion must stay a RequestError

executeQueryOrMutation's final Error branch (added when the ninth review's
fix made a failed non-null root field produce Direct(null, errs)) was also
being reached by executeRootOperation's own getArgumentValues failure for
inline (literal) root field arguments, since both errors flowed into the
same collectFields-aggregated Result. That reclassified inline argument and
input object coercion/validation failures as execution results with null
data instead of RequestError, breaking InputObjectValidatorTests's
"Execute handles validation of invalid inline input records with all
fields" on CI.

Inline argument coercion is now checked for every root field up front,
mirroring Executor.eval's coerceVariables step for variables: if any root
field's arguments fail to coerce, the whole request is rejected as
RequestError before any resolver runs. Only a genuine resolver failure on a
non-null root field now reaches the Direct(null, errs) branch. As a side
effect, a mutation with an invalid literal argument on a later root field no
longer executes the earlier root fields' resolvers first.

Verified with a full solution build and the complete unit test suite
locally (dotnet build FSharp.Data.GraphQL.slnx + dotnet test), matching
CI's approach, instead of the previous partial per-project builds.

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

* Add regression coverage for a mixed success/error stream batch

The tenth Copilot review claimed Execution.collectItems' chunk branch
omits a failed item's index from `indicies` while still reserving its
slot in `data`, so GraphQLWebsocketMiddleware.splitBatch's List.map2
would throw on a batch mixing a successful and a failed item.

It does not: both arms of collectItems' `merge` prepend the item's
index (`box index :: indicies`), and only the Ok arm additionally
writes into `data` - so `indicies` and `data` always end up exactly
`chunk.Length` long, with a failed item's slot left null. List.map2
never sees mismatched lengths.

Added two regression tests pinning this shape rather than changing
behavior: one drives the real engine through a @stream query with
Fixed batching where item 0's own field resolution fails and item 1
succeeds, asserting the single DeferredErrors event this produces
(TaskSeqFieldTests.fs); the other exercises splitBatch directly with a
null data slot (IncrementalPayloadSplittingTests.fs). Both pass.

Also addressed the same review's suppressed comment: the "emits each
item as soon as its fields are resolved" test's ordering assertion
depended on the default maxConcurrency (Environment.ProcessorCount),
which could fail on a single-CPU runner; maxConcurrency is now set
explicitly on that test.

Verified with a full solution build and the complete unit test suite
locally, matching CI.

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

* Streamline streaming execution: struct tuples, backgroundTask, renamed AsyncEnumerable module

Avoid allocating reference tuples in the per-item and per-batch stream
collection paths in Execution.fs by switching to struct tuples. In
ObservableExtensions.fs, switch every enumeration/resolution loop from
task to backgroundTask so continuations never resume on a subscriber's
or caller's synchronization context, rename AsyncEnumerableExtensions to
AsyncEnumerable, and replace the ExceptionDispatchInfo round-trip with
ex.Reraise().

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

* PR review fix

* Address PR review feedback

Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>

* Cancel MoveNextAsync on resolution failure

Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>

* Prefer resolution failure over cancellation

Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com>

* Use `CanceledIndependently` active pattern

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.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.

3 participants