Skip to content

Commit 53b888f

Browse files
committed
refactor(execution): track pending work
GraphQL-core needs no AsyncWorkTracker class or shared execution context: the Executor's existing background-settling machinery (settle_in_background plus background_futures) already serves as the tracker; it is exposed to resolvers via the new async_helpers field on GraphQLResolveInfo instead of a getAsyncHelpers getter. Replicates graphql/graphql-js@2e97665
1 parent 8533b24 commit 53b888f

8 files changed

Lines changed: 68 additions & 23 deletions

File tree

docs/modules/type.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ Resolvers
112112
.. autoclass:: GraphQLFieldResolver
113113
.. autoclass:: GraphQLIsTypeOfFn
114114
.. autoclass:: GraphQLResolveInfo
115+
.. autoclass:: GraphQLResolveInfoHelpers
115116
.. autoclass:: GraphQLTypeResolver
116117

117118

src/graphql/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@
407407
GraphQLDefaultInput,
408408
GraphQLIsTypeOfFn,
409409
GraphQLResolveInfo,
410+
GraphQLResolveInfoHelpers,
410411
ResponsePath,
411412
GraphQLTypeResolver,
412413
# Keyword args
@@ -649,6 +650,7 @@
649650
"GraphQLOutputType",
650651
"GraphQLParseFn",
651652
"GraphQLResolveInfo",
653+
"GraphQLResolveInfoHelpers",
652654
"GraphQLScalarInputLiteralCoercer",
653655
"GraphQLScalarInputValueCoercer",
654656
"GraphQLScalarLiteralParser",

src/graphql/execution/execute.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
Any,
3232
Generic,
3333
NamedTuple,
34-
NoReturn,
3534
TypeVar,
3635
cast,
3736
)
@@ -71,6 +70,7 @@
7170
GraphQLObjectType,
7271
GraphQLOutputType,
7372
GraphQLResolveInfo,
73+
GraphQLResolveInfoHelpers,
7474
GraphQLSchema,
7575
GraphQLStreamDirective,
7676
GraphQLTypeResolver,
@@ -218,6 +218,7 @@ class Executor(IncrementalPublisherContext):
218218
enable_early_execution: bool
219219
hide_suggestions: bool
220220
abort_signal: AbortSignal | None
221+
async_helpers: GraphQLResolveInfoHelpers
221222
errors: list[GraphQLError] | None
222223
cancellable_streams: set[CancellableStreamRecord] | None
223224
pending_incremental_futures: set[Future[Any]]
@@ -267,6 +268,7 @@ def __init__( # noqa: PLR0913
267268
self.enable_early_execution = enable_early_execution
268269
self.hide_suggestions = hide_suggestions
269270
self.abort_signal = abort_signal
271+
self.async_helpers = GraphQLResolveInfoHelpers(track=self.track_async_work)
270272
self.middleware_manager = middleware_manager
271273
self.error_propagation = not any(
272274
directive.name.value == GraphQLDisableErrorPropagationDirective.name
@@ -817,6 +819,7 @@ def build_resolve_info(
817819
self.context_value,
818820
self.is_awaitable,
819821
self.abort_signal,
822+
self.async_helpers,
820823
)
821824

822825
def handle_field_error(
@@ -1061,6 +1064,20 @@ def settle_in_background(self, awaitables: list[Awaitable[Any]]) -> None:
10611064
background_futures.add(future)
10621065
future.add_done_callback(background_futures.discard)
10631066

1067+
def track_async_work(self, values: Sequence[Any]) -> None:
1068+
"""Track possibly awaitable values as pending asynchronous work.
1069+
1070+
Awaitables among the given values are settled in the background, so that
1071+
they are still settled and their errors observed when they would otherwise
1072+
be abandoned. Non-awaitable values are ignored.
1073+
"""
1074+
is_awaitable = self.is_awaitable
1075+
awaitables: list[Awaitable[Any]] = [
1076+
value for value in values if is_awaitable(value)
1077+
]
1078+
if awaitables:
1079+
self.settle_in_background(awaitables)
1080+
10641081
def cancellable_iterable(self, iterable: AsyncIterable[T]) -> AsyncIterable[T]:
10651082
"""Wrap an async iterable so pending iteration is cancelled on abort.
10661083
@@ -2709,25 +2726,13 @@ def default_type_resolver(
27092726
append_awaitable_type(type_)
27102727
elif is_type_of_result:
27112728
if awaitable_is_type_of_results:
2712-
2713-
async def await_is_type_of_and_return_type(
2714-
resolved_type_name: str = type_.name,
2715-
) -> str:
2716-
with suppress(Exception):
2717-
await gather_with_cancel(*awaitable_is_type_of_results)
2718-
return resolved_type_name
2719-
2720-
return await_is_type_of_and_return_type()
2729+
info.async_helpers.track(awaitable_is_type_of_results)
27212730
return type_.name
2722-
except Exception as error:
2731+
except Exception:
27232732
if awaitable_is_type_of_results:
2724-
# Settle the pending isTypeOf results so that their errors can be
2725-
# observed before they would be orphaned.
2726-
async def settle_and_raise(error: Exception = error) -> NoReturn:
2727-
await gather(*awaitable_is_type_of_results, return_exceptions=True)
2728-
raise error # noqa: TRY201
2729-
2730-
return settle_and_raise()
2733+
# Settle the pending isTypeOf results in the background so that
2734+
# their errors can be observed before they would be orphaned.
2735+
info.async_helpers.track(awaitable_is_type_of_results)
27312736
raise
27322737

27332738
if awaitable_is_type_of_results:

src/graphql/type/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
GraphQLTypeResolver,
134134
GraphQLIsTypeOfFn,
135135
GraphQLResolveInfo,
136+
GraphQLResolveInfoHelpers,
136137
)
137138

138139
from .directives import (
@@ -246,6 +247,7 @@
246247
"GraphQLOneOfDirective",
247248
"GraphQLOutputType",
248249
"GraphQLResolveInfo",
250+
"GraphQLResolveInfoHelpers",
249251
"GraphQLScalarInputLiteralCoercer",
250252
"GraphQLScalarInputValueCoercer",
251253
"GraphQLScalarLiteralParser",

src/graphql/type/definition.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Awaitable, Callable, Collection, Mapping
5+
from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
66
from enum import Enum
77
from typing import (
88
TYPE_CHECKING,
@@ -110,6 +110,7 @@
110110
"GraphQLObjectTypeKwargs",
111111
"GraphQLOutputType",
112112
"GraphQLResolveInfo",
113+
"GraphQLResolveInfoHelpers",
113114
"GraphQLScalarInputLiteralCoercer",
114115
"GraphQLScalarInputValueCoercer",
115116
"GraphQLScalarLiteralParser",
@@ -630,6 +631,18 @@ def assert_field(field: Any) -> GraphQLField:
630631

631632
TContext = TypeVar("TContext") # pylint: disable=invalid-name
632633

634+
635+
class GraphQLResolveInfoHelpers(NamedTuple):
636+
"""Helpers for resolvers to interact with the execution engine.
637+
638+
The ``track`` helper registers possibly awaitable values as pending
639+
asynchronous work of the execution, so that they are still settled and
640+
their errors observed when they would otherwise be abandoned.
641+
"""
642+
643+
track: Callable[[Sequence[Any]], None]
644+
645+
633646
try:
634647

635648
class GraphQLResolveInfo(NamedTuple, Generic[TContext]): # pyright: ignore
@@ -656,6 +669,7 @@ class GraphQLResolveInfo(NamedTuple, Generic[TContext]): # pyright: ignore
656669
context: TContext
657670
is_awaitable: Callable[[Any], TypeGuard[Awaitable]]
658671
abort_signal: AbortSignal | None
672+
async_helpers: GraphQLResolveInfoHelpers
659673
except TypeError as error: # pragma: no cover
660674
if "Multiple inheritance with NamedTuple is not supported" not in str(error):
661675
raise # only catch expected error for Python 3.10
@@ -684,6 +698,7 @@ class GraphQLResolveInfo(NamedTuple): # type: ignore[no-redef]
684698
context: Any
685699
is_awaitable: Callable[[Any], TypeGuard[Awaitable]]
686700
abort_signal: AbortSignal | None
701+
async_helpers: GraphQLResolveInfoHelpers
687702

688703

689704
# Note: Contrary to the Javascript implementation of GraphQLFieldResolver,

tests/execution/test_executor.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
GraphQLNonNull,
2727
GraphQLObjectType,
2828
GraphQLResolveInfo,
29+
GraphQLResolveInfoHelpers,
2930
GraphQLScalarType,
3031
GraphQLSchema,
3132
GraphQLStreamDirective,
@@ -254,6 +255,13 @@ def resolve(_obj, info):
254255
execute_sync(schema, document, root_value, variable_values=variable_values)
255256

256257
assert len(resolved_infos) == 1
258+
async_helpers = resolved_infos[0].async_helpers
259+
assert isinstance(async_helpers, GraphQLResolveInfoHelpers)
260+
assert async_helpers._fields == ("track",)
261+
track = async_helpers.track
262+
assert callable(track)
263+
track(["not awaitable"]) # non-awaitable values are ignored
264+
257265
operation = cast("OperationDefinitionNode", document.definitions[0])
258266
assert operation
259267
assert operation.kind == "operation_definition"
@@ -285,6 +293,7 @@ def resolve(_obj, info):
285293
context=None,
286294
is_awaitable=resolved_infos[0].is_awaitable,
287295
abort_signal=None,
296+
async_helpers=async_helpers,
288297
)
289298

290299
def it_populates_path_correctly_with_complex_types():

tests/execution/test_union_interface.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from asyncio import sleep
4+
35
import pytest
46

57
from graphql.execution import ExecutionResult, execute, execute_sync
@@ -579,8 +581,7 @@ async def handles_rejections_from_is_type_of_after_an_is_type_of_returns_true():
579581
context_value = {"authToken": "123abc"}
580582

581583
result = execute(schema, document, root_value, context_value)
582-
assert not isinstance(result, ExecutionResult)
583-
result = await result
584+
# the synchronous isTypeOf match keeps the result synchronous
584585
assert isinstance(result, ExecutionResult)
585586

586587
assert result == (
@@ -592,6 +593,10 @@ async def handles_rejections_from_is_type_of_after_an_is_type_of_returns_true():
592593
None,
593594
)
594595

596+
# give the pending isTypeOf rejection a chance to settle in the background
597+
await sleep(0)
598+
await sleep(0)
599+
595600
@pytest.mark.filterwarnings("error:.*was never awaited:RuntimeWarning")
596601
async def handles_pending_is_type_of_rejections_when_a_later_one_throws_sync():
597602
throwing_searchable_interface = GraphQLInterfaceType(
@@ -656,10 +661,13 @@ def is_type_of_throwing(_value, _info) -> bool:
656661
)
657662

658663
result = execute(schema_with_throwing_is_type_of, document)
659-
assert not isinstance(result, ExecutionResult)
660-
result = await result
664+
# the synchronously throwing isTypeOf keeps the result synchronous
661665
assert isinstance(result, ExecutionResult)
662666

663667
assert result.data == {"search": None}
664668
assert result.errors
665669
assert result.errors[0].message == "TypeThrowing_isTypeOf_threw"
670+
671+
# give the pending isTypeOf rejection a chance to settle in the background
672+
await sleep(0)
673+
await sleep(0)

tests/type/test_definition.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
GraphQLObjectType,
5555
GraphQLOutputType,
5656
GraphQLResolveInfo,
57+
GraphQLResolveInfoHelpers,
5758
GraphQLScalarType,
5859
GraphQLSchema,
5960
GraphQLString,
@@ -1613,6 +1614,7 @@ class InfoArgs(TypedDict):
16131614
variable_values: VariableValues
16141615
is_awaitable: Callable[[Any], TypeGuard[Awaitable[Any]]]
16151616
abort_signal: AbortSignal | None
1617+
async_helpers: GraphQLResolveInfoHelpers
16161618

16171619
info_args: InfoArgs = {
16181620
"field_name": "foo",
@@ -1629,6 +1631,7 @@ class InfoArgs(TypedDict):
16291631
"variable_values": VariableValues({}, {}),
16301632
"is_awaitable": is_awaitable,
16311633
"abort_signal": None,
1634+
"async_helpers": GraphQLResolveInfoHelpers(track=lambda _values: None),
16321635
}
16331636

16341637
def resolve_info_with_unspecified_context_type_can_use_any_type():

0 commit comments

Comments
 (0)