Skip to content

Commit d3ab169

Browse files
committed
cancel execution on triggered abort signal despite hanging async resolvers
Replicates graphql/graphql-js@b0b8abe
1 parent 2fb5f1f commit d3ab169

2 files changed

Lines changed: 232 additions & 19 deletions

File tree

src/graphql/execution/execute.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from __future__ import annotations
44

55
from asyncio import (
6+
FIRST_COMPLETED,
7+
CancelledError,
68
TimeoutError, # only needed for Python < 3.11 # noqa: A004
79
ensure_future,
810
sleep,
11+
wait,
912
)
1013
from collections.abc import (
1114
AsyncGenerator,
@@ -927,6 +930,40 @@ def complete_value(
927930
) # pragma: no cover
928931
raise TypeError(msg) # pragma: no cover
929932

933+
async def with_abort_signal(self, awaitable: Awaitable[T]) -> T:
934+
"""Await a value, but cancel immediately if the abort signal is triggered.
935+
936+
This wraps awaitables returned by resolvers (and awaitable list items) so
937+
that a triggered abort signal interrupts execution *immediately* instead of
938+
only at the next field boundary. Without this, a hanging asynchronous
939+
resolver would prevent the operation from ever being cancelled.
940+
941+
If the abort signal fires before the awaitable settles, the underlying
942+
awaitable is cancelled and the abort reason is raised (an exception reason
943+
is raised as is, any other value is reported as an unexpected error value).
944+
"""
945+
abort_signal = self.abort_signal
946+
if abort_signal is None:
947+
return await awaitable
948+
task = ensure_future(awaitable)
949+
if not abort_signal.aborted:
950+
abort = ensure_future(abort_signal.wait())
951+
try:
952+
await wait({task, abort}, return_when=FIRST_COMPLETED)
953+
finally:
954+
if not abort.done():
955+
abort.cancel()
956+
if task.done():
957+
return task.result()
958+
task.cancel()
959+
with suppress(CancelledError):
960+
await task
961+
reason = abort_signal.reason
962+
if isinstance(reason, Exception):
963+
raise reason
964+
msg = f"Unexpected error value: {inspect(reason)}"
965+
raise TypeError(msg)
966+
930967
async def complete_awaitable_value(
931968
self,
932969
return_type: GraphQLOutputType,
@@ -939,7 +976,7 @@ async def complete_awaitable_value(
939976
) -> GraphQLWrappedResult[Any]:
940977
"""Complete an awaitable value."""
941978
try:
942-
resolved = await result
979+
resolved = await self.with_abort_signal(result)
943980
completed = self.complete_value(
944981
return_type,
945982
field_details_list,
@@ -1363,7 +1400,7 @@ async def complete_awaitable_list_item_value(
13631400
) -> Any:
13641401
"""Complete an awaitable list item value."""
13651402
try:
1366-
resolved = await item
1403+
resolved = await self.with_abort_signal(item)
13671404
completed = self.complete_value(
13681405
item_type,
13691406
field_details_list,
@@ -1527,14 +1564,6 @@ def complete_object_value(
15271564
defer_map: RefMap[DeferUsage, DeferredFragmentRecord] | None,
15281565
) -> AwaitableOrValue[GraphQLWrappedResult[dict[str, Any]]]:
15291566
"""Complete an Object value by executing all sub-selections."""
1530-
abort_signal = self.abort_signal
1531-
if abort_signal is not None and abort_signal.aborted:
1532-
raise located_error(
1533-
abort_signal.reason,
1534-
to_nodes(field_details_list),
1535-
path.as_list(),
1536-
)
1537-
15381567
# If there is an `is_type_of()` predicate function, call it with the current
15391568
# result. If `is_type_of()` returns False, then raise an error rather than
15401569
# continuing execution.
@@ -2845,7 +2874,7 @@ def execute_subscription(
28452874

28462875
async def await_result() -> AsyncIterable[Any]:
28472876
try:
2848-
return assert_event_stream(await result)
2877+
return assert_event_stream(await context.with_abort_signal(result))
28492878
except Exception as error:
28502879
raise located_error(error, field_nodes, path.as_list()) from error
28512880

tests/execution/test_abort_signal.py

Lines changed: 192 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
from __future__ import annotations
44

5-
from asyncio import ensure_future, sleep
5+
from asyncio import Event, Future, ensure_future, sleep
66
from collections.abc import Awaitable
77

88
import pytest
99

1010
from graphql import build_schema
11-
from graphql.execution import execute
11+
from graphql.execution import execute, subscribe
1212
from graphql.language import parse
1313
from graphql.pyutils import AbortController, AbortError
1414

@@ -19,7 +19,7 @@
1919
"""
2020
type Todo {
2121
id: ID
22-
text: String
22+
items: [String]
2323
author: User
2424
}
2525
@@ -30,12 +30,17 @@
3030
3131
type Query {
3232
todo: Todo
33+
nonNullableTodo: Todo!
3334
}
3435
3536
type Mutation {
3637
foo: String
3738
bar: String
3839
}
40+
41+
type Subscription {
42+
foo: String
43+
}
3944
"""
4045
)
4146

@@ -97,7 +102,6 @@ async def stops_the_execution_when_aborted_during_object_field_completion():
97102
async def todo(_info):
98103
return {
99104
"id": "1",
100-
"text": "Hello, World!",
101105
"author": must_not_be_called,
102106
}
103107

@@ -190,7 +194,6 @@ async def stops_the_execution_when_aborted_during_completion_with_custom_error()
190194
async def todo(_info):
191195
return {
192196
"id": "1",
193-
"text": "Hello, World!",
194197
"author": must_not_be_called,
195198
}
196199

@@ -238,7 +241,6 @@ async def stops_the_execution_when_aborted_during_completion_with_custom_string(
238241
async def todo(_info):
239242
return {
240243
"id": "1",
241-
"text": "Hello, World!",
242244
"author": must_not_be_called,
243245
}
244246

@@ -290,7 +292,6 @@ async def author(_info):
290292
root_value={
291293
"todo": {
292294
"id": "1",
293-
"text": "Hello, World!",
294295
"author": author,
295296
}
296297
},
@@ -312,6 +313,146 @@ async def author(_info):
312313
],
313314
)
314315

316+
async def stops_the_execution_when_aborted_despite_a_hanging_resolver():
317+
abort_controller = AbortController()
318+
document = parse(
319+
"""
320+
query {
321+
todo {
322+
id
323+
author {
324+
id
325+
}
326+
}
327+
}
328+
"""
329+
)
330+
331+
started = Event()
332+
333+
async def todo(_info):
334+
started.set()
335+
await Future() # will never resolve
336+
337+
awaitable_result = execute(
338+
schema,
339+
document,
340+
abort_signal=abort_controller.signal,
341+
root_value={"todo": todo},
342+
)
343+
assert isinstance(awaitable_result, Awaitable)
344+
345+
# Abort only once the resolver is actually in flight, so that cancellation
346+
# must interrupt the hanging resolver instead of being caught up front.
347+
task = ensure_future(awaitable_result)
348+
await started.wait()
349+
abort_controller.abort()
350+
351+
result = await task
352+
353+
assert result.errors is not None
354+
assert isinstance(result.errors[0].original_error, AbortError)
355+
assert result == (
356+
{"todo": None},
357+
[
358+
{
359+
"message": "This operation was aborted",
360+
"locations": [(3, 9)],
361+
"path": ["todo"],
362+
}
363+
],
364+
)
365+
366+
async def stops_the_execution_when_aborted_despite_a_hanging_item():
367+
abort_controller = AbortController()
368+
document = parse(
369+
"""
370+
query {
371+
todo {
372+
id
373+
items
374+
}
375+
}
376+
"""
377+
)
378+
379+
def todo(_info):
380+
return {
381+
"id": "1",
382+
"items": [Future()], # will never resolve
383+
}
384+
385+
awaitable_result = execute(
386+
schema,
387+
document,
388+
abort_signal=abort_controller.signal,
389+
root_value={"todo": todo},
390+
)
391+
assert isinstance(awaitable_result, Awaitable)
392+
393+
abort_controller.abort()
394+
395+
result = await awaitable_result
396+
397+
assert result.errors is not None
398+
assert isinstance(result.errors[0].original_error, AbortError)
399+
assert result == (
400+
{"todo": {"id": "1", "items": [None]}},
401+
[
402+
{
403+
"message": "This operation was aborted",
404+
"locations": [(5, 11)],
405+
"path": ["todo", "items", 0],
406+
}
407+
],
408+
)
409+
410+
async def stops_the_execution_when_aborted_with_proper_null_bubbling():
411+
abort_controller = AbortController()
412+
document = parse(
413+
"""
414+
query {
415+
nonNullableTodo {
416+
id
417+
author {
418+
id
419+
}
420+
}
421+
}
422+
"""
423+
)
424+
425+
async def non_nullable_todo(_info):
426+
return {
427+
"id": "1",
428+
"author": must_not_be_called,
429+
}
430+
431+
awaitable_result = execute(
432+
schema,
433+
document,
434+
abort_signal=abort_controller.signal,
435+
root_value={"nonNullableTodo": non_nullable_todo},
436+
)
437+
assert isinstance(awaitable_result, Awaitable)
438+
439+
abort_controller.abort()
440+
441+
result = await awaitable_result
442+
443+
assert result.errors is not None
444+
assert isinstance(result.errors[0].original_error, AbortError)
445+
assert result == (
446+
None,
447+
[
448+
{
449+
"message": "This operation was aborted",
450+
"locations": [(3, 9)],
451+
"path": ["nonNullableTodo"],
452+
}
453+
],
454+
)
455+
315456
async def stops_the_execution_when_aborted_mid_mutation():
316457
abort_controller = AbortController()
317458
document = parse(
@@ -334,9 +475,16 @@ async def foo(_info):
334475
)
335476
assert isinstance(awaitable_result, Awaitable)
336477

478+
# Let the first field resolve before aborting, so that the abort is only
479+
# observed when serially moving on to the second field (mirrors the
480+
# ``resolveOnNextTick`` calls in the GraphQL.js test).
481+
task = ensure_future(awaitable_result)
482+
for _ in range(3):
483+
await sleep(0)
484+
337485
abort_controller.abort()
338486

339-
result = await awaitable_result
487+
result = await task
340488

341489
assert result == (
342490
{"foo": "baz", "bar": None},
@@ -373,3 +521,39 @@ async def stops_the_execution_when_aborted_pre_execute():
373521
)
374522

375523
assert result == (None, [{"message": "This operation was aborted"}])
524+
525+
async def stops_the_execution_when_aborted_during_subscription():
526+
abort_controller = AbortController()
527+
document = parse(
528+
"""
529+
subscription {
530+
foo
531+
}
532+
"""
533+
)
534+
535+
def foo(_info):
536+
return Future() # will never resolve
537+
538+
awaitable_result = subscribe(
539+
schema,
540+
document,
541+
abort_signal=abort_controller.signal,
542+
root_value={"foo": foo},
543+
)
544+
assert isinstance(awaitable_result, Awaitable)
545+
546+
abort_controller.abort()
547+
548+
result = await awaitable_result
549+
550+
assert result == (
551+
None,
552+
[
553+
{
554+
"message": "This operation was aborted",
555+
"locations": [(3, 9)],
556+
"path": ["foo"],
557+
}
558+
],
559+
)

0 commit comments

Comments
 (0)