Skip to content

refactor(streaming): resolve-then-split strategy for @defer#9

Merged
jwaldrip merged 5 commits into
mainfrom
fix/streaming-resolution-resolve-then-split
Apr 12, 2026
Merged

refactor(streaming): resolve-then-split strategy for @defer#9
jwaldrip merged 5 commits into
mainfrom
fix/streaming-resolution-resolve-then-split

Conversation

@jwaldrip
Copy link
Copy Markdown
Member

@jwaldrip jwaldrip commented Apr 12, 2026

Summary

Internal branch copy of juscyllan's fix (originally PR #8) pushed to this repo so we can track it on a gigsmart-owned branch and open a matching upstream PR to absinthe-graphql/absinthe.

  • Rewrites StreamingResolution to resolve everything in one pass (standard resolution), then store @defer metadata in the streaming context
  • Removes complex sub-blueprint re-resolution that caused multiple crashes
  • The transport layer (absinthe_plug) handles splitting the final result into initial/incremental SSE payloads

Review feedback addressed

  • Handle Fragment.Spread nodes (not just Fragment.Inline) when walking for @defer, including traversal of named fragments
  • Populate backwards-compat keys (deferred_fragments, streamed_fields, deferred_tasks, stream_tasks) on __streaming__ context so downstream Absinthe.Incremental.* consumers don't raise KeyError

Related

Depends on

  • gigsmart/absinthe_plug PR for SSE wiring (companion PR)

Note

Medium Risk
Changes the incremental-delivery execution strategy by removing deferred re-resolution and always running full Resolution when @defer is present, which can affect response shape/timing and load. Risk is moderated by being scoped to incremental delivery and mainly refactoring into metadata collection plus context flags.

Overview
Simplifies StreamingResolution to only support @defer by running standard Resolution once (resolving all fields) and deferring payload splitting to the transport layer.

Replaces the prior streaming implementation (skipping nodes, building sub-blueprints, and creating deferred/stream tasks) with a lightweight walk that collects @defer metadata (including Fragment.Spread and nested inline fragments) and stores it under execution.context[:__streaming__], while setting execution.incremental_delivery = true and keeping backwards-compatible Absinthe.Incremental.* keys populated.

Reviewed by Cursor Bugbot for commit 3f68ce1. Bugbot is set up for automated code reviews on this repo. Configure here.

…lit strategy

Replace complex sub-blueprint re-resolution with a simpler approach:
1. Run standard resolution (resolves everything including @defer fields)
2. Collect @defer metadata (label, field_names, parent_path) via AST walk
3. Store in streaming context for the transport layer to split

The transport layer (absinthe_plug) handles splitting the final result
into initial/incremental SSE payloads after the Result phase runs.

Fixes: CaseClauseError in Projector.do_collect (nil selections),
KeyError in build_sub_blueprint (:path not in Execution struct),
and broken path tracking across sibling nodes in prewalk.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3f68ce1. Configure here.

# Create a minimal blueprint with just the node to resolve
%{blueprint | execution: execution, operations: [wrap_in_operation(node, blueprint)]}
[
%{label: config[:label], field_names: field_names, parent_path: parent_path}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Defer info uses parent_path but consumers expect path

High Severity

The defer info maps use the key parent_path but downstream consumers in Absinthe.Incremental.Response and Absinthe.Incremental.Dataloader access .path on these entries. Since deferred_fragments is set to defer_info for backwards compatibility, calling Response.build_initial/1 (which is invoked by both transport.ex and delivery.ex) will raise a KeyError when build_pending_list or filter_deferred_fragments tries to access fragment.path.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3f68ce1. Configure here.

defp extract_field_names(%{selections: sels}) when is_list(sels) do
Enum.flat_map(sels, &extract_field_names/1)
end
defp extract_field_names(_), do: []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

extract_field_names silently drops Fragment.Spread fields

Medium Severity

extract_field_names doesn't handle Fragment.Spread nodes. Since Spread structs have no selections key, they fall through to the catch-all clause returning []. When a @defer fragment contains a spread (e.g., ... @defer { ...MyFragment }), the fields contributed by that named fragment are silently omitted from field_names, causing the transport layer to potentially miss fields when splitting the response.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3f68ce1. Configure here.

jwaldrip and others added 3 commits April 11, 2026 21:33
Formats 16 files flagged by CI's `mix format --check-formatted` step on
the Elixir 1.18 / OTP 28 matrix entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dialyzer (14 → 0):
- dataloader.ex: replace non-existent Resolution.resolver/0 with inline
  function type
- error_handler.ex: drop unused default nil on format_exception/2
- response.ex: broaden @type definitions and @specs for incremental
  response shapes to match what functions actually return
- transport.ex: use any() for conn_or_socket (Plug/Phoenix are optional
  deps)
- coordinate.ex: drop unreachable resolve_parsed clauses and unused
  get_enum_value/2, get_input_field/2
- incremental_directives.ex: store :defer/:stream config on node.flags
  via update_in (matches auto_defer_stream pattern), rather than calling
  Blueprint.put_flag/3 which demands a module as third arg

Compile warnings:
- remove unused aliases (error_handler, complexity_test)
- underscore-prefix unused variables (dataloader, response, complexity,
  auto_defer_stream)
- group update_stats/2 clauses together (resource_manager)

Verified: mix format, mix compile (no warnings from our code), mix
dialyzer (0 errors, exit 0), mix test (1696 tests, 0 failures) all pass
on Elixir 1.18 / OTP 28.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two clauses regressed line-length during the dialyzer fix commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jwaldrip jwaldrip merged commit e69667d into main Apr 12, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants