Skip to content

feat: add experimental GraphQL incremental delivery (G5) - #1596

Merged
jlouvel merged 6 commits into
feat/graphql-g4-websocket-serverfrom
feat/graphql-g5-incremental
Nov 1, 2026
Merged

jlouvel merged 6 commits into
feat/graphql-g4-websocket-serverfrom
feat/graphql-g5-incremental

Conversation

@jlouvel

@jlouvel jlouvel commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #1588. Part of #1581.

Draft: depends on #1595 (streaming multipart, #1587). This branch is the G4 server branch (#1593) merged with feat/streaming-multipart, so the diff against #1593 also shows #1595's commit until it is merged. It does not depend on the WebSocket client (#1594).

Summary

@defer results over multipart/mixed, experimental and off by default on both sides. The GraphQL incremental delivery RFC is not finalized, and graphql-java 26.1 implements @defer only (Directives.DeferDirective); there is no @stream. Schemas must declare the directive.

server.setIncrementalDeliveryEnabled(true);

client.setIncrementalDeliveryEnabled(true);
ExecutionResult result = client.execute("{ fast ... @defer(label: \"d\") { slow } }", null, null);
if (result instanceof IncrementalExecutionResult incremental && incremental.hasNext()) {
    incremental.getIncrementalItemPublisher().subscribe(subscriber);   // DelayedIncrementalPartialResult
}

Server

  • When a query or mutation accepts multipart/mixed, graphql-java's incremental support is enabled for that execution only (GraphQL.unusualConfiguration(input).incrementalSupport()). An IncrementalExecutionResult is streamed with MultipartStreamRepresentation: the initial result, then each DelayedIncrementalPartialResult, as application/json parts (boundary -, deferSpec=20220824, graphql-java's format).
  • Delayed results are requested one at a time and bounded by maxQueuedBytes. A failure aborts the response rather than ending it cleanly, so that clients can't mistake a partial result for a complete one.
  • Without multipart/mixed in Accept, or when disabled, @defer is executed inline as before. Results that are not incremental are plain JSON.

Client

  • execute() also accepts multipart/mixed and returns graphql-java's own IncrementalExecutionResult, with incremental payloads rebuilt as DeferPayload / StreamPayload (data or items, path, label, errors, extensions).
  • Its item publisher reads the following parts on demand from a virtual thread. It accepts a single subscriber, and the response is released on completion, failure or cancellation (cancel aborts the request). Parts are bounded by maxResponseBytes, and a truncated stream is a GraphqlTransportException.

GraphqlResultQueue (internal) now serializes any result type, so SSE, WebSocket and incremental delivery share the same back-pressure and byte accounting.

Validation

./mvnw -B verify -P default,test -pl org.restlet.ext.graphql (with core from this branch): 63 tests, 0 failures; 91.2% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc, -Werror and dependency convergence pass.

New GraphqlIncrementalTestCase (7 tests): wire format, with the initial part read while the deferred field is still blocked; inline execution without multipart/mixed or when disabled (406 when only multipart/mixed is acceptable); client publisher with DeferPayload data and label, single subscriber; deferred field errors as payload errors; plain results stay plain; cancellation and invalid demand; a server-side failure aborting the response and surfacing as a GraphqlTransportException.

🤖 Generated with Claude Code

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

Critical multipart handling flaws allow forged parts and can falsely signal successful stream termination after a writer failure.

6 open issues
What changed in this PR

Adds experimental, opt-in GraphQL @defer delivery over multipart/mixed, building on streaming multipart support in Restlet core.

Changes:

  • Adds a streaming multipart writer, bounded reader, and media-type constant.
  • Integrates incremental responses into the GraphQL server and client, reusing shared result-queue accounting.
  • Adds multipart and incremental-delivery tests and updates the changelog.
File Description
org.restlet/​src/​test/​java/​org/​restlet/​representation/​MultipartStreamTestCase.java Tests multipart framing, limits, validation, and streaming.
org.restlet/​src/​main/​java/​org/​restlet/​representation/​MultipartStreamRepresentation.java Writes and flushes multipart parts as produced.
org.restlet/​src/​main/​java/​org/​restlet/​representation/​MultipartStreamReader.java Reads streamed parts with size limits.
org.restlet/​src/​main/​java/​org/​restlet/​data/​MediaType.java Adds MULTIPART_MIXED.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlIncrementalTestCase.java Tests incremental delivery, fallback, cancellation, and failures.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​internal/​GraphqlResultQueue.java Supports serialization of additional result types.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​internal/​GraphqlIncrementalStream.java Streams initial and delayed GraphQL results.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlServerAdapter.java Enables negotiated, opt-in incremental execution.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlIncrementalPublisher.java Converts multipart responses into incremental results.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlClientAdapter.java Adds opt-in multipart response handling.
changes.md Documents multipart APIs and incremental GraphQL support.

Review effort: Balanced

@jlouvel
jlouvel force-pushed the feat/graphql-g5-incremental branch 2 times, most recently from 1e79749 to 417e97d Compare October 31, 2026 07:32
@jlouvel
jlouvel force-pushed the feat/graphql-g5-incremental branch from 417e97d to dd693ba Compare November 1, 2026 17:10
jlouvel and others added 6 commits November 1, 2026 15:09
Add protocol-neutral streaming multipart/* support (RFC 2046), the
counterpart of EventStreamRepresentation and EventStreamReader for
protocols whose responses are made of parts produced over time, such
as GraphQL incremental delivery:

- MultipartStreamRepresentation: a PartWriter sends parts through a
  PartSink; each part is flushed followed by the next delimiter, so
  that readers can process it at once, and the close delimiter is
  written when the writer returns. Parts whose content contains the
  delimiter are rejected, as they would forge parts (protocols can use
  short fixed boundaries such as "-"). The other parameters of the
  media type are kept.
- MultipartStreamReader: returns parts as they arrive, as
  MultiPartRepresentation.Part, ignoring the preamble, the epilogue and
  transport padding, with a maximum part size and a Knuth-Morris-Pratt
  delimiter search.
- MediaType.MULTIPART_MIXED.

Refs #1587

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
- Drop the old boundary from the media type name, not only from its
  parameters, so the Content-Type advertises a single boundary.
- Reject part content starting with the boundary, which would form a
  delimiter after the header line break and forge parts.
- Validate the boundary and size limit before acquiring the entity
  stream, which would otherwise be left open.
- Look up the boundary parameter case-insensitively.
- Avoid overflowing a Long.MAX_VALUE part size limit.
- Don't count the opening delimiter in the preamble size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Writing the close delimiter after a writer failure made a truncated
stream look complete to readers. Only write it when the writer returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
@defer results over multipart/mixed, on the streaming multipart
support of core. Experimental and off by default: the GraphQL
incremental delivery RFC is not finalized, and graphql-java 26.1
implements @defer only (no @stream).

- Server: setIncrementalDeliveryEnabled(true). When a query or mutation
  accepts multipart/mixed, graphql-java's incremental support is
  enabled for that execution. An IncrementalExecutionResult is streamed
  as parts (boundary "-", deferSpec=20220824, graphql-java's format):
  the initial result, then each DelayedIncrementalPartialResult,
  requested one at a time and bounded by maxQueuedBytes. A failure
  aborts the response, so that clients can't mistake a partial result
  for a complete one. Without multipart/mixed, @defer is executed
  inline as before.
- Client: setIncrementalDeliveryEnabled(true) accepts multipart/mixed.
  execute() returns graphql-java's IncrementalExecutionResult, with its
  incremental payloads rebuilt as DeferPayload and StreamPayload, and an
  item publisher that reads the following parts on demand (single
  subscriber, released on completion, failure or cancellation, parts
  bounded by maxResponseBytes).
- GraphqlResultQueue now serializes any result type.

Refs #1588

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Signal non-positive demand without waiting for the next part, keep null
extension values, require the close delimiter after the last result, and
close the multipart reader on completion, failure and cancellation,
including cancellation before any demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A POST answered with 406 Not Acceptable left its body unread, so the
Netty server closed the connection after the response, sometimes after
the client had already reused it for its next request (1001
Communication Error, seen in GraphqlIncrementalTestCase). Discard bodies
within the request size limit before answering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jlouvel
jlouvel force-pushed the feat/graphql-g5-incremental branch from dd693ba to cfdefaf Compare November 1, 2026 20:09
@jlouvel
jlouvel marked this pull request as ready for review November 1, 2026 20:10
@jlouvel
jlouvel merged commit a06ad02 into 3.0 Nov 1, 2026
1 check failed
@jlouvel
jlouvel deleted the feat/graphql-g5-incremental branch November 1, 2026 20:10
@sonarqubecloud

sonarqubecloud Bot commented Nov 1, 2026

Copy link
Copy Markdown

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