From f6ddcc5ac9be0bfa7c9c0387102be4e014fa039e Mon Sep 17 00:00:00 2001 From: Bryan Joseph Date: Tue, 9 Dec 2025 08:07:40 -0600 Subject: [PATCH 1/8] feat: Add :registry_partition_strategy option to Absinthe.Subscription (#1395) * feat: Add :keys option to Absinthe.Subscription * change option for specifying how the registry is partitioned --- lib/absinthe/subscription/supervisor.ex | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/absinthe/subscription/supervisor.ex b/lib/absinthe/subscription/supervisor.ex index c61a001bf5..a355b8c0c4 100644 --- a/lib/absinthe/subscription/supervisor.ex +++ b/lib/absinthe/subscription/supervisor.ex @@ -31,17 +31,34 @@ defmodule Absinthe.Subscription.Supervisor do # systems. Setting `async` to false makes it so that the requests are processed one at a time. async? = Keyword.get(opts, :async, true) - Supervisor.start_link(__MODULE__, {pubsub, pool_size, compress_registry?, async?}) + # Determines how keys in the registry are partitioned. + # Absinthe expects duplicate keys and by default used the :duplicate option. + # In Elixir 1.19 there are more options to determine how the duplicate keys + # are partitioned. {:duplicate, :pid} which is the same as :duplicate and + # {:duplicate, :keys} which partitioned by key. + registry_partition_strategy = Keyword.get(opts, :registry_partition_strategy, :pid) + + Supervisor.start_link( + __MODULE__, + {pubsub, pool_size, compress_registry?, async?, registry_partition_strategy} + ) end - def init({pubsub, pool_size, compress_registry?, async?}) do + def init({pubsub, pool_size, compress_registry?, async?, registry_partition_strategy}) do registry_name = Absinthe.Subscription.registry_name(pubsub) meta = [pool_size: pool_size] + keys = + case registry_partition_strategy do + # to support Elixir versions before 1.19 + :pid -> :duplicate + _ -> {:duplicate, :key} + end + children = [ {Registry, [ - keys: :duplicate, + keys: keys, name: registry_name, partitions: System.schedulers_online(), meta: meta, From 407ff8f647f8f9f7ca4727e482f7f9be15c227d6 Mon Sep 17 00:00:00 2001 From: Curtis Schiewek Date: Tue, 9 Dec 2025 09:17:39 -0500 Subject: [PATCH 2/8] chore(ci): Enable dependabot updates for github actions (#1397) --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..3e9fd8797c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From 24471709ebea3badefa7b4c2cdf259b05cb7d254 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:22:26 -0500 Subject: [PATCH 3/8] Bump actions/checkout from 3 to 6 (#1398) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check_dependents.yml | 4 ++-- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check_dependents.yml b/.github/workflows/check_dependents.yml index 70687ddafe..316a957288 100644 --- a/.github/workflows/check_dependents.yml +++ b/.github/workflows/check_dependents.yml @@ -26,10 +26,10 @@ jobs: otp-version: "27" - name: Checkout absinthe - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Checkout ${{ matrix.dependent }} - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: "absinthe-graphql/${{ matrix.dependent }}" path: "${{ matrix.dependent }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52ed5b6366..000e37518b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Elixir uses: erlef/setup-beam@v1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5243dadc8..ef2ef6e7be 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: ref: ${{ inputs.tag || github.ref }} From 77155e3f5593d802e24b94cf49f9d78e3541fcab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:23:01 -0500 Subject: [PATCH 4/8] chore(ci): Bump amannn/action-semantic-pull-request from 5 to 6 (#1399) Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5 to 6. - [Release notes](https://github.com/amannn/action-semantic-pull-request/releases) - [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/action-semantic-pull-request/compare/v5...v6) --- updated-dependencies: - dependency-name: amannn/action-semantic-pull-request dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index eca3706847..960e435792 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,6 +11,6 @@ jobs: permissions: pull-requests: read steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a035261bd0172d84c1d8ac72f74d72e64aaa4769 Mon Sep 17 00:00:00 2001 From: nulian Date: Tue, 9 Dec 2025 15:23:24 +0100 Subject: [PATCH 5/8] fix: only null values should also trigger error (#1394) Co-authored-by: Peter Arentsen --- lib/absinthe/phase/document/validation/one_of_directive.ex | 2 +- .../phase/document/validation/one_of_directive_test.exs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/absinthe/phase/document/validation/one_of_directive.ex b/lib/absinthe/phase/document/validation/one_of_directive.ex index 7ca9a3ddba..153123d530 100644 --- a/lib/absinthe/phase/document/validation/one_of_directive.ex +++ b/lib/absinthe/phase/document/validation/one_of_directive.ex @@ -20,7 +20,7 @@ defmodule Absinthe.Phase.Document.Validation.OneOfDirective do defp process(%Argument{input_value: %{normalized: %Object{} = object}} = node) do if Keyword.has_key?(object.schema_node.__private__, :one_of) and - field_count(object.fields) > 1 do + field_count(object.fields) != 1 do message = ~s[The Input Type "#{object.schema_node.name}" has the @oneOf directive. It must have exactly one non-null field.] diff --git a/test/absinthe/phase/document/validation/one_of_directive_test.exs b/test/absinthe/phase/document/validation/one_of_directive_test.exs index 66d989fa74..c299759ca0 100644 --- a/test/absinthe/phase/document/validation/one_of_directive_test.exs +++ b/test/absinthe/phase/document/validation/one_of_directive_test.exs @@ -57,6 +57,13 @@ defmodule Absinthe.Phase.Document.Validation.OneOfDirectiveTest do refute result[:data] end + test "with both inline args nil" do + query = ~s[query { valid(input: {id: null, name: null}) }] + assert {:ok, %{errors: [error]} = result} = Absinthe.run(query, Schema) + assert %{locations: [%{column: 15, line: 1}], message: @message} = error + refute result[:data] + end + test "with both variable args" do options = [variables: %{"input" => %{"id" => 1, "name" => "a"}}] assert {:ok, %{errors: [error]} = result} = Absinthe.run(@query, Schema, options) From c7451ec04d6d1e89f802fedcf0c3754bbfb7dfb0 Mon Sep 17 00:00:00 2001 From: Jason Waldrip Date: Mon, 9 Feb 2026 09:44:43 -0700 Subject: [PATCH 6/8] feat: Implement @defer and @stream directives for incremental delivery (#1377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update * fix introspection * add claude.md * Fix mix tasks to respect schema adapter for proper naming conventions - Fix mix absinthe.schema.json to use schema's adapter for introspection - Fix mix absinthe.schema.sdl to use schema's adapter for directive names - Update SDL renderer to accept adapter parameter and use it for directive definitions - Ensure directive names follow naming conventions (camelCase, etc.) in generated SDL 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: Add field description inheritance from referenced types When a field has no description, it now inherits the description from its referenced type during introspection. This provides better documentation for GraphQL APIs by automatically propagating type descriptions to fields. - Modified __field introspection resolver to fall back to type descriptions - Handles wrapped types (non_null, list_of) correctly by unwrapping first - Added comprehensive test coverage for various inheritance scenarios - Updated field documentation to explain the new behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * gitignore local settings * fix sdl render * feat: Add @defer and @stream directive support for incremental delivery - Add @defer directive for deferred fragment execution - Add @stream directive for incremental list delivery - Implement streaming resolution phase - Add incremental response builder - Add transport abstraction layer - Implement Dataloader integration for streaming - Add error handling and resource management - Add complexity analysis for streaming operations - Add auto-optimization middleware - Add comprehensive test suite - Add performance benchmarks - Add pipeline integration hooks - Add configuration system * docs: Add comprehensive incremental delivery documentation - Complete usage guide with examples - API reference for @defer and @stream directives - Performance optimization guidelines - Transport configuration details - Troubleshooting and monitoring guidance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: Correct Elixir syntax errors in incremental delivery implementation - Fix Ruby-style return statements in auto_defer_stream middleware - Correct Elixir typespec syntax in response module - Mark unused variables with underscore prefix - Remove invalid optional() syntax from typespecs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: Update test infrastructure for incremental delivery - Fix supervisor startup handling in tests - Simplify test helpers to use standard Absinthe.run - Enable basic test execution for incremental delivery features - Address compilation issues and warnings Tests now run successfully and provide baseline for further development. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: Complete @defer and @stream directive implementation This commit finalizes the implementation of GraphQL @defer and @stream directives for incremental delivery in Absinthe: - Fix streaming resolution phase to properly handle defer/stream flags - Update projector to gracefully handle defer/stream flags without crashing - Improve telemetry phases to handle missing blueprint context gracefully - Add comprehensive test infrastructure for incremental delivery - Create debug script for testing directive processing - Add BuiltIns module for proper directive loading The @defer and @stream directives now work correctly according to the GraphQL specification, allowing for incremental query result delivery. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * docs: Add comprehensive incremental delivery guide Add detailed guide for @defer and @stream directives following the same structure as other Absinthe feature guides. Includes: - Basic usage examples - Configuration options - Transport integration (WebSocket, SSE) - Advanced patterns (conditional, nested) - Error handling - Performance considerations - Relay integration - Testing approaches - Migration guidance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Add incremental delivery guide to documentation extras Include guides/incremental-delivery.md in the mix.exs extras list so it appears in the generated documentation alongside other guides. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Remove automatic field description inheritance Based on community feedback from PR #1373, automatic field description inheritance was not well received. The community preferred explicit field descriptions that are specific to each field's context rather than automatically inheriting from the referenced type. This commit: - Reverts the automatic inheritance behavior in introspection - Removes the associated test file - Returns to the standard field description handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix code formatting Run mix format to fix formatting issues detected by CI. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix dialyzer * remove elixir 1.19 * fix: resolve @defer/@stream incremental delivery issues - Fix Absinthe.Type.list?/1 undefined function by using pattern matching - Fix directive expand callbacks to return node directly (not {:ok, node}) - Add missing analyze_node clauses for Operation and Fragment.Named nodes - Fix defer depth tracking for nested defers - Fix projector to only skip __skip_initial__ flagged nodes, not all defer/stream - Update introspection tests for new @defer/@stream directives - Remove duplicate documentation files per PR review - Add comprehensive complexity analysis tests Co-Authored-By: Claude Opus 4.5 * docs: clarify supervisor startup and dataloader integration Address review comments: - Add detailed documentation on how to start the Incremental Supervisor - Include configuration options and examples in supervisor docs - Add usage documentation for Dataloader integration - Explain how streaming-aware resolvers work with batching Co-Authored-By: Claude Opus 4.5 * chore: remove debug test file * feat: add on_event callback for monitoring integrations Add an `on_event` callback option to the incremental delivery system that allows sending defer/stream events to external monitoring services like Sentry, DataDog, or custom telemetry systems. The callback is invoked at each stage of incremental delivery: - `:initial` - When the initial response is sent - `:incremental` - When each deferred/streamed payload is delivered - `:complete` - When the stream completes successfully - `:error` - When an error occurs during streaming Each event includes payload data and metadata such as: - `operation_id` - Unique identifier for tracking - `path` - GraphQL path to the deferred field - `label` - Label from @defer/@stream directive - `duration_ms` - Time taken for the operation - `task_type` - `:defer` or `:stream` Example usage: Absinthe.run(query, schema, on_event: fn :error, payload, metadata -> Sentry.capture_message("GraphQL streaming error", extra: %{payload: payload, metadata: metadata} ) _, _, _ -> :ok end ) Co-Authored-By: Claude Opus 4.5 * feat: add telemetry events for incremental delivery instrumentation Add telemetry events for the incremental delivery transport layer to enable integration with instrumentation libraries like opentelemetry_absinthe. New telemetry events: - `[:absinthe, :incremental, :delivery, :initial]` Emitted when initial response is sent with has_next, pending_count - `[:absinthe, :incremental, :delivery, :payload]` Emitted for each @defer/@stream payload with path, label, task_type, duration, and success status - `[:absinthe, :incremental, :delivery, :complete]` Emitted when streaming completes successfully with total duration - `[:absinthe, :incremental, :delivery, :error]` Emitted on errors with reason and message All events include operation_id for correlation across spans. Events follow the same pattern as existing Absinthe telemetry events with measurements (system_time, duration) and metadata. This enables opentelemetry_absinthe and other instrumentation libraries to create proper spans for @defer/@stream operations. Co-Authored-By: Claude Opus 4.5 * docs: add incremental delivery telemetry documentation Update the telemetry guide to document the new @defer/@stream events: - [:absinthe, :incremental, :delivery, :initial] - [:absinthe, :incremental, :delivery, :payload] - [:absinthe, :incremental, :delivery, :complete] - [:absinthe, :incremental, :delivery, :error] Includes detailed documentation of measurements and metadata for each event, plus examples for attaching handlers and using the on_event callback for custom monitoring integrations. Co-Authored-By: Claude Opus 4.5 * docs: add incremental delivery to CHANGELOG Co-Authored-By: Claude Opus 4.5 * docs: clarify @defer/@stream are draft/RFC, not finalized spec The incremental delivery directives are still in the RFC stage and not yet part of the finalized GraphQL specification. Updated documentation to make this clear and link to the actual RFC. Co-Authored-By: Claude Opus 4.5 * feat: make @defer/@stream directives opt-in Move @defer and @stream directives from core built-ins to a new opt-in module Absinthe.Type.BuiltIns.IncrementalDirectives. Since @defer/@stream are draft-spec features (not yet finalized), users must now explicitly opt-in by adding: import_types Absinthe.Type.BuiltIns.IncrementalDirectives to their schema definition. Co-Authored-By: Claude Opus 4.5 * chore: fix formatting across incremental delivery files Run mix format to fix whitespace and formatting issues that were causing CI to fail. Co-Authored-By: Claude Opus 4.5 * ci: restore Elixir 1.19 support Restore Elixir 1.19 to the CI matrix to match upstream main. Co-Authored-By: Claude Opus 4.5 * feat: unify streaming architecture for subscriptions and incremental delivery - Add Absinthe.Streaming module with shared abstractions - Add Absinthe.Streaming.Executor behaviour for pluggable task execution - Add Absinthe.Streaming.TaskExecutor as default executor (Task.async_stream) - Add Absinthe.Streaming.Delivery for pubsub incremental delivery - Enable @defer/@stream in subscriptions (automatic multi-payload delivery) - Refactor Transport to use shared TaskExecutor - Update Subscription.Local to detect and handle incremental directives - Add comprehensive backwards compatibility tests - Update guides and documentation Subscriptions with @defer/@stream now automatically deliver multiple payloads using the standard GraphQL incremental format. Existing PubSub implementations work unchanged - publish_subscription/2 is called multiple times. Custom executors (Oban, RabbitMQ, etc.) can be configured via: - Schema attribute: @streaming_executor MyApp.ObanExecutor - Context: context: %{streaming_executor: MyApp.ObanExecutor} - Application config: config :absinthe, :streaming_executor, MyApp.ObanExecutor Co-Authored-By: Claude Opus 4.5 * refactor: extract middleware and telemetry modules for better discoverability - Move Absinthe.Middleware.IncrementalComplexity to its own file in lib/absinthe/middleware/ - Move Absinthe.Incremental.TelemetryReporter to its own file in lib/absinthe/incremental/ - Improves code organization and makes these modules easier to find Addresses PR review feedback from @bryanjos Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude --- .gitignore | 3 +- CHANGELOG.md | 33 + benchmarks/incremental_benchmark.exs | 463 ++++++++++++ debug_test.exs | 61 ++ guides/incremental-delivery.md | 674 ++++++++++++++++++ guides/subscriptions.md | 98 +++ guides/telemetry.md | 116 ++- lib/absinthe/incremental/complexity.ex | 613 ++++++++++++++++ lib/absinthe/incremental/config.ex | 359 ++++++++++ lib/absinthe/incremental/dataloader.ex | 366 ++++++++++ lib/absinthe/incremental/error_handler.ex | 418 +++++++++++ lib/absinthe/incremental/resource_manager.ex | 349 +++++++++ lib/absinthe/incremental/response.ex | 271 +++++++ lib/absinthe/incremental/supervisor.ex | 198 +++++ .../incremental/telemetry_reporter.ex | 81 +++ lib/absinthe/incremental/transport.ex | 533 ++++++++++++++ lib/absinthe/middleware/auto_defer_stream.ex | 542 ++++++++++++++ .../middleware/incremental_complexity.ex | 94 +++ .../execution/streaming_resolution.ex | 451 ++++++++++++ lib/absinthe/pipeline/incremental.ex | 375 ++++++++++ lib/absinthe/resolution/projector.ex | 6 + lib/absinthe/schema/notation/sdl_render.ex | 70 +- lib/absinthe/streaming.ex | 128 ++++ lib/absinthe/streaming/delivery.ex | 261 +++++++ lib/absinthe/streaming/executor.ex | 201 ++++++ lib/absinthe/streaming/task_executor.ex | 236 ++++++ lib/absinthe/subscription/local.ex | 79 +- lib/absinthe/type/built_ins.ex | 13 + .../type/built_ins/incremental_directives.ex | 122 ++++ lib/absinthe/type/field.ex | 4 +- lib/mix/tasks/absinthe.schema.json.ex | 9 +- lib/mix/tasks/absinthe.schema.sdl.ex | 9 +- mix.exs | 2 + mix.lock | 3 + test/absinthe/incremental/complexity_test.exs | 399 +++++++++++ test/absinthe/incremental/config_test.exs | 143 ++++ test/absinthe/incremental/defer_test.exs | 301 ++++++++ test/absinthe/incremental/stream_test.exs | 320 +++++++++ .../introspection/directives_test.exs | 101 +-- test/absinthe/introspection_test.exs | 2 + .../streaming/backwards_compat_test.exs | 272 +++++++ .../absinthe/streaming/task_executor_test.exs | 195 +++++ 42 files changed, 8845 insertions(+), 129 deletions(-) create mode 100644 benchmarks/incremental_benchmark.exs create mode 100644 debug_test.exs create mode 100644 guides/incremental-delivery.md create mode 100644 lib/absinthe/incremental/complexity.ex create mode 100644 lib/absinthe/incremental/config.ex create mode 100644 lib/absinthe/incremental/dataloader.ex create mode 100644 lib/absinthe/incremental/error_handler.ex create mode 100644 lib/absinthe/incremental/resource_manager.ex create mode 100644 lib/absinthe/incremental/response.ex create mode 100644 lib/absinthe/incremental/supervisor.ex create mode 100644 lib/absinthe/incremental/telemetry_reporter.ex create mode 100644 lib/absinthe/incremental/transport.ex create mode 100644 lib/absinthe/middleware/auto_defer_stream.ex create mode 100644 lib/absinthe/middleware/incremental_complexity.ex create mode 100644 lib/absinthe/phase/document/execution/streaming_resolution.ex create mode 100644 lib/absinthe/pipeline/incremental.ex create mode 100644 lib/absinthe/streaming.ex create mode 100644 lib/absinthe/streaming/delivery.ex create mode 100644 lib/absinthe/streaming/executor.ex create mode 100644 lib/absinthe/streaming/task_executor.ex create mode 100644 lib/absinthe/type/built_ins.ex create mode 100644 lib/absinthe/type/built_ins/incremental_directives.ex create mode 100644 test/absinthe/incremental/complexity_test.exs create mode 100644 test/absinthe/incremental/config_test.exs create mode 100644 test/absinthe/incremental/defer_test.exs create mode 100644 test/absinthe/incremental/stream_test.exs create mode 100644 test/absinthe/streaming/backwards_compat_test.exs create mode 100644 test/absinthe/streaming/task_executor_test.exs diff --git a/.gitignore b/.gitignore index 814da1a8fb..80560a3d47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.claude +.vscode /bench /_build /cover @@ -7,7 +9,6 @@ erl_crash.dump *.ez src/*.erl -.tool-versions* missing_rules.rb .DS_Store /priv/plts/*.plt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce4726249..bc22fdacfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## Unreleased + +### Features + +* **draft-spec:** Add `@defer` and `@stream` directives for incremental delivery ([#1377](https://github.com/absinthe-graphql/absinthe/pull/1377)) + - **Note:** These directives are still in draft/RFC stage and not yet part of the finalized GraphQL specification + - **Opt-in required:** `import_directives Absinthe.Type.BuiltIns.IncrementalDirectives` in your schema + - Split GraphQL responses into initial + incremental payloads + - Configure via `Absinthe.Pipeline.Incremental.enable/2` + - Resource limits (max concurrent streams, memory, duration) + - Dataloader integration for batched loading + - SSE and WebSocket transport support +* **subscriptions:** Support `@defer` and `@stream` in subscriptions + - Subscriptions with deferred content deliver multiple payloads automatically + - Existing PubSub implementations work unchanged (calls `publish_subscription/2` multiple times) + - Uses standard GraphQL incremental delivery format that clients already understand +* **streaming:** Unified streaming architecture for queries and subscriptions + - New `Absinthe.Streaming` module consolidates shared abstractions + - `Absinthe.Streaming.Executor` behaviour for pluggable task execution backends + - `Absinthe.Streaming.TaskExecutor` default executor using `Task.async_stream` + - `Absinthe.Streaming.Delivery` handles pubsub delivery for subscriptions + - Both query and subscription incremental delivery share the same execution path +* **executors:** Pluggable task execution backends + - Implement `Absinthe.Streaming.Executor` to use custom backends (Oban, RabbitMQ, etc.) + - Configure via `@streaming_executor` schema attribute, context, or application config + - Default executor uses `Task.async_stream` with configurable concurrency and timeouts +* **telemetry:** Add telemetry events for incremental delivery + - `[:absinthe, :incremental, :delivery, :initial]` - initial response + - `[:absinthe, :incremental, :delivery, :payload]` - each deferred/streamed payload + - `[:absinthe, :incremental, :delivery, :complete]` - stream completed + - `[:absinthe, :incremental, :delivery, :error]` - error during streaming +* **monitoring:** Add `on_event` callback for custom monitoring integrations (Sentry, DataDog) + ## [1.9.0](https://github.com/absinthe-graphql/absinthe/compare/v1.8.0...v1.9.0) (2025-11-21) diff --git a/benchmarks/incremental_benchmark.exs b/benchmarks/incremental_benchmark.exs new file mode 100644 index 0000000000..122e130c5b --- /dev/null +++ b/benchmarks/incremental_benchmark.exs @@ -0,0 +1,463 @@ +defmodule Absinthe.IncrementalBenchmark do + @moduledoc """ + Performance benchmarks for incremental delivery features. + + Run with: mix run benchmarks/incremental_benchmark.exs + """ + + alias Absinthe.Incremental.{Config, Complexity} + + defmodule BenchmarkSchema do + use Absinthe.Schema + + @users Enum.map(1..1000, fn i -> + %{ + id: "user_#{i}", + name: "User #{i}", + email: "user#{i}@example.com", + posts: Enum.map(1..10, fn j -> + "post_#{i}_#{j}" + end) + } + end) + + @posts Enum.map(1..10000, fn i -> + %{ + id: "post_#{i}", + title: "Post #{i}", + content: String.duplicate("Content ", 100), + comments: Enum.map(1..20, fn j -> + "comment_#{i}_#{j}" + end), + author_id: "user_#{rem(i, 1000) + 1}" + } + end) + + @comments Enum.map(1..50000, fn i -> + %{ + id: "comment_#{i}", + text: "Comment text #{i}", + author_id: "user_#{rem(i, 1000) + 1}" + } + end) + + query do + field :users, list_of(:user) do + arg :limit, :integer, default_value: 100 + + # Add complexity calculation + middleware Absinthe.Middleware.IncrementalComplexity, %{ + max_complexity: 10000 + } + + resolve fn args, _ -> + users = Enum.take(@users, args.limit) + {:ok, users} + end + end + + field :posts, list_of(:post) do + arg :limit, :integer, default_value: 100 + + resolve fn args, _ -> + posts = Enum.take(@posts, args.limit) + {:ok, posts} + end + end + end + + object :user do + field :id, :id + field :name, :string + field :email, :string + + field :posts, list_of(:post) do + # Complexity: list type with potential N+1 + complexity fn _, child_complexity -> + # Base cost of 10 + child complexity + 10 + child_complexity + end + + resolve fn user, _ -> + posts = Enum.filter(@posts, & &1.author_id == user.id) + {:ok, posts} + end + end + end + + object :post do + field :id, :id + field :title, :string + field :content, :string + + field :author, :user do + complexity 2 # Simple lookup + + resolve fn post, _ -> + user = Enum.find(@users, & &1.id == post.author_id) + {:ok, user} + end + end + + field :comments, list_of(:comment) do + # High complexity for nested list + complexity fn _, child_complexity -> + 20 + child_complexity + end + + resolve fn post, _ -> + comments = Enum.filter(@comments, fn c -> + Enum.member?(post.comments, c.id) + end) + {:ok, comments} + end + end + end + + object :comment do + field :id, :id + field :text, :string + + field :author, :user do + complexity 2 + + resolve fn comment, _ -> + user = Enum.find(@users, & &1.id == comment.author_id) + {:ok, user} + end + end + end + end + + def run do + IO.puts("\n=== Absinthe Incremental Delivery Benchmarks ===\n") + + # Warm up + warmup() + + # Run benchmarks + benchmark_standard_vs_defer() + benchmark_standard_vs_stream() + benchmark_complexity_analysis() + benchmark_memory_usage() + benchmark_concurrent_operations() + + IO.puts("\n=== Benchmark Complete ===\n") + end + + defp warmup do + IO.puts("Warming up...") + + query = "{ users(limit: 1) { id } }" + Absinthe.run(query, BenchmarkSchema) + + IO.puts("Warmup complete\n") + end + + defp benchmark_standard_vs_defer do + IO.puts("## Standard vs Defer Performance\n") + + standard_query = """ + query { + users(limit: 50) { + id + name + posts { + id + title + comments { + id + text + } + } + } + } + """ + + defer_query = """ + query { + users(limit: 50) { + id + name + ... @defer(label: "userPosts") { + posts { + id + title + ... @defer(label: "postComments") { + comments { + id + text + } + } + } + } + } + } + """ + + standard_time = measure_time(fn -> + Absinthe.run(standard_query, BenchmarkSchema) + end, 100) + + defer_time = measure_time(fn -> + run_with_streaming(defer_query) + end, 100) + + IO.puts("Standard query: #{format_time(standard_time)}") + IO.puts("Defer query (initial): #{format_time(defer_time)}") + IO.puts("Improvement: #{format_percentage(standard_time, defer_time)}\n") + end + + defp benchmark_standard_vs_stream do + IO.puts("## Standard vs Stream Performance\n") + + standard_query = """ + query { + posts(limit: 100) { + id + title + content + } + } + """ + + stream_query = """ + query { + posts(limit: 100) @stream(initialCount: 10) { + id + title + content + } + } + """ + + standard_time = measure_time(fn -> + Absinthe.run(standard_query, BenchmarkSchema) + end, 100) + + stream_time = measure_time(fn -> + run_with_streaming(stream_query) + end, 100) + + IO.puts("Standard query: #{format_time(standard_time)}") + IO.puts("Stream query (initial): #{format_time(stream_time)}") + IO.puts("Improvement: #{format_percentage(standard_time, stream_time)}\n") + end + + defp benchmark_complexity_analysis do + IO.puts("## Complexity Analysis Performance\n") + + queries = [ + {"Simple", "{ users(limit: 10) { id name } }"}, + {"With defer", "{ users(limit: 10) { id ... @defer { name email } } }"}, + {"With stream", "{ users(limit: 100) @stream(initialCount: 10) { id name } }"}, + {"Nested defer", """ + { + users(limit: 10) { + id + ... @defer { + posts { + id + ... @defer { + comments { id } + } + } + } + } + } + """} + ] + + Enum.each(queries, fn {name, query} -> + time = measure_time(fn -> + {:ok, blueprint} = Absinthe.Phase.Parse.run(query) + Complexity.analyze(blueprint) + end, 1000) + + {:ok, blueprint} = Absinthe.Phase.Parse.run(query) + {:ok, info} = Complexity.analyze(blueprint) + + IO.puts("#{name}:") + IO.puts(" Analysis time: #{format_time(time)}") + IO.puts(" Complexity: #{info.total_complexity}") + IO.puts(" Defer count: #{info.defer_count}") + IO.puts(" Stream count: #{info.stream_count}") + IO.puts(" Estimated payloads: #{info.estimated_payloads}") + end) + + IO.puts("") + end + + defp benchmark_memory_usage do + IO.puts("## Memory Usage\n") + + query = """ + query { + users(limit: 100) { + id + name + posts { + id + title + comments { + id + text + } + } + } + } + """ + + defer_query = """ + query { + users(limit: 100) { + id + name + ... @defer { + posts { + id + title + ... @defer { + comments { + id + text + } + } + } + } + } + } + """ + + standard_memory = measure_memory(fn -> + Absinthe.run(query, BenchmarkSchema) + end) + + defer_memory = measure_memory(fn -> + run_with_streaming(defer_query) + end) + + IO.puts("Standard query memory: #{format_memory(standard_memory)}") + IO.puts("Defer query memory: #{format_memory(defer_memory)}") + IO.puts("Memory savings: #{format_percentage(standard_memory, defer_memory)}\n") + end + + defp benchmark_concurrent_operations do + IO.puts("## Concurrent Operations\n") + + query = """ + query { + users(limit: 20) @stream(initialCount: 5) { + id + name + ... @defer { + posts { + id + title + } + } + } + } + """ + + concurrency_levels = [1, 5, 10, 20, 50] + + Enum.each(concurrency_levels, fn level -> + time = measure_concurrent(fn -> + run_with_streaming(query) + end, level, 10) + + IO.puts("Concurrency #{level}: #{format_time(time)}/op") + end) + + IO.puts("") + end + + # Helper functions + + defp run_with_streaming(query) do + config = Config.from_options(enabled: true) + + pipeline = + BenchmarkSchema + |> Absinthe.Pipeline.for_document(context: %{incremental_config: config}) + |> Absinthe.Pipeline.Incremental.enable() + + Absinthe.Pipeline.run(query, pipeline) + end + + defp measure_time(fun, iterations) do + times = for _ <- 1..iterations do + {time, _} = :timer.tc(fun) + time + end + + Enum.sum(times) / iterations + end + + defp measure_memory(fun) do + :erlang.garbage_collect() + before = :erlang.memory(:total) + + fun.() + + :erlang.garbage_collect() + after_mem = :erlang.memory(:total) + + after_mem - before + end + + defp measure_concurrent(fun, concurrency, iterations) do + total_time = + 1..iterations + |> Enum.map(fn _ -> + tasks = for _ <- 1..concurrency do + Task.async(fun) + end + + {time, _} = :timer.tc(fn -> + Task.await_many(tasks, 30_000) + end) + + time + end) + |> Enum.sum() + + total_time / (iterations * concurrency) + end + + defp format_time(microseconds) do + cond do + microseconds < 1_000 -> + "#{Float.round(microseconds, 2)}μs" + microseconds < 1_000_000 -> + "#{Float.round(microseconds / 1_000, 2)}ms" + true -> + "#{Float.round(microseconds / 1_000_000, 2)}s" + end + end + + defp format_memory(bytes) do + cond do + bytes < 1024 -> + "#{bytes}B" + bytes < 1024 * 1024 -> + "#{Float.round(bytes / 1024, 2)}KB" + true -> + "#{Float.round(bytes / (1024 * 1024), 2)}MB" + end + end + + defp format_percentage(original, optimized) do + improvement = (1 - optimized / original) * 100 + + if improvement > 0 do + "#{Float.round(improvement, 1)}% faster" + else + "#{Float.round(-improvement, 1)}% slower" + end + end +end + +# Run the benchmark +Absinthe.IncrementalBenchmark.run() \ No newline at end of file diff --git a/debug_test.exs b/debug_test.exs new file mode 100644 index 0000000000..c7366895f8 --- /dev/null +++ b/debug_test.exs @@ -0,0 +1,61 @@ +#!/usr/bin/env elixir + +# Simple script to debug directive processing + +defmodule DebugSchema do + use Absinthe.Schema + + query do + field :test, :string do + resolve fn _, _ -> {:ok, "test"} end + end + end +end + +# Test query with defer directive +query = """ +{ + test + ... @defer(label: "test") { + test + } +} +""" + +IO.puts("Testing defer directive processing...") + +# Skip standard pipeline test - it crashes on defer flags +# This is expected behavior - the standard pipeline can't handle defer flags +IO.puts("\n=== Standard Pipeline ===") +IO.puts("Skipping standard pipeline - defer flags require streaming resolution") + +# Test with incremental pipeline +IO.puts("\n=== Incremental Pipeline ===") +pipeline_modifier = fn pipeline, _options -> + IO.puts("Pipeline before modification:") + IO.inspect(pipeline |> Enum.map(fn + {phase, _opts} -> phase + phase when is_atom(phase) -> phase + phase -> inspect(phase) + end), label: "Pipeline phases") + + modified = Absinthe.Pipeline.Incremental.enable(pipeline, + enabled: true, + enable_defer: true, + enable_stream: true + ) + + IO.puts("Pipeline after modification:") + IO.inspect(modified |> Enum.map(fn + {phase, _opts} -> phase + phase when is_atom(phase) -> phase + phase -> inspect(phase) + end), label: "Modified pipeline phases") + + modified +end + +result2 = Absinthe.run(query, DebugSchema, pipeline_modifier: pipeline_modifier) +IO.inspect(result2, label: "Incremental result") + +IO.puts("\nDone!") \ No newline at end of file diff --git a/guides/incremental-delivery.md b/guides/incremental-delivery.md new file mode 100644 index 0000000000..f9b9e320c7 --- /dev/null +++ b/guides/incremental-delivery.md @@ -0,0 +1,674 @@ +# Incremental Delivery + +> **Note:** The `@defer` and `@stream` directives are currently in draft/RFC stage and not yet part of the finalized GraphQL specification. The implementation follows the [Incremental Delivery RFC](https://github.com/graphql/graphql-spec/blob/main/rfcs/DeferStream.md) and may change as the specification evolves. + +GraphQL's incremental delivery allows responses to be sent in multiple parts, reducing initial response time and improving user experience. Absinthe supports this through the `@defer` and `@stream` directives. + +## Overview + +Incremental delivery splits GraphQL responses into: + +- **Initial response**: Fast delivery of immediately available data +- **Incremental responses**: Subsequent delivery of deferred/streamed data + +This pattern is especially useful for: +- Complex queries with expensive fields +- Large lists that can be paginated +- Progressive data loading in UIs + +## Installation + +Incremental delivery is built into Absinthe 1.7+ and requires no additional dependencies. + +```elixir +def deps do + [ + {:absinthe, "~> 1.7"}, + {:absinthe_phoenix, "~> 2.0"} # For WebSocket transport + ] +end +``` + +## Schema Setup + +Since `@defer` and `@stream` are draft-spec features, you must explicitly opt-in by importing the directives in your schema: + +```elixir +defmodule MyApp.Schema do + use Absinthe.Schema + + # Import the draft-spec @defer and @stream directives + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + query do + # ... + end +end +``` + +Without this import, the `@defer` and `@stream` directives will not be available in your schema. + +## Basic Usage + +### The @defer Directive + +The `@defer` directive allows you to defer execution of fragments: + +```elixir +# In your schema +query do + field :user, :user do + arg :id, non_null(:id) + resolve &MyApp.Resolvers.user_by_id/2 + end +end + +object :user do + field :id, non_null(:id) + field :name, non_null(:string) + + # These fields will be resolved when deferred + field :email, :string + field :profile, :profile +end +``` + +```graphql +query GetUser($userId: ID!) { + user(id: $userId) { + id + name + + # This fragment will be deferred + ... @defer(label: "profile") { + email + profile { + bio + avatar + } + } + } +} +``` + +**Response sequence:** + +1. Initial response: +```json +{ + "data": { + "user": { + "id": "123", + "name": "John Doe" + } + }, + "pending": [ + {"id": "0", "label": "profile", "path": ["user"]} + ] +} +``` + +2. Deferred response: +```json +{ + "id": "0", + "data": { + "email": "john@example.com", + "profile": { + "bio": "Software Engineer", + "avatar": "avatar.jpg" + } + } +} +``` + +### The @stream Directive + +The `@stream` directive allows you to stream list fields: + +```elixir +# In your schema +query do + field :posts, list_of(:post) do + resolve &MyApp.Resolvers.all_posts/2 + end +end + +object :post do + field :id, non_null(:id) + field :title, non_null(:string) + field :content, :string +end +``` + +```graphql +query GetPosts { + # Stream posts 3 at a time, starting with 2 initially + posts @stream(initialCount: 2, label: "morePosts") { + id + title + content + } +} +``` + +**Response sequence:** + +1. Initial response with first 2 posts: +```json +{ + "data": { + "posts": [ + {"id": "1", "title": "First Post", "content": "..."}, + {"id": "2", "title": "Second Post", "content": "..."} + ] + }, + "pending": [ + {"id": "0", "label": "morePosts", "path": ["posts"]} + ] +} +``` + +2. Streamed responses with remaining posts: +```json +{ + "id": "0", + "items": [ + {"id": "3", "title": "Third Post", "content": "..."}, + {"id": "4", "title": "Fourth Post", "content": "..."}, + {"id": "5", "title": "Fifth Post", "content": "..."} + ] +} +``` + +## Enabling Incremental Delivery + +### Using Pipeline Modifier + +Enable incremental delivery using a pipeline modifier: + +```elixir +# In your controller/resolver +def execute_query(query, variables) do + pipeline_modifier = fn pipeline, _options -> + Absinthe.Pipeline.Incremental.enable(pipeline, + enabled: true, + enable_defer: true, + enable_stream: true + ) + end + + Absinthe.run(query, MyApp.Schema, + variables: variables, + pipeline_modifier: pipeline_modifier + ) +end +``` + +### Configuration Options + +```elixir +config = [ + # Feature flags + enabled: true, + enable_defer: true, + enable_stream: true, + + # Resource limits + max_concurrent_streams: 100, + max_stream_duration: 30_000, # 30 seconds + max_memory_mb: 500, + + # Batching settings + default_stream_batch_size: 10, + max_stream_batch_size: 100, + + # Transport settings + transport: :auto, # :auto | :sse | :websocket + + # Error handling + error_recovery_enabled: true, + max_retry_attempts: 3 +] + +Absinthe.Pipeline.Incremental.enable(pipeline, config) +``` + +## Transport Integration + +### Phoenix WebSocket + +```elixir +# In your Phoenix socket +def handle_in("doc", payload, socket) do + pipeline_modifier = fn pipeline, _options -> + Absinthe.Pipeline.Incremental.enable(pipeline, enabled: true) + end + + case Absinthe.run(payload["query"], MyApp.Schema, + variables: payload["variables"], + pipeline_modifier: pipeline_modifier + ) do + {:ok, %{data: data, pending: pending}} -> + push(socket, "data", %{data: data}) + + # Handle incremental responses + handle_incremental_responses(socket, pending) + + {:ok, %{data: data}} -> + push(socket, "data", %{data: data}) + end + + {:noreply, socket} +end + +defp handle_incremental_responses(socket, pending) do + # Implementation depends on your transport + # This would handle the streaming of deferred/streamed data +end +``` + +### Server-Sent Events (SSE) + +```elixir +# In your Phoenix controller +def stream_query(conn, params) do + conn = conn + |> put_resp_header("content-type", "text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> send_chunked(:ok) + + pipeline_modifier = fn pipeline, _options -> + Absinthe.Pipeline.Incremental.enable(pipeline, enabled: true) + end + + case Absinthe.run(params["query"], MyApp.Schema, + variables: params["variables"], + pipeline_modifier: pipeline_modifier + ) do + {:ok, result} -> + send_sse_event(conn, "data", result.data) + + if Map.has_key?(result, :pending) do + handle_sse_streaming(conn, result.pending) + end + end +end +``` + +## Advanced Usage + +### Conditional Deferral + +Use the `if` argument to conditionally defer: + +```graphql +query GetUser($userId: ID!, $includeProfile: Boolean = false) { + user(id: $userId) { + id + name + + ... @defer(if: $includeProfile, label: "profile") { + email + profile { bio } + } + } +} +``` + +### Nested Deferral + +Defer nested fragments: + +```graphql +query GetUserData($userId: ID!) { + user(id: $userId) { + id + name + + ... @defer(label: "level1") { + email + posts { + id + title + + ... @defer(label: "level2") { + content + comments { text } + } + } + } + } +} +``` + +### Complex Streaming + +Stream with different batch sizes: + +```graphql +query GetDashboard { + # Stream recent posts quickly + recentPosts @stream(initialCount: 3, label: "recentPosts") { + id + title + } + + # Stream popular posts more slowly + popularPosts @stream(initialCount: 1, label: "popularPosts") { + id + title + metrics { views } + } +} +``` + +## Error Handling + +Incremental delivery handles errors gracefully: + +```elixir +# Errors in deferred fragments don't affect initial response +{:ok, %{ + data: %{"user" => %{"id" => "123", "name" => "John"}}, + pending: [%{id: "0", label: "profile"}] +}} + +# Later, deferred response with error +{:error, %{ + id: "0", + errors: [%{message: "Profile not found", path: ["user", "profile"]}] +}} +``` + +## Performance Considerations + +### Batching with Dataloader + +Incremental delivery works with Dataloader: + +```elixir +# The dataloader will batch across all streaming operations +field :posts, list_of(:post) do + resolve dataloader(Blog, :posts_by_user_id) +end +``` + +### Resource Management + +Configure limits to prevent resource exhaustion: + +```elixir +config = [ + max_concurrent_streams: 50, + max_stream_duration: 30_000, + max_memory_mb: 200 +] +``` + +### Monitoring + +Use telemetry for observability: + +```elixir +# Attach telemetry handlers +:telemetry.attach_many( + "incremental-delivery", + [ + [:absinthe, :incremental, :start], + [:absinthe, :incremental, :stop], + [:absinthe, :incremental, :defer, :start], + [:absinthe, :incremental, :stream, :start] + ], + &MyApp.Telemetry.handle_event/4, + nil +) +``` + +## Relay Integration + +Incremental delivery works seamlessly with Relay connections: + +```graphql +query GetUserPosts($userId: ID!, $first: Int) { + user(id: $userId) { + id + name + + posts(first: $first) @stream(initialCount: 5, label: "morePosts") { + edges { + node { id title } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +``` + +## Testing + +Test incremental delivery in your test suite: + +```elixir +test "incremental delivery with @defer" do + query = """ + query GetUser($id: ID!) { + user(id: $id) { + id + name + ... @defer(label: "profile") { + email + } + } + } + """ + + pipeline_modifier = fn pipeline, _options -> + Absinthe.Pipeline.Incremental.enable(pipeline, enabled: true) + end + + assert {:ok, result} = Absinthe.run(query, MyApp.Schema, + variables: %{"id" => "123"}, + pipeline_modifier: pipeline_modifier + ) + + # Check initial response + assert result.data["user"]["id"] == "123" + assert result.data["user"]["name"] == "John" + refute Map.has_key?(result.data["user"], "email") + + # Check pending operations + assert [%{label: "profile"}] = result.pending +end +``` + +## Migration Guide + +Existing queries work without changes. To add incremental delivery: + +1. **Identify expensive fields** that can be deferred +2. **Find large lists** that can be streamed +3. **Add directives gradually** to minimize risk +4. **Configure transport** to handle streaming responses +5. **Add monitoring** to track performance improvements + +## Subscriptions with @defer/@stream + +Subscriptions support the same `@defer` and `@stream` directives as queries. When a subscription contains deferred content, clients receive multiple payloads: + +1. **Initial payload**: Immediately available subscription data +2. **Incremental payloads**: Deferred/streamed content as it resolves + +```graphql +subscription OnOrderUpdated($orderId: ID!) { + orderUpdated(orderId: $orderId) { + id + status + + # Defer expensive customer lookup + ... @defer(label: "customer") { + customer { + name + email + loyaltyTier + } + } + } +} +``` + +This is handled automatically by the subscription system. Existing PubSub implementations work unchanged - the same `publish_subscription/2` callback is called multiple times with the standard GraphQL incremental format. + +### How It Works + +When a mutation triggers a subscription with `@defer`/`@stream`: + +1. `Subscription.Local` detects the directives in the subscription document +2. The `StreamingResolution` phase executes, collecting deferred tasks +3. `Streaming.Delivery` publishes the initial payload via `pubsub.publish_subscription/2` +4. Deferred tasks are executed via the configured executor +5. Each result is published as an incremental payload + +```elixir +# What happens internally (you don't need to do this manually) +pubsub.publish_subscription(topic, %{ + data: %{orderUpdated: %{id: "123", status: "SHIPPED"}}, + pending: [%{id: "0", label: "customer", path: ["orderUpdated"]}], + hasNext: true +}) + +# Later... +pubsub.publish_subscription(topic, %{ + incremental: [%{ + id: "0", + data: %{customer: %{name: "John", email: "john@example.com", loyaltyTier: "GOLD"}} + }], + hasNext: false +}) +``` + +## Custom Executors + +By default, deferred and streamed tasks are executed using `Task.async_stream` for in-process concurrent execution. You can implement a custom executor for alternative backends: + +- **Oban** - Persistent, retryable job processing +- **RabbitMQ** - Distributed task queuing +- **GenStage** - Backpressure-aware pipelines +- **Custom** - Any execution strategy you need + +### Implementing a Custom Executor + +Implement the `Absinthe.Streaming.Executor` behaviour: + +```elixir +defmodule MyApp.ObanExecutor do + @behaviour Absinthe.Streaming.Executor + + @impl true + def execute(tasks, opts) do + timeout = Keyword.get(opts, :timeout, 30_000) + + # Queue tasks to Oban and stream results + tasks + |> Enum.map(&queue_to_oban/1) + |> stream_results(timeout) + end + + defp queue_to_oban(task) do + %{task_id: task.id, execute_fn: task.execute} + |> MyApp.DeferredWorker.new() + |> Oban.insert!() + end + + defp stream_results(jobs, timeout) do + # Return an enumerable of results matching this shape: + # %{ + # task: original_task, + # result: {:ok, data} | {:error, reason}, + # has_next: boolean, + # success: boolean, + # duration_ms: integer + # } + Stream.resource( + fn -> {jobs, timeout} end, + &poll_next_result/1, + fn _ -> :ok end + ) + end +end +``` + +### Configuring a Custom Executor + +**Schema-level** (recommended): + +```elixir +defmodule MyApp.Schema do + use Absinthe.Schema + + # Use custom executor for all @defer/@stream operations + @streaming_executor MyApp.ObanExecutor + + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + query do + # ... + end +end +``` + +**Per-request** (via context): + +```elixir +Absinthe.run(query, MyApp.Schema, + context: %{streaming_executor: MyApp.ObanExecutor} +) +``` + +**Application config** (global default): + +```elixir +# config/config.exs +config :absinthe, :streaming_executor, MyApp.ObanExecutor +``` + +### When to Use Custom Executors + +| Use Case | Recommended Executor | +|----------|---------------------| +| Simple deployments | Default `TaskExecutor` | +| Long-running deferred operations | Oban (with persistence) | +| Distributed systems | RabbitMQ or similar | +| High-throughput with backpressure | GenStage | +| Retry on failure | Oban | + +## Architecture + +The streaming system is unified across queries, mutations, and subscriptions: + +``` +Absinthe.Streaming +├── Executor - Behaviour for pluggable execution backends +├── TaskExecutor - Default executor (Task.async_stream) +└── Delivery - Handles pubsub delivery for subscriptions + +Query/Mutation Path: + Request → Pipeline → StreamingResolution → Transport → Client + +Subscription Path: + Mutation → Subscription.Local → StreamingResolution → Streaming.Delivery + → pubsub.publish_subscription/2 (multiple times) → Client +``` + +Both paths share the same `Executor` for task execution, ensuring consistent behavior and allowing a single configuration point for custom backends. + +## See Also + +- [Subscriptions](subscriptions.md) for real-time data +- [Dataloader](dataloader.md) for efficient data fetching +- [Telemetry](telemetry.md) for observability +- [GraphQL Incremental Delivery RFC](https://github.com/graphql/graphql-spec/blob/main/rfcs/DeferStream.md) \ No newline at end of file diff --git a/guides/subscriptions.md b/guides/subscriptions.md index d0b7384121..785ed629df 100644 --- a/guides/subscriptions.md +++ b/guides/subscriptions.md @@ -266,3 +266,101 @@ Since we provided a `context_id`, Absinthe will only run two documents per publi 1. Once for _user 1_ and _user 3_ because they have the same context ID (`"global"`) and sent the same document. 2. Once for _user 2_. While _user 2_ has the same context ID (`"global"`), they provided a different document, so it cannot be de-duplicated with the other two. + +### Incremental Delivery with Subscriptions + +Subscriptions support `@defer` and `@stream` directives for incremental delivery. This allows you to receive subscription data progressively - immediately available data first, followed by deferred content. + +First, import the incremental directives in your schema: + +```elixir +defmodule MyAppWeb.Schema do + use Absinthe.Schema + + # Enable @defer and @stream directives + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + # ... rest of schema +end +``` + +Then use `@defer` in your subscription queries: + +```graphql +subscription { + commentAdded(repoName: "absinthe-graphql/absinthe") { + id + content + author { + name + } + + # Defer expensive operations + ... @defer(label: "authorDetails") { + author { + email + avatarUrl + recentActivity { + type + timestamp + } + } + } + } +} +``` + +When a mutation triggers this subscription, clients receive multiple payloads: + +**Initial payload** (sent immediately): +```json +{ + "data": { + "commentAdded": { + "id": "123", + "content": "Great library!", + "author": { "name": "John" } + } + }, + "pending": [{"id": "0", "label": "authorDetails", "path": ["commentAdded"]}], + "hasNext": true +} +``` + +**Incremental payload** (sent when deferred data resolves): +```json +{ + "incremental": [{ + "id": "0", + "data": { + "author": { + "email": "john@example.com", + "avatarUrl": "https://...", + "recentActivity": [...] + } + } + }], + "hasNext": false +} +``` + +This is handled automatically by the subscription system. Your existing PubSub implementation works unchanged - it receives multiple `publish_subscription/2` calls with the standard GraphQL incremental format. + +#### Custom Executors for Subscriptions + +For long-running deferred operations in subscriptions, you can configure a custom executor (e.g., Oban for persistence): + +```elixir +defmodule MyAppWeb.Schema do + use Absinthe.Schema + + # Use Oban for deferred task execution + @streaming_executor MyApp.ObanExecutor + + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + # ... +end +``` + +See the [Incremental Delivery guide](incremental-delivery.md) for details on implementing custom executors. diff --git a/guides/telemetry.md b/guides/telemetry.md index a9c607b878..ae7db7f919 100644 --- a/guides/telemetry.md +++ b/guides/telemetry.md @@ -13,11 +13,22 @@ handler function to any of the following event names: - `[:absinthe, :resolve, :field, :stop]` when field resolution finishes - `[:absinthe, :middleware, :batch, :start]` when the batch processing starts - `[:absinthe, :middleware, :batch, :stop]` when the batch processing finishes -- `[:absinthe, :middleware, :batch, :timeout]` whe the batch processing times out +- `[:absinthe, :middleware, :batch, :timeout]` when the batch processing times out + +### Incremental Delivery Events (@defer/@stream) + +When using `@defer` or `@stream` directives, additional events are emitted: + +- `[:absinthe, :incremental, :start]` when incremental delivery begins +- `[:absinthe, :incremental, :stop]` when incremental delivery ends +- `[:absinthe, :incremental, :delivery, :initial]` when the initial response is sent +- `[:absinthe, :incremental, :delivery, :payload]` when each deferred/streamed payload is delivered +- `[:absinthe, :incremental, :delivery, :complete]` when all payloads have been delivered +- `[:absinthe, :incremental, :delivery, :error]` when an error occurs during streaming Telemetry handlers are called with `measurements` and `metadata`. For details on what is passed, checkout `Absinthe.Phase.Telemetry`, `Absinthe.Middleware.Telemetry`, -and `Absinthe.Middleware.Batch`. +`Absinthe.Middleware.Batch`, and `Absinthe.Incremental.Transport`. For async, batch, and dataloader fields, Absinthe sends the final event when it gets the results. That might be later than when the results are ready. If @@ -89,3 +100,104 @@ Instead, you can add the `:opentelemetry_process_propagator` package to your dependencies, which has a `Task.async/1` wrapper that will attach the context automatically. If the package is installed, the middleware will use it in place of the default `Task.async/1`. + +## Incremental Delivery Telemetry Details + +The incremental delivery events provide detailed information for tracing `@defer` and +`@stream` operations. All delivery events include an `operation_id` for correlating +events within the same operation. + +### `[:absinthe, :incremental, :delivery, :initial]` + +Emitted when the initial response (with `hasNext: true`) is sent to the client. + +**Measurements:** +- `system_time` - System time when the event occurred + +**Metadata:** +- `operation_id` - Unique identifier for correlating events +- `has_next` - Always `true` for initial response +- `pending_count` - Number of pending deferred/streamed operations +- `response` - The initial response payload + +### `[:absinthe, :incremental, :delivery, :payload]` + +Emitted for each `@defer` or `@stream` payload delivered to the client. + +**Measurements:** +- `system_time` - System time when the event occurred +- `duration` - Time to execute this specific deferred/streamed task (native time units) + +**Metadata:** +- `operation_id` - Unique identifier for correlating events +- `path` - GraphQL path to the deferred/streamed field (e.g., `["user", "profile"]`) +- `label` - Label from the directive (e.g., `@defer(label: "userProfile")`) +- `task_type` - Either `:defer` or `:stream` +- `has_next` - Whether more payloads are expected +- `duration_ms` - Duration in milliseconds +- `success` - Whether the task completed successfully +- `response` - The incremental response payload + +### `[:absinthe, :incremental, :delivery, :complete]` + +Emitted when all payloads have been delivered successfully. + +**Measurements:** +- `system_time` - System time when the event occurred +- `duration` - Total duration of the incremental delivery (native time units) + +**Metadata:** +- `operation_id` - Unique identifier for correlating events +- `duration_ms` - Total duration in milliseconds + +### `[:absinthe, :incremental, :delivery, :error]` + +Emitted when an error occurs during incremental delivery. + +**Measurements:** +- `system_time` - System time when the event occurred +- `duration` - Duration until the error occurred (native time units) + +**Metadata:** +- `operation_id` - Unique identifier for correlating events +- `duration_ms` - Duration in milliseconds +- `error` - Map with `:reason` and `:message` keys + +### Example: Tracing Incremental Delivery + +```elixir +:telemetry.attach_many( + :incremental_delivery_tracer, + [ + [:absinthe, :incremental, :delivery, :initial], + [:absinthe, :incremental, :delivery, :payload], + [:absinthe, :incremental, :delivery, :complete], + [:absinthe, :incremental, :delivery, :error] + ], + fn event_name, measurements, metadata, _config -> + IO.inspect({event_name, metadata.operation_id, measurements}) + end, + [] +) +``` + +### Custom Event Callbacks + +In addition to telemetry events, you can pass an `on_event` callback option for +custom monitoring integrations (e.g., Sentry, DataDog): + +```elixir +Absinthe.run(query, schema, + on_event: fn + :error, payload, metadata -> + Sentry.capture_message("GraphQL streaming error", + extra: %{payload: payload, metadata: metadata} + ) + :incremental, _payload, %{duration_ms: ms} when ms > 1000 -> + Logger.warning("Slow @defer/@stream operation: #{ms}ms") + _, _, _ -> :ok + end +) +``` + +Event types for `on_event`: `:initial`, `:incremental`, `:complete`, `:error` diff --git a/lib/absinthe/incremental/complexity.ex b/lib/absinthe/incremental/complexity.ex new file mode 100644 index 0000000000..1b468a9f22 --- /dev/null +++ b/lib/absinthe/incremental/complexity.ex @@ -0,0 +1,613 @@ +defmodule Absinthe.Incremental.Complexity do + @moduledoc """ + Complexity analysis for incremental delivery operations. + + This module analyzes the complexity of queries with @defer and @stream directives, + helping to prevent resource exhaustion from overly complex streaming operations. + + ## Per-Chunk Complexity + + In addition to analyzing total query complexity, this module supports per-chunk + complexity analysis. This ensures that individual deferred fragments or streamed + batches don't exceed reasonable complexity limits, even if the total complexity + is acceptable. + + ## Usage + + # Analyze full query complexity + {:ok, info} = Complexity.analyze(blueprint, %{max_complexity: 500}) + + # Check per-chunk limits + :ok = Complexity.check_chunk_limits(blueprint, %{max_chunk_complexity: 100}) + """ + + alias Absinthe.{Blueprint, Type} + + @default_config %{ + # Base complexity costs + field_cost: 1, + object_cost: 1, + list_cost: 10, + + # Incremental delivery multipliers + # Deferred operations cost 50% more + defer_multiplier: 1.5, + # Streamed operations cost 2x more + stream_multiplier: 2.0, + # Nested defers are more expensive + nested_defer_multiplier: 2.5, + + # Total query limits + max_complexity: 1000, + max_defer_depth: 3, + # Maximum number of @defer directives + max_defer_operations: 10, + max_stream_operations: 10, + max_total_streamed_items: 1000, + + # Per-chunk limits + # Max complexity for any single deferred chunk + max_chunk_complexity: 200, + # Max complexity per stream batch + max_stream_batch_complexity: 100, + # Max complexity for initial response + max_initial_complexity: 500 + } + + @type complexity_result :: {:ok, complexity_info()} | {:error, term()} + + @type complexity_info :: %{ + total_complexity: number(), + defer_count: non_neg_integer(), + stream_count: non_neg_integer(), + max_defer_depth: non_neg_integer(), + estimated_payloads: non_neg_integer(), + breakdown: map(), + chunk_complexities: list(chunk_info()) + } + + @type chunk_info :: %{ + type: :defer | :stream | :initial, + label: String.t() | nil, + path: list(), + complexity: number() + } + + @doc """ + Analyze the complexity of a blueprint with incremental delivery. + + Returns detailed complexity information including: + - Total complexity score + - Number of defer operations + - Number of stream operations + - Maximum defer nesting depth + - Estimated number of payloads + - Per-chunk complexity breakdown + """ + @spec analyze(Blueprint.t(), map()) :: complexity_result() + def analyze(blueprint, config \\ %{}) do + config = Map.merge(@default_config, config) + + analysis = %{ + total_complexity: 0, + defer_count: 0, + stream_count: 0, + max_defer_depth: 0, + # Initial payload + estimated_payloads: 1, + breakdown: %{ + immediate: 0, + deferred: 0, + streamed: 0 + }, + chunk_complexities: [], + defer_stack: [], + current_chunk: :initial, + current_chunk_complexity: 0, + errors: [] + } + + result = + analyze_document( + blueprint.fragments ++ blueprint.operations, + blueprint.schema, + config, + analysis + ) + + # Add the final initial chunk complexity + result = finalize_initial_chunk(result) + + if Enum.empty?(result.errors) do + {:ok, format_result(result)} + else + {:error, result.errors} + end + end + + @doc """ + Check if a query exceeds complexity limits including per-chunk limits. + + This is a convenience function that returns a simple pass/fail result. + """ + @spec check_limits(Blueprint.t(), map()) :: :ok | {:error, term()} + def check_limits(blueprint, config \\ %{}) do + config = Map.merge(@default_config, config) + + case analyze(blueprint, config) do + {:ok, info} -> + cond do + info.total_complexity > config.max_complexity -> + {:error, {:complexity_exceeded, info.total_complexity, config.max_complexity}} + + info.defer_count > config.max_defer_operations -> + {:error, {:too_many_defers, info.defer_count}} + + info.stream_count > config.max_stream_operations -> + {:error, {:too_many_streams, info.stream_count}} + + info.max_defer_depth > config.max_defer_depth -> + {:error, {:defer_too_deep, info.max_defer_depth}} + + true -> + check_chunk_limits_from_info(info, config) + end + + error -> + error + end + end + + @doc """ + Check per-chunk complexity limits. + + This validates that each individual chunk (deferred fragment or stream batch) + doesn't exceed its complexity limit. This is important because even if the total + complexity is acceptable, having one extremely complex deferred chunk can cause + problems. + """ + @spec check_chunk_limits(Blueprint.t(), map()) :: :ok | {:error, term()} + def check_chunk_limits(blueprint, config \\ %{}) do + config = Map.merge(@default_config, config) + + case analyze(blueprint, config) do + {:ok, info} -> + check_chunk_limits_from_info(info, config) + + error -> + error + end + end + + # Check chunk limits from analyzed info + defp check_chunk_limits_from_info(info, config) do + Enum.reduce_while(info.chunk_complexities, :ok, fn chunk, _acc -> + case check_single_chunk(chunk, config) do + :ok -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp check_single_chunk(%{type: :initial, complexity: complexity}, config) do + if complexity > config.max_initial_complexity do + {:error, {:initial_too_complex, complexity, config.max_initial_complexity}} + else + :ok + end + end + + defp check_single_chunk(%{type: :defer, complexity: complexity, label: label}, config) do + if complexity > config.max_chunk_complexity do + {:error, {:chunk_too_complex, :defer, label, complexity, config.max_chunk_complexity}} + else + :ok + end + end + + defp check_single_chunk(%{type: :stream, complexity: complexity, label: label}, config) do + if complexity > config.max_stream_batch_complexity do + {:error, + {:chunk_too_complex, :stream, label, complexity, config.max_stream_batch_complexity}} + else + :ok + end + end + + @doc """ + Analyze the complexity of a specific deferred chunk. + + Use this to validate complexity when a deferred fragment is about to be resolved. + """ + @spec analyze_chunk(map(), Blueprint.t(), map()) :: {:ok, number()} | {:error, term()} + def analyze_chunk(chunk_info, blueprint, config \\ %{}) do + config = Map.merge(@default_config, config) + + node = chunk_info.node + + chunk_analysis = + analyze_node( + node, + blueprint.schema, + config, + %{ + total_complexity: 0, + chunk_complexities: [], + defer_count: 0, + stream_count: 0, + max_defer_depth: 0, + estimated_payloads: 0, + breakdown: %{immediate: 0, deferred: 0, streamed: 0}, + defer_stack: [], + current_chunk: :chunk, + current_chunk_complexity: 0, + errors: [] + }, + 0 + ) + + complexity = chunk_analysis.total_complexity + + limit = + case chunk_info do + %{type: :defer} -> config.max_chunk_complexity + %{type: :stream} -> config.max_stream_batch_complexity + _ -> config.max_chunk_complexity + end + + if complexity > limit do + {:error, {:chunk_too_complex, chunk_info.type, chunk_info[:label], complexity, limit}} + else + {:ok, complexity} + end + end + + @doc """ + Calculate the cost of a specific field with incremental delivery. + """ + @spec field_cost(Type.Field.t(), map(), map()) :: number() + def field_cost(field, flags \\ %{}, config \\ %{}) do + config = Map.merge(@default_config, config) + base_cost = calculate_base_cost(field, config) + + multiplier = + cond do + Map.get(flags, :defer) -> config.defer_multiplier + Map.get(flags, :stream) -> config.stream_multiplier + true -> 1.0 + end + + base_cost * multiplier + end + + @doc """ + Estimate the number of payloads for a streaming operation. + """ + @spec estimate_payloads(Blueprint.t()) :: non_neg_integer() + def estimate_payloads(blueprint) do + streaming_context = get_in(blueprint, [:execution, :context, :__streaming__]) + + if streaming_context do + defer_count = length(Map.get(streaming_context, :deferred_fragments, [])) + _stream_count = length(Map.get(streaming_context, :streamed_fields, [])) + + # Initial + each defer + estimated stream batches + 1 + defer_count + estimate_stream_batches(streaming_context) + else + 1 + end + end + + @doc """ + Get complexity summary suitable for telemetry/logging. + """ + @spec summary(Blueprint.t(), map()) :: map() + def summary(blueprint, config \\ %{}) do + case analyze(blueprint, config) do + {:ok, info} -> + %{ + total: info.total_complexity, + defers: info.defer_count, + streams: info.stream_count, + max_depth: info.max_defer_depth, + payloads: info.estimated_payloads, + chunks: length(info.chunk_complexities), + max_chunk: info.chunk_complexities |> Enum.map(& &1.complexity) |> Enum.max(fn -> 0 end) + } + + {:error, _} -> + %{error: true} + end + end + + # Private functions + + defp analyze_document([], _schema, _config, analysis) do + analysis + end + + defp analyze_document([node | rest], schema, config, analysis) do + analysis = analyze_node(node, schema, config, analysis, 0) + analyze_document(rest, schema, config, analysis) + end + + # Handle Operation nodes (root of queries/mutations/subscriptions) + defp analyze_node(%Blueprint.Document.Operation{} = node, schema, config, analysis, depth) do + analyze_selections(node.selections, schema, config, analysis, depth) + end + + # Handle named fragments + defp analyze_node(%Blueprint.Document.Fragment.Named{} = node, schema, config, analysis, depth) do + analyze_selections(node.selections, schema, config, analysis, depth) + end + + defp analyze_node(%Blueprint.Document.Fragment.Inline{} = node, schema, config, analysis, depth) do + {analysis, in_defer} = check_defer_directive(node, config, analysis, depth) + + # If we entered a deferred fragment, track its complexity separately + # and increment depth for nested content + {analysis, nested_depth} = + if in_defer do + # Start a new chunk and increase depth for nested defers + {%{ + analysis + | current_chunk: {:defer, get_defer_label(node)}, + current_chunk_complexity: 0 + }, depth + 1} + else + {analysis, depth} + end + + analysis = analyze_selections(node.selections, schema, config, analysis, nested_depth) + + # If we're leaving a deferred fragment, finalize its chunk complexity + if in_defer do + finalize_defer_chunk(analysis, get_defer_label(node), []) + else + analysis + end + end + + defp analyze_node(%Blueprint.Document.Fragment.Spread{} = node, schema, config, analysis, depth) do + {analysis, _in_defer} = check_defer_directive(node, config, analysis, depth) + # Would need to look up the fragment definition for full analysis + analysis + end + + defp analyze_node(%Blueprint.Document.Field{} = node, schema, config, analysis, depth) do + # Calculate field cost + base_cost = calculate_field_cost(node, schema, config) + + # Check for streaming + analysis = + if has_stream_directive?(node) do + stream_config = get_stream_config(node) + stream_cost = calculate_stream_cost(base_cost, stream_config, config) + + # Record stream chunk + chunk = %{ + type: :stream, + label: stream_config[:label], + # Would need path tracking + path: [], + complexity: stream_cost + } + + analysis + |> update_in([:total_complexity], &(&1 + stream_cost)) + |> update_in([:stream_count], &(&1 + 1)) + |> update_in([:breakdown, :streamed], &(&1 + stream_cost)) + |> update_in([:chunk_complexities], &[chunk | &1]) + |> update_estimated_payloads(stream_config) + else + # Add to current chunk complexity + analysis + |> update_in([:total_complexity], &(&1 + base_cost)) + |> update_in([:breakdown, :immediate], &(&1 + base_cost)) + |> update_in([:current_chunk_complexity], &(&1 + base_cost)) + end + + # Analyze child selections + if node.selections do + analyze_selections(node.selections, schema, config, analysis, depth) + else + analysis + end + end + + defp analyze_node(_node, _schema, _config, analysis, _depth) do + analysis + end + + defp analyze_selections([], _schema, _config, analysis, _depth) do + analysis + end + + defp analyze_selections([selection | rest], schema, config, analysis, depth) do + analysis = analyze_node(selection, schema, config, analysis, depth) + analyze_selections(rest, schema, config, analysis, depth) + end + + defp check_defer_directive(node, config, analysis, depth) do + if has_defer_directive?(node) do + defer_cost = calculate_defer_cost(node, config, depth) + + analysis = + analysis + |> update_in([:defer_count], &(&1 + 1)) + |> update_in([:total_complexity], &(&1 + defer_cost)) + |> update_in([:breakdown, :deferred], &(&1 + defer_cost)) + |> update_in([:max_defer_depth], &max(&1, depth + 1)) + |> update_in([:estimated_payloads], &(&1 + 1)) + + {analysis, true} + else + {analysis, false} + end + end + + defp finalize_defer_chunk(analysis, label, path) do + chunk = %{ + type: :defer, + label: label, + path: path, + complexity: analysis.current_chunk_complexity + } + + analysis + |> update_in([:chunk_complexities], &[chunk | &1]) + |> Map.put(:current_chunk, :initial) + |> Map.put(:current_chunk_complexity, 0) + end + + defp finalize_initial_chunk(analysis) do + if analysis.current_chunk_complexity > 0 do + chunk = %{ + type: :initial, + label: nil, + path: [], + complexity: analysis.current_chunk_complexity + } + + update_in(analysis.chunk_complexities, &[chunk | &1]) + else + analysis + end + end + + defp get_defer_label(node) do + case Map.get(node, :directives) do + nil -> + nil + + directives -> + directives + |> Enum.find(&(&1.name == "defer")) + |> case do + nil -> nil + directive -> get_directive_arg(directive, "label") + end + end + end + + defp has_defer_directive?(node) do + case Map.get(node, :directives) do + nil -> false + directives -> Enum.any?(directives, &(&1.name == "defer")) + end + end + + defp has_stream_directive?(node) do + case Map.get(node, :directives) do + nil -> false + directives -> Enum.any?(directives, &(&1.name == "stream")) + end + end + + defp get_stream_config(node) do + node.directives + |> Enum.find(&(&1.name == "stream")) + |> case do + nil -> + %{} + + directive -> + %{ + initial_count: get_directive_arg(directive, "initialCount", 0), + label: get_directive_arg(directive, "label") + } + end + end + + defp get_directive_arg(directive, name, default \\ nil) do + directive.arguments + |> Enum.find(&(&1.name == name)) + |> case do + nil -> default + arg -> arg.value + end + end + + defp calculate_field_cost(field, _schema, config) do + # Base cost for the field + base = config.field_cost + + # Add cost for list types + if is_list_type?(field) do + base + config.list_cost + else + base + end + end + + defp calculate_stream_cost(base_cost, stream_config, config) do + # Streaming adds complexity based on expected items + estimated_items = estimate_list_size(stream_config) + base_cost * config.stream_multiplier * (1 + estimated_items / 100) + end + + defp calculate_defer_cost(_node, config, depth) do + # Deeper nesting is more expensive + multiplier = + if depth > 1 do + config.nested_defer_multiplier + else + config.defer_multiplier + end + + config.object_cost * multiplier + end + + defp calculate_base_cost(field, config) do + type = Map.get(field, :type) + + if is_list_type?(type) do + config.list_cost + else + config.field_cost + end + end + + defp is_list_type?(%Absinthe.Type.List{}), do: true + defp is_list_type?(%Absinthe.Type.NonNull{of_type: inner}), do: is_list_type?(inner) + defp is_list_type?(_), do: false + + defp estimate_list_size(stream_config) do + # Estimate based on initial count and typical patterns + initial = Map.get(stream_config, :initial_count, 0) + + # Assume lists are typically 10-100 items + initial + 50 + end + + defp estimate_stream_batches(streaming_context) do + streamed_fields = Map.get(streaming_context, :streamed_fields, []) + + Enum.reduce(streamed_fields, 0, fn field, acc -> + # Estimate batches based on initial_count + initial_count = Map.get(field, :initial_count, 0) + # Estimate remaining items + estimated_total = initial_count + 50 + batches = div(estimated_total - initial_count, 10) + 1 + acc + batches + end) + end + + defp update_estimated_payloads(analysis, stream_config) do + # Estimate number of payloads based on stream configuration + estimated_batches = div(estimate_list_size(stream_config), 10) + 1 + update_in(analysis.estimated_payloads, &(&1 + estimated_batches)) + end + + defp format_result(analysis) do + %{ + total_complexity: analysis.total_complexity, + defer_count: analysis.defer_count, + stream_count: analysis.stream_count, + max_defer_depth: analysis.max_defer_depth, + estimated_payloads: analysis.estimated_payloads, + breakdown: analysis.breakdown, + chunk_complexities: Enum.reverse(analysis.chunk_complexities) + } + end +end diff --git a/lib/absinthe/incremental/config.ex b/lib/absinthe/incremental/config.ex new file mode 100644 index 0000000000..e25788a1cc --- /dev/null +++ b/lib/absinthe/incremental/config.ex @@ -0,0 +1,359 @@ +defmodule Absinthe.Incremental.Config do + @moduledoc """ + Configuration for incremental delivery features. + + This module manages configuration options for @defer and @stream directives, + including resource limits, timeouts, and transport settings. + """ + + @default_config %{ + # Feature flags + enabled: false, + enable_defer: true, + enable_stream: true, + + # Resource limits + max_concurrent_streams: 100, + # 30 seconds + max_stream_duration: 30_000, + max_memory_mb: 500, + max_pending_operations: 1000, + + # Batching settings + default_stream_batch_size: 10, + max_stream_batch_size: 100, + enable_dataloader_batching: true, + dataloader_timeout: 5_000, + + # Transport settings + # :auto | :sse | :websocket | :graphql_ws + transport: :auto, + enable_compression: false, + chunk_timeout: 1_000, + + # Relay optimizations + enable_relay_optimizations: true, + connection_stream_batch_size: 20, + + # Error handling + error_recovery_enabled: true, + max_retry_attempts: 3, + retry_delay_ms: 100, + + # Monitoring + enable_telemetry: true, + enable_logging: true, + log_level: :debug, + + # Event callbacks - for sending events to Sentry, DataDog, etc. + # fn (event_type, payload, metadata) -> :ok end + on_event: nil + } + + @type t :: %__MODULE__{ + enabled: boolean(), + enable_defer: boolean(), + enable_stream: boolean(), + max_concurrent_streams: non_neg_integer(), + max_stream_duration: non_neg_integer(), + max_memory_mb: non_neg_integer(), + max_pending_operations: non_neg_integer(), + default_stream_batch_size: non_neg_integer(), + max_stream_batch_size: non_neg_integer(), + enable_dataloader_batching: boolean(), + dataloader_timeout: non_neg_integer(), + transport: atom(), + enable_compression: boolean(), + chunk_timeout: non_neg_integer(), + enable_relay_optimizations: boolean(), + connection_stream_batch_size: non_neg_integer(), + error_recovery_enabled: boolean(), + max_retry_attempts: non_neg_integer(), + retry_delay_ms: non_neg_integer(), + enable_telemetry: boolean(), + enable_logging: boolean(), + log_level: atom(), + on_event: event_callback() | nil + } + + @typedoc """ + Event callback function for monitoring integrations. + + Called with: + - `event_type` - One of `:initial`, `:incremental`, `:complete`, `:error` + - `payload` - The event payload (response data, error info, etc.) + - `metadata` - Additional context (timing, path, label, operation_id, etc.) + + ## Examples + + # Send to Sentry + on_event: fn + :error, payload, metadata -> + Sentry.capture_message("GraphQL incremental error", + extra: %{payload: payload, metadata: metadata} + ) + _, _, _ -> :ok + end + + # Send to DataDog + on_event: fn event_type, payload, metadata -> + Datadog.event("graphql.incremental.\#{event_type}", payload, metadata) + end + """ + @type event_callback :: (atom(), map(), map() -> any()) + + defstruct Map.keys(@default_config) + + @doc """ + Create a configuration from options. + + ## Examples + + iex> Config.from_options(enabled: true, max_concurrent_streams: 50) + %Config{enabled: true, max_concurrent_streams: 50, ...} + """ + @spec from_options(Keyword.t() | map()) :: t() + def from_options(opts) when is_list(opts) do + from_options(Enum.into(opts, %{})) + end + + def from_options(opts) when is_map(opts) do + config = Map.merge(@default_config, opts) + struct(__MODULE__, config) + end + + @doc """ + Load configuration from application environment. + + Reads configuration from `:absinthe, :incremental_delivery` in the application environment. + """ + @spec from_env() :: t() + def from_env do + Application.get_env(:absinthe, :incremental_delivery, []) + |> from_options() + end + + @doc """ + Validate a configuration. + + Ensures all values are within acceptable ranges and compatible with each other. + """ + @spec validate(t()) :: {:ok, t()} | {:error, list(String.t())} + def validate(config) do + errors = + [] + |> validate_transport(config) + |> validate_limits(config) + |> validate_timeouts(config) + |> validate_features(config) + + if Enum.empty?(errors) do + {:ok, config} + else + {:error, errors} + end + end + + @doc """ + Check if incremental delivery is enabled. + """ + @spec enabled?(t()) :: boolean() + def enabled?(%__MODULE__{enabled: enabled}), do: enabled + def enabled?(_), do: false + + @doc """ + Check if defer is enabled. + """ + @spec defer_enabled?(t()) :: boolean() + def defer_enabled?(%__MODULE__{enabled: true, enable_defer: defer}), do: defer + def defer_enabled?(_), do: false + + @doc """ + Check if stream is enabled. + """ + @spec stream_enabled?(t()) :: boolean() + def stream_enabled?(%__MODULE__{enabled: true, enable_stream: stream}), do: stream + def stream_enabled?(_), do: false + + @doc """ + Get the appropriate transport module for the configuration. + """ + @spec transport_module(t()) :: module() + def transport_module(%__MODULE__{transport: transport}) do + case transport do + :auto -> detect_transport() + :sse -> Absinthe.Incremental.Transport.SSE + :websocket -> Absinthe.Incremental.Transport.WebSocket + :graphql_ws -> Absinthe.GraphqlWS.Incremental.Transport + module when is_atom(module) -> module + end + end + + @doc """ + Apply configuration to a blueprint. + + Adds the configuration to the blueprint's execution context. + """ + @spec apply_to_blueprint(t(), Absinthe.Blueprint.t()) :: Absinthe.Blueprint.t() + def apply_to_blueprint(config, blueprint) do + put_in( + blueprint.execution.context[:incremental_config], + config + ) + end + + @doc """ + Get configuration from a blueprint. + """ + @spec from_blueprint(Absinthe.Blueprint.t()) :: t() | nil + def from_blueprint(blueprint) do + get_in(blueprint, [:execution, :context, :incremental_config]) + end + + @doc """ + Merge two configurations. + + The second configuration takes precedence. + """ + @spec merge(t(), t() | Keyword.t() | map()) :: t() + def merge(config1, config2) when is_struct(config2, __MODULE__) do + Map.merge(config1, config2) + end + + def merge(config1, opts) do + config2 = from_options(opts) + merge(config1, config2) + end + + @doc """ + Get a specific configuration value. + """ + @spec get(t(), atom(), any()) :: any() + def get(config, key, default \\ nil) do + Map.get(config, key, default) + end + + @doc """ + Emit an event to the configured callback. + + Safely invokes the `on_event` callback if configured. Errors in the callback + are caught and logged but do not affect the incremental delivery. + + ## Event Types + + - `:initial` - Initial response with immediately available data + - `:incremental` - Deferred or streamed data payload + - `:complete` - Stream completed successfully + - `:error` - Error occurred during streaming + + ## Metadata + + The metadata map includes: + - `:operation_id` - Unique identifier for the operation + - `:path` - GraphQL path to the deferred/streamed field + - `:label` - Label from @defer or @stream directive + - `:started_at` - Timestamp when operation started + - `:duration_ms` - Duration in milliseconds (for incremental/complete) + - `:task_type` - `:defer` or `:stream` + + ## Examples + + Config.emit_event(config, :initial, response, %{operation_id: "abc123"}) + + Config.emit_event(config, :error, error_payload, %{ + operation_id: "abc123", + path: ["user", "posts"], + label: "userPosts" + }) + """ + @spec emit_event(t() | nil, atom(), map(), map()) :: :ok + def emit_event(nil, _event_type, _payload, _metadata), do: :ok + def emit_event(%__MODULE__{on_event: nil}, _event_type, _payload, _metadata), do: :ok + + def emit_event(%__MODULE__{on_event: callback}, event_type, payload, metadata) + when is_function(callback, 3) do + try do + callback.(event_type, payload, metadata) + :ok + rescue + error -> + require Logger + Logger.warning("Incremental delivery on_event callback failed: #{inspect(error)}") + :ok + end + end + + def emit_event(_config, _event_type, _payload, _metadata), do: :ok + + # Private functions + + defp validate_transport(errors, %{transport: transport}) do + valid_transports = [:auto, :sse, :websocket, :graphql_ws] + + if transport in valid_transports or is_atom(transport) do + errors + else + ["Invalid transport: #{inspect(transport)}" | errors] + end + end + + defp validate_limits(errors, config) do + errors + |> validate_positive(:max_concurrent_streams, config) + |> validate_positive(:max_memory_mb, config) + |> validate_positive(:max_pending_operations, config) + |> validate_positive(:default_stream_batch_size, config) + |> validate_positive(:max_stream_batch_size, config) + |> validate_batch_sizes(config) + end + + defp validate_timeouts(errors, config) do + errors + |> validate_positive(:max_stream_duration, config) + |> validate_positive(:dataloader_timeout, config) + |> validate_positive(:chunk_timeout, config) + |> validate_positive(:retry_delay_ms, config) + end + + defp validate_features(errors, config) do + cond do + config.enabled and not (config.enable_defer or config.enable_stream) -> + ["Incremental delivery enabled but both defer and stream are disabled" | errors] + + true -> + errors + end + end + + defp validate_positive(errors, field, config) do + value = Map.get(config, field) + + if is_integer(value) and value > 0 do + errors + else + ["#{field} must be a positive integer, got: #{inspect(value)}" | errors] + end + end + + defp validate_batch_sizes(errors, config) do + if config.default_stream_batch_size > config.max_stream_batch_size do + ["default_stream_batch_size cannot exceed max_stream_batch_size" | errors] + else + errors + end + end + + defp detect_transport do + # Auto-detect the best available transport + cond do + Code.ensure_loaded?(Absinthe.GraphqlWS.Incremental.Transport) -> + Absinthe.GraphqlWS.Incremental.Transport + + Code.ensure_loaded?(Absinthe.Incremental.Transport.SSE) -> + Absinthe.Incremental.Transport.SSE + + true -> + Absinthe.Incremental.Transport.WebSocket + end + end +end diff --git a/lib/absinthe/incremental/dataloader.ex b/lib/absinthe/incremental/dataloader.ex new file mode 100644 index 0000000000..97a320cbce --- /dev/null +++ b/lib/absinthe/incremental/dataloader.ex @@ -0,0 +1,366 @@ +defmodule Absinthe.Incremental.Dataloader do + @moduledoc """ + Dataloader integration for incremental delivery. + + This module ensures that batching continues to work efficiently even when + fields are deferred or streamed. It groups deferred/streamed fields by their + batch keys and resolves them together to maintain the benefits of batching. + + ## Usage + + This module is used automatically when you have both Dataloader and incremental + delivery enabled. No additional configuration is required for basic usage. + + ### Using with existing Dataloader resolvers + + Your existing Dataloader resolvers will continue to work. For optimal performance + with incremental delivery, you can use the streaming-aware resolver: + + field :posts, list_of(:post) do + resolve Absinthe.Incremental.Dataloader.streaming_dataloader(:db, :posts) + end + + This ensures that deferred fields using the same batch key are resolved together, + maintaining the N+1 prevention benefits of Dataloader even with @defer/@stream. + + ### Manual batch control + + For advanced use cases, you can manually prepare and resolve batches: + + # Get grouped batches from the blueprint + batches = Absinthe.Incremental.Dataloader.prepare_streaming_batch(blueprint) + + # Resolve each batch + for batch <- batches.deferred do + results = Absinthe.Incremental.Dataloader.resolve_streaming_batch(batch, dataloader) + # Process results... + end + + ## How it works + + When a query contains @defer or @stream directives, this module: + 1. Groups deferred/streamed fields by their Dataloader batch keys + 2. Ensures fields with the same batch key are resolved together + 3. Maintains efficient batching even when fields are delivered incrementally + """ + + alias Absinthe.Resolution + alias Absinthe.Blueprint + + @type batch_key :: {atom(), any()} + @type batch_context :: %{ + source: atom(), + batch_key: any(), + fields: list(map()), + ids: list(any()) + } + + @doc """ + Prepare batches for streaming operations. + + Groups deferred and streamed fields by their batch keys to ensure + efficient resolution even with incremental delivery. + """ + @spec prepare_streaming_batch(Blueprint.t()) :: %{ + deferred: list(batch_context()), + streamed: list(batch_context()) + } + def prepare_streaming_batch(blueprint) do + streaming_context = get_streaming_context(blueprint) + + %{ + deferred: prepare_deferred_batches(streaming_context), + streamed: prepare_streamed_batches(streaming_context) + } + end + + @doc """ + Resolve a batch of fields together for streaming. + + This ensures that even deferred/streamed fields benefit from + Dataloader's batching capabilities. + """ + @spec resolve_streaming_batch(batch_context(), Dataloader.t()) :: + list({map(), any()}) + def resolve_streaming_batch(batch_context, dataloader) do + # Load all the data for this batch + dataloader = + dataloader + |> Dataloader.load_many( + batch_context.source, + batch_context.batch_key, + batch_context.ids + ) + |> Dataloader.run() + + # Extract results for each field + Enum.map(batch_context.fields, fn field -> + result = + Dataloader.get( + dataloader, + batch_context.source, + batch_context.batch_key, + field.id + ) + + {field, result} + end) + end + + @doc """ + Create a Dataloader instance for streaming operations. + + This sets up a new Dataloader with appropriate configuration + for incremental delivery. + """ + @spec create_streaming_dataloader(Keyword.t()) :: Dataloader.t() + def create_streaming_dataloader(opts \\ []) do + sources = Keyword.get(opts, :sources, []) + + Enum.reduce(sources, Dataloader.new(), fn {name, source}, dataloader -> + Dataloader.add_source(dataloader, name, source) + end) + end + + @doc """ + Wrap a resolver with Dataloader support for streaming. + + This allows existing Dataloader resolvers to work with incremental delivery. + """ + @spec streaming_dataloader(atom(), any()) :: Resolution.resolver() + def streaming_dataloader(source, batch_key \\ nil) do + fn parent, args, %{context: context} = resolution -> + # Check if we're in a streaming context + case Map.get(context, :__streaming__) do + nil -> + # Standard dataloader resolution + resolver = Resolution.Helpers.dataloader(source, batch_key) + resolver.(parent, args, resolution) + + streaming_context -> + # Streaming-aware resolution + resolve_with_streaming_dataloader( + source, + batch_key, + parent, + args, + resolution, + streaming_context + ) + end + end + end + + @doc """ + Batch multiple streaming operations together. + + This is used by the streaming resolution phase to group + operations that can be batched. + """ + @spec batch_streaming_operations(list(map())) :: list(list(map())) + def batch_streaming_operations(operations) do + operations + |> Enum.group_by(&extract_batch_key/1) + |> Map.values() + end + + # Private functions + + defp prepare_deferred_batches(streaming_context) do + deferred_fragments = Map.get(streaming_context, :deferred_fragments, []) + + deferred_fragments + |> group_by_batch_key() + |> Enum.map(&create_batch_context/1) + end + + defp prepare_streamed_batches(streaming_context) do + streamed_fields = Map.get(streaming_context, :streamed_fields, []) + + streamed_fields + |> group_by_batch_key() + |> Enum.map(&create_batch_context/1) + end + + defp group_by_batch_key(nodes) do + Enum.group_by(nodes, &extract_batch_key/1) + end + + defp extract_batch_key(%{node: node}) do + extract_batch_key(node) + end + + defp extract_batch_key(node) do + # Extract the batch key from the node's resolver configuration + case get_resolver_info(node) do + {:dataloader, source, batch_key} -> + {source, batch_key} + + _ -> + :no_batch + end + end + + defp get_resolver_info(node) do + # Navigate the node structure to find resolver info + case node do + %{schema_node: %{resolver: resolver}} -> + parse_resolver(resolver) + + %{resolver: resolver} -> + parse_resolver(resolver) + + _ -> + nil + end + end + + defp parse_resolver({:dataloader, source}), do: {:dataloader, source, nil} + defp parse_resolver({:dataloader, source, batch_key}), do: {:dataloader, source, batch_key} + defp parse_resolver(_), do: nil + + defp create_batch_context({batch_key, fields}) do + {source, key} = + case batch_key do + {s, k} -> {s, k} + :no_batch -> {nil, nil} + s -> {s, nil} + end + + ids = + Enum.map(fields, fn field -> + get_field_id(field) + end) + + %{ + source: source, + batch_key: key, + fields: fields, + ids: ids + } + end + + defp get_field_id(field) do + # Extract the ID for batching from the field + case field do + %{node: %{argument_data: %{id: id}}} -> id + %{node: %{source: %{id: id}}} -> id + %{id: id} -> id + _ -> nil + end + end + + defp resolve_with_streaming_dataloader( + source, + batch_key, + parent, + args, + resolution, + streaming_context + ) do + # Check if this is part of a deferred/streamed operation + if in_streaming_operation?(resolution, streaming_context) do + # Queue for batch resolution + queue_for_batch(source, batch_key, parent, args, resolution) + else + # Regular dataloader resolution + resolver = Resolution.Helpers.dataloader(source, batch_key) + resolver.(parent, args, resolution) + end + end + + defp in_streaming_operation?(resolution, streaming_context) do + # Check if the current resolution is part of a deferred/streamed operation + path = Resolution.path(resolution) + + deferred_paths = + Enum.map( + streaming_context.deferred_fragments || [], + & &1.path + ) + + streamed_paths = + Enum.map( + streaming_context.streamed_fields || [], + & &1.path + ) + + Enum.any?(deferred_paths ++ streamed_paths, fn streaming_path -> + path_matches?(path, streaming_path) + end) + end + + defp path_matches?(current_path, streaming_path) do + # Check if the current path is under a streaming path + List.starts_with?(current_path, streaming_path) + end + + defp queue_for_batch(source, batch_key, parent, _args, resolution) do + # Queue this resolution for batch processing + batch_data = %{ + source: source, + batch_key: batch_key || fn parent -> Map.get(parent, :id) end, + parent: parent, + resolution: resolution + } + + # Add to the batch queue in the resolution context + resolution = + update_in( + resolution.context[:__dataloader_batch_queue__], + &[batch_data | &1 || []] + ) + + # Return a placeholder that will be resolved in batch + {:middleware, Absinthe.Middleware.Dataloader, {source, batch_key}} + end + + defp get_streaming_context(blueprint) do + get_in(blueprint, [:execution, :context, :__streaming__]) || %{} + end + + @doc """ + Process queued batch operations for streaming. + + This is called after the initial resolution to process + any queued dataloader operations in batch. + """ + @spec process_batch_queue(Resolution.t()) :: Resolution.t() + def process_batch_queue(%{context: context} = resolution) do + case Map.get(context, :__dataloader_batch_queue__) do + nil -> + resolution + + [] -> + resolution + + queue -> + # Group by source and batch key + batches = + queue + |> Enum.group_by(fn %{source: s, batch_key: k} -> {s, k} end) + + # Process each batch + dataloader = Map.get(context, :loader) || Dataloader.new() + + dataloader = + Enum.reduce(batches, dataloader, fn {{source, batch_key}, items}, dl -> + ids = + Enum.map(items, fn %{parent: parent} -> + case batch_key do + nil -> Map.get(parent, :id) + fun when is_function(fun) -> fun.(parent) + key -> Map.get(parent, key) + end + end) + + Dataloader.load_many(dl, source, batch_key, ids) + end) + |> Dataloader.run() + + # Update context with results + context = Map.put(context, :loader, dataloader) + %{resolution | context: context} + end + end +end diff --git a/lib/absinthe/incremental/error_handler.ex b/lib/absinthe/incremental/error_handler.ex new file mode 100644 index 0000000000..28bba898b3 --- /dev/null +++ b/lib/absinthe/incremental/error_handler.ex @@ -0,0 +1,418 @@ +defmodule Absinthe.Incremental.ErrorHandler do + @moduledoc """ + Comprehensive error handling for incremental delivery. + + This module provides error handling, recovery, and cleanup for + streaming operations, ensuring robust behavior even when things go wrong. + """ + + alias Absinthe.Incremental.Response + require Logger + + @type error_type :: + :timeout + | :dataloader_error + | :transport_error + | :resolution_error + | :resource_limit + | :cancelled + + @type error_context :: %{ + operation_id: String.t(), + path: list(), + label: String.t() | nil, + error_type: error_type(), + details: any() + } + + @doc """ + Handle errors that occur during streaming operations. + + Returns an appropriate error response based on the error type. + """ + @spec handle_streaming_error(any(), error_context()) :: map() + def handle_streaming_error(error, context) do + error_type = classify_error(error) + + case error_type do + :timeout -> + build_timeout_response(error, context) + + :dataloader_error -> + build_dataloader_error_response(error, context) + + :transport_error -> + build_transport_error_response(error, context) + + :resource_limit -> + build_resource_limit_response(error, context) + + :cancelled -> + build_cancellation_response(error, context) + + _ -> + build_generic_error_response(error, context) + end + end + + @doc """ + Wrap a streaming task with error handling. + + Ensures that errors in async tasks are properly caught and reported. + """ + @spec wrap_streaming_task((-> any())) :: (-> any()) + def wrap_streaming_task(task_fn) do + fn -> + try do + task_fn.() + rescue + exception -> + stacktrace = __STACKTRACE__ + Logger.error("Streaming task error: #{Exception.message(exception)}") + {:error, format_exception(exception, stacktrace)} + catch + :exit, reason -> + Logger.error("Streaming task exit: #{inspect(reason)}") + {:error, {:exit, reason}} + + :throw, value -> + Logger.error("Streaming task throw: #{inspect(value)}") + {:error, {:throw, value}} + end + end + end + + @doc """ + Monitor a streaming operation for timeouts. + + Sets up timeout monitoring and cancels the operation if it exceeds + the configured duration. + """ + @spec monitor_timeout(pid(), non_neg_integer(), error_context()) :: reference() + def monitor_timeout(pid, timeout_ms, context) do + Process.send_after( + self(), + {:streaming_timeout, pid, context}, + timeout_ms + ) + end + + @doc """ + Handle a timeout for a streaming operation. + """ + @spec handle_timeout(pid(), error_context()) :: :ok + def handle_timeout(pid, context) do + if Process.alive?(pid) do + Process.exit(pid, :timeout) + + # Log the timeout + Logger.warning( + "Streaming operation timeout - operation_id: #{context.operation_id}, path: #{inspect(context.path)}" + ) + end + + :ok + end + + @doc """ + Recover from a failed streaming operation. + + Attempts to recover or provide fallback data when a streaming + operation fails. + """ + @spec recover_streaming_operation(any(), error_context()) :: + {:ok, any()} | {:error, any()} + def recover_streaming_operation(error, context) do + case context.error_type do + :timeout -> + # For timeouts, we might return partial data + {:error, :timeout_no_recovery} + + :dataloader_error -> + # Try to load without batching + attempt_direct_load(context) + + :transport_error -> + # Transport errors are not recoverable + {:error, :transport_failure} + + _ -> + # Generic recovery attempt + {:error, error} + end + end + + @doc """ + Clean up resources after a streaming operation completes or fails. + """ + @spec cleanup_streaming_resources(map()) :: :ok + def cleanup_streaming_resources(streaming_context) do + # Cancel any pending tasks + cancel_pending_tasks(streaming_context) + + # Clear dataloader caches if needed + clear_dataloader_caches(streaming_context) + + # Release any held resources + release_resources(streaming_context) + + :ok + end + + @doc """ + Validate that a streaming operation can proceed. + + Checks resource limits and other constraints. + """ + @spec validate_streaming_operation(map()) :: :ok | {:error, term()} + def validate_streaming_operation(context) do + with :ok <- check_concurrent_streams(context), + :ok <- check_memory_usage(context), + :ok <- check_complexity(context) do + :ok + end + end + + # Private functions + + defp classify_error({:timeout, _}), do: :timeout + defp classify_error({:dataloader_error, _, _}), do: :dataloader_error + defp classify_error({:transport_error, _}), do: :transport_error + defp classify_error({:resource_limit, _}), do: :resource_limit + defp classify_error(:cancelled), do: :cancelled + defp classify_error(_), do: :unknown + + defp build_timeout_response(_error, context) do + %{ + incremental: [ + %{ + errors: [ + %{ + message: + "Operation timeout: The deferred/streamed operation took too long to complete", + path: context.path, + extensions: %{ + code: "STREAMING_TIMEOUT", + label: context.label, + operation_id: context.operation_id + } + } + ], + path: context.path + } + ], + hasNext: false + } + end + + defp build_dataloader_error_response({:dataloader_error, source, error}, context) do + %{ + incremental: [ + %{ + errors: [ + %{ + message: "Dataloader error: Failed to load data from source #{inspect(source)}", + path: context.path, + extensions: %{ + code: "DATALOADER_ERROR", + source: source, + details: inspect(error), + label: context.label + } + } + ], + path: context.path + } + ], + hasNext: false + } + end + + defp build_transport_error_response({:transport_error, reason}, context) do + %{ + incremental: [ + %{ + errors: [ + %{ + message: "Transport error: Failed to deliver incremental response", + path: context.path, + extensions: %{ + code: "TRANSPORT_ERROR", + reason: inspect(reason), + label: context.label + } + } + ], + path: context.path + } + ], + hasNext: false + } + end + + defp build_resource_limit_response({:resource_limit, limit_type}, context) do + %{ + incremental: [ + %{ + errors: [ + %{ + message: "Resource limit exceeded: #{limit_type}", + path: context.path, + extensions: %{ + code: "RESOURCE_LIMIT_EXCEEDED", + limit_type: limit_type, + label: context.label + } + } + ], + path: context.path + } + ], + hasNext: false + } + end + + defp build_cancellation_response(_error, context) do + %{ + incremental: [ + %{ + errors: [ + %{ + message: "Operation cancelled", + path: context.path, + extensions: %{ + code: "OPERATION_CANCELLED", + label: context.label + } + } + ], + path: context.path + } + ], + hasNext: false + } + end + + defp build_generic_error_response(error, context) do + %{ + incremental: [ + %{ + errors: [ + %{ + message: "Unexpected error during incremental delivery", + path: context.path, + extensions: %{ + code: "STREAMING_ERROR", + details: inspect(error), + label: context.label + } + } + ], + path: context.path + } + ], + hasNext: false + } + end + + defp format_exception(exception, stacktrace \\ nil) do + formatted_stacktrace = + if stacktrace do + Exception.format_stacktrace(stacktrace) + else + "stacktrace not available" + end + + %{ + message: Exception.message(exception), + type: exception.__struct__, + stacktrace: formatted_stacktrace + } + end + + defp attempt_direct_load(_context) do + # Attempt to load data directly without batching + # This is a fallback when dataloader fails + Logger.debug("Attempting direct load after dataloader failure") + {:error, :direct_load_not_implemented} + end + + defp cancel_pending_tasks(streaming_context) do + tasks = + Map.get(streaming_context, :deferred_tasks, []) ++ + Map.get(streaming_context, :stream_tasks, []) + + Enum.each(tasks, fn task -> + if Map.get(task, :pid) && Process.alive?(task.pid) do + Process.exit(task.pid, :shutdown) + end + end) + end + + defp clear_dataloader_caches(streaming_context) do + # Clear any dataloader caches associated with this streaming operation + # This helps prevent memory leaks + if _dataloader = Map.get(streaming_context, :dataloader) do + # Clear caches (implementation depends on Dataloader version) + Logger.debug("Clearing dataloader caches for streaming operation") + end + end + + defp release_resources(streaming_context) do + # Release any other resources held by the streaming operation + if resource_manager = Map.get(streaming_context, :resource_manager) do + operation_id = Map.get(streaming_context, :operation_id) + send(resource_manager, {:release, operation_id}) + end + end + + defp check_concurrent_streams(_context) do + # Check if we're within concurrent stream limits + max_streams = get_config(:max_concurrent_streams, 100) + current_streams = get_current_stream_count() + + if current_streams < max_streams do + :ok + else + {:error, {:resource_limit, :max_concurrent_streams}} + end + end + + defp check_memory_usage(_context) do + # Check current memory usage + memory_limit = get_config(:max_memory_mb, 500) * 1_048_576 + current_memory = :erlang.memory(:total) + + if current_memory < memory_limit do + :ok + else + {:error, {:resource_limit, :memory_limit}} + end + end + + defp check_complexity(context) do + # Check query complexity if configured + if complexity = Map.get(context, :complexity) do + max_complexity = get_config(:max_streaming_complexity, 1000) + + if complexity <= max_complexity do + :ok + else + {:error, {:resource_limit, :query_complexity}} + end + else + :ok + end + end + + defp get_config(key, default) do + Application.get_env(:absinthe, :incremental_delivery, []) + |> Keyword.get(key, default) + end + + defp get_current_stream_count do + # This would track active streams globally + # For now, return a placeholder + 0 + end +end diff --git a/lib/absinthe/incremental/resource_manager.ex b/lib/absinthe/incremental/resource_manager.ex new file mode 100644 index 0000000000..3181fae390 --- /dev/null +++ b/lib/absinthe/incremental/resource_manager.ex @@ -0,0 +1,349 @@ +defmodule Absinthe.Incremental.ResourceManager do + @moduledoc """ + Manages resources for streaming operations. + + This GenServer tracks and limits concurrent streaming operations, + monitors memory usage, and ensures proper cleanup of resources. + """ + + use GenServer + require Logger + + @default_config %{ + max_concurrent_streams: 100, + # 30 seconds + max_stream_duration: 30_000, + max_memory_mb: 500, + # Check resources every 5 seconds + check_interval: 5_000 + } + + defstruct [ + :config, + :active_streams, + :stream_stats, + :memory_baseline + ] + + @type stream_info :: %{ + operation_id: String.t(), + started_at: integer(), + memory_baseline: integer(), + pid: pid() | nil, + label: String.t() | nil, + path: list() + } + + # Client API + + @doc """ + Start the resource manager. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Acquire a slot for a new streaming operation. + + Returns :ok if resources are available, or an error if limits are exceeded. + """ + @spec acquire_stream_slot(String.t(), Keyword.t()) :: :ok | {:error, term()} + def acquire_stream_slot(operation_id, opts \\ []) do + GenServer.call(__MODULE__, {:acquire, operation_id, opts}) + end + + @doc """ + Release a streaming slot when operation completes. + """ + @spec release_stream_slot(String.t()) :: :ok + def release_stream_slot(operation_id) do + GenServer.cast(__MODULE__, {:release, operation_id}) + end + + @doc """ + Get current resource usage statistics. + """ + @spec get_stats() :: map() + def get_stats do + GenServer.call(__MODULE__, :get_stats) + end + + @doc """ + Check if a streaming operation is still active. + """ + @spec stream_active?(String.t()) :: boolean() + def stream_active?(operation_id) do + GenServer.call(__MODULE__, {:check_active, operation_id}) + end + + @doc """ + Update configuration at runtime. + """ + @spec update_config(map()) :: :ok + def update_config(config) do + GenServer.call(__MODULE__, {:update_config, config}) + end + + # Server Callbacks + + @impl true + def init(opts) do + config = + @default_config + |> Map.merge(Enum.into(opts, %{})) + + # Schedule periodic resource checks + schedule_resource_check(config.check_interval) + + {:ok, + %__MODULE__{ + config: config, + active_streams: %{}, + stream_stats: init_stats(), + memory_baseline: :erlang.memory(:total) + }} + end + + @impl true + def handle_call({:acquire, operation_id, opts}, _from, state) do + cond do + # Check if we already have this operation + Map.has_key?(state.active_streams, operation_id) -> + {:reply, {:error, :duplicate_operation}, state} + + # Check concurrent stream limit + map_size(state.active_streams) >= state.config.max_concurrent_streams -> + {:reply, {:error, :max_concurrent_streams}, state} + + # Check memory limit + exceeds_memory_limit?(state) -> + {:reply, {:error, :memory_limit_exceeded}, state} + + true -> + # Acquire the slot + stream_info = %{ + operation_id: operation_id, + started_at: System.monotonic_time(:millisecond), + memory_baseline: :erlang.memory(:total), + pid: Keyword.get(opts, :pid), + label: Keyword.get(opts, :label), + path: Keyword.get(opts, :path, []) + } + + new_state = + state + |> put_in([:active_streams, operation_id], stream_info) + |> update_stats(:stream_acquired) + + # Schedule timeout for this stream + schedule_stream_timeout(operation_id, state.config.max_stream_duration) + + Logger.debug("Acquired stream slot for operation #{operation_id}") + + {:reply, :ok, new_state} + end + end + + @impl true + def handle_call({:check_active, operation_id}, _from, state) do + {:reply, Map.has_key?(state.active_streams, operation_id), state} + end + + @impl true + def handle_call(:get_stats, _from, state) do + stats = %{ + active_streams: map_size(state.active_streams), + total_streams: state.stream_stats.total_count, + failed_streams: state.stream_stats.failed_count, + memory_usage_mb: :erlang.memory(:total) / 1_048_576, + avg_stream_duration_ms: calculate_avg_duration(state.stream_stats), + config: state.config + } + + {:reply, stats, state} + end + + @impl true + def handle_call({:update_config, new_config}, _from, state) do + updated_config = Map.merge(state.config, new_config) + {:reply, :ok, %{state | config: updated_config}} + end + + @impl true + def handle_cast({:release, operation_id}, state) do + case Map.get(state.active_streams, operation_id) do + nil -> + {:noreply, state} + + stream_info -> + duration = System.monotonic_time(:millisecond) - stream_info.started_at + + new_state = + state + |> update_in([:active_streams], &Map.delete(&1, operation_id)) + |> update_stats(:stream_released, duration) + + Logger.debug( + "Released stream slot for operation #{operation_id} (duration: #{duration}ms)" + ) + + {:noreply, new_state} + end + end + + @impl true + def handle_info({:stream_timeout, operation_id}, state) do + case Map.get(state.active_streams, operation_id) do + nil -> + # Already released + {:noreply, state} + + stream_info -> + Logger.warning("Stream timeout for operation #{operation_id}") + + # Kill the associated process if it exists + if stream_info.pid && Process.alive?(stream_info.pid) do + Process.exit(stream_info.pid, :timeout) + end + + # Release the stream + new_state = + state + |> update_in([:active_streams], &Map.delete(&1, operation_id)) + |> update_stats(:stream_timeout) + + {:noreply, new_state} + end + end + + @impl true + def handle_info(:check_resources, state) do + # Periodic resource check + state = + state + |> check_memory_pressure() + |> check_stale_streams() + + # Schedule next check + schedule_resource_check(state.config.check_interval) + + {:noreply, state} + end + + @impl true + def handle_info({:DOWN, _ref, :process, pid, reason}, state) do + # Handle process crashes + case find_stream_by_pid(state.active_streams, pid) do + nil -> + {:noreply, state} + + {operation_id, _stream_info} -> + Logger.warning("Stream process crashed for operation #{operation_id}: #{inspect(reason)}") + + new_state = + state + |> update_in([:active_streams], &Map.delete(&1, operation_id)) + |> update_stats(:stream_crashed) + + {:noreply, new_state} + end + end + + # Private functions + + defp init_stats do + %{ + total_count: 0, + completed_count: 0, + failed_count: 0, + timeout_count: 0, + total_duration: 0, + max_duration: 0, + min_duration: nil + } + end + + defp update_stats(state, :stream_acquired) do + update_in(state.stream_stats.total_count, &(&1 + 1)) + end + + defp update_stats(state, :stream_released, duration) do + state + |> update_in([:stream_stats, :completed_count], &(&1 + 1)) + |> update_in([:stream_stats, :total_duration], &(&1 + duration)) + |> update_in([:stream_stats, :max_duration], &max(&1, duration)) + |> update_in([:stream_stats, :min_duration], fn + nil -> duration + min -> min(min, duration) + end) + end + + defp update_stats(state, :stream_timeout) do + state + |> update_in([:stream_stats, :timeout_count], &(&1 + 1)) + |> update_in([:stream_stats, :failed_count], &(&1 + 1)) + end + + defp update_stats(state, :stream_crashed) do + update_in(state.stream_stats.failed_count, &(&1 + 1)) + end + + defp exceeds_memory_limit?(state) do + current_memory_mb = :erlang.memory(:total) / 1_048_576 + current_memory_mb > state.config.max_memory_mb + end + + defp schedule_stream_timeout(operation_id, timeout_ms) do + Process.send_after(self(), {:stream_timeout, operation_id}, timeout_ms) + end + + defp schedule_resource_check(interval_ms) do + Process.send_after(self(), :check_resources, interval_ms) + end + + defp check_memory_pressure(state) do + if exceeds_memory_limit?(state) do + Logger.warning("Memory pressure detected, may reject new streams") + + # Could implement more aggressive cleanup here + # For now, just log the warning + end + + state + end + + defp check_stale_streams(state) do + now = System.monotonic_time(:millisecond) + max_duration = state.config.max_stream_duration + + stale_streams = + state.active_streams + |> Enum.filter(fn {_id, info} -> + # 2x timeout = definitely stale + now - info.started_at > max_duration * 2 + end) + + if not Enum.empty?(stale_streams) do + Logger.warning("Found #{length(stale_streams)} stale streams, cleaning up") + + Enum.reduce(stale_streams, state, fn {operation_id, _info}, acc -> + update_in(acc.active_streams, &Map.delete(&1, operation_id)) + end) + else + state + end + end + + defp find_stream_by_pid(active_streams, pid) do + Enum.find(active_streams, fn {_id, info} -> + info.pid == pid + end) + end + + defp calculate_avg_duration(%{completed_count: 0}), do: 0 + + defp calculate_avg_duration(stats) do + div(stats.total_duration, stats.completed_count) + end +end diff --git a/lib/absinthe/incremental/response.ex b/lib/absinthe/incremental/response.ex new file mode 100644 index 0000000000..8d016ab501 --- /dev/null +++ b/lib/absinthe/incremental/response.ex @@ -0,0 +1,271 @@ +defmodule Absinthe.Incremental.Response do + @moduledoc """ + Builds incremental delivery responses according to the GraphQL incremental delivery specification. + + This module handles formatting of initial and incremental payloads for @defer and @stream directives. + """ + + alias Absinthe.Blueprint + + @type initial_response :: %{ + data: map(), + pending: list(pending_item()), + hasNext: boolean(), + errors: list(map()) | nil + } + + @type incremental_response :: %{ + incremental: list(incremental_item()), + hasNext: boolean(), + completed: list(completed_item()) | nil + } + + @type pending_item :: %{ + id: String.t(), + path: list(String.t() | integer()), + label: String.t() | nil + } + + @type incremental_item :: %{ + data: any(), + path: list(String.t() | integer()), + label: String.t() | nil, + errors: list(map()) | nil + } + + @type completed_item :: %{ + id: String.t(), + errors: list(map()) | nil + } + + @doc """ + Build the initial response for a query with incremental delivery. + + The initial response contains: + - The immediately available data + - A list of pending operations that will be delivered incrementally + - A hasNext flag indicating more payloads are coming + """ + @spec build_initial(Blueprint.t()) :: initial_response() + def build_initial(blueprint) do + streaming_context = get_streaming_context(blueprint) + + response = %{ + data: extract_initial_data(blueprint), + pending: build_pending_list(streaming_context), + hasNext: has_pending_operations?(streaming_context) + } + + # Add errors if present + case blueprint.result[:errors] do + nil -> response + [] -> response + errors -> Map.put(response, :errors, errors) + end + end + + @doc """ + Build an incremental response for deferred or streamed data. + + Each incremental response contains: + - The incremental data items + - A hasNext flag indicating if more payloads are coming + - Optional completed items to signal completion of specific operations + """ + @spec build_incremental(any(), list(), String.t() | nil, boolean()) :: incremental_response() + def build_incremental(data, path, label, has_next) do + incremental_item = %{ + data: data, + path: path + } + + incremental_item = + if label do + Map.put(incremental_item, :label, label) + else + incremental_item + end + + %{ + incremental: [incremental_item], + hasNext: has_next + } + end + + @doc """ + Build an incremental response for streamed list items. + """ + @spec build_stream_incremental(list(), list(), String.t() | nil, boolean()) :: + incremental_response() + def build_stream_incremental(items, path, label, has_next) do + incremental_item = %{ + items: items, + path: path + } + + incremental_item = + if label do + Map.put(incremental_item, :label, label) + else + incremental_item + end + + %{ + incremental: [incremental_item], + hasNext: has_next + } + end + + @doc """ + Build a completion response to signal the end of incremental delivery. + """ + @spec build_completed(list(String.t())) :: incremental_response() + def build_completed(completed_ids) do + completed_items = + Enum.map(completed_ids, fn id -> + %{id: id} + end) + + %{ + completed: completed_items, + hasNext: false + } + end + + @doc """ + Build an error response for a failed incremental operation. + """ + @spec build_error(list(map()), list(), String.t() | nil, boolean()) :: incremental_response() + def build_error(errors, path, label, has_next) do + incremental_item = %{ + errors: errors, + path: path + } + + incremental_item = + if label do + Map.put(incremental_item, :label, label) + else + incremental_item + end + + %{ + incremental: [incremental_item], + hasNext: has_next + } + end + + # Private functions + + defp extract_initial_data(blueprint) do + # Extract the data from the blueprint result + # Skip any fields/fragments marked as deferred or streamed + result = blueprint.result[:data] || %{} + + # If we have streaming context, we need to filter the data + case get_streaming_context(blueprint) do + nil -> + result + + streaming_context -> + filter_initial_data(result, streaming_context) + end + end + + defp filter_initial_data(data, streaming_context) do + # Remove deferred fragments and limit streamed fields + data + |> filter_deferred_fragments(streaming_context.deferred_fragments) + |> filter_streamed_fields(streaming_context.streamed_fields) + end + + defp filter_deferred_fragments(data, deferred_fragments) do + # Remove data for deferred fragments from initial response + Enum.reduce(deferred_fragments, data, fn fragment, acc -> + remove_at_path(acc, fragment.path) + end) + end + + defp filter_streamed_fields(data, streamed_fields) do + # Limit streamed fields to initial_count items + Enum.reduce(streamed_fields, data, fn field, acc -> + limit_at_path(acc, field.path, field.initial_count) + end) + end + + defp remove_at_path(data, []), do: nil + + defp remove_at_path(data, [key | rest]) when is_map(data) do + case Map.get(data, key) do + nil -> data + _value when rest == [] -> Map.delete(data, key) + value -> Map.put(data, key, remove_at_path(value, rest)) + end + end + + defp remove_at_path(data, _path), do: data + + defp limit_at_path(data, [], _limit), do: data + + defp limit_at_path(data, [key | rest], limit) when is_map(data) do + case Map.get(data, key) do + nil -> + data + + value when rest == [] and is_list(value) -> + Map.put(data, key, Enum.take(value, limit)) + + value -> + Map.put(data, key, limit_at_path(value, rest, limit)) + end + end + + defp limit_at_path(data, _path, _limit), do: data + + defp build_pending_list(streaming_context) do + deferred = + Enum.map(streaming_context.deferred_fragments || [], fn fragment -> + pending = %{ + id: generate_pending_id(), + path: fragment.path + } + + if fragment.label do + Map.put(pending, :label, fragment.label) + else + pending + end + end) + + streamed = + Enum.map(streaming_context.streamed_fields || [], fn field -> + pending = %{ + id: generate_pending_id(), + path: field.path + } + + if field.label do + Map.put(pending, :label, field.label) + else + pending + end + end) + + deferred ++ streamed + end + + defp has_pending_operations?(streaming_context) do + has_deferred = not Enum.empty?(streaming_context.deferred_fragments || []) + has_streamed = not Enum.empty?(streaming_context.streamed_fields || []) + + has_deferred or has_streamed + end + + defp get_streaming_context(blueprint) do + get_in(blueprint, [:execution, :context, :__streaming__]) + end + + defp generate_pending_id do + :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) + end +end diff --git a/lib/absinthe/incremental/supervisor.ex b/lib/absinthe/incremental/supervisor.ex new file mode 100644 index 0000000000..d7e45ce95e --- /dev/null +++ b/lib/absinthe/incremental/supervisor.ex @@ -0,0 +1,198 @@ +defmodule Absinthe.Incremental.Supervisor do + @moduledoc """ + Supervisor for incremental delivery components. + + This supervisor manages the resource manager and task supervisors + needed for @defer and @stream operations. + + ## Starting the Supervisor + + To enable incremental delivery, add this supervisor to your application's + supervision tree in `application.ex`: + + defmodule MyApp.Application do + use Application + + def start(_type, _args) do + children = [ + # ... other children + {Absinthe.Incremental.Supervisor, [ + enabled: true, + enable_defer: true, + enable_stream: true, + max_concurrent_defers: 10, + max_concurrent_streams: 5 + ]} + ] + + opts = [strategy: :one_for_one, name: MyApp.Supervisor] + Supervisor.start_link(children, opts) + end + end + + ## Configuration Options + + - `:enabled` - Enable/disable incremental delivery (default: false) + - `:enable_defer` - Enable @defer directive support (default: true when enabled) + - `:enable_stream` - Enable @stream directive support (default: true when enabled) + - `:max_concurrent_defers` - Max concurrent deferred operations (default: 100) + - `:max_concurrent_streams` - Max concurrent stream operations (default: 50) + + ## Note + + The supervisor is only required for actual incremental delivery over transports + (SSE, WebSocket). Standard query execution with @defer/@stream directives will + work without the supervisor, but will return all data in a single response. + """ + + use Supervisor + + @doc """ + Start the incremental delivery supervisor. + """ + def start_link(opts \\ []) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + config = Absinthe.Incremental.Config.from_options(opts) + + children = + if config.enabled do + [ + # Resource manager for tracking and limiting concurrent operations + {Absinthe.Incremental.ResourceManager, Map.to_list(config)}, + + # Task supervisor for deferred operations + {Task.Supervisor, name: Absinthe.Incremental.DeferredTaskSupervisor}, + + # Task supervisor for streamed operations + {Task.Supervisor, name: Absinthe.Incremental.StreamTaskSupervisor}, + + # Telemetry reporter if enabled + telemetry_reporter(config) + ] + |> Enum.filter(& &1) + else + [] + end + + Supervisor.init(children, strategy: :one_for_one) + end + + @doc """ + Check if the supervisor is running. + """ + @spec running?() :: boolean() + def running? do + case Process.whereis(__MODULE__) do + nil -> false + pid -> Process.alive?(pid) + end + end + + @doc """ + Restart the supervisor with new configuration. + """ + @spec restart(Keyword.t()) :: {:ok, pid()} | {:error, term()} + def restart(opts \\ []) do + if running?() do + Supervisor.stop(__MODULE__) + end + + start_link(opts) + end + + @doc """ + Get the current configuration. + """ + @spec get_config() :: Absinthe.Incremental.Config.t() | nil + def get_config do + if running?() do + # Get config from resource manager + stats = Absinthe.Incremental.ResourceManager.get_stats() + Map.get(stats, :config) + end + end + + @doc """ + Update configuration at runtime. + """ + @spec update_config(map()) :: :ok | {:error, :not_running} + def update_config(config) do + if running?() do + Absinthe.Incremental.ResourceManager.update_config(config) + else + {:error, :not_running} + end + end + + @doc """ + Start a deferred task under supervision. + """ + @spec start_deferred_task((-> any())) :: {:ok, pid()} | {:error, term()} + def start_deferred_task(fun) do + if running?() do + Task.Supervisor.async_nolink( + Absinthe.Incremental.DeferredTaskSupervisor, + fun + ) + |> Map.get(:pid) + |> then(&{:ok, &1}) + else + {:error, :supervisor_not_running} + end + end + + @doc """ + Start a streaming task under supervision. + """ + @spec start_stream_task((-> any())) :: {:ok, pid()} | {:error, term()} + def start_stream_task(fun) do + if running?() do + Task.Supervisor.async_nolink( + Absinthe.Incremental.StreamTaskSupervisor, + fun + ) + |> Map.get(:pid) + |> then(&{:ok, &1}) + else + {:error, :supervisor_not_running} + end + end + + @doc """ + Get statistics about current operations. + """ + @spec get_stats() :: map() | {:error, :not_running} + def get_stats do + if running?() do + resource_stats = Absinthe.Incremental.ResourceManager.get_stats() + + deferred_tasks = + Task.Supervisor.children(Absinthe.Incremental.DeferredTaskSupervisor) + |> length() + + stream_tasks = + Task.Supervisor.children(Absinthe.Incremental.StreamTaskSupervisor) + |> length() + + Map.merge(resource_stats, %{ + active_deferred_tasks: deferred_tasks, + active_stream_tasks: stream_tasks, + total_active_tasks: deferred_tasks + stream_tasks + }) + else + {:error, :not_running} + end + end + + # Private functions + + defp telemetry_reporter(%{enable_telemetry: true}) do + {Absinthe.Incremental.TelemetryReporter, []} + end + + defp telemetry_reporter(_), do: nil +end diff --git a/lib/absinthe/incremental/telemetry_reporter.ex b/lib/absinthe/incremental/telemetry_reporter.ex new file mode 100644 index 0000000000..89774f8bb2 --- /dev/null +++ b/lib/absinthe/incremental/telemetry_reporter.ex @@ -0,0 +1,81 @@ +defmodule Absinthe.Incremental.TelemetryReporter do + @moduledoc """ + Reports telemetry events for incremental delivery operations. + """ + + use GenServer + require Logger + + @events [ + [:absinthe, :incremental, :defer, :start], + [:absinthe, :incremental, :defer, :stop], + [:absinthe, :incremental, :stream, :start], + [:absinthe, :incremental, :stream, :stop], + [:absinthe, :incremental, :error] + ] + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + # Attach telemetry handlers + Enum.each(@events, fn event -> + :telemetry.attach( + {__MODULE__, event}, + event, + &handle_event/4, + nil + ) + end) + + {:ok, %{}} + end + + @impl true + def terminate(_reason, _state) do + # Detach telemetry handlers + Enum.each(@events, fn event -> + :telemetry.detach({__MODULE__, event}) + end) + + :ok + end + + defp handle_event([:absinthe, :incremental, :defer, :start], _measurements, metadata, _config) do + Logger.debug( + "Defer operation started - label: #{metadata.label}, path: #{inspect(metadata.path)}" + ) + end + + defp handle_event([:absinthe, :incremental, :defer, :stop], measurements, metadata, _config) do + duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond) + + Logger.debug( + "Defer operation completed - label: #{metadata.label}, duration: #{duration_ms}ms" + ) + end + + defp handle_event([:absinthe, :incremental, :stream, :start], _measurements, metadata, _config) do + Logger.debug( + "Stream operation started - label: #{metadata.label}, initial_count: #{metadata.initial_count}" + ) + end + + defp handle_event([:absinthe, :incremental, :stream, :stop], measurements, metadata, _config) do + duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond) + + Logger.debug( + "Stream operation completed - label: #{metadata.label}, " <> + "items_streamed: #{metadata.items_count}, duration: #{duration_ms}ms" + ) + end + + defp handle_event([:absinthe, :incremental, :error], _measurements, metadata, _config) do + Logger.error( + "Incremental delivery error - type: #{metadata.error_type}, " <> + "operation: #{metadata.operation_id}, details: #{inspect(metadata.error)}" + ) + end +end diff --git a/lib/absinthe/incremental/transport.ex b/lib/absinthe/incremental/transport.ex new file mode 100644 index 0000000000..3bfc63034e --- /dev/null +++ b/lib/absinthe/incremental/transport.ex @@ -0,0 +1,533 @@ +defmodule Absinthe.Incremental.Transport do + @moduledoc """ + Protocol for incremental delivery across different transports. + + This module provides a behaviour and common functionality for implementing + incremental delivery over various transport mechanisms (HTTP/SSE, WebSocket, etc.). + + ## Telemetry Events + + The following telemetry events are emitted during incremental delivery for + instrumentation libraries (e.g., opentelemetry_absinthe): + + ### `[:absinthe, :incremental, :delivery, :initial]` + + Emitted when the initial response is sent. + + **Measurements:** + - `system_time` - System time when the event occurred + + **Metadata:** + - `operation_id` - Unique identifier for the operation + - `has_next` - Boolean indicating if more payloads are expected + - `pending_count` - Number of pending deferred/streamed operations + - `response` - The initial response payload + + ### `[:absinthe, :incremental, :delivery, :payload]` + + Emitted when each incremental payload is delivered. + + **Measurements:** + - `system_time` - System time when the event occurred + - `duration` - Time taken to execute the deferred/streamed task (native units) + + **Metadata:** + - `operation_id` - Unique identifier for the operation + - `path` - GraphQL path to the deferred/streamed field + - `label` - Label from @defer or @stream directive + - `task_type` - `:defer` or `:stream` + - `has_next` - Boolean indicating if more payloads are expected + - `duration_ms` - Duration in milliseconds + - `success` - Boolean indicating if the task succeeded + - `response` - The incremental response payload + + ### `[:absinthe, :incremental, :delivery, :complete]` + + Emitted when incremental delivery completes successfully. + + **Measurements:** + - `system_time` - System time when the event occurred + - `duration` - Total duration of the incremental delivery (native units) + + **Metadata:** + - `operation_id` - Unique identifier for the operation + - `duration_ms` - Total duration in milliseconds + + ### `[:absinthe, :incremental, :delivery, :error]` + + Emitted when an error occurs during incremental delivery. + + **Measurements:** + - `system_time` - System time when the event occurred + - `duration` - Duration until the error occurred (native units) + + **Metadata:** + - `operation_id` - Unique identifier for the operation + - `duration_ms` - Duration in milliseconds + - `error` - Map containing `:reason` and `:message` keys + """ + + alias Absinthe.Blueprint + alias Absinthe.Incremental.{Config, Response} + alias Absinthe.Streaming.Executor + + @type conn_or_socket :: Plug.Conn.t() | Phoenix.Socket.t() | any() + @type state :: any() + @type response :: map() + + @doc """ + Initialize the transport for incremental delivery. + """ + @callback init(conn_or_socket, options :: Keyword.t()) :: {:ok, state} | {:error, term()} + + @doc """ + Send the initial response containing immediately available data. + """ + @callback send_initial(state, response) :: {:ok, state} | {:error, term()} + + @doc """ + Send an incremental response containing deferred or streamed data. + """ + @callback send_incremental(state, response) :: {:ok, state} | {:error, term()} + + @doc """ + Complete the incremental delivery stream. + """ + @callback complete(state) :: :ok | {:error, term()} + + @doc """ + Handle errors during incremental delivery. + """ + @callback handle_error(state, error :: term()) :: {:ok, state} | {:error, term()} + + @optional_callbacks [handle_error: 2] + + @default_timeout 30_000 + + @telemetry_initial [:absinthe, :incremental, :delivery, :initial] + @telemetry_payload [:absinthe, :incremental, :delivery, :payload] + @telemetry_complete [:absinthe, :incremental, :delivery, :complete] + @telemetry_error [:absinthe, :incremental, :delivery, :error] + + defmacro __using__(_opts) do + quote do + @behaviour Absinthe.Incremental.Transport + + alias Absinthe.Incremental.{Config, Response, ErrorHandler} + + # Telemetry event names for instrumentation (e.g., opentelemetry_absinthe) + @telemetry_initial unquote(@telemetry_initial) + @telemetry_payload unquote(@telemetry_payload) + @telemetry_complete unquote(@telemetry_complete) + @telemetry_error unquote(@telemetry_error) + + @doc """ + Handle a streaming response from the resolution phase. + + This is the main entry point for transport implementations. + + ## Options + + - `:timeout` - Maximum time to wait for streaming operations (default: 30s) + - `:on_event` - Callback for monitoring events (Sentry, DataDog, etc.) + - `:operation_id` - Unique identifier for tracking this operation + + ## Event Callbacks + + When `on_event` is provided, it will be called at each stage of incremental + delivery with event type, payload, and metadata: + + on_event: fn event_type, payload, metadata -> + case event_type do + :initial -> Logger.info("Initial response sent") + :incremental -> Logger.info("Incremental payload delivered") + :complete -> Logger.info("Stream completed") + :error -> Sentry.capture_message("GraphQL error", extra: payload) + end + end + """ + def handle_streaming_response(conn_or_socket, blueprint, options \\ []) do + timeout = Keyword.get(options, :timeout, unquote(@default_timeout)) + started_at = System.monotonic_time(:millisecond) + operation_id = Keyword.get(options, :operation_id, generate_operation_id()) + + # Build config with on_event callback + config = build_event_config(options) + + # Add tracking metadata to options + options = + options + |> Keyword.put(:__config__, config) + |> Keyword.put(:__started_at__, started_at) + |> Keyword.put(:__operation_id__, operation_id) + + with {:ok, state} <- init(conn_or_socket, options), + {:ok, state} <- send_initial_response(state, blueprint, options), + {:ok, state} <- execute_and_stream_incremental(state, blueprint, timeout, options) do + emit_complete_event(config, operation_id, started_at) + complete(state) + else + {:error, reason} = error -> + emit_error_event(config, reason, operation_id, started_at) + handle_transport_error(conn_or_socket, error, options) + end + end + + defp build_event_config(options) do + case Keyword.get(options, :on_event) do + nil -> nil + callback when is_function(callback, 3) -> Config.from_options(on_event: callback) + _ -> nil + end + end + + defp generate_operation_id do + Base.encode16(:crypto.strong_rand_bytes(8), case: :lower) + end + + defp send_initial_response(state, blueprint, options) do + initial = Response.build_initial(blueprint) + + config = Keyword.get(options, :__config__) + operation_id = Keyword.get(options, :__operation_id__) + + metadata = %{ + operation_id: operation_id, + has_next: Map.get(initial, :hasNext, false), + pending_count: length(Map.get(initial, :pending, [])) + } + + # Emit telemetry event for instrumentation + :telemetry.execute( + @telemetry_initial, + %{system_time: System.system_time()}, + Map.merge(metadata, %{response: initial}) + ) + + # Emit to custom on_event callback + Config.emit_event(config, :initial, initial, metadata) + + send_initial(state, initial) + end + + # Execute deferred/streamed tasks and deliver results as they complete + defp execute_and_stream_incremental(state, blueprint, timeout, options) do + streaming_context = get_streaming_context(blueprint) + + all_tasks = + Map.get(streaming_context, :deferred_tasks, []) ++ + Map.get(streaming_context, :stream_tasks, []) + + if Enum.empty?(all_tasks) do + {:ok, state} + else + execute_tasks_with_streaming(state, all_tasks, timeout, options) + end + end + + # Execute tasks using configurable executor for controlled concurrency + defp execute_tasks_with_streaming(state, tasks, timeout, options) do + config = Keyword.get(options, :__config__) + operation_id = Keyword.get(options, :__operation_id__) + started_at = Keyword.get(options, :__started_at__) + schema = Keyword.get(options, :schema) + + # Get configurable executor (defaults to TaskExecutor) + executor = Absinthe.Streaming.Executor.get_executor(schema, options) + executor_opts = [ + timeout: timeout, + max_concurrency: System.schedulers_online() * 2 + ] + + tasks + |> executor.execute(executor_opts) + |> Enum.reduce_while({:ok, state}, fn task_result, {:ok, acc_state} -> + case task_result.success do + true -> + case send_task_result_from_executor( + acc_state, + task_result, + config, + operation_id + ) do + {:ok, new_state} -> {:cont, {:ok, new_state}} + {:error, _} = error -> {:halt, error} + end + + false -> + # Handle errors (timeout, exit, etc.) + error_response = build_error_response_from_executor(task_result) + emit_error_event(config, task_result.result, operation_id, started_at) + + case send_incremental(acc_state, error_response) do + {:ok, new_state} -> {:cont, {:ok, new_state}} + error -> {:halt, error} + end + end + end) + end + + # Send task result from TaskExecutor output + defp send_task_result_from_executor(state, task_result, config, operation_id) do + task = task_result.task + result = task_result.result + has_next = task_result.has_next + duration_ms = task_result.duration_ms + + response = build_task_response(task, result, has_next) + + metadata = %{ + operation_id: operation_id, + path: task.path, + label: task.label, + task_type: task.type, + has_next: has_next, + duration_ms: duration_ms, + success: true + } + + # Emit telemetry event for instrumentation + :telemetry.execute( + @telemetry_payload, + %{ + system_time: System.system_time(), + duration: duration_ms * 1_000_000 + }, + Map.merge(metadata, %{response: response}) + ) + + # Emit to custom on_event callback + Config.emit_event(config, :incremental, response, metadata) + + send_incremental(state, response) + end + + # Build error response from TaskExecutor result + defp build_error_response_from_executor(task_result) do + error_message = + case task_result.result do + {:error, :timeout} -> "Operation timed out" + {:error, {:exit, reason}} -> "Operation failed: #{inspect(reason)}" + {:error, msg} when is_binary(msg) -> msg + {:error, other} -> inspect(other) + end + + Response.build_error( + [%{message: error_message}], + (task_result.task && task_result.task.path) || [], + task_result.task && task_result.task.label, + task_result.has_next + ) + end + + # Build the appropriate response based on task type and result + defp build_task_response(task, {:ok, result}, has_next) do + case task.type do + :defer -> + Response.build_incremental( + result.data, + result.path, + result.label, + has_next + ) + + :stream -> + Response.build_stream_incremental( + result.items, + result.path, + result.label, + has_next + ) + end + end + + defp build_task_response(task, {:error, error}, has_next) do + errors = + case error do + %{message: _} = err -> [err] + message when is_binary(message) -> [%{message: message}] + other -> [%{message: inspect(other)}] + end + + Response.build_error( + errors, + task.path, + task.label, + has_next + ) + end + + defp get_streaming_context(blueprint) do + get_in(blueprint.execution.context, [:__streaming__]) || %{} + end + + defp handle_transport_error(conn_or_socket, error, options) do + if function_exported?(__MODULE__, :handle_error, 2) do + with {:ok, state} <- init(conn_or_socket, options) do + apply(__MODULE__, :handle_error, [state, error]) + end + else + error + end + end + + defp emit_complete_event(config, operation_id, started_at) do + duration_ms = System.monotonic_time(:millisecond) - started_at + + metadata = %{ + operation_id: operation_id, + duration_ms: duration_ms + } + + # Emit telemetry event for instrumentation + :telemetry.execute( + @telemetry_complete, + %{ + system_time: System.system_time(), + # Convert to native time units + duration: duration_ms * 1_000_000 + }, + metadata + ) + + # Emit to custom on_event callback + Config.emit_event(config, :complete, %{}, metadata) + end + + defp emit_error_event(config, reason, operation_id, started_at) do + duration_ms = System.monotonic_time(:millisecond) - started_at + + payload = %{ + reason: reason, + message: format_error_message(reason) + } + + metadata = %{ + operation_id: operation_id, + duration_ms: duration_ms + } + + # Emit telemetry event for instrumentation + :telemetry.execute( + @telemetry_error, + %{ + system_time: System.system_time(), + # Convert to native time units + duration: duration_ms * 1_000_000 + }, + Map.merge(metadata, %{error: payload}) + ) + + # Emit to custom on_event callback + Config.emit_event(config, :error, payload, metadata) + end + + defp format_error_message(:timeout), do: "Operation timed out" + defp format_error_message({:error, msg}) when is_binary(msg), do: msg + defp format_error_message(reason), do: inspect(reason) + + defoverridable handle_streaming_response: 3 + end + end + + @doc """ + Check if a blueprint has incremental delivery enabled. + """ + @spec incremental_delivery_enabled?(Blueprint.t()) :: boolean() + def incremental_delivery_enabled?(blueprint) do + get_in(blueprint.execution, [:incremental_delivery]) == true + end + + @doc """ + Get the operation ID for tracking incremental delivery. + """ + @spec get_operation_id(Blueprint.t()) :: String.t() | nil + def get_operation_id(blueprint) do + get_in(blueprint.execution.context, [:__streaming__, :operation_id]) + end + + @doc """ + Get streaming context from a blueprint. + """ + @spec get_streaming_context(Blueprint.t()) :: map() + def get_streaming_context(blueprint) do + get_in(blueprint.execution.context, [:__streaming__]) || %{} + end + + @doc """ + Execute incremental delivery for a blueprint. + + This is the main entry point that transport implementations call. + """ + @spec execute(module(), conn_or_socket, Blueprint.t(), Keyword.t()) :: + {:ok, state} | {:error, term()} + def execute(transport_module, conn_or_socket, blueprint, options \\ []) do + if incremental_delivery_enabled?(blueprint) do + transport_module.handle_streaming_response(conn_or_socket, blueprint, options) + else + {:error, :incremental_delivery_not_enabled} + end + end + + @doc """ + Create a simple collector that accumulates all incremental responses. + + Useful for testing and non-streaming contexts. + """ + @spec collect_all(Blueprint.t(), Keyword.t()) :: {:ok, map()} | {:error, term()} + def collect_all(blueprint, options \\ []) do + timeout = Keyword.get(options, :timeout, @default_timeout) + schema = Keyword.get(options, :schema) + streaming_context = get_streaming_context(blueprint) + + initial = Response.build_initial(blueprint) + + all_tasks = + Map.get(streaming_context, :deferred_tasks, []) ++ + Map.get(streaming_context, :stream_tasks, []) + + # Use configurable executor (defaults to TaskExecutor) + executor = Executor.get_executor(schema, options) + incremental_results = + all_tasks + |> executor.execute(timeout: timeout) + |> Enum.map(fn task_result -> + task = task_result.task + + case task_result.result do + {:ok, result} -> + %{ + type: task.type, + label: task.label, + path: task.path, + data: Map.get(result, :data), + items: Map.get(result, :items), + errors: Map.get(result, :errors) + } + + {:error, error} -> + error_msg = + case error do + :timeout -> "Operation timed out" + {:exit, reason} -> "Task failed: #{inspect(reason)}" + msg when is_binary(msg) -> msg + other -> inspect(other) + end + + %{ + type: task && task.type, + label: task && task.label, + path: task && task.path, + errors: [%{message: error_msg}] + } + end + end) + + {:ok, + %{ + initial: initial, + incremental: incremental_results, + hasNext: false + }} + end +end diff --git a/lib/absinthe/middleware/auto_defer_stream.ex b/lib/absinthe/middleware/auto_defer_stream.ex new file mode 100644 index 0000000000..cc332be899 --- /dev/null +++ b/lib/absinthe/middleware/auto_defer_stream.ex @@ -0,0 +1,542 @@ +defmodule Absinthe.Middleware.AutoDeferStream do + @moduledoc """ + Middleware that automatically suggests or applies @defer and @stream directives + based on field complexity and performance characteristics. + + This middleware can: + - Analyze field complexity and suggest defer/stream + - Automatically apply defer/stream to expensive fields + - Learn from execution patterns to optimize future queries + """ + + @behaviour Absinthe.Middleware + + require Logger + + @default_config %{ + # Thresholds for automatic optimization + # Complexity threshold for auto-defer + auto_defer_threshold: 100, + # List size threshold for auto-stream + auto_stream_threshold: 50, + # Default initial count for auto-stream + auto_stream_initial_count: 10, + + # Learning configuration + enable_learning: true, + # Sample 10% of queries for learning + learning_sample_rate: 0.1, + + # Field-specific hints + field_hints: %{}, + + # Performance history + performance_history: %{}, + + # Modes + # :suggest | :auto | :off + mode: :suggest, + + # Complexity weights + complexity_weights: %{ + resolver_time: 1.0, + data_size: 0.5, + depth: 0.3 + } + } + + @doc """ + Middleware call that analyzes and potentially modifies the query. + """ + def call(resolution, config \\ %{}) do + config = Map.merge(@default_config, config) + + case config.mode do + :off -> + resolution + + :suggest -> + suggest_optimizations(resolution, config) + + :auto -> + apply_optimizations(resolution, config) + end + end + + @doc """ + Analyze a field and determine if it should be deferred. + """ + def should_defer?(field, resolution, config) do + # Check if field is already deferred + if has_defer_directive?(field) do + false + else + # Calculate field complexity + complexity = calculate_field_complexity(field, resolution, config) + + # Check against threshold + complexity > config.auto_defer_threshold + end + end + + @doc """ + Analyze a list field and determine if it should be streamed. + """ + def should_stream?(field, resolution, config) do + # Check if field is already streamed + if has_stream_directive?(field) do + false + else + # Must be a list type + if not is_list_field?(field) do + false + else + # Estimate list size + estimated_size = estimate_list_size(field, resolution, config) + + # Check against threshold + estimated_size > config.auto_stream_threshold + end + end + end + + @doc """ + Get optimization suggestions for a query. + """ + def get_suggestions(blueprint, config \\ %{}) do + config = Map.merge(@default_config, config) + suggestions = [] + + # Walk the blueprint and collect suggestions + Absinthe.Blueprint.prewalk(blueprint, suggestions, fn + %{__struct__: Absinthe.Blueprint.Document.Field} = field, acc -> + suggestion = analyze_field_for_suggestions(field, config) + + if suggestion do + {field, [suggestion | acc]} + else + {field, acc} + end + + node, acc -> + {node, acc} + end) + |> elem(1) + |> Enum.reverse() + end + + @doc """ + Learn from execution results to improve future suggestions. + """ + def learn_from_execution(field_path, execution_time, data_size, config) do + if config.enable_learning do + update_performance_history( + field_path, + %{ + execution_time: execution_time, + data_size: data_size, + timestamp: System.system_time(:second) + }, + config + ) + end + end + + # Private functions + + defp suggest_optimizations(resolution, config) do + field = resolution.definition + + cond do + should_defer?(field, resolution, config) -> + add_suggestion(resolution, :defer, field) + + should_stream?(field, resolution, config) -> + add_suggestion(resolution, :stream, field) + + true -> + resolution + end + end + + defp apply_optimizations(resolution, config) do + field = resolution.definition + + cond do + should_defer?(field, resolution, config) -> + apply_defer(resolution, config) + + should_stream?(field, resolution, config) -> + apply_stream(resolution, config) + + true -> + resolution + end + end + + defp calculate_field_complexity(field, resolution, config) do + base_complexity = get_base_complexity(field) + + # Factor in historical performance data + historical_factor = + if config.enable_learning do + get_historical_complexity(field, config) + else + 1.0 + end + + # Factor in depth + depth_factor = length(resolution.path) * config.complexity_weights.depth + + # Factor in child selections + child_factor = count_child_selections(field) * 10 + + base_complexity * historical_factor + depth_factor + child_factor + end + + defp get_base_complexity(field) do + # Get complexity from field definition or default + case field do + %{complexity: complexity} when is_number(complexity) -> + complexity + + %{complexity: fun} when is_function(fun) -> + # Call complexity function with default child complexity + fun.(0, 1) + + _ -> + # Default complexity based on type + if is_list_field?(field), do: 50, else: 10 + end + end + + defp get_historical_complexity(field, config) do + field_path = field_path(field) + + case Map.get(config.performance_history, field_path) do + nil -> + 1.0 + + history -> + # Calculate average execution time + avg_time = average_execution_time(history) + + # Convert to complexity factor (ms to factor) + cond do + # Fast field + avg_time < 10 -> 0.5 + # Normal field + avg_time < 50 -> 1.0 + # Slow field + avg_time < 200 -> 2.0 + # Very slow field + true -> 5.0 + end + end + end + + defp estimate_list_size(field, resolution, config) do + # Check for limit/first arguments + limit = get_argument_value(resolution.arguments, [:limit, :first]) + + if limit do + limit + else + # Use historical data or default estimate + field_path = field_path(field) + + case Map.get(config.performance_history, field_path) do + nil -> + # Default estimate + 100 + + history -> + average_data_size(history) + end + end + end + + defp has_defer_directive?(field) do + field.directives + |> Enum.any?(&(&1.name == "defer")) + end + + defp has_stream_directive?(field) do + field.directives + |> Enum.any?(&(&1.name == "stream")) + end + + defp is_list_field?(field) do + # Check if the field type is a list + case field.schema_node do + %{type: type} -> + is_list_type?(type) + + _ -> + false + end + end + + defp is_list_type?(%Absinthe.Type.List{}), do: true + defp is_list_type?(%Absinthe.Type.NonNull{of_type: inner}), do: is_list_type?(inner) + defp is_list_type?(_), do: false + + defp count_child_selections(field) do + case field do + %{selections: selections} when is_list(selections) -> + length(selections) + + _ -> + 0 + end + end + + defp field_path(field) do + # Generate a unique path for the field + field.name + end + + defp get_argument_value(arguments, names) do + Enum.find_value(names, fn name -> + Map.get(arguments, name) + end) + end + + defp add_suggestion(resolution, type, field) do + suggestion = build_suggestion(type, field) + + # Add to resolution private data + suggestions = Map.get(resolution.private, :optimization_suggestions, []) + + put_in( + resolution.private[:optimization_suggestions], + [suggestion | suggestions] + ) + end + + defp build_suggestion(:defer, field) do + %{ + type: :defer, + field: field.name, + path: field.source_location, + message: "Consider adding @defer to field '#{field.name}' - high complexity detected", + suggested_directive: "@defer(label: \"#{field.name}\")" + } + end + + defp build_suggestion(:stream, field) do + %{ + type: :stream, + field: field.name, + path: field.source_location, + message: "Consider adding @stream to field '#{field.name}' - large list detected", + suggested_directive: "@stream(initialCount: 10, label: \"#{field.name}\")" + } + end + + defp apply_defer(resolution, config) do + # Add defer flag to the field + field = + put_in( + resolution.definition.flags[:defer], + %{label: "auto_#{resolution.definition.name}", enabled: true} + ) + + %{resolution | definition: field} + end + + defp apply_stream(resolution, config) do + # Add stream flag to the field + field = + put_in( + resolution.definition.flags[:stream], + %{ + label: "auto_#{resolution.definition.name}", + initial_count: config.auto_stream_initial_count, + enabled: true + } + ) + + %{resolution | definition: field} + end + + defp update_performance_history(field_path, metrics, config) do + history = Map.get(config.performance_history, field_path, []) + + # Keep last 100 entries + updated_history = + [metrics | history] + |> Enum.take(100) + + put_in(config.performance_history[field_path], updated_history) + end + + defp average_execution_time(history) do + times = Enum.map(history, & &1.execution_time) + Enum.sum(times) / length(times) + end + + defp average_data_size(history) do + sizes = Enum.map(history, & &1.data_size) + round(Enum.sum(sizes) / length(sizes)) + end + + defp analyze_field_for_suggestions(field, config) do + complexity = get_base_complexity(field) + + cond do + complexity > config.auto_defer_threshold and not has_defer_directive?(field) -> + build_suggestion(:defer, field) + + is_list_field?(field) and not has_stream_directive?(field) -> + build_suggestion(:stream, field) + + true -> + nil + end + end +end + +defmodule Absinthe.Middleware.AutoDeferStream.Analyzer do + @moduledoc """ + Analyzer for collecting performance metrics and generating optimization reports. + """ + + use GenServer + + # Analyze every minute + @analysis_interval 60_000 + + defstruct [ + :config, + :metrics, + :suggestions, + :learning_data + ] + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + # Schedule periodic analysis + schedule_analysis() + + {:ok, + %__MODULE__{ + config: Map.new(opts), + metrics: %{}, + suggestions: [], + learning_data: %{} + }} + end + + @doc """ + Record execution metrics for a field. + """ + def record_metrics(field_path, metrics) do + GenServer.cast(__MODULE__, {:record_metrics, field_path, metrics}) + end + + @doc """ + Get optimization report. + """ + def get_report do + GenServer.call(__MODULE__, :get_report) + end + + @impl true + def handle_cast({:record_metrics, field_path, metrics}, state) do + updated_metrics = + Map.update(state.metrics, field_path, [metrics], &[metrics | &1]) + + {:noreply, %{state | metrics: updated_metrics}} + end + + @impl true + def handle_call(:get_report, _from, state) do + report = generate_report(state) + {:reply, report, state} + end + + @impl true + def handle_info(:analyze, state) do + # Analyze collected metrics + state = analyze_metrics(state) + + # Schedule next analysis + schedule_analysis() + + {:noreply, state} + end + + defp schedule_analysis do + Process.send_after(self(), :analyze, @analysis_interval) + end + + defp analyze_metrics(state) do + suggestions = + state.metrics + |> Enum.map(fn {field_path, metrics} -> + analyze_field_metrics(field_path, metrics) + end) + |> Enum.filter(& &1) + + %{state | suggestions: suggestions} + end + + defp analyze_field_metrics(field_path, metrics) do + avg_time = average(Enum.map(metrics, & &1.execution_time)) + avg_size = average(Enum.map(metrics, & &1.data_size)) + + cond do + avg_time > 100 -> + %{ + field: field_path, + type: :defer, + reason: "Average execution time #{avg_time}ms exceeds threshold" + } + + avg_size > 100 -> + %{ + field: field_path, + type: :stream, + reason: "Average data size #{avg_size} items exceeds threshold" + } + + true -> + nil + end + end + + defp generate_report(state) do + %{ + total_fields_analyzed: map_size(state.metrics), + suggestions: state.suggestions, + top_slow_fields: get_top_slow_fields(state.metrics, 10), + top_large_fields: get_top_large_fields(state.metrics, 10) + } + end + + defp get_top_slow_fields(metrics, limit) do + metrics + |> Enum.map(fn {path, data} -> + {path, average(Enum.map(data, & &1.execution_time))} + end) + |> Enum.sort_by(&elem(&1, 1), :desc) + |> Enum.take(limit) + end + + defp get_top_large_fields(metrics, limit) do + metrics + |> Enum.map(fn {path, data} -> + {path, average(Enum.map(data, & &1.data_size))} + end) + |> Enum.sort_by(&elem(&1, 1), :desc) + |> Enum.take(limit) + end + + defp average([]), do: 0 + defp average(list), do: Enum.sum(list) / length(list) +end diff --git a/lib/absinthe/middleware/incremental_complexity.ex b/lib/absinthe/middleware/incremental_complexity.ex new file mode 100644 index 0000000000..8730bf2d95 --- /dev/null +++ b/lib/absinthe/middleware/incremental_complexity.ex @@ -0,0 +1,94 @@ +defmodule Absinthe.Middleware.IncrementalComplexity do + @moduledoc """ + Middleware to enforce complexity limits for incremental delivery. + + Add this middleware to your schema to automatically check and enforce + complexity limits for queries with @defer and @stream. + + ## Usage + + defmodule MySchema do + use Absinthe.Schema + + def middleware(middleware, _field, _object) do + [Absinthe.Middleware.IncrementalComplexity | middleware] + end + end + + ## Configuration + + Pass a config map with limits: + + config = %{ + max_complexity: 500, + max_chunk_complexity: 100, + max_defer_operations: 5 + } + + def middleware(middleware, _field, _object) do + [{Absinthe.Middleware.IncrementalComplexity, config} | middleware] + end + """ + + @behaviour Absinthe.Middleware + + alias Absinthe.Incremental.Complexity + + def call(resolution, config) do + blueprint = resolution.private[:blueprint] + + if blueprint && should_check?(resolution) do + case Complexity.check_limits(blueprint, config) do + :ok -> + resolution + + {:error, reason} -> + Absinthe.Resolution.put_result( + resolution, + {:error, format_error(reason)} + ) + end + else + resolution + end + end + + defp should_check?(resolution) do + # Only check on the root query/mutation/subscription + resolution.path == [] + end + + defp format_error({:complexity_exceeded, actual, limit}) do + "Query complexity #{actual} exceeds maximum of #{limit}" + end + + defp format_error({:too_many_defers, count}) do + "Too many defer operations: #{count}" + end + + defp format_error({:too_many_streams, count}) do + "Too many stream operations: #{count}" + end + + defp format_error({:defer_too_deep, depth}) do + "Defer nesting too deep: #{depth} levels" + end + + defp format_error({:initial_too_complex, actual, limit}) do + "Initial response complexity #{actual} exceeds maximum of #{limit}" + end + + defp format_error({:chunk_too_complex, :defer, label, actual, limit}) do + label_str = if label, do: " (#{label})", else: "" + "Deferred fragment#{label_str} complexity #{actual} exceeds maximum of #{limit}" + end + + defp format_error({:chunk_too_complex, :stream, label, actual, limit}) do + label_str = if label, do: " (#{label})", else: "" + "Streamed field#{label_str} complexity #{actual} exceeds maximum of #{limit}" + end + + defp format_error(reason) do + "Complexity check failed: #{inspect(reason)}" + end +end diff --git a/lib/absinthe/phase/document/execution/streaming_resolution.ex b/lib/absinthe/phase/document/execution/streaming_resolution.ex new file mode 100644 index 0000000000..65971a3661 --- /dev/null +++ b/lib/absinthe/phase/document/execution/streaming_resolution.ex @@ -0,0 +1,451 @@ +defmodule Absinthe.Phase.Document.Execution.StreamingResolution do + @moduledoc """ + Resolution phase with support for @defer and @stream directives. + Replaces standard resolution when incremental delivery is enabled. + + This phase detects @defer and @stream directives in the query and sets up + the execution context for incremental delivery. The actual streaming happens + through the transport layer. + """ + + use Absinthe.Phase + alias Absinthe.{Blueprint, Phase} + alias Absinthe.Phase.Document.Execution.Resolution + + @doc """ + Run the streaming resolution phase. + + If no streaming directives are detected, falls back to standard resolution. + Otherwise, sets up the blueprint for incremental delivery. + """ + @spec run(Blueprint.t(), Keyword.t()) :: Phase.result_t() + def run(blueprint, options \\ []) do + case detect_streaming_directives(blueprint) do + true -> + run_streaming(blueprint, options) + + false -> + # No streaming directives, use standard resolution + Resolution.run(blueprint, options) + end + end + + # Detect if the query contains @defer or @stream directives + defp detect_streaming_directives(blueprint) do + blueprint + |> Blueprint.prewalk(false, fn + %{flags: %{defer: _}}, _acc -> {nil, true} + %{flags: %{stream: _}}, _acc -> {nil, true} + node, acc -> {node, acc} + end) + |> elem(1) + end + + defp run_streaming(blueprint, options) do + blueprint + |> init_streaming_context() + |> collect_and_prepare_streaming_nodes() + |> run_initial_resolution(options) + |> setup_deferred_execution(options) + end + + # Initialize the streaming context in the blueprint + defp init_streaming_context(blueprint) do + streaming_context = %{ + deferred_fragments: [], + streamed_fields: [], + deferred_tasks: [], + stream_tasks: [], + operation_id: generate_operation_id(), + schema: blueprint.schema, + # Store original operations for deferred re-resolution + original_operations: blueprint.operations + } + + updated_context = Map.put(blueprint.execution.context, :__streaming__, streaming_context) + updated_execution = %{blueprint.execution | context: updated_context} + %{blueprint | execution: updated_execution} + end + + # Collect deferred/streamed nodes and prepare blueprint for initial resolution + defp collect_and_prepare_streaming_nodes(blueprint) do + # Track current path during traversal + initial_acc = %{ + deferred_fragments: [], + streamed_fields: [], + path: [] + } + + {updated_blueprint, collected} = + Blueprint.prewalk(blueprint, initial_acc, &collect_streaming_node/2) + + # Store collected nodes in streaming context + streaming_context = get_streaming_context(updated_blueprint) + + updated_streaming_context = %{ + streaming_context + | deferred_fragments: Enum.reverse(collected.deferred_fragments), + streamed_fields: Enum.reverse(collected.streamed_fields) + } + + put_streaming_context(updated_blueprint, updated_streaming_context) + end + + # Collect streaming nodes during prewalk and mark them appropriately + defp collect_streaming_node(node, acc) do + case node do + # Handle deferred fragments (inline or spread) + %{flags: %{defer: %{enabled: true} = defer_config}} = fragment_node -> + # Build path for this fragment + path = build_node_path(fragment_node, acc.path) + + # Collect the deferred fragment info + deferred_info = %{ + node: fragment_node, + path: path, + label: defer_config[:label], + selections: get_selections(fragment_node) + } + + # Mark the node to skip in initial resolution + updated_node = mark_for_skip(fragment_node) + updated_acc = %{acc | deferred_fragments: [deferred_info | acc.deferred_fragments]} + + {updated_node, updated_acc} + + # Handle streamed list fields + %{flags: %{stream: %{enabled: true} = stream_config}} = field_node -> + # Build path for this field + path = build_node_path(field_node, acc.path) + + # Collect the streamed field info + streamed_info = %{ + node: field_node, + path: path, + label: stream_config[:label], + initial_count: stream_config[:initial_count] || 0 + } + + # Keep the field but mark it with stream config for partial resolution + updated_node = mark_for_streaming(field_node, stream_config) + updated_acc = %{acc | streamed_fields: [streamed_info | acc.streamed_fields]} + + {updated_node, updated_acc} + + # Track path through fields for accurate path building + %Absinthe.Blueprint.Document.Field{name: name} = field_node -> + updated_acc = %{acc | path: acc.path ++ [name]} + {field_node, updated_acc} + + # Pass through other nodes + other -> + {other, acc} + end + end + + # Mark a node to be skipped in initial resolution + defp mark_for_skip(node) do + flags = + node.flags + |> Map.delete(:defer) + |> Map.put(:__skip_initial__, true) + + %{node | flags: flags} + end + + # Mark a field for streaming (partial resolution) + defp mark_for_streaming(node, stream_config) do + flags = + node.flags + |> Map.delete(:stream) + |> Map.put(:__stream_config__, stream_config) + + %{node | flags: flags} + end + + # Build the path for a node + defp build_node_path(%{name: name}, parent_path) when is_binary(name) do + parent_path ++ [name] + end + + defp build_node_path(%Absinthe.Blueprint.Document.Fragment.Spread{name: name}, parent_path) do + parent_path ++ [name] + end + + defp build_node_path(_node, parent_path) do + parent_path + end + + # Get selections from a fragment node + defp get_selections(%{selections: selections}) when is_list(selections), do: selections + defp get_selections(_), do: [] + + # Run initial resolution, skipping deferred content + defp run_initial_resolution(blueprint, options) do + # Filter out deferred nodes before resolution + filtered_blueprint = filter_deferred_selections(blueprint) + + # Run standard resolution on filtered blueprint + Resolution.run(filtered_blueprint, options) + end + + # Filter out selections that are marked for skipping + defp filter_deferred_selections(blueprint) do + Blueprint.prewalk(blueprint, fn + # Skip nodes marked for deferral + %{flags: %{__skip_initial__: true}} -> + nil + + # For streamed fields, limit the resolution to initial_count + %{flags: %{__stream_config__: config}} = node -> + # The stream config is preserved, resolution middleware will handle limiting + node + + node -> + node + end) + end + + # Setup deferred execution after initial resolution + defp setup_deferred_execution({:ok, blueprint}, options) do + streaming_context = get_streaming_context(blueprint) + + if has_pending_operations?(streaming_context) do + blueprint + |> create_deferred_tasks(options) + |> create_stream_tasks(options) + |> mark_as_streaming() + else + {:ok, blueprint} + end + end + + defp setup_deferred_execution(error, _options), do: error + + # Create executable tasks for deferred fragments + defp create_deferred_tasks(blueprint, options) do + streaming_context = get_streaming_context(blueprint) + + deferred_tasks = + Enum.map(streaming_context.deferred_fragments, fn fragment_info -> + create_deferred_task(fragment_info, blueprint, options) + end) + + updated_context = %{streaming_context | deferred_tasks: deferred_tasks} + put_streaming_context(blueprint, updated_context) + end + + # Create executable tasks for streamed fields + defp create_stream_tasks(blueprint, options) do + streaming_context = get_streaming_context(blueprint) + + stream_tasks = + Enum.map(streaming_context.streamed_fields, fn field_info -> + create_stream_task(field_info, blueprint, options) + end) + + updated_context = %{streaming_context | stream_tasks: stream_tasks} + put_streaming_context(blueprint, updated_context) + end + + defp create_deferred_task(fragment_info, blueprint, options) do + %{ + id: generate_task_id(), + type: :defer, + label: fragment_info.label, + path: fragment_info.path, + status: :pending, + execute: fn -> + resolve_deferred_fragment(fragment_info, blueprint, options) + end + } + end + + defp create_stream_task(field_info, blueprint, options) do + %{ + id: generate_task_id(), + type: :stream, + label: field_info.label, + path: field_info.path, + initial_count: field_info.initial_count, + status: :pending, + execute: fn -> + resolve_streamed_field(field_info, blueprint, options) + end + } + end + + # Resolve a deferred fragment by re-running resolution on just that fragment + defp resolve_deferred_fragment(fragment_info, blueprint, options) do + # Restore the original node without skip flag + node = restore_deferred_node(fragment_info.node) + + # Get the parent data at this path from the initial result + parent_data = get_parent_data(blueprint, fragment_info.path) + + # Create a focused blueprint for just this fragment's fields + sub_blueprint = build_sub_blueprint(blueprint, node, parent_data, fragment_info.path) + + # Run resolution + case Resolution.run(sub_blueprint, options) do + {:ok, resolved_blueprint} -> + {:ok, extract_fragment_result(resolved_blueprint, fragment_info)} + + {:error, _} = error -> + error + end + rescue + e -> + {:error, + %{ + message: Exception.message(e), + path: fragment_info.path, + extensions: %{code: "DEFERRED_RESOLUTION_ERROR"} + }} + end + + # Resolve remaining items for a streamed field + defp resolve_streamed_field(field_info, blueprint, options) do + # Get the full list by re-resolving without the limit + node = restore_streamed_node(field_info.node) + + parent_data = get_parent_data(blueprint, Enum.drop(field_info.path, -1)) + + sub_blueprint = build_sub_blueprint(blueprint, node, parent_data, field_info.path) + + case Resolution.run(sub_blueprint, options) do + {:ok, resolved_blueprint} -> + {:ok, extract_stream_result(resolved_blueprint, field_info)} + + {:error, _} = error -> + error + end + rescue + e -> + {:error, + %{ + message: Exception.message(e), + path: field_info.path, + extensions: %{code: "STREAM_RESOLUTION_ERROR"} + }} + end + + # Restore a deferred node for resolution + defp restore_deferred_node(node) do + flags = Map.delete(node.flags, :__skip_initial__) + %{node | flags: flags} + end + + # Restore a streamed node for full resolution + defp restore_streamed_node(node) do + flags = Map.delete(node.flags, :__stream_config__) + %{node | flags: flags} + end + + # Get parent data from the result at a given path + defp get_parent_data(blueprint, []) do + blueprint.result[:data] || %{} + end + + defp get_parent_data(blueprint, path) do + parent_path = Enum.drop(path, -1) + get_in(blueprint.result, [:data | parent_path]) || %{} + end + + # Build a sub-blueprint for resolving deferred/streamed content + defp build_sub_blueprint(blueprint, node, parent_data, path) do + # Create execution context with parent data + execution = %{blueprint.execution | root_value: parent_data, path: path} + + # Create a minimal blueprint with just the node to resolve + %{blueprint | execution: execution, operations: [wrap_in_operation(node, blueprint)]} + end + + # Wrap a node in a minimal operation structure + defp wrap_in_operation(node, blueprint) do + %Absinthe.Blueprint.Document.Operation{ + name: "__deferred__", + type: :query, + selections: get_node_selections(node), + schema_node: get_query_type(blueprint) + } + end + + defp get_node_selections(%{selections: selections}), do: selections + defp get_node_selections(node), do: [node] + + defp get_query_type(blueprint) do + Absinthe.Schema.lookup_type(blueprint.schema, :query) + end + + # Extract result from a resolved deferred fragment + defp extract_fragment_result(blueprint, fragment_info) do + data = blueprint.result[:data] || %{} + errors = blueprint.result[:errors] || [] + + result = %{ + data: data, + path: fragment_info.path, + label: fragment_info.label + } + + if Enum.empty?(errors) do + result + else + Map.put(result, :errors, errors) + end + end + + # Extract remaining items from a resolved stream + defp extract_stream_result(blueprint, field_info) do + full_list = get_in(blueprint.result, [:data | [List.last(field_info.path)]]) || [] + remaining_items = Enum.drop(full_list, field_info.initial_count) + errors = blueprint.result[:errors] || [] + + result = %{ + items: remaining_items, + path: field_info.path, + label: field_info.label + } + + if Enum.empty?(errors) do + result + else + Map.put(result, :errors, errors) + end + end + + defp mark_as_streaming(blueprint) do + updated_execution = Map.put(blueprint.execution, :incremental_delivery, true) + {:ok, %{blueprint | execution: updated_execution}} + end + + defp has_pending_operations?(streaming_context) do + not Enum.empty?(streaming_context.deferred_fragments) or + not Enum.empty?(streaming_context.streamed_fields) + end + + defp get_streaming_context(blueprint) do + get_in(blueprint.execution.context, [:__streaming__]) || + %{ + deferred_fragments: [], + streamed_fields: [], + deferred_tasks: [], + stream_tasks: [] + } + end + + defp put_streaming_context(blueprint, context) do + updated_context = Map.put(blueprint.execution.context, :__streaming__, context) + updated_execution = %{blueprint.execution | context: updated_context} + %{blueprint | execution: updated_execution} + end + + defp generate_operation_id do + :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + end + + defp generate_task_id do + :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) + end +end diff --git a/lib/absinthe/pipeline/incremental.ex b/lib/absinthe/pipeline/incremental.ex new file mode 100644 index 0000000000..fbbc4f62f0 --- /dev/null +++ b/lib/absinthe/pipeline/incremental.ex @@ -0,0 +1,375 @@ +defmodule Absinthe.Pipeline.Incremental do + @moduledoc """ + Pipeline modifications for incremental delivery support. + + This module provides functions to modify the standard Absinthe pipeline + to support @defer and @stream directives. + """ + + alias Absinthe.{Pipeline, Phase, Blueprint} + alias Absinthe.Phase.Document.Execution.StreamingResolution + alias Absinthe.Incremental.Config + + @doc """ + Modify a pipeline to support incremental delivery. + + This function: + 1. Replaces the standard resolution phase with streaming resolution + 2. Adds incremental delivery configuration + 3. Inserts monitoring phases if telemetry is enabled + + ## Examples + + pipeline = + MySchema + |> Pipeline.for_document(opts) + |> Pipeline.Incremental.enable() + """ + @spec enable(Pipeline.t(), Keyword.t()) :: Pipeline.t() + def enable(pipeline, opts \\ []) do + config = Config.from_options(opts) + + if Config.enabled?(config) do + pipeline + |> replace_resolution_phase(config) + |> insert_monitoring_phases(config) + |> add_incremental_config(config) + else + pipeline + end + end + + @doc """ + Check if a pipeline has incremental delivery enabled. + """ + @spec enabled?(Pipeline.t()) :: boolean() + def enabled?(pipeline) do + Enum.any?(pipeline, fn + {StreamingResolution, _} -> true + _ -> false + end) + end + + @doc """ + Insert incremental delivery phases at the appropriate points. + + This is useful for adding custom phases that need to run + before or after specific incremental delivery operations. + """ + @spec insert(Pipeline.t(), atom(), module(), Keyword.t()) :: Pipeline.t() + def insert(pipeline, position, phase_module, opts \\ []) do + phase = {phase_module, opts} + + case position do + :before_streaming -> + insert_before_phase(pipeline, StreamingResolution, phase) + + :after_streaming -> + insert_after_phase(pipeline, StreamingResolution, phase) + + :before_defer -> + insert_before_defer(pipeline, phase) + + :after_defer -> + insert_after_defer(pipeline, phase) + + :before_stream -> + insert_before_stream(pipeline, phase) + + :after_stream -> + insert_after_stream(pipeline, phase) + + _ -> + pipeline + end + end + + @doc """ + Add a custom handler for deferred operations. + + This allows you to customize how deferred fragments are processed. + """ + @spec on_defer(Pipeline.t(), (Blueprint.t() -> Blueprint.t())) :: Pipeline.t() + def on_defer(pipeline, handler) do + insert(pipeline, :before_defer, __MODULE__.DeferHandler, handler: handler) + end + + @doc """ + Add a custom handler for streamed operations. + + This allows you to customize how streamed lists are processed. + """ + @spec on_stream(Pipeline.t(), (Blueprint.t() -> Blueprint.t())) :: Pipeline.t() + def on_stream(pipeline, handler) do + insert(pipeline, :before_stream, __MODULE__.StreamHandler, handler: handler) + end + + @doc """ + Configure batching for streamed operations. + + This allows you to control how items are batched when streaming. + """ + @spec configure_batching(Pipeline.t(), Keyword.t()) :: Pipeline.t() + def configure_batching(pipeline, opts) do + batch_size = Keyword.get(opts, :batch_size, 10) + batch_delay = Keyword.get(opts, :batch_delay, 0) + + add_phase_option(pipeline, StreamingResolution, + batch_size: batch_size, + batch_delay: batch_delay + ) + end + + @doc """ + Add error recovery for incremental delivery. + + This ensures that errors in deferred/streamed operations are handled gracefully. + """ + @spec with_error_recovery(Pipeline.t()) :: Pipeline.t() + def with_error_recovery(pipeline) do + insert(pipeline, :after_streaming, __MODULE__.ErrorRecovery, []) + end + + # Private functions + + defp replace_resolution_phase(pipeline, config) do + Enum.map(pipeline, fn + {Phase.Document.Execution.Resolution, opts} -> + # Replace with streaming resolution + {StreamingResolution, Keyword.put(opts, :config, config)} + + phase -> + phase + end) + end + + defp insert_monitoring_phases(pipeline, %{enable_telemetry: true}) do + pipeline + |> insert_before_phase(StreamingResolution, {__MODULE__.TelemetryStart, []}) + |> insert_after_phase(StreamingResolution, {__MODULE__.TelemetryStop, []}) + end + + defp insert_monitoring_phases(pipeline, _), do: pipeline + + defp add_incremental_config(pipeline, config) do + # Add config to all phases that might need it + Enum.map(pipeline, fn + {module, opts} when is_atom(module) -> + {module, Keyword.put(opts, :incremental_config, config)} + + phase -> + phase + end) + end + + defp insert_before_phase(pipeline, target_phase, new_phase) do + {before, after_with_target} = + Enum.split_while(pipeline, fn + {^target_phase, _} -> false + _ -> true + end) + + before ++ [new_phase | after_with_target] + end + + defp insert_after_phase(pipeline, target_phase, new_phase) do + {before_with_target, after_target} = + Enum.split_while(pipeline, fn + {^target_phase, _} -> true + _ -> false + end) + + case after_target do + [] -> before_with_target ++ [new_phase] + _ -> before_with_target ++ [hd(after_target), new_phase | tl(after_target)] + end + end + + defp insert_before_defer(pipeline, phase) do + # Insert before defer processing in streaming resolution + insert_before_phase(pipeline, __MODULE__.DeferProcessor, phase) + end + + defp insert_after_defer(pipeline, phase) do + insert_after_phase(pipeline, __MODULE__.DeferProcessor, phase) + end + + defp insert_before_stream(pipeline, phase) do + insert_before_phase(pipeline, __MODULE__.StreamProcessor, phase) + end + + defp insert_after_stream(pipeline, phase) do + insert_after_phase(pipeline, __MODULE__.StreamProcessor, phase) + end + + defp add_phase_option(pipeline, target_phase, new_opts) do + Enum.map(pipeline, fn + {^target_phase, opts} -> + {target_phase, Keyword.merge(opts, new_opts)} + + phase -> + phase + end) + end +end + +defmodule Absinthe.Pipeline.Incremental.TelemetryStart do + @moduledoc false + use Absinthe.Phase + + alias Absinthe.Blueprint + + def run(blueprint, _opts) do + start_time = System.monotonic_time() + + :telemetry.execute( + [:absinthe, :incremental, :start], + %{system_time: System.system_time()}, + %{ + operation_id: get_operation_id(blueprint), + has_defer: has_defer?(blueprint), + has_stream: has_stream?(blueprint) + } + ) + + execution = Map.put(blueprint.execution, :incremental_start_time, start_time) + blueprint = %{blueprint | execution: execution} + {:ok, blueprint} + end + + defp get_operation_id(blueprint) do + execution = Map.get(blueprint, :execution, %{}) + context = Map.get(execution, :context, %{}) + streaming_context = Map.get(context, :__streaming__, %{}) + Map.get(streaming_context, :operation_id) + end + + defp has_defer?(blueprint) do + Blueprint.prewalk(blueprint, false, fn + %{flags: %{defer: _}}, _acc -> {nil, true} + node, acc -> {node, acc} + end) + |> elem(1) + end + + defp has_stream?(blueprint) do + Blueprint.prewalk(blueprint, false, fn + %{flags: %{stream: _}}, _acc -> {nil, true} + node, acc -> {node, acc} + end) + |> elem(1) + end +end + +defmodule Absinthe.Pipeline.Incremental.TelemetryStop do + @moduledoc false + use Absinthe.Phase + + def run(blueprint, _opts) do + execution = Map.get(blueprint, :execution, %{}) + start_time = Map.get(execution, :incremental_start_time) + duration = if start_time, do: System.monotonic_time() - start_time, else: 0 + + context = Map.get(execution, :context, %{}) + streaming_context = Map.get(context, :__streaming__, %{}) + + :telemetry.execute( + [:absinthe, :incremental, :stop], + %{duration: duration}, + %{ + operation_id: Map.get(streaming_context, :operation_id), + deferred_count: length(Map.get(streaming_context, :deferred_fragments, [])), + streamed_count: length(Map.get(streaming_context, :streamed_fields, [])) + } + ) + + {:ok, blueprint} + end +end + +defmodule Absinthe.Pipeline.Incremental.ErrorRecovery do + @moduledoc false + use Absinthe.Phase + alias Absinthe.Incremental.ErrorHandler + + def run(blueprint, _opts) do + streaming_context = get_in(blueprint, [:execution, :context, :__streaming__]) + + if streaming_context && has_errors?(blueprint) do + handle_errors(blueprint, streaming_context) + else + {:ok, blueprint} + end + end + + defp has_errors?(blueprint) do + errors = get_in(blueprint, [:result, :errors]) || [] + not Enum.empty?(errors) + end + + defp handle_errors(blueprint, streaming_context) do + errors = get_in(blueprint, [:result, :errors]) || [] + + Enum.each(errors, fn error -> + context = %{ + operation_id: streaming_context[:operation_id], + path: error[:path] || [], + label: nil, + error_type: classify_error(error), + details: error + } + + ErrorHandler.handle_streaming_error(error, context) + end) + + {:ok, blueprint} + end + + defp classify_error(%{extensions: %{code: "TIMEOUT"}}), do: :timeout + defp classify_error(%{extensions: %{code: "DATALOADER_ERROR"}}), do: :dataloader_error + defp classify_error(_), do: :resolution_error +end + +defmodule Absinthe.Pipeline.Incremental.DeferHandler do + @moduledoc false + use Absinthe.Phase + + alias Absinthe.Blueprint + + def run(blueprint, opts) do + handler = Keyword.get(opts, :handler, & &1) + + blueprint = + Blueprint.prewalk(blueprint, fn + %{flags: %{defer: _}} = node -> + handler.(node) + + node -> + node + end) + + {:ok, blueprint} + end +end + +defmodule Absinthe.Pipeline.Incremental.StreamHandler do + @moduledoc false + use Absinthe.Phase + + alias Absinthe.Blueprint + + def run(blueprint, opts) do + handler = Keyword.get(opts, :handler, & &1) + + blueprint = + Blueprint.prewalk(blueprint, fn + %{flags: %{stream: _}} = node -> + handler.(node) + + node -> + node + end) + + {:ok, blueprint} + end +end diff --git a/lib/absinthe/resolution/projector.ex b/lib/absinthe/resolution/projector.ex index 967ecbbdf4..5be0ec6907 100644 --- a/lib/absinthe/resolution/projector.ex +++ b/lib/absinthe/resolution/projector.ex @@ -49,6 +49,12 @@ defmodule Absinthe.Resolution.Projector do %{flags: %{skip: _}} -> do_collect(selections, fragments, parent_type, schema, index, acc) + # Skip nodes that have been explicitly marked for skipping in streaming resolution + # Note: :defer and :stream flags alone do NOT cause skipping in standard resolution + # Only :__skip_initial__ flag (set by streaming_resolution) causes skipping + %{flags: %{__skip_initial__: true}} -> + do_collect(selections, fragments, parent_type, schema, index, acc) + %Blueprint.Document.Field{} = field -> field = update_schema_node(field, parent_type) key = response_key(field) diff --git a/lib/absinthe/schema/notation/sdl_render.ex b/lib/absinthe/schema/notation/sdl_render.ex index b348ef576a..81faf826e3 100644 --- a/lib/absinthe/schema/notation/sdl_render.ex +++ b/lib/absinthe/schema/notation/sdl_render.ex @@ -7,9 +7,11 @@ defmodule Absinthe.Schema.Notation.SDL.Render do @line_width 120 - def inspect(term, %{pretty: true}) do + def inspect(term, %{pretty: true} = options) do + adapter = Map.get(options, :adapter, Absinthe.Adapter.LanguageConventions) + term - |> render() + |> render([], adapter) |> concat(line()) |> format(@line_width) |> to_string @@ -25,9 +27,9 @@ defmodule Absinthe.Schema.Notation.SDL.Render do Absinthe.Type.BuiltIns.Scalars, Absinthe.Type.BuiltIns.Introspection ] - defp render(bp, type_definitions \\ []) - defp render(%Blueprint{} = bp, _) do + # 3-arity render functions (with adapter) + defp render(%Blueprint{} = bp, _, adapter) do %{ schema_definitions: [ %Blueprint.Schema.SchemaDefinition{ @@ -48,11 +50,32 @@ defmodule Absinthe.Schema.Notation.SDL.Render do |> Enum.filter(& &1.__private__[:__absinthe_referenced__]) ([schema_declaration] ++ directive_definitions ++ types_to_render) - |> Enum.map(&render(&1, type_definitions)) + |> Enum.map(&render(&1, type_definitions, adapter)) |> Enum.reject(&(&1 == empty())) |> join([line(), line()]) end + defp render(%Blueprint.Schema.DirectiveDefinition{} = directive, type_definitions, adapter) do + locations = directive.locations |> Enum.map(&String.upcase(to_string(&1))) + + concat([ + "directive ", + "@", + string(adapter.to_external_name(directive.name, :directive)), + arguments(directive.arguments, type_definitions), + repeatable(directive.repeatable), + " on ", + join(locations, " | ") + ]) + |> description(directive.description) + end + + # Catch-all 3-arity render - just ignores adapter and delegates to 2-arity + defp render(term, type_definitions, _adapter) do + render(term, type_definitions) + end + + # 2-arity render functions for all types defp render(%Blueprint.Schema.SchemaDeclaration{} = schema, type_definitions) do block( concat([ @@ -185,21 +208,7 @@ defmodule Absinthe.Schema.Notation.SDL.Render do |> description(scalar_type.description) end - defp render(%Blueprint.Schema.DirectiveDefinition{} = directive, type_definitions) do - locations = directive.locations |> Enum.map(&String.upcase(to_string(&1))) - - concat([ - "directive ", - "@", - string(directive.name), - arguments(directive.arguments, type_definitions), - repeatable(directive.repeatable), - " on ", - join(locations, " | ") - ]) - |> description(directive.description) - end - + # 2-arity render functions defp render(%Blueprint.Directive{} = directive, type_definitions) do concat([ " @", @@ -250,21 +259,36 @@ defmodule Absinthe.Schema.Notation.SDL.Render do render(%Blueprint.TypeReference.Identifier{id: identifier}, type_definitions) end + # General catch-all for 2-arity render - delegates to 3-arity with default adapter + defp render(term, type_definitions) do + render(term, type_definitions, Absinthe.Adapter.LanguageConventions) + end + # SDL Syntax Helpers - defp directives([], _) do + # 3-arity directives functions + defp directives([], _, _) do empty() end - defp directives(directives, type_definitions) do + defp directives(directives, type_definitions, adapter) do directives = Enum.map(directives, fn directive -> - %{directive | name: Absinthe.Utils.camelize(directive.name, lower: true)} + %{directive | name: adapter.to_external_name(directive.name, :directive)} end) concat(Enum.map(directives, &render(&1, type_definitions))) end + # 2-arity directives functions + defp directives([], _) do + empty() + end + + defp directives(directives, type_definitions) do + directives(directives, type_definitions, Absinthe.Adapter.LanguageConventions) + end + defp directive_arguments([], _) do empty() end diff --git a/lib/absinthe/streaming.ex b/lib/absinthe/streaming.ex new file mode 100644 index 0000000000..c2ef71af4a --- /dev/null +++ b/lib/absinthe/streaming.ex @@ -0,0 +1,128 @@ +defmodule Absinthe.Streaming do + @moduledoc """ + Unified streaming delivery for subscriptions and incremental delivery (@defer/@stream). + + This module provides a common foundation for delivering GraphQL results that are + produced over time, whether through subscription updates or incremental delivery + of deferred/streamed content. + + ## Overview + + Both subscriptions and incremental delivery share the pattern of delivering data + in multiple payloads: + + - **Subscriptions**: Each mutation trigger produces a new result + - **Incremental Delivery**: @defer/@stream directives split a single query into + initial + incremental payloads + + This module consolidates the shared abstractions: + + - `Absinthe.Streaming.Executor` - Behaviour for pluggable task execution backends + - `Absinthe.Streaming.TaskExecutor` - Default executor using Task.async_stream + - `Absinthe.Streaming.Delivery` - Unified delivery for subscriptions with @defer/@stream + + ## Architecture + + ``` + Absinthe.Streaming + ├── Executor - Behaviour for custom execution backends (Oban, RabbitMQ, etc.) + ├── TaskExecutor - Default executor (Task.async_stream) + └── Delivery - Handles multi-payload delivery via pubsub + ``` + + ## Custom Executors + + The default executor uses `Task.async_stream` for in-process concurrent execution. + You can implement `Absinthe.Streaming.Executor` to use alternative backends: + + defmodule MyApp.ObanExecutor do + @behaviour Absinthe.Streaming.Executor + + @impl true + def execute(tasks, opts) do + # Queue tasks to Oban and stream results + tasks + |> Enum.map(&queue_to_oban/1) + |> stream_results(opts) + end + end + + Configure at the schema level: + + defmodule MyApp.Schema do + use Absinthe.Schema + + @streaming_executor MyApp.ObanExecutor + + # ... schema definition + end + + Or per-request via context: + + Absinthe.run(query, MyApp.Schema, + context: %{streaming_executor: MyApp.ObanExecutor} + ) + + See `Absinthe.Streaming.Executor` for full documentation. + + ## Usage + + For most use cases, you don't need to interact with this module directly. + The subscription system automatically uses these abstractions when @defer/@stream + directives are detected in subscription documents. + """ + + alias Absinthe.Blueprint + + @doc """ + Check if a blueprint has streaming tasks (deferred fragments or streamed fields). + """ + @spec has_streaming_tasks?(Blueprint.t()) :: boolean() + def has_streaming_tasks?(blueprint) do + context = get_streaming_context(blueprint) + + has_deferred = not Enum.empty?(Map.get(context, :deferred_tasks, [])) + has_streamed = not Enum.empty?(Map.get(context, :stream_tasks, [])) + + has_deferred or has_streamed + end + + @doc """ + Get the streaming context from a blueprint. + """ + @spec get_streaming_context(Blueprint.t()) :: map() + def get_streaming_context(blueprint) do + get_in(blueprint.execution.context, [:__streaming__]) || %{} + end + + @doc """ + Get all streaming tasks from a blueprint. + """ + @spec get_streaming_tasks(Blueprint.t()) :: list(map()) + def get_streaming_tasks(blueprint) do + context = get_streaming_context(blueprint) + + deferred = Map.get(context, :deferred_tasks, []) + streamed = Map.get(context, :stream_tasks, []) + + deferred ++ streamed + end + + @doc """ + Check if a document source contains @defer or @stream directives. + + This is a quick check before running the full pipeline to determine + if incremental delivery should be enabled. + """ + @spec has_streaming_directives?(String.t() | Absinthe.Language.Source.t()) :: boolean() + def has_streaming_directives?(source) when is_binary(source) do + # Quick regex check - not perfect but catches most cases + String.contains?(source, "@defer") or String.contains?(source, "@stream") + end + + def has_streaming_directives?(%{body: body}) when is_binary(body) do + has_streaming_directives?(body) + end + + def has_streaming_directives?(_), do: false +end diff --git a/lib/absinthe/streaming/delivery.ex b/lib/absinthe/streaming/delivery.ex new file mode 100644 index 0000000000..39532b95b7 --- /dev/null +++ b/lib/absinthe/streaming/delivery.ex @@ -0,0 +1,261 @@ +defmodule Absinthe.Streaming.Delivery do + @moduledoc """ + Unified incremental delivery for subscriptions. + + This module handles delivering GraphQL results incrementally via pubsub when + a subscription document contains @defer or @stream directives. It calls + `publish_subscription/2` multiple times with the standard GraphQL incremental + response format: + + 1. Initial payload: `%{data: ..., pending: [...], hasNext: true}` + 2. Incremental payloads: `%{incremental: [...], hasNext: boolean}` + 3. Final payload: `%{hasNext: false}` + + This format is the standard GraphQL incremental delivery format that compliant + clients (Apollo, Relay, urql) already understand. + + ## Usage + + This module is used automatically by `Absinthe.Subscription.Local` when a + subscription document contains @defer or @stream directives. You typically + don't need to call it directly. + + # In Subscription.Local.run_docset/3 + if Absinthe.Streaming.has_streaming_tasks?(blueprint) do + Absinthe.Streaming.Delivery.deliver(pubsub, topic, blueprint) + else + pubsub.publish_subscription(topic, result) + end + + ## How It Works + + 1. Builds the initial response using `Absinthe.Incremental.Response.build_initial/1` + 2. Publishes initial response via `pubsub.publish_subscription(topic, initial)` + 3. Executes deferred/streamed tasks using `TaskExecutor.execute_stream/2` + 4. For each result, builds an incremental payload and publishes it + 5. Existing pubsub implementations work unchanged - they just deliver each message + + ## Backwards Compatibility + + Existing pubsub implementations don't need any changes. The same + `publish_subscription(topic, data)` callback is used - it's just called + multiple times with different payloads. + """ + + require Logger + + alias Absinthe.Blueprint + alias Absinthe.Incremental.Response + alias Absinthe.Streaming + alias Absinthe.Streaming.Executor + + @default_timeout 30_000 + + @type delivery_option :: + {:timeout, non_neg_integer()} + | {:max_concurrency, pos_integer()} + | {:executor, module()} + | {:schema, module()} + + @doc """ + Deliver incremental results via pubsub. + + Calls `pubsub.publish_subscription/2` multiple times with the standard + GraphQL incremental delivery format. + + ## Options + + - `:timeout` - Maximum time to wait for each deferred task (default: #{@default_timeout}ms) + - `:max_concurrency` - Maximum concurrent tasks (default: CPU count * 2) + - `:executor` - Custom executor module (default: uses schema config or `TaskExecutor`) + - `:schema` - Schema module for looking up executor config + + ## Returns + + - `:ok` on successful delivery + - `{:error, reason}` if delivery fails + """ + @spec deliver(module(), String.t(), Blueprint.t(), [delivery_option()]) :: + :ok | {:error, term()} + def deliver(pubsub, topic, blueprint, opts \\ []) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + + # 1. Build and send initial response + initial = Response.build_initial(blueprint) + + case pubsub.publish_subscription(topic, initial) do + :ok -> + # 2. Execute and send incremental payloads + deliver_incremental(pubsub, topic, blueprint, timeout, opts) + + error -> + Logger.error("Failed to publish initial subscription payload: #{inspect(error)}") + {:error, {:initial_delivery_failed, error}} + end + end + + @doc """ + Collect all incremental results without streaming. + + Executes all deferred/streamed tasks and returns the complete result + as a single payload. Useful when you want the full result immediately + without multiple payloads. + + ## Options + + Same as `deliver/4`. + + ## Returns + + A map with the complete result: + + %{ + data: , + errors: [...] # if any + } + """ + @spec collect_all(Blueprint.t(), [delivery_option()]) :: {:ok, map()} | {:error, term()} + def collect_all(blueprint, opts \\ []) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + schema = Keyword.get(opts, :schema) + executor = Executor.get_executor(schema, opts) + tasks = Streaming.get_streaming_tasks(blueprint) + + # Get initial data + initial = Response.build_initial(blueprint) + initial_data = Map.get(initial, :data, %{}) + initial_errors = Map.get(initial, :errors, []) + + # Execute all tasks and collect results using configurable executor + results = executor.execute(tasks, timeout: timeout) |> Enum.to_list() + + # Merge results into final data + {final_data, final_errors} = + Enum.reduce(results, {initial_data, initial_errors}, fn task_result, {data, errors} -> + case task_result.result do + {:ok, result} -> + # Merge deferred data at the correct path + merged_data = merge_at_path(data, task_result.task.path, result) + result_errors = Map.get(result, :errors, []) + {merged_data, errors ++ result_errors} + + {:error, error} -> + error_entry = %{ + message: format_error(error), + path: task_result.task.path + } + + {data, errors ++ [error_entry]} + end + end) + + result = + if Enum.empty?(final_errors) do + %{data: final_data} + else + %{data: final_data, errors: final_errors} + end + + {:ok, result} + end + + # Deliver incremental payloads + defp deliver_incremental(pubsub, topic, blueprint, timeout, opts) do + tasks = Streaming.get_streaming_tasks(blueprint) + + if Enum.empty?(tasks) do + :ok + else + do_deliver_incremental(pubsub, topic, tasks, timeout, opts) + end + end + + defp do_deliver_incremental(pubsub, topic, tasks, timeout, opts) do + max_concurrency = Keyword.get(opts, :max_concurrency, System.schedulers_online() * 2) + schema = Keyword.get(opts, :schema) + executor = Executor.get_executor(schema, opts) + + executor_opts = [timeout: timeout, max_concurrency: max_concurrency] + + result = + tasks + |> executor.execute(executor_opts) + |> Enum.reduce_while(:ok, fn task_result, :ok -> + payload = build_incremental_payload(task_result) + + case pubsub.publish_subscription(topic, payload) do + :ok -> + {:cont, :ok} + + error -> + Logger.error("Failed to publish incremental payload: #{inspect(error)}") + {:halt, {:error, {:incremental_delivery_failed, error}}} + end + end) + + result + end + + # Build an incremental payload from a task result + defp build_incremental_payload(task_result) do + case task_result.result do + {:ok, result} -> + build_success_payload(task_result.task, result, task_result.has_next) + + {:error, error} -> + build_error_payload(task_result.task, error, task_result.has_next) + end + end + + defp build_success_payload(task, result, has_next) do + case task.type do + :defer -> + Response.build_incremental( + Map.get(result, :data), + Map.get(result, :path, task.path), + Map.get(result, :label, task.label), + has_next + ) + + :stream -> + Response.build_stream_incremental( + Map.get(result, :items, []), + Map.get(result, :path, task.path), + Map.get(result, :label, task.label), + has_next + ) + end + end + + defp build_error_payload(task, error, has_next) do + errors = [%{message: format_error(error), path: task && task.path}] + path = (task && task.path) || [] + label = task && task.label + + Response.build_error(errors, path, label, has_next) + end + + # Merge data at a specific path + defp merge_at_path(data, [], result) do + case result do + %{data: new_data} when is_map(new_data) -> Map.merge(data, new_data) + %{items: items} when is_list(items) -> items + _ -> data + end + end + + defp merge_at_path(data, [key | rest], result) when is_map(data) do + current = Map.get(data, key, %{}) + updated = merge_at_path(current, rest, result) + Map.put(data, key, updated) + end + + defp merge_at_path(data, _path, _result), do: data + + # Format error for display + defp format_error(:timeout), do: "Operation timed out" + defp format_error({:exit, reason}), do: "Task failed: #{inspect(reason)}" + defp format_error(%{message: msg}), do: msg + defp format_error(error) when is_binary(error), do: error + defp format_error(error), do: inspect(error) +end diff --git a/lib/absinthe/streaming/executor.ex b/lib/absinthe/streaming/executor.ex new file mode 100644 index 0000000000..295d6dd89b --- /dev/null +++ b/lib/absinthe/streaming/executor.ex @@ -0,0 +1,201 @@ +defmodule Absinthe.Streaming.Executor do + @moduledoc """ + Behaviour for pluggable task execution backends. + + The default executor uses `Task.async_stream` for in-process concurrent execution. + You can implement this behaviour to use alternative backends like: + + - **Oban** - For persistent, retryable job processing + - **RabbitMQ** - For distributed task queuing + - **GenStage** - For backpressure-aware pipelines + - **Custom** - Any execution strategy you need + + ## Implementing a Custom Executor + + Implement the `execute/2` callback to process tasks and return results: + + defmodule MyApp.ObanExecutor do + @behaviour Absinthe.Streaming.Executor + + @impl true + def execute(tasks, opts) do + # Queue tasks to Oban and return results as they complete + timeout = Keyword.get(opts, :timeout, 30_000) + + tasks + |> Enum.map(&queue_to_oban/1) + |> wait_for_results(timeout) + end + + defp queue_to_oban(task) do + # Insert Oban job and track it + {:ok, job} = Oban.insert(MyApp.DeferredWorker.new(%{task_id: task.id})) + {task, job} + end + + defp wait_for_results(jobs, timeout) do + # Stream results as jobs complete + Stream.resource( + fn -> {jobs, timeout} end, + &poll_next_result/1, + fn _ -> :ok end + ) + end + end + + ## Configuration + + Configure the executor at different levels: + + ### Schema-level (recommended for schema-wide settings) + + defmodule MyApp.Schema do + use Absinthe.Schema + + @streaming_executor MyApp.ObanExecutor + + # ... schema definition + end + + ### Runtime (per-request) + + Absinthe.run(query, MyApp.Schema, + context: %{streaming_executor: MyApp.ObanExecutor} + ) + + ### Application config (global default) + + config :absinthe, :streaming_executor, MyApp.ObanExecutor + + ## Result Format + + Your executor must return an enumerable (list or stream) of result maps: + + %{ + task: task, # The original task map + result: {:ok, data} | {:error, reason}, + has_next: boolean, # true if more results coming + success: boolean, # true if result is {:ok, _} + duration_ms: integer # execution time in milliseconds + } + + """ + + @type task :: %{ + required(:id) => String.t(), + required(:type) => :defer | :stream, + required(:path) => [String.t() | integer()], + required(:execute) => (-> {:ok, map()} | {:error, term()}), + optional(:label) => String.t() | nil + } + + @type result :: %{ + task: task(), + result: {:ok, map()} | {:error, term()}, + has_next: boolean(), + success: boolean(), + duration_ms: non_neg_integer() + } + + @type option :: + {:timeout, non_neg_integer()} + | {:max_concurrency, pos_integer()} + + @doc """ + Execute a list of deferred/streamed tasks and return results. + + This callback receives a list of tasks and must return an enumerable + of results. The results can be returned as: + + - A list (all results computed eagerly) + - A Stream (results yielded as they complete) + + ## Parameters + + - `tasks` - List of task maps with `:id`, `:type`, `:path`, `:execute`, and optional `:label` + - `opts` - Keyword list of options: + - `:timeout` - Maximum time per task (default: 30_000ms) + - `:max_concurrency` - Maximum concurrent tasks (default: CPU count * 2) + + ## Return Value + + Must return an enumerable of result maps. Each result must include: + + - `:task` - The original task map + - `:result` - `{:ok, data}` or `{:error, reason}` + - `:has_next` - `true` if more results are coming, `false` for the last result + - `:success` - `true` if result is `{:ok, _}`, `false` otherwise + - `:duration_ms` - Execution time in milliseconds + + ## Example + + def execute(tasks, opts) do + timeout = Keyword.get(opts, :timeout, 30_000) + task_count = length(tasks) + + tasks + |> Enum.with_index() + |> Enum.map(fn {task, index} -> + started = System.monotonic_time(:millisecond) + result = safe_execute(task.execute, timeout) + duration = System.monotonic_time(:millisecond) - started + + %{ + task: task, + result: result, + has_next: index < task_count - 1, + success: match?({:ok, _}, result), + duration_ms: duration + } + end) + end + """ + @callback execute(tasks :: [task()], opts :: [option()]) :: Enumerable.t(result()) + + @doc """ + Optional callback for cleanup when execution is cancelled. + + Implement this if your executor needs to clean up resources (e.g., cancel + queued jobs, close connections) when a subscription is unsubscribed or + a request is cancelled. + + The default implementation is a no-op. + """ + @callback cancel(reference :: term()) :: :ok + + @optional_callbacks [cancel: 1] + + @doc """ + Get the configured executor module. + + Checks in order: + 1. Explicit executor passed in opts + 2. Schema-level `@streaming_executor` attribute + 3. Application config `:absinthe, :streaming_executor` + 4. Default `Absinthe.Streaming.TaskExecutor` + """ + @spec get_executor(schema :: module() | nil, opts :: keyword()) :: module() + def get_executor(schema \\ nil, opts \\ []) do + cond do + # 1. Explicit option + executor = Keyword.get(opts, :executor) -> + executor + + # 2. Context option (for runtime config) + executor = get_in(opts, [:context, :streaming_executor]) -> + executor + + # 3. Schema-level attribute + schema && function_exported?(schema, :__absinthe_streaming_executor__, 0) -> + schema.__absinthe_streaming_executor__() + + # 4. Application config + executor = Application.get_env(:absinthe, :streaming_executor) -> + executor + + # 5. Default + true -> + Absinthe.Streaming.TaskExecutor + end + end +end diff --git a/lib/absinthe/streaming/task_executor.ex b/lib/absinthe/streaming/task_executor.ex new file mode 100644 index 0000000000..5228fd47d3 --- /dev/null +++ b/lib/absinthe/streaming/task_executor.ex @@ -0,0 +1,236 @@ +defmodule Absinthe.Streaming.TaskExecutor do + @moduledoc """ + Default executor using `Task.async_stream` for concurrent task execution. + + This is the default implementation of `Absinthe.Streaming.Executor` behaviour. + It uses Elixir's built-in `Task.async_stream` for concurrent execution with + configurable timeouts and concurrency limits. + + ## Features + + - Concurrent execution with configurable concurrency limits + - Timeout handling per task + - Error wrapping and recovery + - Streaming results (lazy evaluation) + + ## Usage + + tasks = Absinthe.Streaming.get_streaming_tasks(blueprint) + + # Stream results (lazy evaluation) + tasks + |> TaskExecutor.execute_stream(timeout: 30_000) + |> Enum.each(fn result -> ... end) + + # Or collect all at once + results = TaskExecutor.execute_all(tasks, timeout: 30_000) + + ## Custom Executors + + To use a different execution backend (Oban, RabbitMQ, etc.), implement the + `Absinthe.Streaming.Executor` behaviour and configure it in your schema: + + defmodule MyApp.Schema do + use Absinthe.Schema + + @streaming_executor MyApp.ObanExecutor + + # ... schema definition + end + + See `Absinthe.Streaming.Executor` for details on implementing custom executors. + """ + + @behaviour Absinthe.Streaming.Executor + + alias Absinthe.Incremental.ErrorHandler + + @default_timeout 30_000 + @default_max_concurrency System.schedulers_online() * 2 + + @type task :: %{ + id: String.t(), + type: :defer | :stream, + label: String.t() | nil, + path: list(String.t()), + execute: (-> {:ok, map()} | {:error, term()}) + } + + @type task_result :: %{ + task: task(), + result: {:ok, map()} | {:error, term()}, + duration_ms: non_neg_integer(), + has_next: boolean(), + success: boolean() + } + + @type execute_option :: + {:timeout, non_neg_integer()} + | {:max_concurrency, pos_integer()} + + # ============================================================================ + # Executor Behaviour Implementation + # ============================================================================ + + @doc """ + Execute tasks and return results as an enumerable. + + This is the main `Absinthe.Streaming.Executor` callback implementation. + It uses `Task.async_stream` for concurrent execution with backpressure. + + ## Options + + - `:timeout` - Maximum time to wait for each task (default: #{@default_timeout}ms) + - `:max_concurrency` - Maximum concurrent tasks (default: #{@default_max_concurrency}) + + ## Returns + + A `Stream` that yields result maps as tasks complete. + """ + @impl Absinthe.Streaming.Executor + def execute(tasks, opts \\ []) do + execute_stream(tasks, opts) + end + + # ============================================================================ + # Convenience Functions + # ============================================================================ + + @doc """ + Execute tasks and return results as a stream. + + Results are yielded as they complete, allowing for streaming delivery + without waiting for all tasks to finish. + + ## Options + + - `:timeout` - Maximum time to wait for each task (default: #{@default_timeout}ms) + - `:max_concurrency` - Maximum concurrent tasks (default: #{@default_max_concurrency}) + + ## Returns + + A `Stream` that yields `task_result()` maps. + """ + @spec execute_stream(list(task()), [execute_option()]) :: Enumerable.t() + def execute_stream(tasks, opts \\ []) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + max_concurrency = Keyword.get(opts, :max_concurrency, @default_max_concurrency) + task_count = length(tasks) + + tasks + |> Task.async_stream( + fn task -> + task_started = System.monotonic_time(:millisecond) + wrapped_fn = ErrorHandler.wrap_streaming_task(task.execute) + result = wrapped_fn.() + duration_ms = System.monotonic_time(:millisecond) - task_started + {task, result, duration_ms} + end, + timeout: timeout, + on_timeout: :kill_task, + max_concurrency: max_concurrency + ) + |> Stream.with_index() + |> Stream.map(fn {stream_result, index} -> + has_next = index < task_count - 1 + format_stream_result(stream_result, has_next) + end) + end + + @doc """ + Execute all tasks and collect results. + + This is a convenience function that executes `execute_stream/2` and + collects all results into a list. + + ## Options + + Same as `execute_stream/2`. + + ## Returns + + A list of `task_result()` maps. + """ + @spec execute_all(list(task()), [execute_option()]) :: [task_result()] + def execute_all(tasks, opts \\ []) do + tasks + |> execute_stream(opts) + |> Enum.to_list() + end + + @doc """ + Execute a single task with error handling. + + ## Options + + - `:timeout` - Maximum time to wait (default: #{@default_timeout}ms) + + ## Returns + + A `task_result()` map. + """ + @spec execute_one(task(), [execute_option()]) :: task_result() + def execute_one(task, opts \\ []) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + + task_ref = + Task.async(fn -> + task_started = System.monotonic_time(:millisecond) + wrapped_fn = ErrorHandler.wrap_streaming_task(task.execute) + result = wrapped_fn.() + duration_ms = System.monotonic_time(:millisecond) - task_started + {task, result, duration_ms} + end) + + case Task.yield(task_ref, timeout) || Task.shutdown(task_ref) do + {:ok, {task, result, duration_ms}} -> + %{ + task: task, + result: result, + duration_ms: duration_ms, + has_next: false, + success: match?({:ok, _}, result) + } + + nil -> + %{ + task: task, + result: {:error, :timeout}, + duration_ms: timeout, + has_next: false, + success: false + } + end + end + + # Format the result from Task.async_stream + defp format_stream_result({:ok, {task, result, duration_ms}}, has_next) do + %{ + task: task, + result: result, + duration_ms: duration_ms, + has_next: has_next, + success: match?({:ok, _}, result) + } + end + + defp format_stream_result({:exit, :timeout}, has_next) do + %{ + task: nil, + result: {:error, :timeout}, + duration_ms: 0, + has_next: has_next, + success: false + } + end + + defp format_stream_result({:exit, reason}, has_next) do + %{ + task: nil, + result: {:error, {:exit, reason}}, + duration_ms: 0, + has_next: has_next, + success: false + } + end +end diff --git a/lib/absinthe/subscription/local.ex b/lib/absinthe/subscription/local.ex index 31b9b456f7..322995cbda 100644 --- a/lib/absinthe/subscription/local.ex +++ b/lib/absinthe/subscription/local.ex @@ -1,11 +1,24 @@ defmodule Absinthe.Subscription.Local do @moduledoc """ - This module handles broadcasting documents that are local to this node + This module handles broadcasting documents that are local to this node. + + ## Incremental Delivery Support + + When a subscription document contains `@defer` or `@stream` directives, + this module automatically uses incremental delivery. The subscription will + receive multiple payloads: + + 1. Initial response with immediately available data + 2. Incremental responses as deferred/streamed content resolves + + This is handled transparently by calling `publish_subscription/2` multiple + times with the standard GraphQL incremental delivery format. """ require Logger alias Absinthe.Pipeline.BatchResolver + alias Absinthe.Streaming # This module handles running and broadcasting documents that are local to this # node. @@ -40,18 +53,33 @@ defmodule Absinthe.Subscription.Local do defp run_docset(pubsub, docs_and_topics, mutation_result) do for {topic, key_strategy, doc} <- docs_and_topics do try do - pipeline = pipeline(doc, mutation_result) - - {:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline) - - Logger.debug(""" - Absinthe Subscription Publication - Field Topic: #{inspect(key_strategy)} - Subscription id: #{inspect(topic)} - Data: #{inspect(data)} - """) - - :ok = pubsub.publish_subscription(topic, data) + # Check if document has @defer/@stream directives + enable_incremental = Streaming.has_streaming_directives?(doc.source) + pipeline = pipeline(doc, mutation_result, enable_incremental: enable_incremental) + + {:ok, blueprint, _} = Absinthe.Pipeline.run(doc.source, pipeline) + data = blueprint.result + + # Check if we have streaming tasks to deliver incrementally + if enable_incremental && Streaming.has_streaming_tasks?(blueprint) do + Logger.debug(""" + Absinthe Subscription Publication (Incremental) + Field Topic: #{inspect(key_strategy)} + Subscription id: #{inspect(topic)} + Streaming: true + """) + + Streaming.Delivery.deliver(pubsub, topic, blueprint) + else + Logger.debug(""" + Absinthe Subscription Publication + Field Topic: #{inspect(key_strategy)} + Subscription id: #{inspect(topic)} + Data: #{inspect(data)} + """) + + :ok = pubsub.publish_subscription(topic, data) + end rescue e -> BatchResolver.pipeline_error(e, __STACKTRACE__) @@ -59,7 +87,17 @@ defmodule Absinthe.Subscription.Local do end end - def pipeline(doc, mutation_result) do + @doc """ + Build the execution pipeline for a subscription document. + + ## Options + + - `:enable_incremental` - If `true`, uses `StreamingResolution` phase to + support @defer/@stream directives (default: `false`) + """ + def pipeline(doc, mutation_result, opts \\ []) do + enable_incremental = Keyword.get(opts, :enable_incremental, false) + pipeline = doc.initial_phases |> Pipeline.replace( @@ -71,7 +109,18 @@ defmodule Absinthe.Subscription.Local do Phase.Document.Execution.Resolution, {Phase.Document.OverrideRoot, root_value: mutation_result} ) - |> Pipeline.upto(Phase.Document.Execution.Resolution) + + # Use StreamingResolution when incremental delivery is enabled + pipeline = + if enable_incremental do + pipeline + |> Pipeline.replace( + Phase.Document.Execution.Resolution, + Phase.Document.Execution.StreamingResolution + ) + else + pipeline |> Pipeline.upto(Phase.Document.Execution.Resolution) + end pipeline = [ pipeline, diff --git a/lib/absinthe/type/built_ins.ex b/lib/absinthe/type/built_ins.ex new file mode 100644 index 0000000000..5e47947c42 --- /dev/null +++ b/lib/absinthe/type/built_ins.ex @@ -0,0 +1,13 @@ +defmodule Absinthe.Type.BuiltIns do + @moduledoc """ + Built-in types, including scalars, directives, and introspection types. + + This module can be imported using `import_types Absinthe.Type.BuiltIns` in your schema. + """ + + use Absinthe.Schema.Notation + + import_types Absinthe.Type.BuiltIns.Scalars + import_types Absinthe.Type.BuiltIns.Directives + import_types Absinthe.Type.BuiltIns.Introspection +end diff --git a/lib/absinthe/type/built_ins/incremental_directives.ex b/lib/absinthe/type/built_ins/incremental_directives.ex new file mode 100644 index 0000000000..ae9a32773b --- /dev/null +++ b/lib/absinthe/type/built_ins/incremental_directives.ex @@ -0,0 +1,122 @@ +defmodule Absinthe.Type.BuiltIns.IncrementalDirectives do + @moduledoc """ + Draft-spec incremental delivery directives: @defer and @stream. + + These directives are part of the [Incremental Delivery RFC](https://github.com/graphql/graphql-spec/blob/main/rfcs/DeferStream.md) + and are not yet part of the finalized GraphQL specification. + + ## Usage + + To enable @defer and @stream in your schema, import this module: + + defmodule MyApp.Schema do + use Absinthe.Schema + + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + query do + # ... + end + end + + You will also need to enable incremental delivery in your pipeline: + + pipeline_modifier = fn pipeline, _options -> + Absinthe.Pipeline.Incremental.enable(pipeline, + enabled: true, + enable_defer: true, + enable_stream: true + ) + end + + Absinthe.run(query, MyApp.Schema, + variables: variables, + pipeline_modifier: pipeline_modifier + ) + + ## Directives + + - `@defer` - Defers execution of a fragment spread or inline fragment + - `@stream` - Streams list field items incrementally + """ + + use Absinthe.Schema.Notation + + alias Absinthe.Blueprint + + directive :defer do + description """ + Directs the executor to defer this fragment spread or inline fragment, + delivering it as part of a subsequent response. Used to improve latency + for data that is not immediately required. + """ + + repeatable false + + arg :if, :boolean, + default_value: true, + description: + "When true, fragment may be deferred. When false, fragment will not be deferred and data will be included in the initial response. Defaults to true." + + arg :label, :string, + description: + "A unique label for this deferred fragment, used to identify it in the incremental response." + + on [:fragment_spread, :inline_fragment] + + expand fn + %{if: false}, node -> + # Don't defer when if: false + node + + args, node -> + # Mark node for deferred execution + defer_config = %{ + label: Map.get(args, :label), + enabled: true + } + + Blueprint.put_flag(node, :defer, defer_config) + end + end + + directive :stream do + description """ + Directs the executor to stream list fields, delivering list items incrementally + in multiple responses. Used to improve latency for large lists. + """ + + repeatable false + + arg :if, :boolean, + default_value: true, + description: + "When true, list field may be streamed. When false, list will not be streamed and all data will be included in the initial response. Defaults to true." + + arg :label, :string, + description: + "A unique label for this streamed field, used to identify it in the incremental response." + + arg :initial_count, :integer, + default_value: 0, + description: "The number of list items to return in the initial response. Defaults to 0." + + on [:field] + + expand fn + %{if: false}, node -> + # Don't stream when if: false + node + + args, node -> + # Mark node for streaming execution + stream_config = %{ + label: Map.get(args, :label), + initial_count: Map.get(args, :initial_count, 0), + enabled: true + } + + Blueprint.put_flag(node, :stream, stream_config) + end + end +end diff --git a/lib/absinthe/type/field.ex b/lib/absinthe/type/field.ex index aac93cc6ef..fdce088b9e 100644 --- a/lib/absinthe/type/field.ex +++ b/lib/absinthe/type/field.ex @@ -75,7 +75,9 @@ defmodule Absinthe.Type.Field do * `:name` - The name of the field, usually assigned automatically by the `Absinthe.Schema.Notation.field/4`. Including this option will bypass the snake_case to camelCase conversion. - * `:description` - Description of a field, useful for introspection. + * `:description` - Description of a field, useful for introspection. If no description + is provided, the field will inherit the description of its referenced type during + introspection (e.g., a field of type `:user` will inherit the User type's description). * `:deprecation` - Deprecation information for a field, usually set-up using `Absinthe.Schema.Notation.deprecate/1`. * `:type` - The type the value of the field should resolve to diff --git a/lib/mix/tasks/absinthe.schema.json.ex b/lib/mix/tasks/absinthe.schema.json.ex index 450e247cfe..285887e06e 100644 --- a/lib/mix/tasks/absinthe.schema.json.ex +++ b/lib/mix/tasks/absinthe.schema.json.ex @@ -98,7 +98,14 @@ defmodule Mix.Tasks.Absinthe.Schema.Json do schema: schema, json_codec: json_codec }) do - with {:ok, result} <- Absinthe.Schema.introspect(schema) do + adapter = + if function_exported?(schema, :__absinthe_adapter__, 0) do + schema.__absinthe_adapter__() + else + Absinthe.Adapter.LanguageConventions + end + + with {:ok, result} <- Absinthe.Schema.introspect(schema, adapter: adapter) do content = json_codec.encode!(result, pretty: pretty) {:ok, content} end diff --git a/lib/mix/tasks/absinthe.schema.sdl.ex b/lib/mix/tasks/absinthe.schema.sdl.ex index bb15b594a4..683b0ba572 100644 --- a/lib/mix/tasks/absinthe.schema.sdl.ex +++ b/lib/mix/tasks/absinthe.schema.sdl.ex @@ -67,12 +67,19 @@ defmodule Mix.Tasks.Absinthe.Schema.Sdl do |> Absinthe.Pipeline.upto({Absinthe.Phase.Schema.Validation.Result, pass: :final}) |> Absinthe.Schema.apply_modifiers(schema) + adapter = + if function_exported?(schema, :__absinthe_adapter__, 0) do + schema.__absinthe_adapter__() + else + Absinthe.Adapter.LanguageConventions + end + with {:ok, blueprint, _phases} <- Absinthe.Pipeline.run( schema.__absinthe_blueprint__(), pipeline ) do - {:ok, inspect(blueprint, pretty: true)} + {:ok, inspect(blueprint, pretty: true, adapter: adapter)} else _ -> {:error, "Failed to render schema"} end diff --git a/mix.exs b/mix.exs index fdcb8c47a4..d65fabc4be 100644 --- a/mix.exs +++ b/mix.exs @@ -77,6 +77,7 @@ defmodule Absinthe.Mixfile do [ {:nimble_parsec, "~> 1.2.2 or ~> 1.3"}, {:telemetry, "~> 1.0 or ~> 0.4"}, + {:credo, "~> 1.1", only: [:dev, :test], runtime: false, override: true}, {:dataloader, "~> 1.0.0 or ~> 2.0", optional: true}, {:decimal, "~> 2.0", optional: true}, {:opentelemetry_process_propagator, "~> 0.3 or ~> 0.2.1", optional: true}, @@ -114,6 +115,7 @@ defmodule Absinthe.Mixfile do "guides/dataloader.md", "guides/context-and-authentication.md", "guides/subscriptions.md", + "guides/incremental-delivery.md", "guides/custom-scalars.md", "guides/importing-types.md", "guides/importing-fields.md", diff --git a/mix.lock b/mix.lock index ee5f2a1e62..0f7d6bbd22 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,7 @@ %{ "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "dataloader": {:hex, :dataloader, "2.0.1", "fa06b057b432b993203003fbff5ff040b7f6483a77e732b7dfc18f34ded2634f", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "da7ff00890e1b14f7457419b9508605a8e66ae2cc2d08c5db6a9f344550efa11"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, @@ -8,6 +10,7 @@ "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, diff --git a/test/absinthe/incremental/complexity_test.exs b/test/absinthe/incremental/complexity_test.exs new file mode 100644 index 0000000000..b647f7d624 --- /dev/null +++ b/test/absinthe/incremental/complexity_test.exs @@ -0,0 +1,399 @@ +defmodule Absinthe.Incremental.ComplexityTest do + @moduledoc """ + Tests for complexity analysis with incremental delivery. + + Verifies that: + - Total query complexity is calculated correctly with @defer/@stream + - Per-chunk complexity limits are enforced + - Multipliers are applied correctly for deferred/streamed operations + """ + + use ExUnit.Case, async: true + + alias Absinthe.{Pipeline, Blueprint} + alias Absinthe.Incremental.Complexity + + defmodule TestSchema do + use Absinthe.Schema + + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + query do + field :user, :user do + resolve fn _, _ -> {:ok, %{id: "1", name: "Test User"}} end + end + + field :users, list_of(:user) do + resolve fn _, _ -> + {:ok, Enum.map(1..10, fn i -> %{id: "#{i}", name: "User #{i}"} end)} + end + end + + field :posts, list_of(:post) do + resolve fn _, _ -> + {:ok, Enum.map(1..20, fn i -> %{id: "#{i}", title: "Post #{i}"} end)} + end + end + end + + object :user do + field :id, non_null(:id) + field :name, non_null(:string) + + field :profile, :profile do + resolve fn _, _, _ -> {:ok, %{bio: "Bio", avatar: "avatar.jpg"}} end + end + + field :posts, list_of(:post) do + resolve fn _, _, _ -> + {:ok, Enum.map(1..5, fn i -> %{id: "#{i}", title: "Post #{i}"} end)} + end + end + end + + object :profile do + field :bio, :string + field :avatar, :string + + field :settings, :settings do + resolve fn _, _, _ -> {:ok, %{theme: "dark"}} end + end + end + + object :settings do + field :theme, :string + end + + object :post do + field :id, non_null(:id) + field :title, non_null(:string) + + field :comments, list_of(:comment) do + resolve fn _, _, _ -> + {:ok, Enum.map(1..5, fn i -> %{id: "#{i}", text: "Comment #{i}"} end)} + end + end + end + + object :comment do + field :id, non_null(:id) + field :text, non_null(:string) + end + end + + describe "analyze/2" do + test "calculates complexity for simple query" do + query = """ + query { + user { + id + name + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + assert info.total_complexity > 0 + assert info.defer_count == 0 + assert info.stream_count == 0 + end + + test "calculates complexity with @defer" do + query = """ + query { + user { + id + ... @defer(label: "profile") { + name + profile { + bio + } + } + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + assert info.defer_count == 1 + assert info.max_defer_depth >= 1 + # Initial + deferred + assert info.estimated_payloads >= 2 + end + + test "calculates complexity with @stream" do + query = """ + query { + users @stream(initialCount: 3) { + id + name + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + assert info.stream_count == 1 + # Initial + streamed batches + assert info.estimated_payloads >= 2 + end + + test "tracks nested @defer depth" do + query = """ + query { + user { + id + ... @defer(label: "level1") { + name + profile { + bio + ... @defer(label: "level2") { + settings { + theme + } + } + } + } + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + assert info.defer_count == 2 + assert info.max_defer_depth >= 2 + end + + test "tracks multiple @defer operations" do + query = """ + query { + user { + id + ... @defer(label: "name") { name } + ... @defer(label: "profile") { profile { bio } } + ... @defer(label: "posts") { posts { title } } + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + assert info.defer_count == 3 + # Initial + 3 deferred + assert info.estimated_payloads >= 4 + end + + test "provides breakdown by type" do + query = """ + query { + user { + id + name + ... @defer(label: "extra") { + profile { bio } + } + } + posts @stream(initialCount: 5) { + title + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + assert Map.has_key?(info.breakdown, :immediate) + assert Map.has_key?(info.breakdown, :deferred) + assert Map.has_key?(info.breakdown, :streamed) + end + end + + describe "per-chunk complexity" do + test "tracks complexity per chunk" do + query = """ + query { + user { + id + ... @defer(label: "heavy") { + posts { + title + comments { text } + } + } + } + } + """ + + {:ok, blueprint} = run_phases(query) + {:ok, info} = Complexity.analyze(blueprint) + + # Should have chunk complexities + assert length(info.chunk_complexities) >= 1 + end + end + + describe "check_limits/2" do + test "passes when under all limits" do + query = """ + query { + user { + id + name + } + } + """ + + {:ok, blueprint} = run_phases(query) + assert :ok == Complexity.check_limits(blueprint) + end + + test "fails when total complexity exceeded" do + query = """ + query { + users @stream(initialCount: 0) { + posts { + comments { text } + } + } + } + """ + + {:ok, blueprint} = run_phases(query) + + # Set a very low limit + result = Complexity.check_limits(blueprint, %{max_complexity: 1}) + + assert {:error, {:complexity_exceeded, _, 1}} = result + end + + test "fails when too many @defer operations" do + query = """ + query { + user { + ... @defer { name } + ... @defer { profile { bio } } + ... @defer { posts { title } } + } + } + """ + + {:ok, blueprint} = run_phases(query) + + result = Complexity.check_limits(blueprint, %{max_defer_operations: 2}) + + assert {:error, {:too_many_defers, 3}} = result + end + + test "fails when @defer nesting too deep" do + query = """ + query { + user { + ... @defer(label: "l1") { + profile { + ... @defer(label: "l2") { + settings { + theme + } + } + } + } + } + } + """ + + {:ok, blueprint} = run_phases(query) + + result = Complexity.check_limits(blueprint, %{max_defer_depth: 1}) + + assert {:error, {:defer_too_deep, _}} = result + end + + test "fails when too many @stream operations" do + query = """ + query { + users @stream(initialCount: 1) { id } + posts @stream(initialCount: 1) { id } + } + """ + + {:ok, blueprint} = run_phases(query) + + result = Complexity.check_limits(blueprint, %{max_stream_operations: 1}) + + assert {:error, {:too_many_streams, 2}} = result + end + end + + describe "field_cost/3" do + test "calculates base field cost" do + cost = Complexity.field_cost(%{type: :string}, %{}) + assert cost > 0 + end + + test "applies defer multiplier" do + base_cost = Complexity.field_cost(%{type: :string}, %{}) + defer_cost = Complexity.field_cost(%{type: :string}, %{defer: true}) + + assert defer_cost > base_cost + end + + test "applies stream multiplier" do + base_cost = Complexity.field_cost(%{type: :string}, %{}) + stream_cost = Complexity.field_cost(%{type: :string}, %{stream: true}) + + assert stream_cost > base_cost + end + + test "stream has higher multiplier than defer" do + defer_cost = Complexity.field_cost(%{type: :string}, %{defer: true}) + stream_cost = Complexity.field_cost(%{type: :string}, %{stream: true}) + + # Stream typically costs more due to multiple payloads + assert stream_cost > defer_cost + end + end + + describe "summary/2" do + test "returns summary for telemetry" do + query = """ + query { + user { + id + ... @defer { name } + } + posts @stream(initialCount: 5) { title } + } + """ + + {:ok, blueprint} = run_phases(query) + summary = Complexity.summary(blueprint) + + assert Map.has_key?(summary, :total) + assert Map.has_key?(summary, :defers) + assert Map.has_key?(summary, :streams) + assert Map.has_key?(summary, :payloads) + assert Map.has_key?(summary, :chunks) + end + end + + # Helper functions + + defp run_phases(query, variables \\ %{}) do + pipeline = + TestSchema + |> Pipeline.for_document(variables: variables) + |> Pipeline.without(Absinthe.Phase.Document.Execution.Resolution) + |> Pipeline.without(Absinthe.Phase.Document.Result) + + case Absinthe.Pipeline.run(query, pipeline) do + {:ok, blueprint, _phases} -> {:ok, blueprint} + error -> error + end + end +end diff --git a/test/absinthe/incremental/config_test.exs b/test/absinthe/incremental/config_test.exs new file mode 100644 index 0000000000..481f80da7a --- /dev/null +++ b/test/absinthe/incremental/config_test.exs @@ -0,0 +1,143 @@ +defmodule Absinthe.Incremental.ConfigTest do + @moduledoc """ + Tests for Absinthe.Incremental.Config module. + """ + + use ExUnit.Case, async: true + + alias Absinthe.Incremental.Config + + describe "from_options/1" do + test "creates config with default values" do + config = Config.from_options([]) + assert config.enabled == false + assert config.enable_defer == true + assert config.enable_stream == true + assert config.on_event == nil + end + + test "accepts on_event callback" do + callback = fn _type, _payload, _meta -> :ok end + config = Config.from_options(on_event: callback) + assert config.on_event == callback + end + + test "accepts custom options" do + config = + Config.from_options( + enabled: true, + max_concurrent_streams: 50, + on_event: fn _, _, _ -> :ok end + ) + + assert config.enabled == true + assert config.max_concurrent_streams == 50 + assert is_function(config.on_event, 3) + end + end + + describe "emit_event/4" do + test "does nothing when config is nil" do + assert :ok == Config.emit_event(nil, :initial, %{}, %{}) + end + + test "does nothing when on_event is nil" do + config = Config.from_options([]) + assert :ok == Config.emit_event(config, :initial, %{}, %{}) + end + + test "calls on_event callback with event type, payload, and metadata" do + test_pid = self() + + callback = fn event_type, payload, metadata -> + send(test_pid, {:event, event_type, payload, metadata}) + end + + config = Config.from_options(on_event: callback) + + Config.emit_event(config, :initial, %{data: "test"}, %{operation_id: "abc123"}) + + assert_receive {:event, :initial, %{data: "test"}, %{operation_id: "abc123"}} + end + + test "handles all event types" do + test_pid = self() + callback = fn type, _, _ -> send(test_pid, {:type, type}) end + config = Config.from_options(on_event: callback) + + Config.emit_event(config, :initial, %{}, %{}) + Config.emit_event(config, :incremental, %{}, %{}) + Config.emit_event(config, :complete, %{}, %{}) + Config.emit_event(config, :error, %{}, %{}) + + assert_receive {:type, :initial} + assert_receive {:type, :incremental} + assert_receive {:type, :complete} + assert_receive {:type, :error} + end + + test "catches errors in callback and returns :ok" do + callback = fn _, _, _ -> raise "intentional error" end + config = Config.from_options(on_event: callback) + + # Should not raise, should return :ok + assert :ok == Config.emit_event(config, :error, %{}, %{}) + end + + test "ignores non-function on_event values" do + # Manually create a config with invalid on_event + config = %Config{ + enabled: true, + enable_defer: true, + enable_stream: true, + max_concurrent_streams: 100, + max_stream_duration: 30_000, + max_memory_mb: 500, + max_pending_operations: 1000, + default_stream_batch_size: 10, + max_stream_batch_size: 100, + enable_dataloader_batching: true, + dataloader_timeout: 5_000, + transport: :auto, + enable_compression: false, + chunk_timeout: 1_000, + enable_relay_optimizations: true, + connection_stream_batch_size: 20, + error_recovery_enabled: true, + max_retry_attempts: 3, + retry_delay_ms: 100, + enable_telemetry: true, + enable_logging: true, + log_level: :debug, + on_event: "not a function" + } + + assert :ok == Config.emit_event(config, :initial, %{}, %{}) + end + end + + describe "validate/1" do + test "validates a valid config" do + config = Config.from_options(enabled: true) + assert {:ok, ^config} = Config.validate(config) + end + + test "returns errors for invalid transport" do + config = Config.from_options(transport: 123) + assert {:error, errors} = Config.validate(config) + assert Enum.any?(errors, &String.contains?(&1, "transport")) + end + end + + describe "enabled?/1" do + test "returns false when not enabled" do + config = Config.from_options(enabled: false) + refute Config.enabled?(config) + end + + test "returns true when enabled" do + config = Config.from_options(enabled: true) + assert Config.enabled?(config) + end + end +end diff --git a/test/absinthe/incremental/defer_test.exs b/test/absinthe/incremental/defer_test.exs new file mode 100644 index 0000000000..24bdb5cc43 --- /dev/null +++ b/test/absinthe/incremental/defer_test.exs @@ -0,0 +1,301 @@ +defmodule Absinthe.Incremental.DeferTest do + @moduledoc """ + Tests for @defer directive functionality. + + These tests verify the directive definitions and basic parsing. + Full integration tests require the streaming resolution phase to be + properly integrated into the main Absinthe pipeline. + """ + + use ExUnit.Case, async: true + + alias Absinthe.{Pipeline, Blueprint} + + defmodule TestSchema do + use Absinthe.Schema + + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + query do + field :user, :user do + arg :id, non_null(:id) + + resolve fn %{id: id}, _ -> + {:ok, + %{ + id: id, + name: "User #{id}", + email: "user#{id}@example.com" + }} + end + end + + field :users, list_of(:user) do + resolve fn _, _ -> + {:ok, + [ + %{id: "1", name: "User 1", email: "user1@example.com"}, + %{id: "2", name: "User 2", email: "user2@example.com"}, + %{id: "3", name: "User 3", email: "user3@example.com"} + ]} + end + end + end + + object :user do + field :id, non_null(:id) + field :name, non_null(:string) + field :email, non_null(:string) + + field :profile, :profile do + resolve fn user, _, _ -> + {:ok, + %{ + bio: "Bio for #{user.name}", + avatar: "avatar_#{user.id}.jpg", + followers: 100 + }} + end + end + + field :posts, list_of(:post) do + resolve fn user, _, _ -> + {:ok, + [ + %{id: "p1", title: "Post 1 by #{user.name}"}, + %{id: "p2", title: "Post 2 by #{user.name}"} + ]} + end + end + end + + object :profile do + field :bio, :string + field :avatar, :string + field :followers, :integer + end + + object :post do + field :id, non_null(:id) + field :title, non_null(:string) + end + end + + describe "directive definition" do + test "@defer directive exists in schema" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :defer) + assert directive != nil + assert directive.name == "defer" + end + + test "@defer directive has correct locations" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :defer) + assert :fragment_spread in directive.locations + assert :inline_fragment in directive.locations + end + + test "@defer directive has if argument" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :defer) + assert Map.has_key?(directive.args, :if) + assert directive.args.if.type == :boolean + assert directive.args.if.default_value == true + end + + test "@defer directive has label argument" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :defer) + assert Map.has_key?(directive.args, :label) + assert directive.args.label.type == :string + end + end + + describe "directive parsing" do + test "parses @defer on fragment spread" do + query = """ + query { + user(id: "1") { + id + ...UserProfile @defer(label: "profile") + } + } + + fragment UserProfile on User { + name + email + } + """ + + assert {:ok, blueprint} = run_phases(query) + + # Find the fragment spread with the defer directive + fragment_spread = find_node(blueprint, Absinthe.Blueprint.Document.Fragment.Spread) + assert fragment_spread != nil + + # Check that the directive was parsed + assert length(fragment_spread.directives) > 0 + defer_directive = Enum.find(fragment_spread.directives, &(&1.name == "defer")) + assert defer_directive != nil + end + + test "parses @defer on inline fragment" do + query = """ + query { + user(id: "1") { + id + ... @defer(label: "details") { + name + email + } + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + # Find the inline fragment + inline_fragment = find_node(blueprint, Absinthe.Blueprint.Document.Fragment.Inline) + assert inline_fragment != nil + + # Check the directive + defer_directive = Enum.find(inline_fragment.directives, &(&1.name == "defer")) + assert defer_directive != nil + end + + test "validates @defer cannot be used on fields" do + # @defer should only be valid on fragments + query = """ + query { + user(id: "1") @defer { + id + } + } + """ + + # This should produce a validation error + result = Absinthe.run(query, TestSchema) + assert {:ok, %{errors: errors}} = result + assert length(errors) > 0 + end + end + + describe "directive expansion" do + test "sets defer flag when if: true (default)" do + query = """ + query { + user(id: "1") { + id + ... @defer(label: "profile") { + name + } + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + inline_fragment = find_node(blueprint, Absinthe.Blueprint.Document.Fragment.Inline) + + # The expand callback should have set the :defer flag + assert Map.has_key?(inline_fragment.flags, :defer) + defer_flag = inline_fragment.flags.defer + assert defer_flag.enabled == true + assert defer_flag.label == "profile" + end + + test "does not set defer flag when if: false" do + query = """ + query { + user(id: "1") { + id + ... @defer(if: false, label: "disabled") { + name + } + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + inline_fragment = find_node(blueprint, Absinthe.Blueprint.Document.Fragment.Inline) + + # When if: false, either no defer flag or enabled: false + if Map.has_key?(inline_fragment.flags, :defer) do + assert inline_fragment.flags.defer.enabled == false + end + end + + test "handles @defer with variable for if argument" do + query = """ + query($shouldDefer: Boolean!) { + user(id: "1") { + id + ... @defer(if: $shouldDefer, label: "conditional") { + name + } + } + } + """ + + # With shouldDefer: true + assert {:ok, blueprint_true} = run_phases(query, %{"shouldDefer" => true}) + inline_true = find_node(blueprint_true, Absinthe.Blueprint.Document.Fragment.Inline) + assert inline_true.flags.defer.enabled == true + + # With shouldDefer: false + assert {:ok, blueprint_false} = run_phases(query, %{"shouldDefer" => false}) + inline_false = find_node(blueprint_false, Absinthe.Blueprint.Document.Fragment.Inline) + + if Map.has_key?(inline_false.flags, :defer) do + assert inline_false.flags.defer.enabled == false + end + end + end + + describe "standard execution without streaming" do + test "query with @defer runs normally when streaming not enabled" do + query = """ + query { + user(id: "1") { + id + ... @defer(label: "profile") { + name + email + } + } + } + """ + + # Standard execution should still work + {:ok, result} = Absinthe.run(query, TestSchema) + + # All data should be returned (defer is ignored without streaming pipeline) + assert result.data["user"]["id"] == "1" + assert result.data["user"]["name"] == "User 1" + assert result.data["user"]["email"] == "user1@example.com" + end + end + + # Helper functions + + defp run_phases(query, variables \\ %{}) do + pipeline = + TestSchema + |> Pipeline.for_document(variables: variables) + |> Pipeline.without(Absinthe.Phase.Document.Execution.Resolution) + |> Pipeline.without(Absinthe.Phase.Document.Result) + + case Absinthe.Pipeline.run(query, pipeline) do + {:ok, blueprint, _phases} -> {:ok, blueprint} + error -> error + end + end + + defp find_node(blueprint, type) do + {_, found} = + Blueprint.prewalk(blueprint, nil, fn + %{__struct__: ^type} = node, nil -> {node, node} + node, acc -> {node, acc} + end) + + found + end +end diff --git a/test/absinthe/incremental/stream_test.exs b/test/absinthe/incremental/stream_test.exs new file mode 100644 index 0000000000..fca2cb51f3 --- /dev/null +++ b/test/absinthe/incremental/stream_test.exs @@ -0,0 +1,320 @@ +defmodule Absinthe.Incremental.StreamTest do + @moduledoc """ + Tests for @stream directive functionality. + + These tests verify the directive definitions and basic parsing. + Full integration tests require the streaming resolution phase to be + properly integrated into the main Absinthe pipeline. + """ + + use ExUnit.Case, async: true + + alias Absinthe.{Pipeline, Blueprint} + + defmodule TestSchema do + use Absinthe.Schema + + import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + + query do + field :users, list_of(:user) do + resolve fn _, _ -> + {:ok, + Enum.map(1..10, fn i -> + %{id: "#{i}", name: "User #{i}"} + end)} + end + end + + field :posts, list_of(:post) do + resolve fn _, _ -> + {:ok, + Enum.map(1..20, fn i -> + %{id: "#{i}", title: "Post #{i}"} + end)} + end + end + end + + object :user do + field :id, non_null(:id) + field :name, non_null(:string) + + field :friends, list_of(:user) do + resolve fn _, _, _ -> + {:ok, + Enum.map(1..3, fn i -> + %{id: "f#{i}", name: "Friend #{i}"} + end)} + end + end + end + + object :post do + field :id, non_null(:id) + field :title, non_null(:string) + + field :comments, list_of(:comment) do + resolve fn _, _, _ -> + {:ok, + Enum.map(1..5, fn i -> + %{id: "c#{i}", text: "Comment #{i}"} + end)} + end + end + end + + object :comment do + field :id, non_null(:id) + field :text, non_null(:string) + end + end + + describe "directive definition" do + test "@stream directive exists in schema" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :stream) + assert directive != nil + assert directive.name == "stream" + end + + test "@stream directive has correct locations" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :stream) + assert :field in directive.locations + end + + test "@stream directive has if argument" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :stream) + assert Map.has_key?(directive.args, :if) + assert directive.args.if.type == :boolean + assert directive.args.if.default_value == true + end + + test "@stream directive has label argument" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :stream) + assert Map.has_key?(directive.args, :label) + assert directive.args.label.type == :string + end + + test "@stream directive has initial_count argument" do + directive = Absinthe.Schema.lookup_directive(TestSchema, :stream) + assert Map.has_key?(directive.args, :initial_count) + assert directive.args.initial_count.type == :integer + assert directive.args.initial_count.default_value == 0 + end + end + + describe "directive parsing" do + test "parses @stream on list field" do + query = """ + query { + users @stream(label: "users", initialCount: 5) { + id + name + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + users_field = find_field(blueprint, "users") + assert users_field != nil + + # Check that the directive was parsed + assert length(users_field.directives) > 0 + stream_directive = Enum.find(users_field.directives, &(&1.name == "stream")) + assert stream_directive != nil + end + + test "validates @stream cannot be used on non-list fields" do + # Create a schema with a non-list field to test + defmodule NonListSchema do + use Absinthe.Schema + + query do + field :user, :user do + resolve fn _, _ -> {:ok, %{id: "1", name: "Test"}} end + end + end + + object :user do + field :id, non_null(:id) + field :name, non_null(:string) + end + end + + query = """ + query { + user @stream(initialCount: 1) { + id + } + } + """ + + # @stream on non-list should work syntactically but semantically makes no sense + # The behavior depends on implementation + result = Absinthe.run(query, NonListSchema) + + # At minimum it should not crash + assert {:ok, _} = result + end + end + + describe "directive expansion" do + test "sets stream flag when if: true (default)" do + query = """ + query { + users @stream(label: "users", initialCount: 3) { + id + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + users_field = find_field(blueprint, "users") + + # The expand callback should have set the :stream flag + assert Map.has_key?(users_field.flags, :stream) + stream_flag = users_field.flags.stream + assert stream_flag.enabled == true + assert stream_flag.label == "users" + assert stream_flag.initial_count == 3 + end + + test "does not set stream flag when if: false" do + query = """ + query { + users @stream(if: false, initialCount: 3) { + id + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + users_field = find_field(blueprint, "users") + + # When if: false, either no stream flag or enabled: false + if Map.has_key?(users_field.flags, :stream) do + assert users_field.flags.stream.enabled == false + end + end + + test "handles @stream with variable for if argument" do + query = """ + query($shouldStream: Boolean!) { + users @stream(if: $shouldStream, initialCount: 2) { + id + } + } + """ + + # With shouldStream: true + assert {:ok, blueprint_true} = run_phases(query, %{"shouldStream" => true}) + users_true = find_field(blueprint_true, "users") + assert users_true.flags.stream.enabled == true + + # With shouldStream: false + assert {:ok, blueprint_false} = run_phases(query, %{"shouldStream" => false}) + users_false = find_field(blueprint_false, "users") + + if Map.has_key?(users_false.flags, :stream) do + assert users_false.flags.stream.enabled == false + end + end + + test "sets default initial_count to 0" do + query = """ + query { + users @stream(label: "users") { + id + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + users_field = find_field(blueprint, "users") + assert users_field.flags.stream.initial_count == 0 + end + end + + describe "standard execution without streaming" do + test "query with @stream runs normally when streaming not enabled" do + query = """ + query { + users @stream(initialCount: 3) { + id + name + } + } + """ + + # Standard execution should still work + {:ok, result} = Absinthe.run(query, TestSchema) + + # All data should be returned (stream is ignored without streaming pipeline) + assert length(result.data["users"]) == 10 + end + end + + describe "nested streaming" do + test "parses nested @stream directives" do + query = """ + query { + users @stream(label: "users", initialCount: 2) { + id + friends @stream(label: "friends", initialCount: 1) { + id + name + } + } + } + """ + + assert {:ok, blueprint} = run_phases(query) + + users_field = find_field(blueprint, "users") + friends_field = find_nested_field(blueprint, "friends") + + assert users_field.flags.stream.enabled == true + assert friends_field.flags.stream.enabled == true + end + end + + # Helper functions + + defp run_phases(query, variables \\ %{}) do + pipeline = + TestSchema + |> Pipeline.for_document(variables: variables) + |> Pipeline.without(Absinthe.Phase.Document.Execution.Resolution) + |> Pipeline.without(Absinthe.Phase.Document.Result) + + case Absinthe.Pipeline.run(query, pipeline) do + {:ok, blueprint, _phases} -> {:ok, blueprint} + error -> error + end + end + + defp find_field(blueprint, name) do + {_, found} = + Blueprint.prewalk(blueprint, nil, fn + %Absinthe.Blueprint.Document.Field{name: ^name} = node, nil -> {node, node} + node, acc -> {node, acc} + end) + + found + end + + defp find_nested_field(blueprint, name) do + # Find a field that's nested inside another field + {_, found} = + Blueprint.prewalk(blueprint, nil, fn + %Absinthe.Blueprint.Document.Field{name: ^name} = node, _acc -> {node, node} + node, acc -> {node, acc} + end) + + found + end +end diff --git a/test/absinthe/integration/execution/introspection/directives_test.exs b/test/absinthe/integration/execution/introspection/directives_test.exs index 481bdc3267..428483123c 100644 --- a/test/absinthe/integration/execution/introspection/directives_test.exs +++ b/test/absinthe/integration/execution/introspection/directives_test.exs @@ -18,90 +18,21 @@ defmodule Elixir.Absinthe.Integration.Execution.Introspection.DirectivesTest do """ test "scenario #1" do - assert {:ok, - %{ - data: %{ - "__schema" => %{ - "directives" => [ - %{ - "args" => [ - %{"name" => "reason", "type" => %{"kind" => "SCALAR", "ofType" => nil}} - ], - "isRepeatable" => false, - "locations" => [ - "ARGUMENT_DEFINITION", - "ENUM_VALUE", - "FIELD_DEFINITION", - "INPUT_FIELD_DEFINITION" - ], - "name" => "deprecated", - "onField" => false, - "onFragment" => false, - "onOperation" => false - }, - %{ - "args" => [ - %{ - "name" => "if", - "type" => %{ - "kind" => "NON_NULL", - "ofType" => %{"kind" => "SCALAR", "name" => "Boolean"} - } - } - ], - "locations" => ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], - "name" => "include", - "onField" => true, - "onFragment" => true, - "onOperation" => false, - "isRepeatable" => false - }, - %{ - "args" => [], - "isRepeatable" => false, - "locations" => ["INPUT_OBJECT"], - "name" => "oneOf", - "onField" => false, - "onFragment" => false, - "onOperation" => false - }, - %{ - "args" => [ - %{ - "name" => "if", - "type" => %{ - "kind" => "NON_NULL", - "ofType" => %{"kind" => "SCALAR", "name" => "Boolean"} - } - } - ], - "locations" => ["FIELD", "FRAGMENT_SPREAD", "INLINE_FRAGMENT"], - "name" => "skip", - "onField" => true, - "onFragment" => true, - "onOperation" => false, - "isRepeatable" => false - }, - %{ - "isRepeatable" => false, - "locations" => ["SCALAR"], - "name" => "specifiedBy", - "onField" => false, - "onFragment" => false, - "onOperation" => false, - "args" => [ - %{ - "name" => "url", - "type" => %{ - "kind" => "NON_NULL", - "ofType" => %{"kind" => "SCALAR", "name" => "String"} - } - } - ] - } - ] - } - } - }} == Absinthe.run(@query, Absinthe.Fixtures.ContactSchema, []) + # Note: @defer and @stream directives are opt-in and not included in core schemas + # They need to be explicitly imported via: import_directives Absinthe.Type.BuiltIns.IncrementalDirectives + {:ok, result} = Absinthe.run(@query, Absinthe.Fixtures.ContactSchema, []) + + directives = get_in(result, [:data, "__schema", "directives"]) + directive_names = Enum.map(directives, & &1["name"]) + + # Core directives should always be present + assert "deprecated" in directive_names + assert "include" in directive_names + assert "skip" in directive_names + assert "specifiedBy" in directive_names + + # @defer and @stream are opt-in, not in core schema + refute "defer" in directive_names + refute "stream" in directive_names end end diff --git a/test/absinthe/introspection_test.exs b/test/absinthe/introspection_test.exs index 43806b18f5..c6937e65fa 100644 --- a/test/absinthe/introspection_test.exs +++ b/test/absinthe/introspection_test.exs @@ -4,6 +4,8 @@ defmodule Absinthe.IntrospectionTest do alias Absinthe.Schema describe "introspection of directives" do + # Note: @defer and @stream directives are opt-in and not included in core schemas. + # They need to be explicitly imported via: import_directives Absinthe.Type.BuiltIns.IncrementalDirectives test "builtin" do result = """ diff --git a/test/absinthe/streaming/backwards_compat_test.exs b/test/absinthe/streaming/backwards_compat_test.exs new file mode 100644 index 0000000000..20d52023bb --- /dev/null +++ b/test/absinthe/streaming/backwards_compat_test.exs @@ -0,0 +1,272 @@ +defmodule Absinthe.Streaming.BackwardsCompatTest do + @moduledoc """ + Tests to ensure backwards compatibility for existing subscription behavior. + + These tests verify that: + 1. Subscriptions without @defer/@stream work exactly as before + 2. Existing pubsub implementations receive messages in the expected format + 3. Custom run_docset/3 implementations continue to work + 4. Pipeline construction without incremental enabled is unchanged + """ + + use ExUnit.Case, async: true + + alias Absinthe.Subscription.Local + + defmodule TestSchema do + use Absinthe.Schema + + query do + field :placeholder, :string do + resolve fn _, _ -> {:ok, "placeholder"} end + end + end + + subscription do + field :user_created, :user do + config fn _, _ -> {:ok, topic: "users"} end + + resolve fn _, _, _ -> + {:ok, %{id: "1", name: "Test User", email: "test@example.com"}} + end + end + end + + object :user do + field :id, non_null(:id) + field :name, non_null(:string) + field :email, non_null(:string) + end + end + + defmodule TestPubSub do + @behaviour Absinthe.Subscription.Pubsub + + def start_link do + Registry.start_link(keys: :duplicate, name: __MODULE__) + end + + @impl true + def subscribe(topic) do + Registry.register(__MODULE__, topic, []) + :ok + end + + @impl true + def node_name do + to_string(node()) + end + + @impl true + def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do + # Local-only pubsub + :ok + end + + @impl true + def publish_subscription(topic, data) do + # Send to test process + Registry.dispatch(__MODULE__, topic, fn entries -> + for {pid, _} <- entries do + send(pid, {:subscription_data, topic, data}) + end + end) + + :ok + end + end + + describe "backwards compatibility" do + test "subscription without @defer/@stream uses standard pipeline" do + # Query without any streaming directives + query = """ + subscription { + userCreated { + id + name + } + } + """ + + # Should NOT detect streaming directives + refute Absinthe.Streaming.has_streaming_directives?(query) + end + + test "pipeline/2 without options works as before" do + # Simulate a document structure + doc = %{ + source: "subscription { userCreated { id } }", + initial_phases: [ + {Absinthe.Phase.Parse, []}, + {Absinthe.Phase.Blueprint, []}, + {Absinthe.Phase.Telemetry, event: [:execute, :operation, :start]}, + {Absinthe.Phase.Document.Execution.Resolution, []} + ] + } + + # Call pipeline without enable_incremental + pipeline = Local.pipeline(doc, %{}) + + # Verify it's a valid pipeline (list of phases) + assert is_list(List.flatten(pipeline)) + + # Verify Resolution phase is present (not StreamingResolution) + flat_pipeline = List.flatten(pipeline) + + resolution_present = + Enum.any?(flat_pipeline, fn + Absinthe.Phase.Document.Execution.Resolution -> true + {Absinthe.Phase.Document.Execution.Resolution, _} -> true + _ -> false + end) + + streaming_resolution_present = + Enum.any?(flat_pipeline, fn + Absinthe.Phase.Document.Execution.StreamingResolution -> true + {Absinthe.Phase.Document.Execution.StreamingResolution, _} -> true + _ -> false + end) + + assert resolution_present or not streaming_resolution_present, + "Pipeline should use Resolution, not StreamingResolution, when incremental is disabled" + end + + test "pipeline/3 with enable_incremental: false works as before" do + doc = %{ + source: "subscription { userCreated { id } }", + initial_phases: [ + {Absinthe.Phase.Parse, []}, + {Absinthe.Phase.Blueprint, []}, + {Absinthe.Phase.Telemetry, event: [:execute, :operation, :start]}, + {Absinthe.Phase.Document.Execution.Resolution, []} + ] + } + + # Explicitly disable incremental + pipeline = Local.pipeline(doc, %{}, enable_incremental: false) + + assert is_list(List.flatten(pipeline)) + end + + test "has_streaming_directives? returns false for regular queries" do + queries = [ + "subscription { userCreated { id name } }", + "query { user(id: \"1\") { name } }", + "mutation { createUser(name: \"Test\") { id } }", + # With comments + "# This is a comment\nsubscription { userCreated { id } }", + # With fragments (but no @defer) + "subscription { userCreated { ...UserFields } } fragment UserFields on User { id name }" + ] + + for query <- queries do + refute Absinthe.Streaming.has_streaming_directives?(query), + "Should not detect streaming in: #{query}" + end + end + + test "has_streaming_directives? returns true for queries with @defer" do + queries = [ + "subscription { userCreated { id ... @defer { email } } }", + "query { user(id: \"1\") { name ... @defer { profile { bio } } } }", + "subscription { userCreated { ...UserFields @defer } } fragment UserFields on User { id }" + ] + + for query <- queries do + assert Absinthe.Streaming.has_streaming_directives?(query), + "Should detect @defer in: #{query}" + end + end + + test "has_streaming_directives? returns true for queries with @stream" do + queries = [ + "query { users @stream { id name } }", + "subscription { postsCreated { comments @stream(initialCount: 5) { text } } }" + ] + + for query <- queries do + assert Absinthe.Streaming.has_streaming_directives?(query), + "Should detect @stream in: #{query}" + end + end + end + + describe "streaming module helpers" do + test "has_streaming_tasks? returns false for blueprints without streaming context" do + blueprint = %Absinthe.Blueprint{ + execution: %Absinthe.Blueprint.Execution{ + context: %{} + } + } + + refute Absinthe.Streaming.has_streaming_tasks?(blueprint) + end + + test "has_streaming_tasks? returns false for empty task lists" do + blueprint = %Absinthe.Blueprint{ + execution: %Absinthe.Blueprint.Execution{ + context: %{ + __streaming__: %{ + deferred_tasks: [], + stream_tasks: [] + } + } + } + } + + refute Absinthe.Streaming.has_streaming_tasks?(blueprint) + end + + test "has_streaming_tasks? returns true when deferred_tasks present" do + blueprint = %Absinthe.Blueprint{ + execution: %Absinthe.Blueprint.Execution{ + context: %{ + __streaming__: %{ + deferred_tasks: [%{id: "1", execute: fn -> {:ok, %{}} end}], + stream_tasks: [] + } + } + } + } + + assert Absinthe.Streaming.has_streaming_tasks?(blueprint) + end + + test "has_streaming_tasks? returns true when stream_tasks present" do + blueprint = %Absinthe.Blueprint{ + execution: %Absinthe.Blueprint.Execution{ + context: %{ + __streaming__: %{ + deferred_tasks: [], + stream_tasks: [%{id: "1", execute: fn -> {:ok, %{}} end}] + } + } + } + } + + assert Absinthe.Streaming.has_streaming_tasks?(blueprint) + end + + test "get_streaming_tasks returns all tasks" do + task1 = %{id: "1", type: :defer, execute: fn -> {:ok, %{}} end} + task2 = %{id: "2", type: :stream, execute: fn -> {:ok, %{}} end} + + blueprint = %Absinthe.Blueprint{ + execution: %Absinthe.Blueprint.Execution{ + context: %{ + __streaming__: %{ + deferred_tasks: [task1], + stream_tasks: [task2] + } + } + } + } + + tasks = Absinthe.Streaming.get_streaming_tasks(blueprint) + + assert length(tasks) == 2 + assert task1 in tasks + assert task2 in tasks + end + end +end diff --git a/test/absinthe/streaming/task_executor_test.exs b/test/absinthe/streaming/task_executor_test.exs new file mode 100644 index 0000000000..b46170f641 --- /dev/null +++ b/test/absinthe/streaming/task_executor_test.exs @@ -0,0 +1,195 @@ +defmodule Absinthe.Streaming.TaskExecutorTest do + @moduledoc """ + Tests for the TaskExecutor module. + """ + + use ExUnit.Case, async: true + + alias Absinthe.Streaming.TaskExecutor + + describe "execute_stream/2" do + test "executes tasks and returns results as stream" do + tasks = [ + %{ + id: "1", + type: :defer, + label: "first", + path: ["user", "profile"], + execute: fn -> {:ok, %{data: %{bio: "Test bio"}}} end + }, + %{ + id: "2", + type: :defer, + label: "second", + path: ["user", "posts"], + execute: fn -> {:ok, %{data: %{title: "Test post"}}} end + } + ] + + results = tasks |> TaskExecutor.execute_stream() |> Enum.to_list() + + assert length(results) == 2 + + [first, second] = results + + assert first.success == true + assert first.has_next == true + assert first.result == {:ok, %{data: %{bio: "Test bio"}}} + + assert second.success == true + assert second.has_next == false + assert second.result == {:ok, %{data: %{title: "Test post"}}} + end + + test "handles task errors gracefully" do + tasks = [ + %{ + id: "1", + type: :defer, + label: nil, + path: ["error"], + execute: fn -> {:error, "Something went wrong"} end + } + ] + + results = tasks |> TaskExecutor.execute_stream() |> Enum.to_list() + + assert length(results) == 1 + [result] = results + + assert result.success == false + assert result.has_next == false + assert {:error, "Something went wrong"} = result.result + end + + test "handles task exceptions" do + tasks = [ + %{ + id: "1", + type: :defer, + label: nil, + path: ["exception"], + execute: fn -> raise "Boom!" end + } + ] + + results = tasks |> TaskExecutor.execute_stream() |> Enum.to_list() + + assert length(results) == 1 + [result] = results + + assert result.success == false + assert {:error, _} = result.result + end + + test "respects timeout option" do + tasks = [ + %{ + id: "1", + type: :defer, + label: nil, + path: ["slow"], + execute: fn -> + Process.sleep(5000) + {:ok, %{data: %{}}} + end + } + ] + + results = tasks |> TaskExecutor.execute_stream(timeout: 100) |> Enum.to_list() + + assert length(results) == 1 + [result] = results + + assert result.success == false + assert result.result == {:error, :timeout} + end + + test "tracks duration" do + tasks = [ + %{ + id: "1", + type: :defer, + label: nil, + path: ["timed"], + execute: fn -> + Process.sleep(50) + {:ok, %{data: %{}}} + end + } + ] + + results = tasks |> TaskExecutor.execute_stream() |> Enum.to_list() + + [result] = results + assert result.duration_ms >= 50 + end + + test "handles empty task list" do + results = [] |> TaskExecutor.execute_stream() |> Enum.to_list() + assert results == [] + end + end + + describe "execute_all/2" do + test "collects all results into a list" do + tasks = [ + %{ + id: "1", + type: :defer, + label: "a", + path: ["a"], + execute: fn -> {:ok, %{data: %{a: 1}}} end + }, + %{ + id: "2", + type: :defer, + label: "b", + path: ["b"], + execute: fn -> {:ok, %{data: %{b: 2}}} end + } + ] + + results = TaskExecutor.execute_all(tasks) + + assert length(results) == 2 + assert Enum.all?(results, & &1.success) + end + end + + describe "execute_one/2" do + test "executes a single task" do + task = %{ + id: "1", + type: :defer, + label: "single", + path: ["single"], + execute: fn -> {:ok, %{data: %{value: 42}}} end + } + + result = TaskExecutor.execute_one(task) + + assert result.success == true + assert result.has_next == false + assert result.result == {:ok, %{data: %{value: 42}}} + end + + test "handles timeout for single task" do + task = %{ + id: "1", + type: :defer, + label: nil, + path: ["slow"], + execute: fn -> + Process.sleep(5000) + {:ok, %{}} + end + } + + result = TaskExecutor.execute_one(task, timeout: 100) + + assert result.success == false + assert result.result == {:error, :timeout} + end + end +end From 50e3513fd7ebce3537d28fd6f67e2a63d3fda77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 9 Mar 2026 10:34:32 +0100 Subject: [PATCH 7/8] Break function dispatch into groups Currently Absinthe may generate thousands of clauses for a single function, each with a distinct tagged tuple type. While the Elixir team aims to improve the type system performance across all cases, it is undeniable that these many clauses put pressure on both the compiler and the type system, so we split them by identifier in Absinthe itself. --- lib/absinthe/schema/notation.ex | 57 +++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/lib/absinthe/schema/notation.ex b/lib/absinthe/schema/notation.ex index 0bd8fe1ec9..bebf7e2758 100644 --- a/lib/absinthe/schema/notation.ex +++ b/lib/absinthe/schema/notation.ex @@ -2248,7 +2248,7 @@ defmodule Absinthe.Schema.Notation do # TODO: handle multiple schemas [schema] = blueprint.schema_definitions - {schema, functions} = lift_functions(schema, env.module) + {schema, {functions, _counter}} = lift_functions(schema, env.module) sdl_definitions = (Module.get_attribute(env.module, :__absinthe_sdl_definitions__) || []) @@ -2286,12 +2286,12 @@ defmodule Absinthe.Schema.Notation do end def lift_functions(schema, origin) do - Absinthe.Blueprint.prewalk(schema, [], &lift_functions(&1, &2, origin)) + Absinthe.Blueprint.prewalk(schema, {[], %{}}, &lift_functions(&1, &2, origin)) end - def lift_functions(node, acc, origin) do - {node, ast} = functions_for_type(node, origin) - {node, ast ++ acc} + def lift_functions(node, {acc, counter}, origin) do + {node, ast, counter} = functions_for_type(node, origin, counter) + {node, {ast ++ acc, counter}} end defp block_from_directive_attrs(attrs, block \\ []) do @@ -2329,31 +2329,54 @@ defmodule Absinthe.Schema.Notation do end) end - defp functions_for_type(%Schema.FieldDefinition{} = type, origin) do + defp functions_for_type(%Schema.FieldDefinition{} = type, origin, counter) do grab_functions( origin, type, {Schema.FieldDefinition, type.function_ref}, - Schema.functions(Schema.FieldDefinition) + Schema.functions(Schema.FieldDefinition), + counter ) end - defp functions_for_type(%module{identifier: identifier} = type, origin) do - grab_functions(origin, type, {module, identifier}, Schema.functions(module)) + defp functions_for_type(%module{identifier: identifier} = type, origin, counter) do + grab_functions(origin, type, {module, identifier}, Schema.functions(module), counter) end - defp functions_for_type(type, _) do - {type, []} + defp functions_for_type(type, _, counter) do + {type, [], counter} end - def grab_functions(origin, type, identifier, attrs) do + def grab_functions(_origin, type, _identifier, [], counter) do + {type, [], counter} + end + + def grab_functions(origin, type, identifier, attrs, counter) do + {name, counter, dispatch} = + case counter do + %{^identifier => name} -> + {name, counter, []} + + %{} -> + name = :"__absinthe_function_#{map_size(counter)}__" + + def = + quote do + def __absinthe_function__(unquote(identifier), attr) do + unquote(name)(attr) + end + end + + {name, Map.put(counter, identifier, name), [def]} + end + {ast, type} = - Enum.flat_map_reduce(attrs, type, fn attr, type -> + Enum.map_reduce(attrs, type, fn attr, type -> value = Map.fetch!(type, attr) ast = - quote do - def __absinthe_function__(unquote(identifier), unquote(attr)) do + quote generated: true do + defp unquote(name)(unquote(attr)) do unquote(value) end end @@ -2369,10 +2392,10 @@ defmodule Absinthe.Schema.Notation do ref end) - {[ast], type} + {ast, type} end) - {type, ast} + {type, dispatch ++ ast, counter} end @doc false From c0875e0b7c5c710f1124a6dd1aaf4010cd17dc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 9 Mar 2026 17:38:11 +0100 Subject: [PATCH 8/8] mix format --- lib/absinthe/incremental/transport.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/absinthe/incremental/transport.ex b/lib/absinthe/incremental/transport.ex index 3bfc63034e..f738a56c96 100644 --- a/lib/absinthe/incremental/transport.ex +++ b/lib/absinthe/incremental/transport.ex @@ -234,6 +234,7 @@ defmodule Absinthe.Incremental.Transport do # Get configurable executor (defaults to TaskExecutor) executor = Absinthe.Streaming.Executor.get_executor(schema, options) + executor_opts = [ timeout: timeout, max_concurrency: System.schedulers_online() * 2 @@ -488,6 +489,7 @@ defmodule Absinthe.Incremental.Transport do # Use configurable executor (defaults to TaskExecutor) executor = Executor.get_executor(schema, options) + incremental_results = all_tasks |> executor.execute(timeout: timeout)