Skip to content

Commit

Permalink
Allow background tasks a grace period to complete during shutdown
Browse files Browse the repository at this point in the history
This can be user configured by the BACKGROUND_TASK_SHUTDOWN_TIMEOUT
configuration value.
  • Loading branch information
pgjones committed Sep 10, 2023
1 parent 8b5804e commit 4e7d98d
Show file tree
Hide file tree
Showing 4 changed files with 35 additions and 24 deletions.
7 changes: 5 additions & 2 deletions docs/how_to_guides/background_tasks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ method:
The background tasks will have access to the app context. The tasks
will be awaited during shutdown to ensure they complete before the app
shuts down. If your task does not complete it will eventually be
cancelled as the app is forceably shut down by the server.
shuts down. If your task does not complete within the config
``BACKGROUND_TASK_SHUTDOWN_TIMEOUT`` it will be cancelled.

Note ``BACKGROUND_TASK_SHUTDOWN_TIMEOUT`` should ideally be less than
any server shutdown timeout.

Synchronous background tasks are supported and will run in a separate
thread.
Expand Down
10 changes: 9 additions & 1 deletion src/quart/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
WhileServingCallable,
)
from .utils import (
cancel_tasks,
file_path_to_path,
is_coroutine_function,
MustReloadError,
Expand Down Expand Up @@ -215,6 +216,7 @@ class Quart(App):
default_config = ImmutableDict(
{
"APPLICATION_ROOT": None,
"BACKGROUND_TASK_SHUTDOWN_TIMEOUT": 5, # Second
"BODY_TIMEOUT": 60, # Second
"DEBUG": None,
"ENV": None,
Expand Down Expand Up @@ -1611,7 +1613,13 @@ async def startup(self) -> None:
raise

async def shutdown(self) -> None:
await asyncio.gather(*self.background_tasks)
try:
await asyncio.wait_for(
asyncio.gather(*self.background_tasks),
timeout=self.config["BACKGROUND_TASK_SHUTDOWN_TIMEOUT"],
)
except asyncio.TimeoutError:
await cancel_tasks(self.background_tasks) # type: ignore

try:
async with self.app_context():
Expand Down
26 changes: 5 additions & 21 deletions src/quart/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from .debug import traceback_response
from .signals import websocket_received, websocket_sent
from .typing import ResponseTypes
from .utils import encode_headers
from .utils import cancel_tasks, encode_headers, raise_task_exceptions
from .wrappers import Request, Response, Websocket # noqa: F401

if TYPE_CHECKING:
Expand All @@ -48,8 +48,8 @@ async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -
done, pending = await asyncio.wait(
[handler_task, receiver_task], return_when=asyncio.FIRST_COMPLETED
)
await _cancel_tasks(pending)
_raise_exceptions(done)
await cancel_tasks(pending)
raise_task_exceptions(done)

async def handle_messages(self, request: Request, receive: ASGIReceiveCallable) -> None:
while True:
Expand Down Expand Up @@ -162,8 +162,8 @@ async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -
done, pending = await asyncio.wait(
[handler_task, receiver_task], return_when=asyncio.FIRST_COMPLETED
)
await _cancel_tasks(pending)
_raise_exceptions(done)
await cancel_tasks(pending)
raise_task_exceptions(done)

async def handle_messages(self, receive: ASGIReceiveCallable) -> None:
while True:
Expand Down Expand Up @@ -332,22 +332,6 @@ async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -
break


async def _cancel_tasks(tasks: set[asyncio.Task]) -> None:
# Cancel any pending, and wait for the cancellation to
# complete i.e. finish any remaining work.
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
_raise_exceptions(tasks)


def _raise_exceptions(tasks: set[asyncio.Task]) -> None:
# Raise any unexpected exceptions
for task in tasks:
if not task.cancelled() and task.exception() is not None:
raise task.exception()


def _convert_version(raw: str) -> list[int]:
return list(map(int, raw.split(".")))

Expand Down
16 changes: 16 additions & 0 deletions src/quart/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,19 @@ def restart() -> None:
args[:0] = ["-m", import_name.lstrip(".")]

os.execv(executable, [executable] + args)


async def cancel_tasks(tasks: set[asyncio.Task]) -> None:
# Cancel any pending, and wait for the cancellation to
# complete i.e. finish any remaining work.
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise_task_exceptions(tasks)


def raise_task_exceptions(tasks: set[asyncio.Task]) -> None:
# Raise any unexpected exceptions
for task in tasks:
if not task.cancelled() and task.exception() is not None:
raise task.exception()

0 comments on commit 4e7d98d

Please sign in to comment.