Skip to content

Commit d8a96ac

Browse files
committed
fix(incremental): await async incremental cleanup
Asyncio realization of the JS queue/computation cancellation rework: stopping the incremental publisher cancels all pending incremental work and awaits its settlement, stream sources are closed lazily and only on abnormal stop, aborting rejects pending subsequent results with the abort reason after the cleanup has settled, and concurrent consumer cancellation is realized by cancelling the task that awaits the pending anext(). Replicates graphql/graphql-js@3ea5c10. graphql/graphql-js@87e2929 graphql/graphql-js@ce68f97 graphql/graphql-js@06e5bd0 graphql/graphql-js@41a671c
1 parent b933887 commit d8a96ac

7 files changed

Lines changed: 1041 additions & 41 deletions

File tree

src/graphql/execution/execute.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from asyncio import (
66
FIRST_COMPLETED,
7+
Future,
78
TimeoutError, # only needed for Python < 3.11 # noqa: A004
89
ensure_future,
910
gather,
@@ -217,6 +218,7 @@ class Executor(IncrementalPublisherContext):
217218
abort_signal: AbortSignal | None
218219
errors: list[GraphQLError] | None
219220
cancellable_streams: set[CancellableStreamRecord] | None
221+
pending_incremental_futures: set[Future[Any]]
220222
middleware_manager: MiddlewareManager | None
221223
error_propagation: bool
222224

@@ -271,6 +273,7 @@ def __init__( # noqa: PLR0913
271273
self.is_async_iterable = is_async_iterable or default_is_async_iterable
272274
self.errors = None
273275
self.cancellable_streams = None
276+
self.pending_incremental_futures = set()
274277
self._relevant_sub_fields: dict[tuple, CollectedFields] = {}
275278
self._stream_usages: RefMap[FieldDetailsList, StreamUsage] = RefMap()
276279
self._execution_plans: RefMap[GroupedFieldSet, ExecutionPlan] = RefMap()
@@ -469,6 +472,11 @@ async def await_result() -> (
469472
resolved = await self.with_abort_signal(graphql_wrapped_result)
470473
except GraphQLError as error:
471474
return ExecutionResult(None, with_error(self.errors, error))
475+
except Exception:
476+
# cancel incremental work started early and close the
477+
# stream sources before re-raising, e.g. the abort reason
478+
await self.cancel_incremental_work()
479+
raise
472480
return self.build_data_response(
473481
resolved.result, resolved.increments
474482
)
@@ -975,6 +983,50 @@ def abort_error(self) -> Exception:
975983
msg = f"Unexpected error value: {inspect(reason)}"
976984
return TypeError(msg)
977985

986+
def box_incremental_result(
987+
self, result: AwaitableOrValue[T]
988+
) -> BoxedAwaitableOrValue[T]:
989+
"""Box a possibly awaitable incremental result.
990+
991+
A pending result is registered so that it can be cancelled when the
992+
incremental execution is stopped before it has settled.
993+
"""
994+
boxed = BoxedAwaitableOrValue(result)
995+
future = boxed.pending_future
996+
if future is not None:
997+
futures = self.pending_incremental_futures
998+
futures.add(future)
999+
future.add_done_callback(futures.discard)
1000+
return boxed
1001+
1002+
async def cancel_incremental_work(self) -> None:
1003+
"""Cancel all pending incremental work and close the stream sources.
1004+
1005+
Cancels the still pending incremental execution tasks first and waits for
1006+
their cancellation to settle, so that no early execution continues and no
1007+
iteration is pending on the stream sources any more, then triggers and
1008+
awaits the early return of all remaining cancellable streams.
1009+
"""
1010+
futures = self.pending_incremental_futures
1011+
if futures:
1012+
pending = list(futures)
1013+
for future in pending:
1014+
future.cancel()
1015+
await gather(*pending, return_exceptions=True)
1016+
cancellable_streams = self.cancellable_streams
1017+
if cancellable_streams:
1018+
early_returns = [
1019+
early_return
1020+
for early_return in (
1021+
stream_record.early_return()
1022+
for stream_record in cancellable_streams
1023+
)
1024+
if default_is_awaitable(early_return)
1025+
]
1026+
cancellable_streams.clear()
1027+
if early_returns:
1028+
await gather(*early_returns, return_exceptions=True)
1029+
9781030
def cancellable_iterable(self, iterable: AsyncIterable[T]) -> AsyncIterable[T]:
9791031
"""Wrap an async iterable so pending iteration is cancelled on abort.
9801032
@@ -1135,7 +1187,7 @@ async def complete_async_iterator_value(
11351187
)
11361188
else:
11371189
stream_record = CancellableStreamRecord(
1138-
early_return(),
1190+
early_return,
11391191
stream_item_queue,
11401192
path,
11411193
stream_usage.label,
@@ -1840,17 +1892,17 @@ async def execute_async(
18401892
return await result
18411893
return result # type: ignore
18421894

1843-
pending_group.result = BoxedAwaitableOrValue(execute_async())
1895+
pending_group.result = self.box_incremental_result(execute_async())
18441896
else:
1845-
pending_group.result = BoxedAwaitableOrValue(executor())
1897+
pending_group.result = self.box_incremental_result(executor())
18461898
else:
18471899

18481900
def execute_sync(
18491901
executor: Callable[
18501902
[], AwaitableOrValue[CompletedExecutionGroup]
18511903
] = executor,
18521904
) -> BoxedAwaitableOrValue[CompletedExecutionGroup]:
1853-
return BoxedAwaitableOrValue(executor())
1905+
return self.box_incremental_result(executor())
18541906

18551907
pending_group.result = execute_sync
18561908

@@ -1934,7 +1986,7 @@ def first_executor() -> StreamItemResult:
19341986
initial_path = stream_path.add_key(initial_index)
19351987

19361988
first_stream_item: BoxedAwaitableOrValue[StreamItemResult] = (
1937-
BoxedAwaitableOrValue(
1989+
self.box_incremental_result(
19381990
complete_stream_item(
19391991
initial_path,
19401992
initial_item,
@@ -1972,9 +2024,9 @@ def current_executor(
19722024
)
19732025

19742026
current_stream_item = (
1975-
BoxedAwaitableOrValue(current_executor())
2027+
self.box_incremental_result(current_executor())
19762028
if enable_early_execution
1977-
else lambda executor=current_executor: BoxedAwaitableOrValue(
2029+
else lambda executor=current_executor: self.box_incremental_result(
19782030
executor()
19792031
)
19802032
)
@@ -1992,9 +2044,9 @@ def current_executor(
19922044
async def await_first_stream_item() -> StreamItemResult:
19932045
return first_executor()
19942046

1995-
append_stream_item(BoxedAwaitableOrValue(await_first_stream_item()))
2047+
append_stream_item(self.box_incremental_result(await_first_stream_item()))
19962048
else:
1997-
append_stream_item(lambda: BoxedAwaitableOrValue(first_executor()))
2049+
append_stream_item(lambda: self.box_incremental_result(first_executor()))
19982050

19992051
return stream_item_queue
20002052

@@ -2022,9 +2074,9 @@ def executor() -> AwaitableOrValue[StreamItemResult]:
20222074

20232075
stream_item_queue: list[StreamItemRecord] = []
20242076
stream_item_queue.append(
2025-
BoxedAwaitableOrValue(executor())
2077+
self.box_incremental_result(executor())
20262078
if self.enable_early_execution
2027-
else lambda: BoxedAwaitableOrValue(executor())
2079+
else lambda: self.box_incremental_result(executor())
20282080
)
20292081

20302082
return stream_item_queue
@@ -2076,9 +2128,9 @@ def executor() -> AwaitableOrValue[StreamItemResult]:
20762128
)
20772129

20782130
stream_item_queue.append(
2079-
BoxedAwaitableOrValue(executor())
2131+
self.box_incremental_result(executor())
20802132
if self.enable_early_execution
2081-
else lambda: BoxedAwaitableOrValue(executor())
2133+
else lambda: self.box_incremental_result(executor())
20822134
)
20832135

20842136
if self.is_awaitable(result):

src/graphql/execution/incremental_graph.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Future,
77
Task,
88
ensure_future,
9+
gather,
910
get_running_loop,
1011
isfuture,
1112
sleep,
@@ -108,7 +109,8 @@ def next_completed_batch(
108109
def abort(self) -> None:
109110
"""Abort the incremental graph execution."""
110111
for resolve in self._next_queue:
111-
resolve.set_result(None) # pragma: no cover
112+
if not resolve.cancelled(): # pragma: no cover
113+
resolve.set_result(None)
112114

113115
def has_next(self) -> bool:
114116
"""Check if there are more results to process."""
@@ -161,10 +163,20 @@ def remove_stream(self, stream_record: StreamRecord) -> None:
161163
"""Remove a stream record as no longer pending."""
162164
del self._root_nodes[stream_record]
163165

164-
def stop_incremental_data(self) -> None:
165-
"""Stop the delivery of incremental data."""
166+
async def stop_incremental_data(self) -> None:
167+
"""Stop the delivery and execution of incremental data.
168+
169+
Cancels all pending requests for the next completed batch and all still
170+
running incremental execution tasks, waiting until their cancellation
171+
has settled.
172+
"""
166173
for future in self._next_queue:
167-
future.cancel() # pragma: no cover
174+
future.cancel()
175+
tasks = list(self._tasks)
176+
if tasks:
177+
for task in tasks:
178+
task.cancel()
179+
await gather(*tasks, return_exceptions=True)
168180

169181
def _add_incremental_data_records(
170182
self,
@@ -321,12 +333,14 @@ async def _on_stream_items(self, stream_record: StreamRecord) -> None:
321333
def _enqueue(self, completed: IncrementalDataRecordResult) -> None:
322334
"""Enqueue completed incremental data record result."""
323335
self._completed_queue.append(completed)
324-
try:
325-
future = self._next_queue.pop(0)
326-
except IndexError:
327-
pass
328-
else:
336+
next_queue = self._next_queue
337+
while next_queue:
338+
future = next_queue.pop(0)
339+
if future.cancelled(): # pragma: no cover
340+
# defensive guard against a race with a stopping consumer
341+
continue
329342
future.set_result(self.current_completed_batch())
343+
break
330344

331345
def _add_task(self, awaitable: Awaitable[Any]) -> None:
332346
"""Add the given task to the tasks set for later execution."""

src/graphql/execution/incremental_publisher.py

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

33
from __future__ import annotations
44

5-
from asyncio import gather, sleep
5+
from asyncio import FIRST_COMPLETED, ensure_future, sleep, wait
66
from contextlib import suppress
77
from typing import (
88
TYPE_CHECKING,
@@ -12,6 +12,7 @@
1212
cast,
1313
)
1414

15+
from ..pyutils import is_awaitable
1516
from .incremental_graph import IncrementalGraph
1617
from .types import (
1718
CompletedResult,
@@ -30,6 +31,7 @@
3031
from collections.abc import AsyncGenerator, Iterable, Sequence
3132

3233
from ..error import GraphQLError
34+
from ..pyutils import AbortSignal
3335
from .types import (
3436
CancellableStreamRecord,
3537
CompletedExecutionGroup,
@@ -54,8 +56,17 @@
5456
class IncrementalPublisherContext(Protocol):
5557
"""The context for incremental publishing."""
5658

59+
abort_signal: AbortSignal | None
5760
cancellable_streams: set[CancellableStreamRecord] | None
5861

62+
def abort_error(self) -> Exception:
63+
"""Return the exception to raise when execution has been aborted."""
64+
... # pragma: no cover
65+
66+
async def cancel_incremental_work(self) -> None:
67+
"""Cancel all pending incremental work and close the stream sources."""
68+
... # pragma: no cover
69+
5970

6071
class SubsequentIncrementalExecutionResultContext(NamedTuple):
6172
"""The context for subsequent incremental execution results."""
@@ -134,9 +145,13 @@ async def _subscribe(
134145
incremental_graph = self._incremental_graph
135146
check_has_next = incremental_graph.has_next
136147
handle_completed_incremental_data = self._handle_completed_incremental_data
148+
abort_signal = self._context.abort_signal
137149

138150
try:
139151
while True:
152+
if abort_signal is not None and abort_signal.aborted:
153+
raise self._context.abort_error()
154+
140155
batch: Iterable[IncrementalDataRecordResult] | None = (
141156
incremental_graph.current_completed_batch()
142157
)
@@ -160,21 +175,29 @@ async def _subscribe(
160175

161176
if not has_next:
162177
return
163-
batch = await incremental_graph.next_completed_batch()
178+
179+
next_batch = incremental_graph.next_completed_batch()
180+
if abort_signal is None:
181+
batch = await next_batch
182+
else:
183+
# reject the pending request when the operation is aborted
184+
abort = ensure_future(abort_signal.wait())
185+
try:
186+
await wait({next_batch, abort}, return_when=FIRST_COMPLETED)
187+
finally:
188+
if not abort.done():
189+
abort.cancel()
190+
if abort_signal.aborted:
191+
next_batch.cancel()
192+
raise self._context.abort_error()
193+
batch = next_batch.result()
164194
finally:
165195
await self._stop_async_iterators()
166196

167197
async def _stop_async_iterators(self) -> None:
168-
"""Finish all async iterators."""
169-
self._incremental_graph.stop_incremental_data()
170-
cancellable_streams = self._context.cancellable_streams
171-
if cancellable_streams is None:
172-
return
173-
early_returns = [
174-
stream_record.early_return for stream_record in cancellable_streams
175-
]
176-
if early_returns:
177-
await gather(*early_returns, return_exceptions=True)
198+
"""Stop the incremental execution and finish all async iterators."""
199+
await self._incremental_graph.stop_incremental_data()
200+
await self._context.cancel_incremental_work()
178201

179202
async def _handle_completed_incremental_data(
180203
self,
@@ -267,7 +290,9 @@ async def _handle_completed_stream_items(
267290
if cancellable_streams: # pragma: no branch
268291
cancellable_streams.discard(stream_record)
269292
with suppress(Exception):
270-
await stream_record.early_return
293+
early_return = stream_record.early_return()
294+
if is_awaitable(early_return): # pragma: no branch
295+
await early_return
271296
elif stream_items_result.result is None:
272297
context.completed.append(CompletedResult(id_))
273298
incremental_graph.remove_stream(stream_record)

src/graphql/execution/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -891,13 +891,13 @@ def __repr__(self) -> str:
891891
class CancellableStreamRecord(StreamRecord):
892892
"""Cancellable stream record"""
893893

894-
early_return: Awaitable[None]
894+
early_return: Callable[[], Awaitable[None]]
895895

896896
__slots__ = ("early_return",)
897897

898898
def __init__(
899899
self,
900-
early_return: Awaitable[None],
900+
early_return: Callable[[], Awaitable[None]],
901901
stream_item_queue: list[StreamItemRecord],
902902
path: Path,
903903
label: str | None = None,

src/graphql/pyutils/boxed_awaitable_or_value.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ def value(self) -> T:
4343
self._value = value = value.result()
4444
return value # type: ignore
4545

46+
@property
47+
def pending_future(self) -> Future[T] | None:
48+
"""Get the still pending Future, or None if the value is already settled."""
49+
value = self._value
50+
if isfuture(value) and not value.done():
51+
return value
52+
return None
53+
4654
def _update_value(self, value: Future[T]) -> None:
4755
"""Update the boxed value when the Awaitable is done."""
4856
with suppress(CancelledError):

0 commit comments

Comments
 (0)