add
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
# testing/cancellation.py
|
||||
# Copyright (C) 2005-2026 the SQLAlchemy authors and contributors
|
||||
# <see AUTHORS file>
|
||||
#
|
||||
# This module is part of SQLAlchemy and is released under
|
||||
# the MIT License: https://www.opensource.org/licenses/mit-license.php
|
||||
# mypy: ignore-errors
|
||||
|
||||
"""Deterministic cancellation testing for the asyncio dialects.
|
||||
|
||||
An asyncio task may be cancelled at any ``await``, and reproducing a
|
||||
specific one of those points by racing ``task.cancel()`` against real
|
||||
network traffic is unreliable. Here the choice is made exact.
|
||||
:func:`.greenlet_spawn` drives every await performed by an asyncio-driven
|
||||
SQLAlchemy operation, ``connect()`` included, so instrumenting the greenlet
|
||||
it switches into gives a stable enumeration of a scenario's IO points, and
|
||||
cancelling at point ``N`` is reproducible run to run.
|
||||
|
||||
The cancellation is delivered to the task the application is running,
|
||||
which is not necessarily the task performing the await; SQLAlchemy runs
|
||||
some operations, such as the ``close()`` performed by an async context
|
||||
manager on exit, in a task of its own.
|
||||
|
||||
Two things are asserted afterwards:
|
||||
|
||||
* Every DBAPI connection the pool creates is eventually closed through
|
||||
``Pool._close_connection()``. A connection that is never closed is
|
||||
leaked; for an asyncio driver the garbage collector cannot close it
|
||||
either, since closing requires the event loop.
|
||||
|
||||
* Once the application's task has finished and garbage has been
|
||||
collected, no pool slot is still checked out. A slot still checked out
|
||||
at that point is lost: either nothing remains that could return it, or
|
||||
it is being returned by a task SQLAlchemy left running in the
|
||||
background, which the application has no way to wait for. In the
|
||||
latter case, an application that disposes of its engine at that point
|
||||
has the connection returned into the disposed pool afterwards, where
|
||||
nothing closes it.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import gc
|
||||
from unittest import mock
|
||||
|
||||
from .. import exc
|
||||
from ..util import concurrency
|
||||
|
||||
|
||||
class _InstrumentedAwaitable:
|
||||
"""An awaitable that invokes ``hook`` when it is awaited."""
|
||||
|
||||
__slots__ = ("awaitable", "hook")
|
||||
|
||||
def __init__(self, awaitable, hook):
|
||||
self.awaitable = awaitable
|
||||
self.hook = hook
|
||||
|
||||
def __await__(self):
|
||||
self.hook(self.awaitable)
|
||||
return self.awaitable.__await__()
|
||||
|
||||
|
||||
class AwaitPoints:
|
||||
"""Counts await points, and cancels the running task at each of the
|
||||
ones given.
|
||||
|
||||
``cancel_at`` is a tuple of await point numbers; each one is a separate
|
||||
``task.cancel()`` call. asyncio delivers a given cancellation exactly
|
||||
once, so a second number models a second, independent canceller
|
||||
reaching a task that is still cleaning up after the first.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, cancel_at=()):
|
||||
self._count = 0
|
||||
self._cancel_at = cancel_at
|
||||
self._cancelled_at = []
|
||||
self._task = None
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
"""The number of await points counted so far."""
|
||||
|
||||
return self._count
|
||||
|
||||
@property
|
||||
def cancelled_at(self):
|
||||
"""Descriptions of the awaitables at which a cancellation was
|
||||
delivered."""
|
||||
|
||||
return tuple(self._cancelled_at)
|
||||
|
||||
def create_task(self, coro):
|
||||
"""Run ``coro`` in a new task, which is the task that cancellations
|
||||
are delivered to."""
|
||||
|
||||
self._task = asyncio.create_task(coro)
|
||||
return self._task
|
||||
|
||||
def __call__(self, awaitable):
|
||||
self._count += 1
|
||||
if self._count not in self._cancel_at:
|
||||
return
|
||||
|
||||
self._cancelled_at.append(
|
||||
getattr(awaitable, "__qualname__", repr(awaitable))
|
||||
)
|
||||
|
||||
# cancel the task the application is running, which is not
|
||||
# necessarily the one performing this await; SQLAlchemy runs some
|
||||
# operations, such as the close() performed by an async context
|
||||
# manager on exit, in a task of their own
|
||||
task = self._task if self._task is not None else asyncio.current_task()
|
||||
assert task is not None, (
|
||||
"cancellation can only be delivered to an operation running "
|
||||
"within an asyncio task"
|
||||
)
|
||||
|
||||
# cancel the task rather than raise CancelledError from here: the
|
||||
# awaitable then still runs, and asyncio interrupts it at its own
|
||||
# first suspension point, as it does in production
|
||||
task.cancel()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def await_points(cancel_at=()):
|
||||
"""Count the await points performed within the block, cancelling the
|
||||
running task at each of ``cancel_at``."""
|
||||
|
||||
points = AwaitPoints(cancel_at)
|
||||
greenlet_cls = concurrency._concurrency_shim._AsyncIoGreenlet
|
||||
real_switch = greenlet_cls.switch
|
||||
real_throw = greenlet_cls.throw
|
||||
|
||||
def instrument(greenlet, result):
|
||||
# a greenlet that has ended hands back the return value of the
|
||||
# function it ran, which nothing awaits
|
||||
if greenlet.dead:
|
||||
return result
|
||||
return _InstrumentedAwaitable(result, points)
|
||||
|
||||
def switch(self, *arg, **kw):
|
||||
return instrument(self, real_switch(self, *arg, **kw))
|
||||
|
||||
def throw(self, *arg):
|
||||
return instrument(self, real_throw(self, *arg))
|
||||
|
||||
with (
|
||||
mock.patch.object(greenlet_cls, "switch", switch),
|
||||
mock.patch.object(greenlet_cls, "throw", throw),
|
||||
):
|
||||
yield points
|
||||
|
||||
|
||||
class ConnectionAccounting:
|
||||
"""Records which DBAPI connections a pool created and which it managed
|
||||
to close, and how many of the pool's slots were lost.
|
||||
|
||||
Creation is observed at ``Pool._invoke_creator`` rather than through the
|
||||
``connect`` pool event, because the ``connect`` event is itself where
|
||||
``dialect.initialize()`` runs; a listener added there does not fire for
|
||||
precisely the connections that a failure in that event strands.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, pool):
|
||||
self._pool = pool
|
||||
self._real_invoke_creator = pool._invoke_creator
|
||||
self._real_close_connection = pool._close_connection
|
||||
self._lost_slots = 0
|
||||
self._created = set()
|
||||
self._closed = set()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def start(self):
|
||||
"""Account for the pool's connections within the block."""
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
self._pool, "_invoke_creator", self._invoke_creator
|
||||
),
|
||||
mock.patch.object(
|
||||
self._pool, "_close_connection", self._close_connection
|
||||
),
|
||||
):
|
||||
yield self
|
||||
|
||||
def _invoke_creator(self, rec):
|
||||
dbapi_connection = self._real_invoke_creator(rec)
|
||||
self._created.add(dbapi_connection)
|
||||
return dbapi_connection
|
||||
|
||||
def _close_connection(self, dbapi_connection, *, terminate=False):
|
||||
try:
|
||||
self._real_close_connection(dbapi_connection, terminate=terminate)
|
||||
except asyncio.CancelledError:
|
||||
# AsyncAdapt_terminate force-closes the connection before
|
||||
# letting a cancellation of its graceful close propagate
|
||||
if terminate and self._pool._dialect.has_terminate:
|
||||
self._closed.add(dbapi_connection)
|
||||
raise
|
||||
self._closed.add(dbapi_connection)
|
||||
|
||||
@property
|
||||
def leaked(self):
|
||||
"""Connections that were created but never successfully closed."""
|
||||
|
||||
return self._created - self._closed
|
||||
|
||||
def record_lost_slots(self):
|
||||
"""Record the pool slots still checked out.
|
||||
|
||||
To be called once the application's task is done, and before the
|
||||
engine is disposed. Every :class:`._ConnectionFairy` the task made
|
||||
is gone once garbage is collected, and :func:`._finalize_fairy` has
|
||||
returned its record; a record still checked out after that is lost.
|
||||
|
||||
"""
|
||||
gc.collect()
|
||||
self._lost_slots = self._pool.checkedout()
|
||||
|
||||
def get_failure_messages(self):
|
||||
"""Return a list of strings describing each leaked connection and
|
||||
lost pool slot, empty if there were none."""
|
||||
|
||||
failures = []
|
||||
if self.leaked:
|
||||
failures.append(
|
||||
f"{len(self.leaked)} of {len(self._created)} connection(s) "
|
||||
"were left open with nothing able to close them"
|
||||
)
|
||||
if self._lost_slots > 0:
|
||||
failures.append(
|
||||
f"{self._lost_slots} pool slot(s) were left checked out "
|
||||
"with nothing able to return them"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def connection_accounting(engine):
|
||||
"""Account for the DBAPI connections created by ``engine``'s pool.
|
||||
|
||||
Returns a context manager yielding a :class:`.ConnectionAccounting`.
|
||||
|
||||
"""
|
||||
return ConnectionAccounting(engine.pool).start()
|
||||
|
||||
|
||||
async def run_scenario(
|
||||
engine_factory, scenario, cancel_at=(), warm=False, recycle=False
|
||||
):
|
||||
"""Run ``scenario`` against a fresh engine, optionally cancelling it.
|
||||
|
||||
``engine_factory`` is a zero-argument callable returning a new
|
||||
:class:`_asyncio.AsyncEngine`; ``scenario`` is a coroutine function
|
||||
taking that engine. If ``warm`` is True the scenario is run once
|
||||
un-cancelled first, so that the await points counted are those of
|
||||
checkout, execution and return-to-pool rather than those of
|
||||
``connect()``. If ``recycle`` is also True, the warmed pool is then
|
||||
invalidated, so that the checkout counted closes the old connection
|
||||
before opening its replacement.
|
||||
|
||||
Returns the :class:`.AwaitPoints` for the run, and a description of
|
||||
what the cancellation cost the pool, or ``None`` if it cost nothing.
|
||||
|
||||
"""
|
||||
assert warm or not recycle, "recycle requires 'warm'"
|
||||
|
||||
engine = engine_factory()
|
||||
with connection_accounting(engine) as accounting:
|
||||
if warm:
|
||||
await scenario(engine)
|
||||
if recycle:
|
||||
engine.pool._invalidate(None)
|
||||
|
||||
with await_points(cancel_at) as points:
|
||||
try:
|
||||
await points.create_task(scenario(engine))
|
||||
except BaseException:
|
||||
# the cancellation itself, or whatever the dialect raised
|
||||
# in response to it. What the scenario raised is not the
|
||||
# thing under test; what became of the connection is.
|
||||
#
|
||||
# if nothing here asked for a cancellation, this is not
|
||||
# ours; let it out. asyncio.Runner delivers the first
|
||||
# Ctrl-C as a cancellation of the main task rather than as
|
||||
# KeyboardInterrupt, so swallowing everything here would
|
||||
# otherwise absorb it and carry on sweeping
|
||||
if not points.cancelled_at:
|
||||
raise
|
||||
|
||||
# the scenario is over as far as the application can tell
|
||||
accounting.record_lost_slots()
|
||||
|
||||
# the application may now dispose of the engine
|
||||
try:
|
||||
await engine.dispose()
|
||||
except exc.SQLAlchemyError:
|
||||
# a pool holding damaged connections may not be able to close
|
||||
# them either; that is reported as a leak below
|
||||
pass
|
||||
|
||||
# let anything that was left running in the background finish, so
|
||||
# that a connection it returns to the disposed pool is counted as
|
||||
# the leak that it is
|
||||
others = asyncio.all_tasks() - {asyncio.current_task()}
|
||||
if others:
|
||||
await asyncio.wait(others, timeout=5)
|
||||
gc.collect()
|
||||
|
||||
return points, "; ".join(accounting.get_failure_messages()) or None
|
||||
|
||||
|
||||
async def sweep(
|
||||
engine_factory, scenario, warm=False, recycle=False, cancels=1
|
||||
):
|
||||
"""Cancel ``scenario`` at each of its await points in turn.
|
||||
|
||||
With ``cancels=2``, each run that was cancelled at point ``N`` is then
|
||||
repeated with a second cancellation at each await point after ``N``,
|
||||
which is to say at each point of the cleanup the first cancellation
|
||||
set off.
|
||||
|
||||
Returns a list of strings describing what went wrong, empty if every
|
||||
cancellation point was handled cleanly. The strings are meant to be
|
||||
read in an assertion failure::
|
||||
|
||||
eq_(await sweep(engine_factory, scenario), [])
|
||||
|
||||
"""
|
||||
|
||||
reported = []
|
||||
|
||||
async def _sweep(cancel_at):
|
||||
points, _ = await run_scenario(
|
||||
engine_factory,
|
||||
scenario,
|
||||
cancel_at=cancel_at,
|
||||
warm=warm,
|
||||
recycle=recycle,
|
||||
)
|
||||
total = points.count
|
||||
start = cancel_at[-1] + 1 if cancel_at else 1
|
||||
for index in range(start, total + 1):
|
||||
points, failure = await run_scenario(
|
||||
engine_factory,
|
||||
scenario,
|
||||
cancel_at=cancel_at + (index,),
|
||||
warm=warm,
|
||||
recycle=recycle,
|
||||
)
|
||||
if failure is not None:
|
||||
indexes = ", ".join(str(i) for i in cancel_at + (index,))
|
||||
awaitables = ", ".join(points.cancelled_at)
|
||||
reported.append(
|
||||
f"cancel at await point(s) {indexes} of {total} "
|
||||
f"({awaitables}): {failure}"
|
||||
)
|
||||
if len(cancel_at) + 1 < cancels:
|
||||
await _sweep(cancel_at + (index,))
|
||||
|
||||
await _sweep(())
|
||||
return reported
|
||||
Reference in New Issue
Block a user