From ff8167aec8132de4e475759aff77fee1a0680bc8 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Thu, 6 Nov 2025 19:32:57 +0300 Subject: [PATCH 01/36] feat: use one listener queue per notifier --- src/taskiq_cancellation/__init__.py | 1 - src/taskiq_cancellation/abc/backend.py | 35 +++++++++- src/taskiq_cancellation/abc/notifier.py | 6 ++ .../integrations/aiopika/notifier.py | 27 ++++---- .../integrations/queue_notifier.py | 64 +++++++++++++++++++ .../integrations/redis/backend.py | 10 +-- .../integrations/redis/notifier.py | 27 ++++---- .../integrations/redis/state_holder.py | 6 +- src/taskiq_cancellation/modular.py | 8 ++- 9 files changed, 142 insertions(+), 42 deletions(-) create mode 100644 src/taskiq_cancellation/integrations/queue_notifier.py diff --git a/src/taskiq_cancellation/__init__.py b/src/taskiq_cancellation/__init__.py index 4ad9e80..896ff45 100644 --- a/src/taskiq_cancellation/__init__.py +++ b/src/taskiq_cancellation/__init__.py @@ -1,4 +1,3 @@ -from .abc import * from .modular import ModularCancellationBackend diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 4f5ff15..9afc419 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,11 +1,10 @@ import abc import asyncio -import traceback from typing import Callable, Annotated, ParamSpec, TypeVar, Awaitable import anyio from anyio.abc import TaskStatus -from taskiq import Context, TaskiqDepends +from taskiq import Context, TaskiqDepends, AsyncBroker, TaskiqEvents, TaskiqState from ..utils import combines from ..exceptions import TaskCancellationException @@ -16,6 +15,11 @@ class CancellationBackend(abc.ABC): + def __init__(self) -> None: + super().__init__() + + self.broker: AsyncBroker | None = None + @abc.abstractmethod async def is_cancelled(self, task_id: str) -> bool: pass @@ -29,6 +33,27 @@ async def listen_for_cancellation( self, task_id: str, started_listening_task_status: TaskStatus ) -> None: pass + + async def startup(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + def with_broker(self, broker: AsyncBroker) -> "CancellationBackend": + if self.broker is not None: + self.broker.event_handlers[TaskiqEvents.CLIENT_STARTUP].remove(self._broker_startup_handler) + self.broker.event_handlers[TaskiqEvents.WORKER_STARTUP].remove(self._broker_startup_handler) + self.broker.event_handlers[TaskiqEvents.CLIENT_SHUTDOWN].remove(self._broker_shutdown_handler) + self.broker.event_handlers[TaskiqEvents.WORKER_SHUTDOWN].remove(self._broker_shutdown_handler) + + self.broker = broker + self.broker.add_event_handler(TaskiqEvents.CLIENT_STARTUP, self._broker_startup_handler) + self.broker.add_event_handler(TaskiqEvents.WORKER_STARTUP, self._broker_startup_handler) + self.broker.add_event_handler(TaskiqEvents.CLIENT_SHUTDOWN, self._broker_shutdown_handler) + self.broker.add_event_handler(TaskiqEvents.WORKER_SHUTDOWN, self._broker_shutdown_handler) + + return self def cancellable(self, task: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: # Executor type depends on receiver configuration which we @@ -96,3 +121,9 @@ async def call_task(): else: return result return wrapper + + async def _broker_startup_handler(self, state: TaskiqState) -> None: + await self.startup() + + async def _broker_shutdown_handler(self, state: TaskiqState) -> None: + await self.shutdown() \ No newline at end of file diff --git a/src/taskiq_cancellation/abc/notifier.py b/src/taskiq_cancellation/abc/notifier.py index 0faaa91..2512fb5 100644 --- a/src/taskiq_cancellation/abc/notifier.py +++ b/src/taskiq_cancellation/abc/notifier.py @@ -8,6 +8,12 @@ class CancellationNotifier(abc.ABC): def __init__(self, serializer: TaskiqSerializer = JSONSerializer()): self.serializer = serializer + + async def startup(self) -> None: + pass + + async def shutdown(self) -> None: + pass @abc.abstractmethod async def cancel(self, task_id: str) -> None: diff --git a/src/taskiq_cancellation/integrations/aiopika/notifier.py b/src/taskiq_cancellation/integrations/aiopika/notifier.py index b79ad78..d085bb9 100644 --- a/src/taskiq_cancellation/integrations/aiopika/notifier.py +++ b/src/taskiq_cancellation/integrations/aiopika/notifier.py @@ -1,21 +1,19 @@ import time +import asyncio import aio_pika -from anyio.abc import TaskStatus -from taskiq.abc.serializer import TaskiqSerializer -from taskiq.serializers import JSONSerializer from taskiq.compat import model_dump, model_validate -from taskiq_cancellation.abc import CancellationNotifier -from taskiq_cancellation.exceptions import TaskCancellationException from taskiq_cancellation.message import CancellationMessage +from ..queue_notifier import QueueCancellationNotifier -class AioPikaNotifier(CancellationNotifier): + +class AioPikaNotifier(QueueCancellationNotifier): EXCHANGE_NAME = "__taskiq_cancellation" - def __init__(self, url: str, serializer: TaskiqSerializer = JSONSerializer()): - super().__init__(serializer) + def __init__(self, url: str, **kwargs): + super().__init__(**kwargs) self.url: str = url @@ -37,9 +35,7 @@ async def cancel(self, task_id: str) -> None: routing_key="" ) - async def listen_for_cancellation( - self, task_id: str, started_listening_task_status: TaskStatus - ) -> None: + async def _listen(self, started_listening: asyncio.Event): connection = await aio_pika.connect_robust( self.url ) @@ -48,10 +44,11 @@ async def listen_for_cancellation( exchange = await channel.declare_exchange( self.EXCHANGE_NAME, aio_pika.ExchangeType.FANOUT, durable=True ) - queue = await channel.declare_queue(exclusive=True) + queue = await channel.declare_queue(exclusive=True, auto_delete=True) await queue.bind(exchange) - started_listening_task_status.started() + loop = asyncio.get_running_loop() + loop.call_soon_threadsafe(started_listening.set) async with queue.iterator() as queue_iter: async for message in queue_iter: @@ -60,6 +57,6 @@ async def listen_for_cancellation( self.serializer.loadb(message.body) ) - if cancellation_message.task_id == task_id: - raise TaskCancellationException() + for queue in self.queues: + await queue.put(cancellation_message) await message.ack() diff --git a/src/taskiq_cancellation/integrations/queue_notifier.py b/src/taskiq_cancellation/integrations/queue_notifier.py new file mode 100644 index 0000000..b24ea17 --- /dev/null +++ b/src/taskiq_cancellation/integrations/queue_notifier.py @@ -0,0 +1,64 @@ +import abc +import weakref +import asyncio + +from anyio.abc import TaskStatus + +from taskiq_cancellation.abc import CancellationNotifier +from taskiq_cancellation.exceptions import TaskCancellationException +from taskiq_cancellation.message import CancellationMessage + + +class QueueCancellationNotifier(CancellationNotifier): + CHANNEL_NAME = "__taskiq_cancellation_notifications" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + self.listener_task: asyncio.Task | None = None + self.queues: weakref.WeakSet[asyncio.Queue[CancellationMessage]] = weakref.WeakSet() + + async def shutdown(self) -> None: + if self.listener_task is not None: + self.listener_task.cancel() + + async def listen_for_cancellation( + self, task_id: str, started_listening_task_status: TaskStatus + ) -> None: + cancellations: asyncio.Queue[CancellationMessage] = asyncio.Queue() + + if self.listener_task is None: + started_listening = asyncio.Event() + self.listener_task = asyncio.create_task( + self._listen(started_listening) + ) + await started_listening.wait() + + await self._subscribe(cancellations) + started_listening_task_status.started() + + while True: + cancellation_message = await cancellations.get() + + if cancellation_message.task_id == task_id: + raise TaskCancellationException() + + @abc.abstractmethod + async def _listen(self, started_listening: asyncio.Event): + pass + + async def _create_listener_task(self): + if self.listener_task is not None: + self.listener_task.cancel() + + started_listening = asyncio.Event() + self.listener_task = asyncio.create_task( + self._listen(started_listening) + ) + await started_listening.wait() + + async def _subscribe(self, queue: asyncio.Queue[CancellationMessage]): + self.queues.add(queue) + + async def _unsubsribe(self, queue: asyncio.Queue[CancellationMessage]): + self.queues.remove(queue) \ No newline at end of file diff --git a/src/taskiq_cancellation/integrations/redis/backend.py b/src/taskiq_cancellation/integrations/redis/backend.py index ad038d1..90b6447 100644 --- a/src/taskiq_cancellation/integrations/redis/backend.py +++ b/src/taskiq_cancellation/integrations/redis/backend.py @@ -1,5 +1,3 @@ -from typing import Type - from taskiq_cancellation.modular import ModularCancellationBackend from .notifier import PubSubCancellationNotifier @@ -10,11 +8,9 @@ class RedisCancellationBackend(ModularCancellationBackend): def __init__( self, url: str, - state_holder: Type = RedisCancellationStateHolder, - notifier: Type = PubSubCancellationNotifier, - **connection_kwargs + **kwargs ) -> None: super().__init__( - state_holder(url, **connection_kwargs), - PubSubCancellationNotifier(url, **connection_kwargs) + RedisCancellationStateHolder(url, **kwargs), + PubSubCancellationNotifier(url, **kwargs) ) diff --git a/src/taskiq_cancellation/integrations/redis/notifier.py b/src/taskiq_cancellation/integrations/redis/notifier.py index 5ed24fb..bc70047 100644 --- a/src/taskiq_cancellation/integrations/redis/notifier.py +++ b/src/taskiq_cancellation/integrations/redis/notifier.py @@ -1,20 +1,21 @@ import time +import asyncio -from anyio.abc import TaskStatus import redis.asyncio as redis from taskiq.compat import model_dump, model_validate -from taskiq_cancellation.abc import CancellationNotifier -from taskiq_cancellation.exceptions import TaskCancellationException from taskiq_cancellation.message import CancellationMessage +from ..queue_notifier import QueueCancellationNotifier -class PubSubCancellationNotifier(CancellationNotifier): + +class PubSubCancellationNotifier(QueueCancellationNotifier): CHANNEL_NAME = "__taskiq_cancellation_notifications" - def __init__(self, url: str, **connection_kwargs) -> None: - super().__init__() - self.connection_pool = redis.BlockingConnectionPool.from_url(url, **connection_kwargs) + def __init__(self, url: str, **kwargs) -> None: + super().__init__(**kwargs) + + self.connection_pool = redis.BlockingConnectionPool.from_url(url, **kwargs) async def cancel(self, task_id: str) -> None: timestamp = time.time() @@ -27,14 +28,14 @@ async def cancel(self, task_id: str) -> None: ) ) - async def listen_for_cancellation( - self, task_id: str, started_listening_task_status: TaskStatus - ) -> None: + async def _listen(self, started_listening: asyncio.Event): async with redis.Redis(connection_pool=self.connection_pool) as conn: pubsub = conn.pubsub() await pubsub.subscribe(self.CHANNEL_NAME) - started_listening_task_status.started() + # started_listening.set() + loop = asyncio.get_running_loop() + loop.call_soon_threadsafe(started_listening.set) while True: message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None) @@ -48,5 +49,5 @@ async def listen_for_cancellation( CancellationMessage, self.serializer.loadb(message['data']) ) - if cancellation_message.task_id == task_id: - raise TaskCancellationException() + for queue in self.queues: + await queue.put(cancellation_message) diff --git a/src/taskiq_cancellation/integrations/redis/state_holder.py b/src/taskiq_cancellation/integrations/redis/state_holder.py index 4ca397c..262f121 100644 --- a/src/taskiq_cancellation/integrations/redis/state_holder.py +++ b/src/taskiq_cancellation/integrations/redis/state_holder.py @@ -4,9 +4,9 @@ class RedisCancellationStateHolder(CancellationStateHolder): - def __init__(self, url: str, **connection_kwargs) -> None: - super().__init__() - self.connection_pool = redis.BlockingConnectionPool.from_url(url, **connection_kwargs) + def __init__(self, url: str, **kwargs) -> None: + super().__init__(**kwargs) + self.connection_pool = redis.BlockingConnectionPool.from_url(url, **kwargs) async def cancel(self, task_id: str) -> None: async with redis.Redis(connection_pool=self.connection_pool) as conn: diff --git a/src/taskiq_cancellation/modular.py b/src/taskiq_cancellation/modular.py index bead650..54af77a 100644 --- a/src/taskiq_cancellation/modular.py +++ b/src/taskiq_cancellation/modular.py @@ -1,4 +1,8 @@ -from .abc import * +from .abc import ( + CancellationBackend, + CancellationNotifier, + CancellationStateHolder +) import anyio from anyio.abc import TaskStatus @@ -10,6 +14,8 @@ def __init__( state_holder: CancellationStateHolder, notifier: CancellationNotifier ): + super().__init__() + self.notifier: CancellationNotifier = notifier self.state_holder: CancellationStateHolder = state_holder From bb09c6927a1ae3797ca981a9e5c9c5d435080b86 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Thu, 6 Nov 2025 22:24:06 +0300 Subject: [PATCH 02/36] docs,chore: basic docs and ruff format --- examples/counter/main.py | 5 +- src/taskiq_cancellation/__init__.py | 3 +- src/taskiq_cancellation/abc/__init__.py | 6 +- src/taskiq_cancellation/abc/backend.py | 136 ++++++++++++++---- src/taskiq_cancellation/abc/notifier.py | 25 +++- src/taskiq_cancellation/abc/state_holder.py | 16 +++ .../integrations/aiopika/__init__.py | 4 +- .../integrations/aiopika/notifier.py | 27 ++-- .../integrations/queue_notifier.py | 37 +++-- .../integrations/redis/__init__.py | 2 +- .../integrations/redis/backend.py | 8 +- .../integrations/redis/notifier.py | 15 +- src/taskiq_cancellation/modular.py | 23 +-- src/taskiq_cancellation/utils.py | 32 +++-- 14 files changed, 232 insertions(+), 107 deletions(-) diff --git a/examples/counter/main.py b/examples/counter/main.py index 5facc63..a148aae 100644 --- a/examples/counter/main.py +++ b/examples/counter/main.py @@ -6,7 +6,7 @@ url = "redis://localhost" broker = PubSubBroker(url).with_result_backend(RedisAsyncResultBackend(url)) -cancellation_backend = RedisCancellationBackend(url) +cancellation_backend = RedisCancellationBackend(url).with_broker(broker) @broker.task @@ -20,11 +20,14 @@ async def count(up_to: int): async def main(): await broker.startup() + print("Sending task and waiting 5 seconds...") task = await count.kiq(5) await asyncio.sleep(5) + print("Sending task and waiting 2.5 seconds...") task = await count.kiq(5) await asyncio.sleep(2.5) + print("Canceling task...") await cancellation_backend.cancel(task.task_id) await broker.shutdown() diff --git a/src/taskiq_cancellation/__init__.py b/src/taskiq_cancellation/__init__.py index 896ff45..f61572f 100644 --- a/src/taskiq_cancellation/__init__.py +++ b/src/taskiq_cancellation/__init__.py @@ -1,7 +1,8 @@ +from .abc import CancellationBackend from .modular import ModularCancellationBackend __all__ = [ + "CancellationBackend", "ModularCancellationBackend" ] - diff --git a/src/taskiq_cancellation/abc/__init__.py b/src/taskiq_cancellation/abc/__init__.py index cc8b52c..1decfa6 100644 --- a/src/taskiq_cancellation/abc/__init__.py +++ b/src/taskiq_cancellation/abc/__init__.py @@ -3,8 +3,4 @@ from .state_holder import CancellationStateHolder -__all__ = [ - "CancellationBackend", - "CancellationNotifier", - "CancellationStateHolder" -] +__all__ = ["CancellationBackend", "CancellationNotifier", "CancellationStateHolder"] diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 9afc419..6323b07 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,6 +1,6 @@ import abc import asyncio -from typing import Callable, Annotated, ParamSpec, TypeVar, Awaitable +from typing import Callable, Annotated, ParamSpec, TypeVar, Awaitable, Self import anyio from anyio.abc import TaskStatus @@ -15,68 +15,145 @@ class CancellationBackend(abc.ABC): + """ + Base class for cancellation backend + """ def __init__(self) -> None: super().__init__() self.broker: AsyncBroker | None = None - @abc.abstractmethod + @abc.abstractmethod async def is_cancelled(self, task_id: str) -> bool: + """ + Checks if a task with task id of *task_id* is set to be cancelled + + :param task_id: task id to check + :type task_id: str + :returns: task cancellation state + :rtype: bool + """ pass @abc.abstractmethod async def cancel(self, task_id: str) -> None: + """ + Cancels a task with task id of *task_id* + + :param task_id: id of the task to cancel + :type task_id: str + """ pass @abc.abstractmethod async def listen_for_cancellation( self, task_id: str, started_listening_task_status: TaskStatus ) -> None: + """ + Listens for cancellation messages and raises :ref:`TaskCancellationException` when + receives :ref:`CancellationMessage` with same id as *task_id*. + + This function is used in :func:`cancellable` decorator. + Call `started_listening_task_status.started()` when the listener is ready + to receive messages. + + :param task_id: id of task that will be listened for + :type task_id: str + :param started_listening_task_status: + :type started_listening_task_status: anyio.abc.TaskStatus + """ pass async def startup(self) -> None: + """ + Starts up cancellation backend + + Triggered only if backend has a broker set. To set a broker use :func:`with_broker`. + """ pass async def shutdown(self) -> None: + """Shuts down cancellation backend + + Triggered only if backend has a broker set. To set a broker use :ref:`with_broker`. + """ pass - def with_broker(self, broker: AsyncBroker) -> "CancellationBackend": + def with_broker(self, broker: AsyncBroker) -> Self: + """ + Set a broker and return updated cancellation backend + + Sets up startup and shutdown event handlers for backend's startup + and shutdown methods respectfully + + :param broker: new broker + :type broker: AsyncBroker + :returns: self + """ if self.broker is not None: - self.broker.event_handlers[TaskiqEvents.CLIENT_STARTUP].remove(self._broker_startup_handler) - self.broker.event_handlers[TaskiqEvents.WORKER_STARTUP].remove(self._broker_startup_handler) - self.broker.event_handlers[TaskiqEvents.CLIENT_SHUTDOWN].remove(self._broker_shutdown_handler) - self.broker.event_handlers[TaskiqEvents.WORKER_SHUTDOWN].remove(self._broker_shutdown_handler) - + self.broker.event_handlers[TaskiqEvents.CLIENT_STARTUP].remove( + self._broker_startup_handler + ) + self.broker.event_handlers[TaskiqEvents.WORKER_STARTUP].remove( + self._broker_startup_handler + ) + self.broker.event_handlers[TaskiqEvents.CLIENT_SHUTDOWN].remove( + self._broker_shutdown_handler + ) + self.broker.event_handlers[TaskiqEvents.WORKER_SHUTDOWN].remove( + self._broker_shutdown_handler + ) + self.broker = broker - self.broker.add_event_handler(TaskiqEvents.CLIENT_STARTUP, self._broker_startup_handler) - self.broker.add_event_handler(TaskiqEvents.WORKER_STARTUP, self._broker_startup_handler) - self.broker.add_event_handler(TaskiqEvents.CLIENT_SHUTDOWN, self._broker_shutdown_handler) - self.broker.add_event_handler(TaskiqEvents.WORKER_SHUTDOWN, self._broker_shutdown_handler) + self.broker.add_event_handler( + TaskiqEvents.CLIENT_STARTUP, self._broker_startup_handler + ) + self.broker.add_event_handler( + TaskiqEvents.WORKER_STARTUP, self._broker_startup_handler + ) + self.broker.add_event_handler( + TaskiqEvents.CLIENT_SHUTDOWN, self._broker_shutdown_handler + ) + self.broker.add_event_handler( + TaskiqEvents.WORKER_SHUTDOWN, self._broker_shutdown_handler + ) return self - + def cancellable(self, task: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: - # Executor type depends on receiver configuration which we - # can't access in any way + """ + Decorator that makes funcion cancellable + + This decorator makes a new function that creates two tasks in :ref:`anyio.TaskGroup`: + 1. Cancellation message listener (uses :ref:`listen_for_cancellation`) + 2. Wrapped function + + - Returns function's result/exception if it finishes successfully/unsuccessfully + - Raises :ref:`TaskCancellationException` if listener task receives cancellation message + - If listener task raises an exception, task is cancelled and exception is propogated upwards + + :param task: Task function to wrap + :returns: Cancellable task function + """ + # Executor type depends on receiver configuration which we can't accessed in any way if not asyncio.iscoroutinefunction(task): raise ValueError("Can't cancel synchronous function") - + @combines(task) async def wrapper( - *args, - __taskiq_context: Annotated[Context, TaskiqDepends()], - **kwargs + *args, __taskiq_context: Annotated[Context, TaskiqDepends()], **kwargs ): task_id = __taskiq_context.message.task_id result = None listener_exception: Exception | None = None task_exception: Exception | None = None - cancelled_by_request: bool = False + cancelled_by_request: bool = False async with anyio.create_task_group() as group: + async def listen_for_cancellation( - task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED + task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ): nonlocal listener_exception, cancelled_by_request @@ -90,7 +167,7 @@ async def listen_for_cancellation( listener_exception = e finally: group.cancel_scope.cancel() - + async def call_task(): nonlocal result, task_exception @@ -103,15 +180,15 @@ async def call_task(): finally: group.cancel_scope.cancel() - # Listen before checking for cancellation in database so - # the message won't get lost in non-persistent queues + # Listen before checking for cancellation in state holder + # so the message won't get lost in non-persistent queues await group.start(listen_for_cancellation) if await self.is_cancelled(task_id): cancelled_by_request = True group.cancel_scope.cancel() else: group.start_soon(call_task) - + if task_exception is not None: raise task_exception elif cancelled_by_request: @@ -120,10 +197,11 @@ async def call_task(): raise listener_exception else: return result + return wrapper - async def _broker_startup_handler(self, state: TaskiqState) -> None: + async def _broker_startup_handler(self, _: TaskiqState) -> None: await self.startup() - - async def _broker_shutdown_handler(self, state: TaskiqState) -> None: - await self.shutdown() \ No newline at end of file + + async def _broker_shutdown_handler(self, _: TaskiqState) -> None: + await self.shutdown() diff --git a/src/taskiq_cancellation/abc/notifier.py b/src/taskiq_cancellation/abc/notifier.py index 2512fb5..3ba408f 100644 --- a/src/taskiq_cancellation/abc/notifier.py +++ b/src/taskiq_cancellation/abc/notifier.py @@ -6,21 +6,44 @@ class CancellationNotifier(abc.ABC): + """Receives cancellation messages and notifies listeners of these messages""" + def __init__(self, serializer: TaskiqSerializer = JSONSerializer()): self.serializer = serializer async def startup(self) -> None: + """Starts up cancellation notifier""" pass async def shutdown(self) -> None: + """Shuts down cancellation notifier""" pass - + @abc.abstractmethod async def cancel(self, task_id: str) -> None: + """ + Sends a cancellation message of a task with task id of *task_id* + + :param task_id: id of the task to cancel + :type task_id: str + """ pass @abc.abstractmethod async def listen_for_cancellation( self, task_id: str, started_listening_task_status: TaskStatus ) -> None: + """ + Listens for cancellation messages and raises :ref:`TaskCancellationException` when + receives :ref:`CancellationMessage` with same id as *task_id*. + + This function is used in :func:`cancellable` decorator of :ref:`ModularCancellationBackend`. + Call `started_listening_task_status.started()` when the listener is ready + to receive messages. + + :param task_id: id of task that will be listened for + :type task_id: str + :param started_listening_task_status: + :type started_listening_task_status: anyio.abc.TaskStatus + """ pass diff --git a/src/taskiq_cancellation/abc/state_holder.py b/src/taskiq_cancellation/abc/state_holder.py index 45df346..3b2615c 100644 --- a/src/taskiq_cancellation/abc/state_holder.py +++ b/src/taskiq_cancellation/abc/state_holder.py @@ -2,10 +2,26 @@ class CancellationStateHolder(abc.ABC): + """Holds cancellation state of Taskiq tasks""" + @abc.abstractmethod async def cancel(self, task_id: str) -> None: + """ + Sets a state of task with task id of *task_id* to be cancelled + + :param task_id: id of the task to cancel + :type task_id: str + """ pass @abc.abstractmethod async def is_cancelled(self, task_id: str) -> bool: + """ + Checks if a task with task id of *task_id* is set to be cancelled + + :param task_id: task id to check + :type task_id: str + :returns: task cancellation state + :rtype: bool + """ pass diff --git a/src/taskiq_cancellation/integrations/aiopika/__init__.py b/src/taskiq_cancellation/integrations/aiopika/__init__.py index 9d0488c..0af486e 100644 --- a/src/taskiq_cancellation/integrations/aiopika/__init__.py +++ b/src/taskiq_cancellation/integrations/aiopika/__init__.py @@ -1,6 +1,4 @@ from .notifier import AioPikaNotifier -__all__ = [ - "AioPikaNotifier" -] +__all__ = ["AioPikaNotifier"] diff --git a/src/taskiq_cancellation/integrations/aiopika/notifier.py b/src/taskiq_cancellation/integrations/aiopika/notifier.py index d085bb9..7965134 100644 --- a/src/taskiq_cancellation/integrations/aiopika/notifier.py +++ b/src/taskiq_cancellation/integrations/aiopika/notifier.py @@ -19,26 +19,26 @@ def __init__(self, url: str, **kwargs): async def cancel(self, task_id: str) -> None: timestamp = time.time() - connection = await aio_pika.connect_robust( - self.url - ) + connection = await aio_pika.connect_robust(self.url) channel = await connection.channel() exchange = await channel.declare_exchange( self.EXCHANGE_NAME, aio_pika.ExchangeType.FANOUT, durable=True ) - + await exchange.publish( - aio_pika.Message(body=self.serializer.dumpb( - model_dump(CancellationMessage(task_id=task_id, timestamp=timestamp)) - )), - routing_key="" + aio_pika.Message( + body=self.serializer.dumpb( + model_dump( + CancellationMessage(task_id=task_id, timestamp=timestamp) + ) + ) + ), + routing_key="", ) async def _listen(self, started_listening: asyncio.Event): - connection = await aio_pika.connect_robust( - self.url - ) + connection = await aio_pika.connect_robust(self.url) channel = await connection.channel() exchange = await channel.declare_exchange( @@ -53,9 +53,8 @@ async def _listen(self, started_listening: asyncio.Event): async with queue.iterator() as queue_iter: async for message in queue_iter: cancellation_message = model_validate( - CancellationMessage, - self.serializer.loadb(message.body) - ) + CancellationMessage, self.serializer.loadb(message.body) + ) for queue in self.queues: await queue.put(cancellation_message) diff --git a/src/taskiq_cancellation/integrations/queue_notifier.py b/src/taskiq_cancellation/integrations/queue_notifier.py index b24ea17..3190501 100644 --- a/src/taskiq_cancellation/integrations/queue_notifier.py +++ b/src/taskiq_cancellation/integrations/queue_notifier.py @@ -10,13 +10,20 @@ class QueueCancellationNotifier(CancellationNotifier): - CHANNEL_NAME = "__taskiq_cancellation_notifications" + """ + A helper cancellation notifier that uses one listener to receive cancellation messages and + notifies listeners from `listen_for_cancellation` via `asyncio.Queue` + Requires :func:`_listen` to be implemeted + """ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.listener_task: asyncio.Task | None = None - self.queues: weakref.WeakSet[asyncio.Queue[CancellationMessage]] = weakref.WeakSet() + self.queues: weakref.WeakSet[asyncio.Queue[CancellationMessage]] = ( + weakref.WeakSet() + ) + """Set of subscribers' `asyncio.Queue`s to populate when message's received""" async def shutdown(self) -> None: if self.listener_task is not None: @@ -28,12 +35,8 @@ async def listen_for_cancellation( cancellations: asyncio.Queue[CancellationMessage] = asyncio.Queue() if self.listener_task is None: - started_listening = asyncio.Event() - self.listener_task = asyncio.create_task( - self._listen(started_listening) - ) - await started_listening.wait() - + await self._create_listener_task() + await self._subscribe(cancellations) started_listening_task_status.started() @@ -44,21 +47,25 @@ async def listen_for_cancellation( raise TaskCancellationException() @abc.abstractmethod - async def _listen(self, started_listening: asyncio.Event): + async def _listen(self, started_listening: asyncio.Event) -> None: + """ + Listens for cancellation messages and put them into subscribers' `asyncio.Queue`s + + :param started_listening: event to be set when listener is ready to receive messages + :type started_listening: asyncio.Event + """ pass async def _create_listener_task(self): if self.listener_task is not None: self.listener_task.cancel() - + started_listening = asyncio.Event() - self.listener_task = asyncio.create_task( - self._listen(started_listening) - ) + self.listener_task = asyncio.create_task(self._listen(started_listening)) await started_listening.wait() async def _subscribe(self, queue: asyncio.Queue[CancellationMessage]): self.queues.add(queue) - + async def _unsubsribe(self, queue: asyncio.Queue[CancellationMessage]): - self.queues.remove(queue) \ No newline at end of file + self.queues.remove(queue) diff --git a/src/taskiq_cancellation/integrations/redis/__init__.py b/src/taskiq_cancellation/integrations/redis/__init__.py index fe38b68..4aedc97 100644 --- a/src/taskiq_cancellation/integrations/redis/__init__.py +++ b/src/taskiq_cancellation/integrations/redis/__init__.py @@ -6,5 +6,5 @@ __all__ = [ "PubSubCancellationNotifier", "RedisCancellationStateHolder", - "RedisCancellationBackend" + "RedisCancellationBackend", ] diff --git a/src/taskiq_cancellation/integrations/redis/backend.py b/src/taskiq_cancellation/integrations/redis/backend.py index 90b6447..e11b177 100644 --- a/src/taskiq_cancellation/integrations/redis/backend.py +++ b/src/taskiq_cancellation/integrations/redis/backend.py @@ -5,12 +5,8 @@ class RedisCancellationBackend(ModularCancellationBackend): - def __init__( - self, - url: str, - **kwargs - ) -> None: + def __init__(self, url: str, **kwargs) -> None: super().__init__( RedisCancellationStateHolder(url, **kwargs), - PubSubCancellationNotifier(url, **kwargs) + PubSubCancellationNotifier(url, **kwargs), ) diff --git a/src/taskiq_cancellation/integrations/redis/notifier.py b/src/taskiq_cancellation/integrations/redis/notifier.py index bc70047..b76e5b9 100644 --- a/src/taskiq_cancellation/integrations/redis/notifier.py +++ b/src/taskiq_cancellation/integrations/redis/notifier.py @@ -22,10 +22,12 @@ async def cancel(self, task_id: str) -> None: async with redis.Redis(connection_pool=self.connection_pool) as conn: await conn.publish( - self.CHANNEL_NAME, + self.CHANNEL_NAME, self.serializer.dumpb( - model_dump(CancellationMessage(task_id=task_id, timestamp=timestamp)) - ) + model_dump( + CancellationMessage(task_id=task_id, timestamp=timestamp) + ) + ), ) async def _listen(self, started_listening: asyncio.Event): @@ -38,7 +40,9 @@ async def _listen(self, started_listening: asyncio.Event): loop.call_soon_threadsafe(started_listening.set) while True: - message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None) + message = await pubsub.get_message( + ignore_subscribe_messages=True, timeout=None + ) if message is None: continue @@ -46,8 +50,7 @@ async def _listen(self, started_listening: asyncio.Event): continue cancellation_message = model_validate( - CancellationMessage, - self.serializer.loadb(message['data']) + CancellationMessage, self.serializer.loadb(message["data"]) ) for queue in self.queues: await queue.put(cancellation_message) diff --git a/src/taskiq_cancellation/modular.py b/src/taskiq_cancellation/modular.py index 54af77a..087b9a0 100644 --- a/src/taskiq_cancellation/modular.py +++ b/src/taskiq_cancellation/modular.py @@ -1,21 +1,22 @@ -from .abc import ( - CancellationBackend, - CancellationNotifier, - CancellationStateHolder -) +from .abc import CancellationBackend, CancellationNotifier, CancellationStateHolder import anyio from anyio.abc import TaskStatus class ModularCancellationBackend(CancellationBackend): + """ + Modular cancellation backend made up of :class:`CancellationStateHolder` + and :class:`CancellationNotifier` + + - `CancellationStateHolder` stores cancellation state and blocks the task from being run. + - `CancellationNotifier` receives cancellation messages and cancels already running tasks. + """ def __init__( - self, - state_holder: CancellationStateHolder, - notifier: CancellationNotifier + self, state_holder: CancellationStateHolder, notifier: CancellationNotifier ): super().__init__() - + self.notifier: CancellationNotifier = notifier self.state_holder: CancellationStateHolder = state_holder @@ -30,4 +31,6 @@ async def cancel(self, task_id: str): async def listen_for_cancellation( self, task_id: str, started_listening_task_status: TaskStatus[None] ): - await self.notifier.listen_for_cancellation(task_id, started_listening_task_status) + await self.notifier.listen_for_cancellation( + task_id, started_listening_task_status + ) diff --git a/src/taskiq_cancellation/utils.py b/src/taskiq_cancellation/utils.py index b1cda03..4d659f0 100644 --- a/src/taskiq_cancellation/utils.py +++ b/src/taskiq_cancellation/utils.py @@ -12,7 +12,7 @@ def combines(wrapped): In cases of parameter collision wrapper parameters will be used Example: - ''' + ''' import inspect def decorator(func): @@ -20,41 +20,44 @@ def decorator(func): def wrapper(c: int, *args, **kwargs): return foo(*args, **kwargs) * c return wrapper - + @decorator def foo(a: int, b = "lol"): return b * a - + print(inspect.signature(foo)) # (a: int, c: int, b='lol', *args, **kwargs) ''' """ wrapped_signature: inspect.Signature = inspect.signature(wrapped) wrapped_type_hints: typing.Dict[str, str] = typing.get_type_hints(wrapped) - + def decorator(wrapper): wrapper_signature = inspect.signature(wrapper) wrapper_type_hints = typing.get_type_hints(wrapper) for param_name in wrapped_signature.parameters.keys(): if param_name in wrapper_signature.parameters.keys(): - logging.warning(f"Parameter {param_name} will be overwritten by wrapper function") - - parameters = OrderedDict(wrapped_signature.parameters, **wrapper_signature.parameters) + logging.warning( + f"Parameter {param_name} will be overwritten by wrapper function" + ) + + parameters = OrderedDict( + wrapped_signature.parameters, **wrapper_signature.parameters + ) parameters = sorted( parameters.values(), - key=lambda p: p.kind + (0.5 if p.default != inspect.Parameter.empty else 0) + key=lambda p: p.kind + (0.5 if p.default != inspect.Parameter.empty else 0), ) new_return_annotation: inspect.Signature if wrapper_signature.return_annotation is not None: - new_return_annotation = wrapper_signature.return_annotation + new_return_annotation = wrapper_signature.return_annotation else: - new_return_annotation = wrapped_signature.return_annotation + new_return_annotation = wrapped_signature.return_annotation new_signature = inspect.Signature( - parameters=parameters, - return_annotation=new_return_annotation + parameters=parameters, return_annotation=new_return_annotation ) new_annotations = dict(wrapped_type_hints, **wrapper_type_hints) @@ -63,9 +66,8 @@ def decorator(wrapper): wrapper.__signature__ = new_signature return wrapper + return decorator -__all__ = [ - "combines" -] +__all__ = ["combines"] From 414ef205d09890ed6450e61d0c88fa3c11ef45c7 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Fri, 7 Nov 2025 13:24:52 +0300 Subject: [PATCH 03/36] chore: restructure folders by interfaces --- src/taskiq_cancellation/__init__.py | 2 +- src/taskiq_cancellation/abc/backend.py | 4 +-- .../{integrations => backends}/__init__.py | 0 .../{ => backends}/modular.py | 2 +- .../redis/backend.py => backends/redis.py} | 6 ++-- .../integrations/aiopika/__init__.py | 4 --- .../integrations/redis/__init__.py | 10 ------ src/taskiq_cancellation/notifiers/__init__.py | 0 .../notifier.py => notifiers/aiopika.py} | 2 +- .../notifiers/in_memory.py | 35 +++++++++++++++++++ .../queue_notifier.py => notifiers/queue.py} | 0 .../redis/notifier.py => notifiers/redis.py} | 2 +- .../state_holders/__init__.py | 0 .../redis.py} | 0 14 files changed, 44 insertions(+), 23 deletions(-) rename src/taskiq_cancellation/{integrations => backends}/__init__.py (100%) rename src/taskiq_cancellation/{ => backends}/modular.py (92%) rename src/taskiq_cancellation/{integrations/redis/backend.py => backends/redis.py} (53%) delete mode 100644 src/taskiq_cancellation/integrations/aiopika/__init__.py delete mode 100644 src/taskiq_cancellation/integrations/redis/__init__.py create mode 100644 src/taskiq_cancellation/notifiers/__init__.py rename src/taskiq_cancellation/{integrations/aiopika/notifier.py => notifiers/aiopika.py} (97%) create mode 100644 src/taskiq_cancellation/notifiers/in_memory.py rename src/taskiq_cancellation/{integrations/queue_notifier.py => notifiers/queue.py} (100%) rename src/taskiq_cancellation/{integrations/redis/notifier.py => notifiers/redis.py} (97%) create mode 100644 src/taskiq_cancellation/state_holders/__init__.py rename src/taskiq_cancellation/{integrations/redis/state_holder.py => state_holders/redis.py} (100%) diff --git a/src/taskiq_cancellation/__init__.py b/src/taskiq_cancellation/__init__.py index f61572f..57864e4 100644 --- a/src/taskiq_cancellation/__init__.py +++ b/src/taskiq_cancellation/__init__.py @@ -1,5 +1,5 @@ from .abc import CancellationBackend -from .modular import ModularCancellationBackend +from .backends.modular import ModularCancellationBackend __all__ = [ diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 6323b07..ab88f47 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -6,8 +6,8 @@ from anyio.abc import TaskStatus from taskiq import Context, TaskiqDepends, AsyncBroker, TaskiqEvents, TaskiqState -from ..utils import combines -from ..exceptions import TaskCancellationException +from taskiq_cancellation.utils import combines +from taskiq_cancellation.exceptions import TaskCancellationException P = ParamSpec("P") diff --git a/src/taskiq_cancellation/integrations/__init__.py b/src/taskiq_cancellation/backends/__init__.py similarity index 100% rename from src/taskiq_cancellation/integrations/__init__.py rename to src/taskiq_cancellation/backends/__init__.py diff --git a/src/taskiq_cancellation/modular.py b/src/taskiq_cancellation/backends/modular.py similarity index 92% rename from src/taskiq_cancellation/modular.py rename to src/taskiq_cancellation/backends/modular.py index 087b9a0..9343602 100644 --- a/src/taskiq_cancellation/modular.py +++ b/src/taskiq_cancellation/backends/modular.py @@ -1,4 +1,4 @@ -from .abc import CancellationBackend, CancellationNotifier, CancellationStateHolder +from taskiq_cancellation.abc import CancellationBackend, CancellationNotifier, CancellationStateHolder import anyio from anyio.abc import TaskStatus diff --git a/src/taskiq_cancellation/integrations/redis/backend.py b/src/taskiq_cancellation/backends/redis.py similarity index 53% rename from src/taskiq_cancellation/integrations/redis/backend.py rename to src/taskiq_cancellation/backends/redis.py index e11b177..3870e32 100644 --- a/src/taskiq_cancellation/integrations/redis/backend.py +++ b/src/taskiq_cancellation/backends/redis.py @@ -1,7 +1,7 @@ -from taskiq_cancellation.modular import ModularCancellationBackend +from taskiq_cancellation.backends.modular import ModularCancellationBackend -from .notifier import PubSubCancellationNotifier -from .state_holder import RedisCancellationStateHolder +from taskiq_cancellation.notifiers.redis import PubSubCancellationNotifier +from taskiq_cancellation.state_holders.redis import RedisCancellationStateHolder class RedisCancellationBackend(ModularCancellationBackend): diff --git a/src/taskiq_cancellation/integrations/aiopika/__init__.py b/src/taskiq_cancellation/integrations/aiopika/__init__.py deleted file mode 100644 index 0af486e..0000000 --- a/src/taskiq_cancellation/integrations/aiopika/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .notifier import AioPikaNotifier - - -__all__ = ["AioPikaNotifier"] diff --git a/src/taskiq_cancellation/integrations/redis/__init__.py b/src/taskiq_cancellation/integrations/redis/__init__.py deleted file mode 100644 index 4aedc97..0000000 --- a/src/taskiq_cancellation/integrations/redis/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .notifier import PubSubCancellationNotifier -from .state_holder import RedisCancellationStateHolder -from .backend import RedisCancellationBackend - - -__all__ = [ - "PubSubCancellationNotifier", - "RedisCancellationStateHolder", - "RedisCancellationBackend", -] diff --git a/src/taskiq_cancellation/notifiers/__init__.py b/src/taskiq_cancellation/notifiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taskiq_cancellation/integrations/aiopika/notifier.py b/src/taskiq_cancellation/notifiers/aiopika.py similarity index 97% rename from src/taskiq_cancellation/integrations/aiopika/notifier.py rename to src/taskiq_cancellation/notifiers/aiopika.py index 7965134..8e55ab8 100644 --- a/src/taskiq_cancellation/integrations/aiopika/notifier.py +++ b/src/taskiq_cancellation/notifiers/aiopika.py @@ -6,7 +6,7 @@ from taskiq_cancellation.message import CancellationMessage -from ..queue_notifier import QueueCancellationNotifier +from .queue import QueueCancellationNotifier class AioPikaNotifier(QueueCancellationNotifier): diff --git a/src/taskiq_cancellation/notifiers/in_memory.py b/src/taskiq_cancellation/notifiers/in_memory.py new file mode 100644 index 0000000..748a129 --- /dev/null +++ b/src/taskiq_cancellation/notifiers/in_memory.py @@ -0,0 +1,35 @@ +import time +import asyncio + +from taskiq_cancellation.message import CancellationMessage + +from .queue import QueueCancellationNotifier + + +class InMemoryCancellationNotifier(QueueCancellationNotifier): + """In memory cancellation notifier used for testing""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + self.messages: asyncio.Queue[CancellationMessage] = asyncio.Queue() + + async def cancel(self, task_id: str) -> None: + timestamp = time.time() + + await self.messages.put( + CancellationMessage( + task_id=task_id, + timestamp=timestamp + ) + ) + + async def _listen(self, started_listening: asyncio.Event) -> None: + loop = asyncio.get_running_loop() + loop.call_soon_threadsafe(started_listening.set) + + while True: + message = await self.messages.get() + + for queue in self.queues: + await queue.put(message) diff --git a/src/taskiq_cancellation/integrations/queue_notifier.py b/src/taskiq_cancellation/notifiers/queue.py similarity index 100% rename from src/taskiq_cancellation/integrations/queue_notifier.py rename to src/taskiq_cancellation/notifiers/queue.py diff --git a/src/taskiq_cancellation/integrations/redis/notifier.py b/src/taskiq_cancellation/notifiers/redis.py similarity index 97% rename from src/taskiq_cancellation/integrations/redis/notifier.py rename to src/taskiq_cancellation/notifiers/redis.py index b76e5b9..2efa7c2 100644 --- a/src/taskiq_cancellation/integrations/redis/notifier.py +++ b/src/taskiq_cancellation/notifiers/redis.py @@ -6,7 +6,7 @@ from taskiq_cancellation.message import CancellationMessage -from ..queue_notifier import QueueCancellationNotifier +from .queue import QueueCancellationNotifier class PubSubCancellationNotifier(QueueCancellationNotifier): diff --git a/src/taskiq_cancellation/state_holders/__init__.py b/src/taskiq_cancellation/state_holders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taskiq_cancellation/integrations/redis/state_holder.py b/src/taskiq_cancellation/state_holders/redis.py similarity index 100% rename from src/taskiq_cancellation/integrations/redis/state_holder.py rename to src/taskiq_cancellation/state_holders/redis.py From 7d2a95b4f632d178cae0f87980669ffcf21d9951 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Fri, 7 Nov 2025 13:25:33 +0300 Subject: [PATCH 04/36] feat: null and in memory state holders and notifiers --- src/taskiq_cancellation/backends/in_memory.py | 12 ++++++++++++ src/taskiq_cancellation/notifiers/null.py | 19 +++++++++++++++++++ .../state_holders/in_memory.py | 16 ++++++++++++++++ src/taskiq_cancellation/state_holders/null.py | 15 +++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 src/taskiq_cancellation/backends/in_memory.py create mode 100644 src/taskiq_cancellation/notifiers/null.py create mode 100644 src/taskiq_cancellation/state_holders/in_memory.py create mode 100644 src/taskiq_cancellation/state_holders/null.py diff --git a/src/taskiq_cancellation/backends/in_memory.py b/src/taskiq_cancellation/backends/in_memory.py new file mode 100644 index 0000000..ee5e385 --- /dev/null +++ b/src/taskiq_cancellation/backends/in_memory.py @@ -0,0 +1,12 @@ +from taskiq_cancellation.notifiers.in_memory import InMemoryCancellationNotifier +from taskiq_cancellation.state_holders.in_memory import InMemoryCancellationStateHolder + +from .modular import ModularCancellationBackend + + +class InMemoryCancellationBackend(ModularCancellationBackend): + def __init__(self, **kwargs): + super().__init__( + state_holder=InMemoryCancellationStateHolder(**kwargs), + notifier=InMemoryCancellationNotifier(**kwargs) + ) diff --git a/src/taskiq_cancellation/notifiers/null.py b/src/taskiq_cancellation/notifiers/null.py new file mode 100644 index 0000000..9740012 --- /dev/null +++ b/src/taskiq_cancellation/notifiers/null.py @@ -0,0 +1,19 @@ +import asyncio + +from anyio.abc import TaskStatus +from taskiq_cancellation.abc.notifier import CancellationNotifier + + +class NullCancellationNotifier(CancellationNotifier): + """ + \"Do nothing\" cancellation notifier + + May be useful if there's no need or ability to use an actual notifier + """ + + async def cancel(self, task_id: str) -> None: + pass + + async def listen_for_cancellation(self, task_id: str, started_listening_task_status: TaskStatus) -> None: + started_listening_task_status.started() + await asyncio.sleep(float("+inf")) diff --git a/src/taskiq_cancellation/state_holders/in_memory.py b/src/taskiq_cancellation/state_holders/in_memory.py new file mode 100644 index 0000000..23a7089 --- /dev/null +++ b/src/taskiq_cancellation/state_holders/in_memory.py @@ -0,0 +1,16 @@ +from taskiq_cancellation.abc import CancellationStateHolder + + +class InMemoryCancellationStateHolder(CancellationStateHolder): + """In memory cancellation state holder used for testing""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + self.state_holder: dict[str, bool] = {} + + async def cancel(self, task_id: str) -> None: + self.state_holder[task_id] = True + + async def is_cancelled(self, task_id: str) -> bool: + return self.state_holder.get(task_id, False) diff --git a/src/taskiq_cancellation/state_holders/null.py b/src/taskiq_cancellation/state_holders/null.py new file mode 100644 index 0000000..4383071 --- /dev/null +++ b/src/taskiq_cancellation/state_holders/null.py @@ -0,0 +1,15 @@ +from taskiq_cancellation.abc import CancellationStateHolder + + +class NullCancellationStateHolder(CancellationStateHolder): + """ + \"Do nothing\" cancellation state holder + + May be useful if there's no need or ability to use an actual state holder + """ + + async def cancel(self, task_id: str) -> None: + pass + + async def is_cancelled(self, task_id: str) -> bool: + return False From 7918d737431ee965c0f6f7cbf6fa73257cf9ac28 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Fri, 7 Nov 2025 15:31:49 +0300 Subject: [PATCH 05/36] feat: startup and shutdown in state holders --- src/taskiq_cancellation/abc/state_holder.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/taskiq_cancellation/abc/state_holder.py b/src/taskiq_cancellation/abc/state_holder.py index 3b2615c..8416440 100644 --- a/src/taskiq_cancellation/abc/state_holder.py +++ b/src/taskiq_cancellation/abc/state_holder.py @@ -25,3 +25,11 @@ async def is_cancelled(self, task_id: str) -> bool: :rtype: bool """ pass + + async def startup(self) -> None: + """Starts up cancellation state holder""" + pass + + async def shutdown(self) -> None: + """Shuts down cancellation state holder""" + pass From d4dbe976b6c0658062a4ea04fae7f8ac97c11d11 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Fri, 7 Nov 2025 15:33:26 +0300 Subject: [PATCH 06/36] fix: call notifier and state holder startup/shutdown in modular backend --- src/taskiq_cancellation/backends/modular.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/taskiq_cancellation/backends/modular.py b/src/taskiq_cancellation/backends/modular.py index 9343602..2d87dfe 100644 --- a/src/taskiq_cancellation/backends/modular.py +++ b/src/taskiq_cancellation/backends/modular.py @@ -34,3 +34,13 @@ async def listen_for_cancellation( await self.notifier.listen_for_cancellation( task_id, started_listening_task_status ) + + async def startup(self) -> None: + await super().startup() + await self.state_holder.startup() + await self.notifier.startup() + + async def shutdown(self) -> None: + await super().shutdown() + await self.state_holder.shutdown() + await self.notifier.shutdown() From 61ae28ab91dce7ffef194057c62bc6f037ff71fd Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Fri, 7 Nov 2025 15:51:14 +0300 Subject: [PATCH 07/36] test: cancellable task tests aka baby's first tests --- tests/test_cancellation.py | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_cancellation.py diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py new file mode 100644 index 0000000..a790d1e --- /dev/null +++ b/tests/test_cancellation.py @@ -0,0 +1,58 @@ +import pytest +import asyncio + +from taskiq import AsyncBroker, InMemoryBroker + +from taskiq_cancellation.abc import CancellationBackend +from taskiq_cancellation.backends.in_memory import InMemoryCancellationBackend +from taskiq_cancellation.exceptions import TaskCancellationException + + +@pytest.fixture +def broker(): + return InMemoryBroker() + + +@pytest.fixture +def backend(broker): + return InMemoryCancellationBackend().with_broker(broker) + + +@pytest.mark.asyncio +async def test_task_success(broker: AsyncBroker, backend: CancellationBackend): + """Test that cancellable task can run successfully""" + + @broker.task + @backend.cancellable + async def task(): + await asyncio.sleep(0.1) + + await broker.startup() + + t = await task.kiq() + + result = await t.wait_result() + assert result.is_err is False + + await broker.shutdown() + + +@pytest.mark.asyncio +async def test_task_cancellation(broker: AsyncBroker, backend: CancellationBackend): + """Test that cancellable task can be cancelled""" + + @broker.task + @backend.cancellable + async def task(): + await asyncio.sleep(0.3) + + await broker.startup() + + t = await task.kiq() + await backend.cancel(t.task_id) + + with pytest.raises(TaskCancellationException): + result = await t.wait_result() + result.raise_for_error() + + await broker.shutdown() From 1bebc5a65162aa79fca0868a5ddfc89fec9d24ef Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Fri, 7 Nov 2025 23:42:40 +0300 Subject: [PATCH 08/36] ci: linting and testing CI aka baby's first CI --- .github/workflows/lint.yaml | 33 ++++++++++++++++++++++++++++++ .github/workflows/run_tests.yaml | 35 ++++++++++++++++++++++++++++++++ pyproject.toml | 25 ++++++++++++++++++----- 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/run_tests.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..0e31ea4 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,33 @@ +name: Lint + +on: + pull_request: + branches: [develop] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: 3.14 + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Create virtual environment + run: uv venv .venv && source .venv/bin/activate + + - name: Install modules + run: uv sync + + - name: Check code style + run: uv run ruff check + + - name: Check static typing + run: uv run mypy . diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml new file mode 100644 index 0000000..b540751 --- /dev/null +++ b/.github/workflows/run_tests.yaml @@ -0,0 +1,35 @@ +name: Testing + +on: + pull_request: + branches: [develop] + +jobs: + run-tests: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Create virtual environment + run: uv venv .venv && source .venv/bin/activate + + - name: Install modules + run: uv sync + + - name: Run tests for Python ${{ matrix.python-version }} + run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 5c1c6cf..9ec9714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,7 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "taskiq-cancellation" dynamic = ["version"] -description = 'Task cancellation mechanism for taskiq' +description = 'Task cancellation for taskiq' readme = "README.md" requires-python = ">=3.8" license = "MIT" @@ -19,6 +15,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] @@ -33,6 +31,10 @@ Documentation = "https://github.com/ACherryJam/taskiq-cancellation#readme" Issues = "https://github.com/ACherryJam/taskiq-cancellation/issues" Source = "https://github.com/ACherryJam/taskiq-cancellation" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [tool.hatch.version] path = "src/taskiq_cancellation/__about__.py" @@ -41,6 +43,11 @@ extra-dependencies = ["mypy>=1.0.0"] [tool.hatch.envs.types.scripts] check = "mypy --install-types --non-interactive {args:src/taskiq_cancellation tests}" +[tool.mypy] +ignore_missing_imports = true +exclude = ["examples"] + + [tool.coverage.run] source_pkgs = ["taskiq_cancellation", "tests"] branch = true @@ -56,3 +63,11 @@ tests = ["tests", "*/taskiq-cancellation/tests"] [tool.coverage.report] exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] + +[dependency-groups] +dev = [ + "mypy>=1.14.1", + "pytest>=8.3.5", + "pytest-asyncio>=0.24.0", + "ruff>=0.14.4", +] From b8a46c08a5c6dc04a65a6de3d6334d1078d524ae Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sat, 8 Nov 2025 00:12:33 +0300 Subject: [PATCH 09/36] fix!: add typing_extensions to support Python 3.9+ --- .github/workflows/run_tests.yaml | 2 +- pyproject.toml | 13 ++++++------- src/taskiq_cancellation/abc/backend.py | 3 ++- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index b540751..3a45a80 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] fail-fast: false steps: diff --git a/pyproject.toml b/pyproject.toml index 9ec9714..869d263 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,13 @@ name = "taskiq-cancellation" dynamic = ["version"] description = 'Task cancellation for taskiq' readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = "MIT" keywords = ["taskiq", "cancellation"] authors = [{ name = "Alexander Starikov", email = "acherryjam@gmail.com" }] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -20,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] -dependencies = ["taskiq"] +dependencies = ["taskiq", "typing-extensions>=4.13.2"] [project.optional-dependencies] redis = ["redis~=3.0"] @@ -66,8 +65,8 @@ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] [dependency-groups] dev = [ - "mypy>=1.14.1", - "pytest>=8.3.5", - "pytest-asyncio>=0.24.0", - "ruff>=0.14.4", + "mypy>=1.14.1", + "pytest>=8.3.5", + "pytest-asyncio>=0.24.0", + "ruff>=0.14.4", ] diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index ab88f47..c3befb9 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,6 +1,7 @@ import abc import asyncio -from typing import Callable, Annotated, ParamSpec, TypeVar, Awaitable, Self +from typing import Callable, Annotated, TypeVar, Awaitable +from typing_extensions import ParamSpec, Self import anyio from anyio.abc import TaskStatus From 2a65c42d3bb8b35216ddf0ded629640b734d5d3a Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sat, 8 Nov 2025 23:53:12 +0300 Subject: [PATCH 10/36] feat: support level task cancellation (asyncio) --- src/taskiq_cancellation/abc/__init__.py | 3 +- src/taskiq_cancellation/abc/backend.py | 252 +++++++++++++----- src/taskiq_cancellation/abc/notifier.py | 5 +- .../abc/started_listening_event.py | 11 + src/taskiq_cancellation/notifiers/null.py | 11 +- src/taskiq_cancellation/notifiers/queue.py | 8 +- src/taskiq_cancellation/utils.py | 23 +- tests/test_cancellation.py | 209 ++++++++++++--- 8 files changed, 412 insertions(+), 110 deletions(-) create mode 100644 src/taskiq_cancellation/abc/started_listening_event.py diff --git a/src/taskiq_cancellation/abc/__init__.py b/src/taskiq_cancellation/abc/__init__.py index 1decfa6..0e98393 100644 --- a/src/taskiq_cancellation/abc/__init__.py +++ b/src/taskiq_cancellation/abc/__init__.py @@ -1,6 +1,7 @@ from .backend import CancellationBackend from .notifier import CancellationNotifier from .state_holder import CancellationStateHolder +from .started_listening_event import StartedListeningEvent -__all__ = ["CancellationBackend", "CancellationNotifier", "CancellationStateHolder"] +__all__ = ["CancellationBackend", "CancellationNotifier", "CancellationStateHolder", "StartedListeningEvent"] diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index c3befb9..eb34c27 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,4 +1,6 @@ import abc +import enum +import inspect import asyncio from typing import Callable, Annotated, TypeVar, Awaitable from typing_extensions import ParamSpec, Self @@ -7,14 +9,21 @@ from anyio.abc import TaskStatus from taskiq import Context, TaskiqDepends, AsyncBroker, TaskiqEvents, TaskiqState -from taskiq_cancellation.utils import combines +from taskiq_cancellation.utils import combines, StopTaskGroupException from taskiq_cancellation.exceptions import TaskCancellationException +from .started_listening_event import StartedListeningEvent + P = ParamSpec("P") R = TypeVar("R") +class CancellationType(str, enum.Enum): + EDGE = "edge" + LEVEL = "level" + + class CancellationBackend(abc.ABC): """ Base class for cancellation backend @@ -48,7 +57,7 @@ async def cancel(self, task_id: str) -> None: @abc.abstractmethod async def listen_for_cancellation( - self, task_id: str, started_listening_task_status: TaskStatus + self, task_id: str, started_listening_event: StartedListeningEvent ) -> None: """ Listens for cancellation messages and raises :ref:`TaskCancellationException` when @@ -121,7 +130,10 @@ def with_broker(self, broker: AsyncBroker) -> Self: return self - def cancellable(self, task: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + def cancellable( + self, + cancellation_type: CancellationType = CancellationType.EDGE + ) -> Callable[[Callable[..., Awaitable]], Callable[..., Awaitable]]: """ Decorator that makes funcion cancellable @@ -136,73 +148,185 @@ def cancellable(self, task: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[ :param task: Task function to wrap :returns: Cancellable task function """ - # Executor type depends on receiver configuration which we can't accessed in any way - if not asyncio.iscoroutinefunction(task): - raise ValueError("Can't cancel synchronous function") - - @combines(task) - async def wrapper( - *args, __taskiq_context: Annotated[Context, TaskiqDepends()], **kwargs - ): - task_id = __taskiq_context.message.task_id - result = None - - listener_exception: Exception | None = None - task_exception: Exception | None = None - cancelled_by_request: bool = False - - async with anyio.create_task_group() as group: - - async def listen_for_cancellation( - task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED, - ): - nonlocal listener_exception, cancelled_by_request - - try: - await self.listen_for_cancellation(task_id, task_status) - except TaskCancellationException: - cancelled_by_request = True - except anyio.get_cancelled_exc_class(): - pass - except Exception as e: - listener_exception = e - finally: - group.cancel_scope.cancel() - - async def call_task(): - nonlocal result, task_exception - - try: - result = await task(*args, **kwargs) - except anyio.get_cancelled_exc_class(): - pass - except Exception as e: - task_exception = e - finally: - group.cancel_scope.cancel() - # Listen before checking for cancellation in state holder - # so the message won't get lost in non-persistent queues - await group.start(listen_for_cancellation) - if await self.is_cancelled(task_id): - cancelled_by_request = True - group.cancel_scope.cancel() + def decorator(task: Callable[P, Awaitable[R]]) -> Callable[..., Awaitable[R]]: + # Executor type depends on receiver configuration which we can't accessed in any way + if not inspect.iscoroutinefunction(task): + raise ValueError("Can't cancel synchronous function") + + @combines(task) + async def wrapper( + *args, __taskiq_context: Annotated[Context, TaskiqDepends()], **kwargs + ): + task_id = __taskiq_context.message.task_id + + if cancellation_type is CancellationType.EDGE: + task_wrapper = EdgeCancellationWrapper(self, task, task_id) + return await task_wrapper(*args, **kwargs) + elif cancellation_type is CancellationType.LEVEL: + task_wrapper = LevelCancellationWrapper(self, task, task_id) + return await task_wrapper(*args, **kwargs) else: - group.start_soon(call_task) - - if task_exception is not None: - raise task_exception - elif cancelled_by_request: - raise TaskCancellationException() - elif listener_exception is not None: - raise listener_exception - else: - return result + raise ValueError(f"Unknown cancellation type: {cancellation_type!r}") - return wrapper + return wrapper + return decorator + async def _broker_startup_handler(self, _: TaskiqState) -> None: await self.startup() async def _broker_shutdown_handler(self, _: TaskiqState) -> None: await self.shutdown() + + +class EdgeCancellationWrapper: + class ListeningEvent(StartedListeningEvent): + def __init__(self, task_status: TaskStatus) -> None: + self.task_status = task_status + + async def set(self): + self.task_status.started() + + async def wait(self): + # Can ignore, won't execute further before task status is set + pass + + def __init__(self, backend: CancellationBackend, task: Callable, task_id: str): + self.backend = backend + self.task = task + self.task_id = task_id + + async def __call__(self, *args, **kwargs): + result = None + + listener_exception: Exception | None = None + task_exception: Exception | None = None + cancelled_by_request: bool = False + + async with anyio.create_task_group() as group: + async def listen_for_cancellation( + task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ): + nonlocal listener_exception, cancelled_by_request + + event = self.ListeningEvent(task_status) + try: + await self.backend.listen_for_cancellation(self.task_id, event) + except TaskCancellationException: + cancelled_by_request = True + except anyio.get_cancelled_exc_class(): + pass + except Exception as e: + listener_exception = e + finally: + group.cancel_scope.cancel() + + async def call_task(): + nonlocal result, task_exception + + try: + result = await self.task(*args, **kwargs) + except anyio.get_cancelled_exc_class(): + pass + except Exception as e: + task_exception = e + finally: + group.cancel_scope.cancel() + + # Listen before checking for cancellation in state holder + # so the message won't get lost in non-persistent queues + await group.start(listen_for_cancellation) + if await self.backend.is_cancelled(self.task_id): + cancelled_by_request = True + group.cancel_scope.cancel() + else: + group.start_soon(call_task) + + if task_exception is not None: + raise task_exception + elif cancelled_by_request: + raise TaskCancellationException() + elif listener_exception is not None: + raise listener_exception + else: + return result + + +class LevelCancellationWrapper: + class ListeningEvent(StartedListeningEvent): + def __init__(self) -> None: + self.event = asyncio.Event() + + async def set(self): + loop = asyncio.get_running_loop() + loop.call_soon_threadsafe(self.event.set) + + async def wait(self): + await self.event.wait() + + def __init__(self, backend: CancellationBackend, task: Callable, task_id: str): + self.backend = backend + self.task = task + self.task_id = task_id + + async def __call__(self, *args, **kwargs): + result = None + + listener_exception: Exception | None = None + task_exception: Exception | None = None + cancelled_by_request: bool = False + + async def listen_for_cancellation(event: StartedListeningEvent): + nonlocal listener_exception, cancelled_by_request + + try: + await self.backend.listen_for_cancellation(self.task_id, event) + except TaskCancellationException: + cancelled_by_request = True + raise + except asyncio.CancelledError: + raise + except Exception as e: + listener_exception = e + raise + + async def call_task(): + nonlocal result, task_exception + + try: + result = await self.task(*args, **kwargs) + except asyncio.CancelledError: + raise + except Exception as e: + task_exception = e + raise + + try: + async with asyncio.TaskGroup() as tg: + # Listen before checking for cancellation in state holder + # so the message won't get lost in non-persistent queues + event = self.ListeningEvent() + tg.create_task(listen_for_cancellation(event)) + await event.wait() + + if await self.backend.is_cancelled(self.task_id): + cancelled_by_request = True + raise StopTaskGroupException() + + task_task = asyncio.create_task(call_task()) + await task_task + if not task_task.cancelled(): + raise StopTaskGroupException() + except Exception: + # Exceptions are stored in local vars, can ignore + pass + + if task_exception is not None: + raise task_exception + elif cancelled_by_request: + raise TaskCancellationException() + elif listener_exception is not None: + raise listener_exception + else: + return result diff --git a/src/taskiq_cancellation/abc/notifier.py b/src/taskiq_cancellation/abc/notifier.py index 3ba408f..5364e89 100644 --- a/src/taskiq_cancellation/abc/notifier.py +++ b/src/taskiq_cancellation/abc/notifier.py @@ -1,9 +1,10 @@ import abc -from anyio.abc import TaskStatus from taskiq.abc.serializer import TaskiqSerializer from taskiq.serializers import JSONSerializer +from .started_listening_event import StartedListeningEvent + class CancellationNotifier(abc.ABC): """Receives cancellation messages and notifies listeners of these messages""" @@ -31,7 +32,7 @@ async def cancel(self, task_id: str) -> None: @abc.abstractmethod async def listen_for_cancellation( - self, task_id: str, started_listening_task_status: TaskStatus + self, task_id: str, started_listening_event: StartedListeningEvent ) -> None: """ Listens for cancellation messages and raises :ref:`TaskCancellationException` when diff --git a/src/taskiq_cancellation/abc/started_listening_event.py b/src/taskiq_cancellation/abc/started_listening_event.py new file mode 100644 index 0000000..28a51eb --- /dev/null +++ b/src/taskiq_cancellation/abc/started_listening_event.py @@ -0,0 +1,11 @@ +import abc + + +class StartedListeningEvent(abc.ABC): + @abc.abstractmethod + async def set(self): + pass + + @abc.abstractmethod + async def wait(self): + pass diff --git a/src/taskiq_cancellation/notifiers/null.py b/src/taskiq_cancellation/notifiers/null.py index 9740012..7530df5 100644 --- a/src/taskiq_cancellation/notifiers/null.py +++ b/src/taskiq_cancellation/notifiers/null.py @@ -1,7 +1,6 @@ import asyncio -from anyio.abc import TaskStatus -from taskiq_cancellation.abc.notifier import CancellationNotifier +from taskiq_cancellation.abc import CancellationNotifier, StartedListeningEvent class NullCancellationNotifier(CancellationNotifier): @@ -14,6 +13,10 @@ class NullCancellationNotifier(CancellationNotifier): async def cancel(self, task_id: str) -> None: pass - async def listen_for_cancellation(self, task_id: str, started_listening_task_status: TaskStatus) -> None: - started_listening_task_status.started() + async def listen_for_cancellation( + self, + task_id: str, + started_listening_event: StartedListeningEvent + ) -> None: + await started_listening_event.set() await asyncio.sleep(float("+inf")) diff --git a/src/taskiq_cancellation/notifiers/queue.py b/src/taskiq_cancellation/notifiers/queue.py index 3190501..f7f8b6d 100644 --- a/src/taskiq_cancellation/notifiers/queue.py +++ b/src/taskiq_cancellation/notifiers/queue.py @@ -2,9 +2,7 @@ import weakref import asyncio -from anyio.abc import TaskStatus - -from taskiq_cancellation.abc import CancellationNotifier +from taskiq_cancellation.abc import CancellationNotifier, StartedListeningEvent from taskiq_cancellation.exceptions import TaskCancellationException from taskiq_cancellation.message import CancellationMessage @@ -30,7 +28,7 @@ async def shutdown(self) -> None: self.listener_task.cancel() async def listen_for_cancellation( - self, task_id: str, started_listening_task_status: TaskStatus + self, task_id: str, started_listening_event: StartedListeningEvent ) -> None: cancellations: asyncio.Queue[CancellationMessage] = asyncio.Queue() @@ -38,7 +36,7 @@ async def listen_for_cancellation( await self._create_listener_task() await self._subscribe(cancellations) - started_listening_task_status.started() + await started_listening_event.set() while True: cancellation_message = await cancellations.get() diff --git a/src/taskiq_cancellation/utils.py b/src/taskiq_cancellation/utils.py index 4d659f0..d94311b 100644 --- a/src/taskiq_cancellation/utils.py +++ b/src/taskiq_cancellation/utils.py @@ -5,7 +5,7 @@ from collections import OrderedDict -def combines(wrapped): +def combines(wrapped, add_var_parameters=False): """ Combines wrapped and wrapper functions signatures and type hints @@ -28,6 +28,11 @@ def foo(a: int, b = "lol"): print(inspect.signature(foo)) # (a: int, c: int, b='lol', *args, **kwargs) ''' + + :param wrapped: function to be wrapped + :type wrapped: Callable + :param add_var_parameters: add *args and **kwargs from wrapper to new signature + :type add_var_parameters: bool """ wrapped_signature: inspect.Signature = inspect.signature(wrapped) wrapped_type_hints: typing.Dict[str, str] = typing.get_type_hints(wrapped) @@ -42,8 +47,18 @@ def decorator(wrapper): f"Parameter {param_name} will be overwritten by wrapper function" ) + wrapper_parameters = OrderedDict() + for name, parameter in wrapper_signature.parameters.items(): + if not add_var_parameters: + if any(( + parameter.kind is inspect.Parameter.VAR_POSITIONAL, + parameter.kind is inspect.Parameter.VAR_KEYWORD + )): + continue + wrapper_parameters[name] = parameter + parameters = OrderedDict( - wrapped_signature.parameters, **wrapper_signature.parameters + wrapped_signature.parameters, **wrapper_parameters ) parameters = sorted( parameters.values(), @@ -70,4 +85,8 @@ def decorator(wrapper): return decorator +class StopTaskGroupException(Exception): + pass + + __all__ = ["combines"] diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index a790d1e..e3f6727 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -1,9 +1,10 @@ import pytest import asyncio +import anyio from taskiq import AsyncBroker, InMemoryBroker -from taskiq_cancellation.abc import CancellationBackend +from taskiq_cancellation.abc.backend import CancellationBackend, CancellationType from taskiq_cancellation.backends.in_memory import InMemoryCancellationBackend from taskiq_cancellation.exceptions import TaskCancellationException @@ -14,45 +15,189 @@ def broker(): @pytest.fixture -def backend(broker): +def backend(broker: AsyncBroker): return InMemoryCancellationBackend().with_broker(broker) -@pytest.mark.asyncio -async def test_task_success(broker: AsyncBroker, backend: CancellationBackend): - """Test that cancellable task can run successfully""" +class TestLevelCancellation: + @pytest.mark.asyncio + async def test_task_success(self, broker: AsyncBroker, backend: CancellationBackend): + @broker.task + @backend.cancellable(cancellation_type=CancellationType.LEVEL) + async def test_task(): + await asyncio.sleep(0.1) + + await broker.startup() + + task = await test_task.kiq() + result = await task.wait_result() + assert result.is_err is False + + await broker.shutdown() + + @pytest.mark.asyncio + async def test_task_cancellation(self, broker: AsyncBroker, backend: CancellationBackend): + @broker.task + @backend.cancellable(cancellation_type=CancellationType.LEVEL) + async def test_task(): + await asyncio.sleep(0.2) + raise ValueError() + + await broker.startup() + + task = await test_task.kiq() + assert await task.is_ready() is False + + await backend.cancel(task.task_id) + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() + + @pytest.mark.asyncio + async def test_cancellation_interception(self, broker: AsyncBroker, backend: CancellationBackend): + cancelled_for_second_time = False + + task_started = asyncio.Event() + + @broker.task + @backend.cancellable(cancellation_type=CancellationType.LEVEL) + async def test_task(): + nonlocal cancelled_for_second_time + + try: + task_started.set() + await asyncio.sleep(0.5) + except asyncio.CancelledError: + try: + await asyncio.sleep(0) + except asyncio.CancelledError: + cancelled_for_second_time = True + + await broker.startup() + + task = await test_task.kiq() + assert await task.is_ready() is False + + await task_started.wait() + await backend.cancel(task.task_id) + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + assert cancelled_for_second_time is False + + await broker.shutdown() + + +class TestEdgeCancellation: + @pytest.mark.asyncio + async def test_task_success(self, broker: AsyncBroker, backend: CancellationBackend): + @broker.task + @backend.cancellable(cancellation_type=CancellationType.EDGE) + async def test_task(): + await asyncio.sleep(0.1) + + await broker.startup() + + task = await test_task.kiq() + result = await task.wait_result() + assert result.is_err is False + + await broker.shutdown() + + @pytest.mark.asyncio + async def test_task_cancellation(self, broker: AsyncBroker, backend: CancellationBackend): + @broker.task + @backend.cancellable(cancellation_type=CancellationType.EDGE) + async def test_task(): + await asyncio.sleep(0.2) + + await broker.startup() + + task = await test_task.kiq() + assert await task.is_ready() is False + + await backend.cancel(task.task_id) + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() + + @pytest.mark.asyncio + async def test_repeated_cancellation(self, broker: AsyncBroker, backend: CancellationBackend): + cancelled_for_second_time = False + started_event = asyncio.Event() + + @broker.task + @backend.cancellable(cancellation_type=CancellationType.EDGE) + async def test_task(): + nonlocal cancelled_for_second_time + + try: + started_event.set() + await asyncio.sleep(1) + except anyio.get_cancelled_exc_class(): + # anyio cancels on any await after scope's cancellation + try: + await asyncio.sleep(0) + except anyio.get_cancelled_exc_class(): + cancelled_for_second_time = True + + await broker.startup() + + task = await test_task.kiq() + assert await task.is_ready() is False + + await started_event.wait() + await backend.cancel(task.task_id) + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + assert cancelled_for_second_time is True + + await broker.shutdown() + +# @pytest.mark.asyncio +# async def test_task_success(broker: AsyncBroker, backend: CancellationBackend): +# """Test that cancellable task can run successfully""" + +# @broker.task +# @backend.cancellable +# async def task(): +# await asyncio.sleep(0.1) + +# await broker.startup() + +# t = await task.kiq() - @broker.task - @backend.cancellable - async def task(): - await asyncio.sleep(0.1) +# result = await t.wait_result() +# assert result.is_err is False - await broker.startup() +# await broker.shutdown() - t = await task.kiq() - result = await t.wait_result() - assert result.is_err is False +# @pytest.mark.asyncio +# async def test_task_cancellation(broker: AsyncBroker, backend: CancellationBackend): +# """Test that cancellable task can be cancelled""" - await broker.shutdown() +# @broker.task +# @backend.cancellable +# async def task(): +# await asyncio.sleep(0.3) +# await broker.startup() + +# t = await task.kiq() +# await backend.cancel(t.task_id) -@pytest.mark.asyncio -async def test_task_cancellation(broker: AsyncBroker, backend: CancellationBackend): - """Test that cancellable task can be cancelled""" +# with pytest.raises(TaskCancellationException): +# result = await t.wait_result() +# result.raise_for_error() - @broker.task - @backend.cancellable - async def task(): - await asyncio.sleep(0.3) - - await broker.startup() - - t = await task.kiq() - await backend.cancel(t.task_id) - - with pytest.raises(TaskCancellationException): - result = await t.wait_result() - result.raise_for_error() - - await broker.shutdown() +# await broker.shutdown() From 93ab0091543a43fdc70b8d323ab00a87be2a03a2 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sat, 8 Nov 2025 23:54:23 +0300 Subject: [PATCH 11/36] test: level and edge cancellation behaviour tests --- tests/test_cancellation.py | 186 +++++++++++++++---------------------- 1 file changed, 77 insertions(+), 109 deletions(-) diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index e3f6727..2cd665e 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -19,47 +19,80 @@ def backend(broker: AsyncBroker): return InMemoryCancellationBackend().with_broker(broker) -class TestLevelCancellation: - @pytest.mark.asyncio - async def test_task_success(self, broker: AsyncBroker, backend: CancellationBackend): - @broker.task - @backend.cancellable(cancellation_type=CancellationType.LEVEL) - async def test_task(): - await asyncio.sleep(0.1) - - await broker.startup() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("cancellation_type"), (CancellationType.LEVEL, CancellationType.EDGE) +) +async def test_task_success( + broker: AsyncBroker, + backend: CancellationBackend, + cancellation_type: CancellationType, +): + """Tests that cancellable task can successfully finish""" + + @broker.task + @backend.cancellable(cancellation_type=cancellation_type) + async def test_task(): + await asyncio.sleep(0.1) + + await broker.startup() + + task = await test_task.kiq() + result = await task.wait_result() + assert result.is_err is False + + await broker.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("cancellation_type"), (CancellationType.LEVEL, CancellationType.EDGE) +) +async def test_task_cancellation( + broker: AsyncBroker, + backend: CancellationBackend, + cancellation_type: CancellationType, +): + """Tests that cancellable task can successfully cancel""" + + started_event = asyncio.Event() + + @broker.task + @backend.cancellable(cancellation_type=cancellation_type) + async def test_task(): + with pytest.raises(anyio.get_cancelled_exc_class()): + started_event.set() + await asyncio.sleep(0.2) - task = await test_task.kiq() - result = await task.wait_result() - assert result.is_err is False + await broker.startup() - await broker.shutdown() + task = await test_task.kiq() + assert await task.is_ready() is False - @pytest.mark.asyncio - async def test_task_cancellation(self, broker: AsyncBroker, backend: CancellationBackend): - @broker.task - @backend.cancellable(cancellation_type=CancellationType.LEVEL) - async def test_task(): - await asyncio.sleep(0.2) - raise ValueError() - - await broker.startup() + await started_event.wait() + await backend.cancel(task.task_id) - task = await test_task.kiq() - assert await task.is_ready() is False + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() - await backend.cancel(task.task_id) + await broker.shutdown() - with pytest.raises(TaskCancellationException): - result = await task.wait_result() - result.raise_for_error() - await broker.shutdown() - +class TestLevelCancellation: @pytest.mark.asyncio - async def test_cancellation_interception(self, broker: AsyncBroker, backend: CancellationBackend): - cancelled_for_second_time = False + async def test_cancellation_interception( + self, broker: AsyncBroker, backend: CancellationBackend + ): + """ + Tests that tasks can capture task cancellation + + asyncio raises asyncio.CancelledError only once. Task can intercept that to do cleanup, + but also can just ignore the cancellation request. + Docs: https://docs.python.org/3/library/asyncio-task.html#task-cancellation + """ + cancelled_for_second_time = False task_started = asyncio.Event() @broker.task @@ -75,12 +108,12 @@ async def test_task(): await asyncio.sleep(0) except asyncio.CancelledError: cancelled_for_second_time = True - + await broker.startup() task = await test_task.kiq() assert await task.is_ready() is False - + await task_started.wait() await backend.cancel(task.task_id) @@ -94,42 +127,16 @@ async def test_task(): class TestEdgeCancellation: @pytest.mark.asyncio - async def test_task_success(self, broker: AsyncBroker, backend: CancellationBackend): - @broker.task - @backend.cancellable(cancellation_type=CancellationType.EDGE) - async def test_task(): - await asyncio.sleep(0.1) - - await broker.startup() + async def test_repeated_cancellation( + self, broker: AsyncBroker, backend: CancellationBackend + ): + """ + Tests that task will have multiple cancellation exceptions - task = await test_task.kiq() - result = await task.wait_result() - assert result.is_err is False + anyio raises cancellation exception on every await + Docs: https://anyio.readthedocs.io/en/stable/cancellation.html#differences-between-asyncio-and-anyio-cancellation-semantics + """ - await broker.shutdown() - - @pytest.mark.asyncio - async def test_task_cancellation(self, broker: AsyncBroker, backend: CancellationBackend): - @broker.task - @backend.cancellable(cancellation_type=CancellationType.EDGE) - async def test_task(): - await asyncio.sleep(0.2) - - await broker.startup() - - task = await test_task.kiq() - assert await task.is_ready() is False - - await backend.cancel(task.task_id) - - with pytest.raises(TaskCancellationException): - result = await task.wait_result() - result.raise_for_error() - - await broker.shutdown() - - @pytest.mark.asyncio - async def test_repeated_cancellation(self, broker: AsyncBroker, backend: CancellationBackend): cancelled_for_second_time = False started_event = asyncio.Event() @@ -140,7 +147,7 @@ async def test_task(): try: started_event.set() - await asyncio.sleep(1) + await asyncio.sleep(0.5) except anyio.get_cancelled_exc_class(): # anyio cancels on any await after scope's cancellation try: @@ -152,7 +159,7 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - + await started_event.wait() await backend.cancel(task.task_id) @@ -162,42 +169,3 @@ async def test_task(): assert cancelled_for_second_time is True await broker.shutdown() - -# @pytest.mark.asyncio -# async def test_task_success(broker: AsyncBroker, backend: CancellationBackend): -# """Test that cancellable task can run successfully""" - -# @broker.task -# @backend.cancellable -# async def task(): -# await asyncio.sleep(0.1) - -# await broker.startup() - -# t = await task.kiq() - -# result = await t.wait_result() -# assert result.is_err is False - -# await broker.shutdown() - - -# @pytest.mark.asyncio -# async def test_task_cancellation(broker: AsyncBroker, backend: CancellationBackend): -# """Test that cancellable task can be cancelled""" - -# @broker.task -# @backend.cancellable -# async def task(): -# await asyncio.sleep(0.3) - -# await broker.startup() - -# t = await task.kiq() -# await backend.cancel(t.task_id) - -# with pytest.raises(TaskCancellationException): -# result = await t.wait_result() -# result.raise_for_error() - -# await broker.shutdown() From c9eb438e0561f9ef1e8a198300a7bbd55b27dd4a Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 00:26:45 +0300 Subject: [PATCH 12/36] feat: allow cancellable decorator to omit parentesis --- src/taskiq_cancellation/abc/backend.py | 85 +++++++++++++++++--------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index eb34c27..d3eb233 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -2,8 +2,8 @@ import enum import inspect import asyncio -from typing import Callable, Annotated, TypeVar, Awaitable -from typing_extensions import ParamSpec, Self +from typing import Callable, Annotated, Awaitable, overload, Optional, cast, TypeAlias +from typing_extensions import Self import anyio from anyio.abc import TaskStatus @@ -15,8 +15,7 @@ from .started_listening_event import StartedListeningEvent -P = ParamSpec("P") -R = TypeVar("R") +AsyncCallable: TypeAlias = Callable[..., Awaitable] class CancellationType(str, enum.Enum): @@ -129,11 +128,25 @@ def with_broker(self, broker: AsyncBroker) -> Self: ) return self + + @overload + def cancellable( + self, + cancellation_type: AsyncCallable + ) -> AsyncCallable: + pass + @overload def cancellable( self, - cancellation_type: CancellationType = CancellationType.EDGE - ) -> Callable[[Callable[..., Awaitable]], Callable[..., Awaitable]]: + cancellation_type: Optional[CancellationType] = None + ) -> Callable[[AsyncCallable], AsyncCallable]: + pass + + def cancellable( + self, + cancellation_type = None + ): """ Decorator that makes funcion cancellable @@ -149,29 +162,43 @@ def cancellable( :returns: Cancellable task function """ - def decorator(task: Callable[P, Awaitable[R]]) -> Callable[..., Awaitable[R]]: - # Executor type depends on receiver configuration which we can't accessed in any way - if not inspect.iscoroutinefunction(task): - raise ValueError("Can't cancel synchronous function") - - @combines(task) - async def wrapper( - *args, __taskiq_context: Annotated[Context, TaskiqDepends()], **kwargs - ): - task_id = __taskiq_context.message.task_id - - if cancellation_type is CancellationType.EDGE: - task_wrapper = EdgeCancellationWrapper(self, task, task_id) - return await task_wrapper(*args, **kwargs) - elif cancellation_type is CancellationType.LEVEL: - task_wrapper = LevelCancellationWrapper(self, task, task_id) - return await task_wrapper(*args, **kwargs) - else: - raise ValueError(f"Unknown cancellation type: {cancellation_type!r}") - - return wrapper - return decorator - + defaults = { + "cancellation_type": CancellationType.EDGE + } + + def make_decorator( + cancellation_type: CancellationType + ): + def decorator(task: AsyncCallable) -> AsyncCallable: + # Executor type depends on receiver configuration which we can't accessed in any way + if not inspect.iscoroutinefunction(task): + raise ValueError("Can't cancel synchronous function") + + @combines(task) + async def wrapper( + *args, __taskiq_context: Annotated[Context, TaskiqDepends()], **kwargs + ): + task_id = __taskiq_context.message.task_id + + if cancellation_type is CancellationType.EDGE: + task_wrapper = EdgeCancellationWrapper(self, task, task_id) + return await task_wrapper(*args, **kwargs) + elif cancellation_type is CancellationType.LEVEL: + task_wrapper = LevelCancellationWrapper(self, task, task_id) + return await task_wrapper(*args, **kwargs) + else: + raise ValueError(f"Unknown cancellation type: {cancellation_type!r}") + + return wrapper + return decorator + + if callable(cancellation_type): + task = cast(Callable[..., Awaitable], cancellation_type) + return make_decorator(**defaults)(task) + else: + return make_decorator( + cancellation_type=cancellation_type or defaults["cancellation_type"] + ) async def _broker_startup_handler(self, _: TaskiqState) -> None: await self.startup() From 69746a9105d8eb9f1600c37917c1dd9da2dbf2b6 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 00:41:45 +0300 Subject: [PATCH 13/36] fix: allow direct function calls --- src/taskiq_cancellation/abc/backend.py | 8 +++++++- tests/test_cancellation.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index d3eb233..4b895f7 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -176,8 +176,14 @@ def decorator(task: AsyncCallable) -> AsyncCallable: @combines(task) async def wrapper( - *args, __taskiq_context: Annotated[Context, TaskiqDepends()], **kwargs + *args, + __taskiq_context: Annotated[Context, TaskiqDepends()] = None, # type: ignore + **kwargs ): + if __taskiq_context is None: + # Ran the function directly, without kiq + return task(*args, **kwargs) + task_id = __taskiq_context.message.task_id if cancellation_type is CancellationType.EDGE: diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 2cd665e..1b51f3f 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -19,6 +19,17 @@ def backend(broker: AsyncBroker): return InMemoryCancellationBackend().with_broker(broker) +@pytest.mark.asyncio +async def test_task_direct_call(broker: AsyncBroker, backend: CancellationBackend): + @broker.task + @backend.cancellable() + async def test_task(): + return True + + result = await test_task() + assert result + + @pytest.mark.asyncio @pytest.mark.parametrize( ("cancellation_type"), (CancellationType.LEVEL, CancellationType.EDGE) From 9e46f4ab74f2ee58132c695c2d7c38ef057d7be3 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 00:43:17 +0300 Subject: [PATCH 14/36] test: test cancellable w/o parentesis --- tests/test_backend.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_backend.py diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..81d2a8c --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,35 @@ +import pytest +import inspect + +from taskiq import AsyncBroker, InMemoryBroker + +from taskiq_cancellation.abc.backend import CancellationBackend +from taskiq_cancellation.backends.in_memory import InMemoryCancellationBackend + + +@pytest.fixture +def backend(): + return InMemoryCancellationBackend() + + +@pytest.mark.asyncio +async def test_decorator_without_parentesis(backend: CancellationBackend): + @backend.cancellable + async def test_task(): + pass + + task = test_task() + assert inspect.iscoroutine(task) + await task + + +@pytest.mark.asyncio +async def test_decorator_with_parentesis(backend: CancellationBackend): + @backend.cancellable() + async def test_task(): + pass + + task = test_task() + assert inspect.iscoroutine(task) + await task + \ No newline at end of file From 3162c18090ee637f669b5b5f990c0d01f0c31ef6 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 00:44:28 +0300 Subject: [PATCH 15/36] fix,test: remove deadlock from waiting for task to start --- tests/test_cancellation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 1b51f3f..71b6d33 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -80,7 +80,8 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - await started_event.wait() + async with asyncio.timeout(1): + await started_event.wait() await backend.cancel(task.task_id) with pytest.raises(TaskCancellationException): @@ -125,7 +126,8 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - await task_started.wait() + async with asyncio.timeout(1): + await task_started.wait() await backend.cancel(task.task_id) with pytest.raises(TaskCancellationException): @@ -171,7 +173,8 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - await started_event.wait() + async with asyncio.timeout(1): + await started_event.wait() await backend.cancel(task.task_id) with pytest.raises(TaskCancellationException): From acf37248b58116cf1f0ecfc62561046613d2b975 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 00:55:17 +0300 Subject: [PATCH 16/36] fix: await for task when direct calling --- src/taskiq_cancellation/abc/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 4b895f7..a88ff56 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -182,7 +182,7 @@ async def wrapper( ): if __taskiq_context is None: # Ran the function directly, without kiq - return task(*args, **kwargs) + return await task(*args, **kwargs) task_id = __taskiq_context.message.task_id From 6d990f43ab5c3837691eefeccebf9875a77128ce Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 01:03:57 +0300 Subject: [PATCH 17/36] ci: run tests on windows and macos (just like taskiq) --- .github/workflows/run_tests.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index 3a45a80..fdd04d6 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -6,13 +6,13 @@ on: jobs: run-tests: - runs-on: ubuntu-latest - strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + os: [ubuntu-latest, windows-latest, macos-latest] fail-fast: false + runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v5 @@ -25,8 +25,8 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@v7 - - name: Create virtual environment - run: uv venv .venv && source .venv/bin/activate + # - name: Create virtual environment + # run: uv venv .venv && source .venv/bin/activate - name: Install modules run: uv sync From 9b6df4f62b641f43dea65d20d01ddf1a9fa8f3e0 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 01:23:19 +0300 Subject: [PATCH 18/36] fix: add async_timeout for Python <3.11 --- pyproject.toml | 6 +++++- src/taskiq_cancellation/abc/backend.py | 7 ++++++- tests/test_cancellation.py | 12 +++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 869d263..8a9e60f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,11 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] -dependencies = ["taskiq", "typing-extensions>=4.13.2"] +dependencies = [ + "async-timeout>=5.0.1 ; python_full_version < '3.11'", + "taskiq", + "typing-extensions>=4.15.0 ; python_full_version < '3.11'", +] [project.optional-dependencies] redis = ["redis~=3.0"] diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index a88ff56..1231e5f 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,9 +1,14 @@ import abc +import sys import enum import inspect import asyncio from typing import Callable, Annotated, Awaitable, overload, Optional, cast, TypeAlias -from typing_extensions import Self + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self import anyio from anyio.abc import TaskStatus diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 71b6d33..54531d6 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -1,6 +1,12 @@ +import sys import pytest import asyncio +if sys.version_info >= (3, 11): + from asyncio import timeout +else: + from async_timeout import timeout + import anyio from taskiq import AsyncBroker, InMemoryBroker @@ -80,7 +86,7 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - async with asyncio.timeout(1): + async with timeout(1): await started_event.wait() await backend.cancel(task.task_id) @@ -126,7 +132,7 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - async with asyncio.timeout(1): + async with timeout(1): await task_started.wait() await backend.cancel(task.task_id) @@ -173,7 +179,7 @@ async def test_task(): task = await test_task.kiq() assert await task.is_ready() is False - async with asyncio.timeout(1): + async with timeout(1): await started_event.wait() await backend.cancel(task.task_id) From 253cf73cb177fbe0351d8adf08440539d1db568e Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 01:25:15 +0300 Subject: [PATCH 19/36] chore: ruff check fixes and ruff format --- src/taskiq_cancellation/__init__.py | 5 +- src/taskiq_cancellation/abc/__init__.py | 7 +- src/taskiq_cancellation/abc/backend.py | 64 +++++++++---------- src/taskiq_cancellation/abc/notifier.py | 8 +-- src/taskiq_cancellation/abc/state_holder.py | 2 +- src/taskiq_cancellation/backends/in_memory.py | 2 +- src/taskiq_cancellation/backends/modular.py | 13 ++-- .../notifiers/in_memory.py | 9 +-- src/taskiq_cancellation/notifiers/null.py | 8 +-- src/taskiq_cancellation/notifiers/queue.py | 1 + .../state_holders/in_memory.py | 4 +- src/taskiq_cancellation/state_holders/null.py | 4 +- src/taskiq_cancellation/utils.py | 16 ++--- tests/test_backend.py | 3 - tests/test_cancellation.py | 2 +- 15 files changed, 71 insertions(+), 77 deletions(-) diff --git a/src/taskiq_cancellation/__init__.py b/src/taskiq_cancellation/__init__.py index 57864e4..db24b56 100644 --- a/src/taskiq_cancellation/__init__.py +++ b/src/taskiq_cancellation/__init__.py @@ -2,7 +2,4 @@ from .backends.modular import ModularCancellationBackend -__all__ = [ - "CancellationBackend", - "ModularCancellationBackend" -] +__all__ = ["CancellationBackend", "ModularCancellationBackend"] diff --git a/src/taskiq_cancellation/abc/__init__.py b/src/taskiq_cancellation/abc/__init__.py index 0e98393..45239e2 100644 --- a/src/taskiq_cancellation/abc/__init__.py +++ b/src/taskiq_cancellation/abc/__init__.py @@ -4,4 +4,9 @@ from .started_listening_event import StartedListeningEvent -__all__ = ["CancellationBackend", "CancellationNotifier", "CancellationStateHolder", "StartedListeningEvent"] +__all__ = [ + "CancellationBackend", + "CancellationNotifier", + "CancellationStateHolder", + "StartedListeningEvent", +] diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 1231e5f..d3fed52 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -32,6 +32,7 @@ class CancellationBackend(abc.ABC): """ Base class for cancellation backend """ + def __init__(self) -> None: super().__init__() @@ -41,7 +42,7 @@ def __init__(self) -> None: async def is_cancelled(self, task_id: str) -> bool: """ Checks if a task with task id of *task_id* is set to be cancelled - + :param task_id: task id to check :type task_id: str :returns: task cancellation state @@ -67,13 +68,13 @@ async def listen_for_cancellation( Listens for cancellation messages and raises :ref:`TaskCancellationException` when receives :ref:`CancellationMessage` with same id as *task_id*. - This function is used in :func:`cancellable` decorator. - Call `started_listening_task_status.started()` when the listener is ready + This function is used in :func:`cancellable` decorator. + Call `started_listening_task_status.started()` when the listener is ready to receive messages. - :param task_id: id of task that will be listened for + :param task_id: id of task that will be listened for :type task_id: str - :param started_listening_task_status: + :param started_listening_task_status: :type started_listening_task_status: anyio.abc.TaskStatus """ pass @@ -88,7 +89,7 @@ async def startup(self) -> None: async def shutdown(self) -> None: """Shuts down cancellation backend - + Triggered only if backend has a broker set. To set a broker use :ref:`with_broker`. """ pass @@ -97,7 +98,7 @@ def with_broker(self, broker: AsyncBroker) -> Self: """ Set a broker and return updated cancellation backend - Sets up startup and shutdown event handlers for backend's startup + Sets up startup and shutdown event handlers for backend's startup and shutdown methods respectfully :param broker: new broker @@ -133,25 +134,18 @@ def with_broker(self, broker: AsyncBroker) -> Self: ) return self - + @overload - def cancellable( - self, - cancellation_type: AsyncCallable - ) -> AsyncCallable: + def cancellable(self, cancellation_type: AsyncCallable) -> AsyncCallable: pass @overload def cancellable( - self, - cancellation_type: Optional[CancellationType] = None + self, cancellation_type: Optional[CancellationType] = None ) -> Callable[[AsyncCallable], AsyncCallable]: pass - def cancellable( - self, - cancellation_type = None - ): + def cancellable(self, cancellation_type=None): """ Decorator that makes funcion cancellable @@ -167,13 +161,9 @@ def cancellable( :returns: Cancellable task function """ - defaults = { - "cancellation_type": CancellationType.EDGE - } + defaults = {"cancellation_type": CancellationType.EDGE} - def make_decorator( - cancellation_type: CancellationType - ): + def make_decorator(cancellation_type: CancellationType): def decorator(task: AsyncCallable) -> AsyncCallable: # Executor type depends on receiver configuration which we can't accessed in any way if not inspect.iscoroutinefunction(task): @@ -181,9 +171,9 @@ def decorator(task: AsyncCallable) -> AsyncCallable: @combines(task) async def wrapper( - *args, + *args, __taskiq_context: Annotated[Context, TaskiqDepends()] = None, # type: ignore - **kwargs + **kwargs, ): if __taskiq_context is None: # Ran the function directly, without kiq @@ -192,17 +182,20 @@ async def wrapper( task_id = __taskiq_context.message.task_id if cancellation_type is CancellationType.EDGE: - task_wrapper = EdgeCancellationWrapper(self, task, task_id) + task_wrapper = EdgeCancellationWrapper(self, task, task_id) return await task_wrapper(*args, **kwargs) - elif cancellation_type is CancellationType.LEVEL: - task_wrapper = LevelCancellationWrapper(self, task, task_id) + elif cancellation_type is CancellationType.LEVEL: + task_wrapper = LevelCancellationWrapper(self, task, task_id) return await task_wrapper(*args, **kwargs) else: - raise ValueError(f"Unknown cancellation type: {cancellation_type!r}") + raise ValueError( + f"Unknown cancellation type: {cancellation_type!r}" + ) return wrapper + return decorator - + if callable(cancellation_type): task = cast(Callable[..., Awaitable], cancellation_type) return make_decorator(**defaults)(task) @@ -229,7 +222,7 @@ async def set(self): async def wait(self): # Can ignore, won't execute further before task status is set pass - + def __init__(self, backend: CancellationBackend, task: Callable, task_id: str): self.backend = backend self.task = task @@ -243,6 +236,7 @@ async def __call__(self, *args, **kwargs): cancelled_by_request: bool = False async with anyio.create_task_group() as group: + async def listen_for_cancellation( task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ): @@ -295,7 +289,7 @@ class LevelCancellationWrapper: class ListeningEvent(StartedListeningEvent): def __init__(self) -> None: self.event = asyncio.Event() - + async def set(self): loop = asyncio.get_running_loop() loop.call_soon_threadsafe(self.event.set) @@ -351,11 +345,11 @@ async def call_task(): if await self.backend.is_cancelled(self.task_id): cancelled_by_request = True raise StopTaskGroupException() - + task_task = asyncio.create_task(call_task()) await task_task if not task_task.cancelled(): - raise StopTaskGroupException() + raise StopTaskGroupException() except Exception: # Exceptions are stored in local vars, can ignore pass diff --git a/src/taskiq_cancellation/abc/notifier.py b/src/taskiq_cancellation/abc/notifier.py index 5364e89..938618a 100644 --- a/src/taskiq_cancellation/abc/notifier.py +++ b/src/taskiq_cancellation/abc/notifier.py @@ -38,13 +38,13 @@ async def listen_for_cancellation( Listens for cancellation messages and raises :ref:`TaskCancellationException` when receives :ref:`CancellationMessage` with same id as *task_id*. - This function is used in :func:`cancellable` decorator of :ref:`ModularCancellationBackend`. - Call `started_listening_task_status.started()` when the listener is ready + This function is used in :func:`cancellable` decorator of :ref:`ModularCancellationBackend`. + Call `started_listening_task_status.started()` when the listener is ready to receive messages. - :param task_id: id of task that will be listened for + :param task_id: id of task that will be listened for :type task_id: str - :param started_listening_task_status: + :param started_listening_task_status: :type started_listening_task_status: anyio.abc.TaskStatus """ pass diff --git a/src/taskiq_cancellation/abc/state_holder.py b/src/taskiq_cancellation/abc/state_holder.py index 8416440..bb2a707 100644 --- a/src/taskiq_cancellation/abc/state_holder.py +++ b/src/taskiq_cancellation/abc/state_holder.py @@ -18,7 +18,7 @@ async def cancel(self, task_id: str) -> None: async def is_cancelled(self, task_id: str) -> bool: """ Checks if a task with task id of *task_id* is set to be cancelled - + :param task_id: task id to check :type task_id: str :returns: task cancellation state diff --git a/src/taskiq_cancellation/backends/in_memory.py b/src/taskiq_cancellation/backends/in_memory.py index ee5e385..6c8f172 100644 --- a/src/taskiq_cancellation/backends/in_memory.py +++ b/src/taskiq_cancellation/backends/in_memory.py @@ -8,5 +8,5 @@ class InMemoryCancellationBackend(ModularCancellationBackend): def __init__(self, **kwargs): super().__init__( state_holder=InMemoryCancellationStateHolder(**kwargs), - notifier=InMemoryCancellationNotifier(**kwargs) + notifier=InMemoryCancellationNotifier(**kwargs), ) diff --git a/src/taskiq_cancellation/backends/modular.py b/src/taskiq_cancellation/backends/modular.py index 2d87dfe..a1e7b49 100644 --- a/src/taskiq_cancellation/backends/modular.py +++ b/src/taskiq_cancellation/backends/modular.py @@ -1,4 +1,8 @@ -from taskiq_cancellation.abc import CancellationBackend, CancellationNotifier, CancellationStateHolder +from taskiq_cancellation.abc import ( + CancellationBackend, + CancellationNotifier, + CancellationStateHolder, +) import anyio from anyio.abc import TaskStatus @@ -6,12 +10,13 @@ class ModularCancellationBackend(CancellationBackend): """ - Modular cancellation backend made up of :class:`CancellationStateHolder` + Modular cancellation backend made up of :class:`CancellationStateHolder` and :class:`CancellationNotifier` - - `CancellationStateHolder` stores cancellation state and blocks the task from being run. + - `CancellationStateHolder` stores cancellation state and blocks the task from being run. - `CancellationNotifier` receives cancellation messages and cancels already running tasks. """ + def __init__( self, state_holder: CancellationStateHolder, notifier: CancellationNotifier ): @@ -39,7 +44,7 @@ async def startup(self) -> None: await super().startup() await self.state_holder.startup() await self.notifier.startup() - + async def shutdown(self) -> None: await super().shutdown() await self.state_holder.shutdown() diff --git a/src/taskiq_cancellation/notifiers/in_memory.py b/src/taskiq_cancellation/notifiers/in_memory.py index 748a129..b03dbeb 100644 --- a/src/taskiq_cancellation/notifiers/in_memory.py +++ b/src/taskiq_cancellation/notifiers/in_memory.py @@ -11,17 +11,14 @@ class InMemoryCancellationNotifier(QueueCancellationNotifier): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - + self.messages: asyncio.Queue[CancellationMessage] = asyncio.Queue() async def cancel(self, task_id: str) -> None: timestamp = time.time() - + await self.messages.put( - CancellationMessage( - task_id=task_id, - timestamp=timestamp - ) + CancellationMessage(task_id=task_id, timestamp=timestamp) ) async def _listen(self, started_listening: asyncio.Event) -> None: diff --git a/src/taskiq_cancellation/notifiers/null.py b/src/taskiq_cancellation/notifiers/null.py index 7530df5..bb19211 100644 --- a/src/taskiq_cancellation/notifiers/null.py +++ b/src/taskiq_cancellation/notifiers/null.py @@ -6,17 +6,15 @@ class NullCancellationNotifier(CancellationNotifier): """ \"Do nothing\" cancellation notifier - + May be useful if there's no need or ability to use an actual notifier """ - + async def cancel(self, task_id: str) -> None: pass async def listen_for_cancellation( - self, - task_id: str, - started_listening_event: StartedListeningEvent + self, task_id: str, started_listening_event: StartedListeningEvent ) -> None: await started_listening_event.set() await asyncio.sleep(float("+inf")) diff --git a/src/taskiq_cancellation/notifiers/queue.py b/src/taskiq_cancellation/notifiers/queue.py index f7f8b6d..28ef1ee 100644 --- a/src/taskiq_cancellation/notifiers/queue.py +++ b/src/taskiq_cancellation/notifiers/queue.py @@ -14,6 +14,7 @@ class QueueCancellationNotifier(CancellationNotifier): Requires :func:`_listen` to be implemeted """ + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) diff --git a/src/taskiq_cancellation/state_holders/in_memory.py b/src/taskiq_cancellation/state_holders/in_memory.py index 23a7089..b63277e 100644 --- a/src/taskiq_cancellation/state_holders/in_memory.py +++ b/src/taskiq_cancellation/state_holders/in_memory.py @@ -3,10 +3,10 @@ class InMemoryCancellationStateHolder(CancellationStateHolder): """In memory cancellation state holder used for testing""" - + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - + self.state_holder: dict[str, bool] = {} async def cancel(self, task_id: str) -> None: diff --git a/src/taskiq_cancellation/state_holders/null.py b/src/taskiq_cancellation/state_holders/null.py index 4383071..b24462d 100644 --- a/src/taskiq_cancellation/state_holders/null.py +++ b/src/taskiq_cancellation/state_holders/null.py @@ -4,10 +4,10 @@ class NullCancellationStateHolder(CancellationStateHolder): """ \"Do nothing\" cancellation state holder - + May be useful if there's no need or ability to use an actual state holder """ - + async def cancel(self, task_id: str) -> None: pass diff --git a/src/taskiq_cancellation/utils.py b/src/taskiq_cancellation/utils.py index d94311b..9a9bd4f 100644 --- a/src/taskiq_cancellation/utils.py +++ b/src/taskiq_cancellation/utils.py @@ -50,16 +50,16 @@ def decorator(wrapper): wrapper_parameters = OrderedDict() for name, parameter in wrapper_signature.parameters.items(): if not add_var_parameters: - if any(( - parameter.kind is inspect.Parameter.VAR_POSITIONAL, - parameter.kind is inspect.Parameter.VAR_KEYWORD - )): + if any( + ( + parameter.kind is inspect.Parameter.VAR_POSITIONAL, + parameter.kind is inspect.Parameter.VAR_KEYWORD, + ) + ): continue wrapper_parameters[name] = parameter - - parameters = OrderedDict( - wrapped_signature.parameters, **wrapper_parameters - ) + + parameters = OrderedDict(wrapped_signature.parameters, **wrapper_parameters) parameters = sorted( parameters.values(), key=lambda p: p.kind + (0.5 if p.default != inspect.Parameter.empty else 0), diff --git a/tests/test_backend.py b/tests/test_backend.py index 81d2a8c..4c4b8c2 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,8 +1,6 @@ import pytest import inspect -from taskiq import AsyncBroker, InMemoryBroker - from taskiq_cancellation.abc.backend import CancellationBackend from taskiq_cancellation.backends.in_memory import InMemoryCancellationBackend @@ -32,4 +30,3 @@ async def test_task(): task = test_task() assert inspect.iscoroutine(task) await task - \ No newline at end of file diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 54531d6..5494673 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -32,7 +32,7 @@ async def test_task_direct_call(broker: AsyncBroker, backend: CancellationBacken async def test_task(): return True - result = await test_task() + result = await test_task() assert result From fda260c331b19c30c853644ac3fed4047c3b9f7a Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 01:26:47 +0300 Subject: [PATCH 20/36] fix: adapt counter example to new module structure --- examples/counter/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/counter/main.py b/examples/counter/main.py index a148aae..61f9789 100644 --- a/examples/counter/main.py +++ b/examples/counter/main.py @@ -1,7 +1,7 @@ import asyncio from taskiq_redis import PubSubBroker, RedisAsyncResultBackend -from taskiq_cancellation.integrations.redis import RedisCancellationBackend +from taskiq_cancellation.backends.redis import RedisCancellationBackend url = "redis://localhost" From 4581f462b06f9126666260a12e6f6abc83686a55 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Sun, 9 Nov 2025 22:23:58 +0300 Subject: [PATCH 21/36] fix: a lot of things 1. 3.10 tests crashing with "TypeError: Cannot instantiate typing.Union" Added type to TaskiqDepends so dependency would instantiate correctly 2. 3.9 tests crashing with "ImportError: cannot import name 'TypeAlias' from 'typing'" Added TypeAlias from typing_extensions 3. Rename LevelCancellation to EdgeCancellation and vice versa because my terminology was wrong :p 4. Move cancellation handlers to a separate submodule to restrict edge cancellation to Python 3.11+ because that uses asyncio.TaskGroup 5. InMemoryBackend asyncio.Queue behaviour changes for 3.9 support 6. Typing fixes --- pyproject.toml | 13 +- src/taskiq_cancellation/abc/backend.py | 191 ++---------------- src/taskiq_cancellation/backends/modular.py | 8 +- .../cancellation_handlers/__init__.py | 22 ++ .../cancellation_type.py | 6 + .../cancellation_handlers/edge.py | 110 ++++++++++ .../cancellation_handlers/level.py | 84 ++++++++ .../notifiers/in_memory.py | 11 +- src/taskiq_cancellation/notifiers/queue.py | 3 +- tests/test_cancellation.py | 146 ++++++++----- 10 files changed, 359 insertions(+), 235 deletions(-) create mode 100644 src/taskiq_cancellation/cancellation_handlers/__init__.py create mode 100644 src/taskiq_cancellation/cancellation_handlers/cancellation_type.py create mode 100644 src/taskiq_cancellation/cancellation_handlers/edge.py create mode 100644 src/taskiq_cancellation/cancellation_handlers/level.py diff --git a/pyproject.toml b/pyproject.toml index 8a9e60f..54d2eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,8 +48,12 @@ check = "mypy --install-types --non-interactive {args:src/taskiq_cancellation te [tool.mypy] ignore_missing_imports = true -exclude = ["examples"] - +exclude = [ + # Added so that "mypy ." would work + "examples", + # Contains Python 3.11+ code, has to be excluded. Runtime checks don't work. + "src/taskiq_cancellation/cancellation_handlers/edge.py", +] [tool.coverage.run] source_pkgs = ["taskiq_cancellation", "tests"] @@ -74,3 +78,8 @@ dev = [ "pytest-asyncio>=0.24.0", "ruff>=0.14.4", ] + +[tool.ruff] +# Edge cancellation is currently Python 3.11+ which causes linter to freak out +# For such versioning cases tests must be written +target-version = "py314" diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index d3fed52..13260c9 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,21 +1,21 @@ import abc import sys -import enum import inspect -import asyncio -from typing import Callable, Annotated, Awaitable, overload, Optional, cast, TypeAlias +from typing import Callable, Annotated, Awaitable, overload, Optional, cast, Union if sys.version_info >= (3, 11): - from typing import Self + from typing import Self, TypeAlias else: - from typing_extensions import Self + from typing_extensions import Self, TypeAlias -import anyio -from anyio.abc import TaskStatus from taskiq import Context, TaskiqDepends, AsyncBroker, TaskiqEvents, TaskiqState -from taskiq_cancellation.utils import combines, StopTaskGroupException -from taskiq_cancellation.exceptions import TaskCancellationException +from taskiq_cancellation.utils import combines +from taskiq_cancellation.cancellation_handlers import ( + CancellationType, + LevelCancellationHandler, + EdgeCancellationHandler, +) from .started_listening_event import StartedListeningEvent @@ -23,11 +23,6 @@ AsyncCallable: TypeAlias = Callable[..., Awaitable] -class CancellationType(str, enum.Enum): - EDGE = "edge" - LEVEL = "level" - - class CancellationBackend(abc.ABC): """ Base class for cancellation backend @@ -36,7 +31,7 @@ class CancellationBackend(abc.ABC): def __init__(self) -> None: super().__init__() - self.broker: AsyncBroker | None = None + self.broker: Union[AsyncBroker, None] = None @abc.abstractmethod async def is_cancelled(self, task_id: str) -> bool: @@ -161,7 +156,7 @@ def cancellable(self, cancellation_type=None): :returns: Cancellable task function """ - defaults = {"cancellation_type": CancellationType.EDGE} + defaults = {"cancellation_type": CancellationType.LEVEL} def make_decorator(cancellation_type: CancellationType): def decorator(task: AsyncCallable) -> AsyncCallable: @@ -172,7 +167,8 @@ def decorator(task: AsyncCallable) -> AsyncCallable: @combines(task) async def wrapper( *args, - __taskiq_context: Annotated[Context, TaskiqDepends()] = None, # type: ignore + __taskiq_context: Annotated[Context, TaskiqDepends(Context)] = None, # type: ignore + # __taskiq_context: Annotated[Context, TaskiqDepends()], # type: ignore **kwargs, ): if __taskiq_context is None: @@ -182,11 +178,11 @@ async def wrapper( task_id = __taskiq_context.message.task_id if cancellation_type is CancellationType.EDGE: - task_wrapper = EdgeCancellationWrapper(self, task, task_id) - return await task_wrapper(*args, **kwargs) + edge_handler = EdgeCancellationHandler(self, task, task_id) + return await edge_handler(*args, **kwargs) elif cancellation_type is CancellationType.LEVEL: - task_wrapper = LevelCancellationWrapper(self, task, task_id) - return await task_wrapper(*args, **kwargs) + level_handler = LevelCancellationHandler(self, task, task_id) + return await level_handler(*args, **kwargs) else: raise ValueError( f"Unknown cancellation type: {cancellation_type!r}" @@ -209,156 +205,3 @@ async def _broker_startup_handler(self, _: TaskiqState) -> None: async def _broker_shutdown_handler(self, _: TaskiqState) -> None: await self.shutdown() - - -class EdgeCancellationWrapper: - class ListeningEvent(StartedListeningEvent): - def __init__(self, task_status: TaskStatus) -> None: - self.task_status = task_status - - async def set(self): - self.task_status.started() - - async def wait(self): - # Can ignore, won't execute further before task status is set - pass - - def __init__(self, backend: CancellationBackend, task: Callable, task_id: str): - self.backend = backend - self.task = task - self.task_id = task_id - - async def __call__(self, *args, **kwargs): - result = None - - listener_exception: Exception | None = None - task_exception: Exception | None = None - cancelled_by_request: bool = False - - async with anyio.create_task_group() as group: - - async def listen_for_cancellation( - task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED, - ): - nonlocal listener_exception, cancelled_by_request - - event = self.ListeningEvent(task_status) - try: - await self.backend.listen_for_cancellation(self.task_id, event) - except TaskCancellationException: - cancelled_by_request = True - except anyio.get_cancelled_exc_class(): - pass - except Exception as e: - listener_exception = e - finally: - group.cancel_scope.cancel() - - async def call_task(): - nonlocal result, task_exception - - try: - result = await self.task(*args, **kwargs) - except anyio.get_cancelled_exc_class(): - pass - except Exception as e: - task_exception = e - finally: - group.cancel_scope.cancel() - - # Listen before checking for cancellation in state holder - # so the message won't get lost in non-persistent queues - await group.start(listen_for_cancellation) - if await self.backend.is_cancelled(self.task_id): - cancelled_by_request = True - group.cancel_scope.cancel() - else: - group.start_soon(call_task) - - if task_exception is not None: - raise task_exception - elif cancelled_by_request: - raise TaskCancellationException() - elif listener_exception is not None: - raise listener_exception - else: - return result - - -class LevelCancellationWrapper: - class ListeningEvent(StartedListeningEvent): - def __init__(self) -> None: - self.event = asyncio.Event() - - async def set(self): - loop = asyncio.get_running_loop() - loop.call_soon_threadsafe(self.event.set) - - async def wait(self): - await self.event.wait() - - def __init__(self, backend: CancellationBackend, task: Callable, task_id: str): - self.backend = backend - self.task = task - self.task_id = task_id - - async def __call__(self, *args, **kwargs): - result = None - - listener_exception: Exception | None = None - task_exception: Exception | None = None - cancelled_by_request: bool = False - - async def listen_for_cancellation(event: StartedListeningEvent): - nonlocal listener_exception, cancelled_by_request - - try: - await self.backend.listen_for_cancellation(self.task_id, event) - except TaskCancellationException: - cancelled_by_request = True - raise - except asyncio.CancelledError: - raise - except Exception as e: - listener_exception = e - raise - - async def call_task(): - nonlocal result, task_exception - - try: - result = await self.task(*args, **kwargs) - except asyncio.CancelledError: - raise - except Exception as e: - task_exception = e - raise - - try: - async with asyncio.TaskGroup() as tg: - # Listen before checking for cancellation in state holder - # so the message won't get lost in non-persistent queues - event = self.ListeningEvent() - tg.create_task(listen_for_cancellation(event)) - await event.wait() - - if await self.backend.is_cancelled(self.task_id): - cancelled_by_request = True - raise StopTaskGroupException() - - task_task = asyncio.create_task(call_task()) - await task_task - if not task_task.cancelled(): - raise StopTaskGroupException() - except Exception: - # Exceptions are stored in local vars, can ignore - pass - - if task_exception is not None: - raise task_exception - elif cancelled_by_request: - raise TaskCancellationException() - elif listener_exception is not None: - raise listener_exception - else: - return result diff --git a/src/taskiq_cancellation/backends/modular.py b/src/taskiq_cancellation/backends/modular.py index a1e7b49..2da1ec4 100644 --- a/src/taskiq_cancellation/backends/modular.py +++ b/src/taskiq_cancellation/backends/modular.py @@ -2,10 +2,10 @@ CancellationBackend, CancellationNotifier, CancellationStateHolder, + StartedListeningEvent, ) import anyio -from anyio.abc import TaskStatus class ModularCancellationBackend(CancellationBackend): @@ -34,11 +34,9 @@ async def cancel(self, task_id: str): group.start_soon(self.notifier.cancel, task_id) async def listen_for_cancellation( - self, task_id: str, started_listening_task_status: TaskStatus[None] + self, task_id: str, started_listening_event: StartedListeningEvent ): - await self.notifier.listen_for_cancellation( - task_id, started_listening_task_status - ) + await self.notifier.listen_for_cancellation(task_id, started_listening_event) async def startup(self) -> None: await super().startup() diff --git a/src/taskiq_cancellation/cancellation_handlers/__init__.py b/src/taskiq_cancellation/cancellation_handlers/__init__.py new file mode 100644 index 0000000..731a4a1 --- /dev/null +++ b/src/taskiq_cancellation/cancellation_handlers/__init__.py @@ -0,0 +1,22 @@ +import sys + +from .cancellation_type import CancellationType +from .level import LevelCancellationHandler + +if sys.version_info >= (3, 11): + from .edge import EdgeCancellationHandler +else: + + class EdgeCancellationHandler: + def __init__(self, *args, **kwargs) -> None: + raise NotImplementedError( + "Edge cancellation is not supported for Python <3.11" + ) + + async def __call__(self, *args, **kwargs) -> None: + raise NotImplementedError( + "Edge cancellation is not supported for Python <3.11" + ) + + +__all__ = ["CancellationType", "LevelCancellationHandler", "EdgeCancellationHandler"] diff --git a/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py b/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py new file mode 100644 index 0000000..0dd0d89 --- /dev/null +++ b/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py @@ -0,0 +1,6 @@ +import enum + + +class CancellationType(str, enum.Enum): + EDGE = "edge" + LEVEL = "level" diff --git a/src/taskiq_cancellation/cancellation_handlers/edge.py b/src/taskiq_cancellation/cancellation_handlers/edge.py new file mode 100644 index 0000000..b8cd29b --- /dev/null +++ b/src/taskiq_cancellation/cancellation_handlers/edge.py @@ -0,0 +1,110 @@ +# FIXME: bunch of issues because of Python 3.11+ exclusivity +# +# Edge cancellation handler is using asyncio.TaskGroup which was introduced in 3.11 and +# uses expect-star syntax that was also introduced in the same version +# - mypy has to ignore this file because it can't finish static parsing +# - ruff's python version has to be set at 3.11+ so it wouldn't complain +# +# I'm not sure how to mitigate these issues. Maybe this can be put in a separate module somehow +# and then integrated? Maybe this can be rewritten to not use TaskGroup (probably easier to do)? + +import logging +import asyncio +from typing import Callable, TYPE_CHECKING + +from taskiq_cancellation.abc.started_listening_event import StartedListeningEvent +from taskiq_cancellation.exceptions import TaskCancellationException +from taskiq_cancellation.utils import StopTaskGroupException + +if TYPE_CHECKING: + from taskiq_cancellation.abc.backend import CancellationBackend + + +class EdgeCancellationHandler: + class ListeningEvent(StartedListeningEvent): + def __init__(self) -> None: + self.event = asyncio.Event() + + async def set(self): + loop = asyncio.get_running_loop() + loop.call_soon_threadsafe(self.event.set) + + async def wait(self): + await self.event.wait() + + def __init__(self, backend: "CancellationBackend", task: Callable, task_id: str): + self.backend = backend + self.task = task + self.task_id = task_id + + async def __call__(self, *args, **kwargs): + result = None + + listener_exception: Exception | None = None + task_exception: Exception | None = None + cancelled_by_request: bool = False + + async def listen_for_cancellation(event: StartedListeningEvent): + nonlocal listener_exception, cancelled_by_request + + try: + await self.backend.listen_for_cancellation(self.task_id, event) + except TaskCancellationException: + cancelled_by_request = True + raise + except asyncio.CancelledError: + raise + except Exception as e: + listener_exception = e + raise + + async def call_task(): + nonlocal result, task_exception + + try: + result = await self.task(*args, **kwargs) + except asyncio.CancelledError: + raise + except Exception as e: + task_exception = e + raise + + try: + async with asyncio.TaskGroup() as tg: + # Listen before checking for cancellation in state holder + # so the message won't get lost in non-persistent queues + event = self.ListeningEvent() + tg.create_task(listen_for_cancellation(event)) + await event.wait() + + if await self.backend.is_cancelled(self.task_id): + cancelled_by_request = True + raise StopTaskGroupException() + + task_task = asyncio.create_task(call_task()) + await task_task + if not task_task.cancelled(): + raise StopTaskGroupException() + except* StopTaskGroupException: + pass + except* Exception as exc_group: + uncaught_exceptions = list( + filter( + lambda e: e == task_exception or e == listener_exception, + exc_group.exceptions, + ) + ) + + if uncaught_exceptions: + logging.log(logging.ERROR, "Uncaught exception in TaskGroup") + for e in uncaught_exceptions: + logging.exception(e) + + if task_exception is not None: + raise task_exception + elif cancelled_by_request: + raise TaskCancellationException() + elif listener_exception is not None: + raise listener_exception + else: + return result diff --git a/src/taskiq_cancellation/cancellation_handlers/level.py b/src/taskiq_cancellation/cancellation_handlers/level.py new file mode 100644 index 0000000..a068f64 --- /dev/null +++ b/src/taskiq_cancellation/cancellation_handlers/level.py @@ -0,0 +1,84 @@ +from typing import Callable, TYPE_CHECKING + +import anyio +from anyio.abc import TaskStatus + +from taskiq_cancellation.abc.started_listening_event import StartedListeningEvent +from taskiq_cancellation.exceptions import TaskCancellationException + +if TYPE_CHECKING: + from taskiq_cancellation.abc.backend import CancellationBackend + + +class LevelCancellationHandler: + class ListeningEvent(StartedListeningEvent): + def __init__(self, task_status: TaskStatus) -> None: + self.task_status = task_status + + async def set(self): + self.task_status.started() + + async def wait(self): + # Can ignore, won't execute further before task status is set + pass + + def __init__(self, backend: "CancellationBackend", task: Callable, task_id: str): + self.backend = backend + self.task = task + self.task_id = task_id + + async def __call__(self, *args, **kwargs): + result = None + + listener_exception: Exception | None = None + task_exception: Exception | None = None + cancelled_by_request: bool = False + + async with anyio.create_task_group() as group: + + async def listen_for_cancellation( + task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ): + nonlocal listener_exception, cancelled_by_request + + event = self.ListeningEvent(task_status) + try: + await self.backend.listen_for_cancellation(self.task_id, event) + except TaskCancellationException: + cancelled_by_request = True + except anyio.get_cancelled_exc_class(): + pass + except Exception as e: + listener_exception = e + finally: + group.cancel_scope.cancel() + + async def call_task(): + nonlocal result, task_exception + + try: + result = await self.task(*args, **kwargs) + except anyio.get_cancelled_exc_class(): + pass + except Exception as e: + task_exception = e + finally: + group.cancel_scope.cancel() + + # Listen before checking for cancellation in state holder + # so the message won't get lost in non-persistent queues + await group.start(listen_for_cancellation) + if await self.backend.is_cancelled(self.task_id): + cancelled_by_request = True + group.cancel_scope.cancel() + else: + group.start_soon(call_task) + + if task_exception is not None: + raise task_exception + elif cancelled_by_request: + raise TaskCancellationException() + elif listener_exception is not None: + raise listener_exception + else: + return result diff --git a/src/taskiq_cancellation/notifiers/in_memory.py b/src/taskiq_cancellation/notifiers/in_memory.py index b03dbeb..1dfe59f 100644 --- a/src/taskiq_cancellation/notifiers/in_memory.py +++ b/src/taskiq_cancellation/notifiers/in_memory.py @@ -1,5 +1,6 @@ import time import asyncio +from typing import Union from taskiq_cancellation.message import CancellationMessage @@ -12,9 +13,14 @@ class InMemoryCancellationNotifier(QueueCancellationNotifier): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.messages: asyncio.Queue[CancellationMessage] = asyncio.Queue() + # In Python 3.9 queues must be created inside a running loop + # Source: https://stackoverflow.com/questions/53724665 + self.messages: Union[asyncio.Queue[CancellationMessage], None] = None async def cancel(self, task_id: str) -> None: + if self.messages is None: + self.messages = asyncio.Queue() + timestamp = time.time() await self.messages.put( @@ -22,6 +28,9 @@ async def cancel(self, task_id: str) -> None: ) async def _listen(self, started_listening: asyncio.Event) -> None: + if self.messages is None: + self.messages = asyncio.Queue() + loop = asyncio.get_running_loop() loop.call_soon_threadsafe(started_listening.set) diff --git a/src/taskiq_cancellation/notifiers/queue.py b/src/taskiq_cancellation/notifiers/queue.py index 28ef1ee..f18c083 100644 --- a/src/taskiq_cancellation/notifiers/queue.py +++ b/src/taskiq_cancellation/notifiers/queue.py @@ -1,6 +1,7 @@ import abc import weakref import asyncio +from typing import Union from taskiq_cancellation.abc import CancellationNotifier, StartedListeningEvent from taskiq_cancellation.exceptions import TaskCancellationException @@ -18,7 +19,7 @@ class QueueCancellationNotifier(CancellationNotifier): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.listener_task: asyncio.Task | None = None + self.listener_task: Union[asyncio.Task, None] = None self.queues: weakref.WeakSet[asyncio.Queue[CancellationMessage]] = ( weakref.WeakSet() ) diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 5494673..1d9e2ba 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -25,80 +25,122 @@ def backend(broker: AsyncBroker): return InMemoryCancellationBackend().with_broker(broker) -@pytest.mark.asyncio -async def test_task_direct_call(broker: AsyncBroker, backend: CancellationBackend): - @broker.task - @backend.cancellable() - async def test_task(): - return True - - result = await test_task() - assert result - - @pytest.mark.asyncio @pytest.mark.parametrize( ("cancellation_type"), (CancellationType.LEVEL, CancellationType.EDGE) ) -async def test_task_success( +async def test_task_direct_call( broker: AsyncBroker, backend: CancellationBackend, cancellation_type: CancellationType, ): - """Tests that cancellable task can successfully finish""" - @broker.task @backend.cancellable(cancellation_type=cancellation_type) async def test_task(): - await asyncio.sleep(0.1) + return True - await broker.startup() + result = await test_task() + assert result - task = await test_task.kiq() - result = await task.wait_result() - assert result.is_err is False - await broker.shutdown() +class TestTaskSuccess: + types = [CancellationType.LEVEL] + if sys.version_info >= (3, 11): + types.append(CancellationType.EDGE) + @pytest.mark.asyncio + @pytest.mark.parametrize(("cancellation_type"), types) + @staticmethod + async def test_task_success( + broker: AsyncBroker, + backend: CancellationBackend, + cancellation_type: CancellationType, + ): + """Tests that cancellable task can successfully finish""" -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("cancellation_type"), (CancellationType.LEVEL, CancellationType.EDGE) -) -async def test_task_cancellation( - broker: AsyncBroker, - backend: CancellationBackend, - cancellation_type: CancellationType, -): - """Tests that cancellable task can successfully cancel""" + @broker.task + @backend.cancellable(cancellation_type=cancellation_type) + async def test_task(): + await asyncio.sleep(0.1) - started_event = asyncio.Event() + await broker.startup() - @broker.task - @backend.cancellable(cancellation_type=cancellation_type) - async def test_task(): - with pytest.raises(anyio.get_cancelled_exc_class()): - started_event.set() - await asyncio.sleep(0.2) + task = await test_task.kiq() + result = await task.wait_result() + assert result.is_err is False - await broker.startup() + await broker.shutdown() - task = await test_task.kiq() - assert await task.is_ready() is False - async with timeout(1): - await started_event.wait() - await backend.cancel(task.task_id) +class TestTaskCancellation: + types = [CancellationType.LEVEL] + if sys.version_info > (3, 11): + types.append(CancellationType.EDGE) - with pytest.raises(TaskCancellationException): - result = await task.wait_result() - result.raise_for_error() + @pytest.mark.asyncio + @pytest.mark.parametrize(("cancellation_type"), types) + @staticmethod + async def test_task_cancellation( + broker: AsyncBroker, + backend: CancellationBackend, + cancellation_type: CancellationType, + ): + """Tests that cancellable task can successfully cancel""" - await broker.shutdown() + started_event = asyncio.Event() + @broker.task + @backend.cancellable(cancellation_type=cancellation_type) + async def test_task(): + with pytest.raises(anyio.get_cancelled_exc_class()): + started_event.set() + await asyncio.sleep(0.2) + + await broker.startup() + + task = await test_task.kiq() + assert await task.is_ready() is False + + async with timeout(1): + await started_event.wait() + await backend.cancel(task.task_id) + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() + + +class TestEdgeCancellation: + @pytest.mark.asyncio + @pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="Edge cancellation is currently not supported for Python <3.11", + ) + async def test_non_supported( + self, broker: AsyncBroker, backend: CancellationBackend + ): + @broker.task + @backend.cancellable(cancellation_type=CancellationType.EDGE) + async def test_task(): + await asyncio.sleep(0.1) + + await broker.startup() + + task = await test_task.kiq() + + with pytest.raises(NotImplementedError): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() -class TestLevelCancellation: @pytest.mark.asyncio + @pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Edge cancellation is currently not supported for Python <3.11", + ) async def test_cancellation_interception( self, broker: AsyncBroker, backend: CancellationBackend ): @@ -114,7 +156,7 @@ async def test_cancellation_interception( task_started = asyncio.Event() @broker.task - @backend.cancellable(cancellation_type=CancellationType.LEVEL) + @backend.cancellable(cancellation_type=CancellationType.EDGE) async def test_task(): nonlocal cancelled_for_second_time @@ -144,7 +186,7 @@ async def test_task(): await broker.shutdown() -class TestEdgeCancellation: +class TestLevelCancellation: @pytest.mark.asyncio async def test_repeated_cancellation( self, broker: AsyncBroker, backend: CancellationBackend @@ -160,13 +202,13 @@ async def test_repeated_cancellation( started_event = asyncio.Event() @broker.task - @backend.cancellable(cancellation_type=CancellationType.EDGE) + @backend.cancellable(cancellation_type=CancellationType.LEVEL) async def test_task(): nonlocal cancelled_for_second_time try: started_event.set() - await asyncio.sleep(0.5) + await asyncio.sleep(1) except anyio.get_cancelled_exc_class(): # anyio cancels on any await after scope's cancellation try: From c7d06ec6c17a6bf87e30ccb957546b6d6e70979d Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Mon, 10 Nov 2025 18:33:11 +0300 Subject: [PATCH 22/36] feat: actually working type hinting --- pyproject.toml | 2 +- src/taskiq_cancellation/abc/backend.py | 29 ++++++++++------- .../cancellation_handlers/__init__.py | 14 ++------ .../{edge.py => edge_3_11.py} | 29 +++++++++++++---- .../edge_non_supported.py | 27 ++++++++++++++++ .../cancellation_handlers/level.py | 32 +++++++++++++++---- src/taskiq_cancellation/utils.py | 4 ++- 7 files changed, 98 insertions(+), 39 deletions(-) rename src/taskiq_cancellation/cancellation_handlers/{edge.py => edge_3_11.py} (83%) create mode 100644 src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py diff --git a/pyproject.toml b/pyproject.toml index 54d2eec..677fb5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ exclude = [ # Added so that "mypy ." would work "examples", # Contains Python 3.11+ code, has to be excluded. Runtime checks don't work. - "src/taskiq_cancellation/cancellation_handlers/edge.py", + "src/taskiq_cancellation/cancellation_handlers/edge_3_11.py", ] [tool.coverage.run] diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 13260c9..9c4abd7 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -1,12 +1,12 @@ import abc import sys import inspect -from typing import Callable, Annotated, Awaitable, overload, Optional, cast, Union +from typing import Callable, Annotated, overload, Optional, cast, Union if sys.version_info >= (3, 11): - from typing import Self, TypeAlias + from typing import Self, ParamSpec, TypeVar else: - from typing_extensions import Self, TypeAlias + from typing_extensions import Self, ParamSpec, TypeVar from taskiq import Context, TaskiqDepends, AsyncBroker, TaskiqEvents, TaskiqState @@ -20,7 +20,8 @@ from .started_listening_event import StartedListeningEvent -AsyncCallable: TypeAlias = Callable[..., Awaitable] +Params = ParamSpec("Params") +Result = TypeVar("Result") class CancellationBackend(abc.ABC): @@ -131,13 +132,15 @@ def with_broker(self, broker: AsyncBroker) -> Self: return self @overload - def cancellable(self, cancellation_type: AsyncCallable) -> AsyncCallable: + def cancellable( + self, cancellation_type: Callable[Params, Result] + ) -> Callable[Params, Result]: pass @overload def cancellable( self, cancellation_type: Optional[CancellationType] = None - ) -> Callable[[AsyncCallable], AsyncCallable]: + ) -> Callable[[Callable[Params, Result]], Callable[Params, Result]]: pass def cancellable(self, cancellation_type=None): @@ -159,7 +162,9 @@ def cancellable(self, cancellation_type=None): defaults = {"cancellation_type": CancellationType.LEVEL} def make_decorator(cancellation_type: CancellationType): - def decorator(task: AsyncCallable) -> AsyncCallable: + def decorator( + task: Callable[Params, Result], / + ) -> Callable[Params, Result]: # Executor type depends on receiver configuration which we can't accessed in any way if not inspect.iscoroutinefunction(task): raise ValueError("Can't cancel synchronous function") @@ -168,9 +173,8 @@ def decorator(task: AsyncCallable) -> AsyncCallable: async def wrapper( *args, __taskiq_context: Annotated[Context, TaskiqDepends(Context)] = None, # type: ignore - # __taskiq_context: Annotated[Context, TaskiqDepends()], # type: ignore **kwargs, - ): + ) -> Result: if __taskiq_context is None: # Ran the function directly, without kiq return await task(*args, **kwargs) @@ -188,13 +192,14 @@ async def wrapper( f"Unknown cancellation type: {cancellation_type!r}" ) - return wrapper + # Wrapper adds a key-word only param with default value + casted_wrapper = cast(Callable[Params, Result], wrapper) + return casted_wrapper return decorator if callable(cancellation_type): - task = cast(Callable[..., Awaitable], cancellation_type) - return make_decorator(**defaults)(task) + return make_decorator(**defaults)(cancellation_type) else: return make_decorator( cancellation_type=cancellation_type or defaults["cancellation_type"] diff --git a/src/taskiq_cancellation/cancellation_handlers/__init__.py b/src/taskiq_cancellation/cancellation_handlers/__init__.py index 731a4a1..a228c8d 100644 --- a/src/taskiq_cancellation/cancellation_handlers/__init__.py +++ b/src/taskiq_cancellation/cancellation_handlers/__init__.py @@ -4,19 +4,9 @@ from .level import LevelCancellationHandler if sys.version_info >= (3, 11): - from .edge import EdgeCancellationHandler + from .edge_3_11 import EdgeCancellationHandler else: - - class EdgeCancellationHandler: - def __init__(self, *args, **kwargs) -> None: - raise NotImplementedError( - "Edge cancellation is not supported for Python <3.11" - ) - - async def __call__(self, *args, **kwargs) -> None: - raise NotImplementedError( - "Edge cancellation is not supported for Python <3.11" - ) + from .edge_non_supported import EdgeCancellationHandler __all__ = ["CancellationType", "LevelCancellationHandler", "EdgeCancellationHandler"] diff --git a/src/taskiq_cancellation/cancellation_handlers/edge.py b/src/taskiq_cancellation/cancellation_handlers/edge_3_11.py similarity index 83% rename from src/taskiq_cancellation/cancellation_handlers/edge.py rename to src/taskiq_cancellation/cancellation_handlers/edge_3_11.py index b8cd29b..441d66e 100644 --- a/src/taskiq_cancellation/cancellation_handlers/edge.py +++ b/src/taskiq_cancellation/cancellation_handlers/edge_3_11.py @@ -8,9 +8,16 @@ # I'm not sure how to mitigate these issues. Maybe this can be put in a separate module somehow # and then integrated? Maybe this can be rewritten to not use TaskGroup (probably easier to do)? +import sys import logging import asyncio -from typing import Callable, TYPE_CHECKING +from collections.abc import Coroutine +from typing import Callable, TYPE_CHECKING, Generic, Any + +if sys.version_info >= (3, 11): + from typing import ParamSpec, TypeVar +else: + from typing_extensions import ParamSpec, TypeVar from taskiq_cancellation.abc.started_listening_event import StartedListeningEvent from taskiq_cancellation.exceptions import TaskCancellationException @@ -20,7 +27,11 @@ from taskiq_cancellation.abc.backend import CancellationBackend -class EdgeCancellationHandler: +Params = ParamSpec("Params") +Result = TypeVar("Result") + + +class EdgeCancellationHandler(Generic[Params, Result]): class ListeningEvent(StartedListeningEvent): def __init__(self) -> None: self.event = asyncio.Event() @@ -32,13 +43,19 @@ async def set(self): async def wait(self): await self.event.wait() - def __init__(self, backend: "CancellationBackend", task: Callable, task_id: str): + def __init__( + self, + backend: "CancellationBackend", + task: Callable[Params, Coroutine[Any, Any, Result]], + task_id: str, + ): self.backend = backend self.task = task self.task_id = task_id - async def __call__(self, *args, **kwargs): - result = None + async def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Result: + result: Result = None + # type: ignore listener_exception: Exception | None = None task_exception: Exception | None = None @@ -96,7 +113,7 @@ async def call_task(): ) if uncaught_exceptions: - logging.log(logging.ERROR, "Uncaught exception in TaskGroup") + logging.log(logging.ERROR, "Uncaught exceptions in TaskGroup") for e in uncaught_exceptions: logging.exception(e) diff --git a/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py b/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py new file mode 100644 index 0000000..7184105 --- /dev/null +++ b/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py @@ -0,0 +1,27 @@ +import sys +from typing import Callable, TYPE_CHECKING, Coroutine, Generic, Any + +if sys.version_info >= (3, 11): + from typing import ParamSpec, TypeVar +else: + from typing_extensions import ParamSpec, TypeVar + +if TYPE_CHECKING: + from taskiq_cancellation.abc.backend import CancellationBackend + + +Params = ParamSpec("Params") +Result = TypeVar("Result") + + +class EdgeCancellationHandler(Generic[Params, Result]): + def __init__( + self, + backend: "CancellationBackend", + task: Callable[Params, Coroutine[Any, Any, Result]], + task_id: str, + ) -> None: + raise NotImplementedError("Edge cancellation is not supported for Python <3.11") + + async def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Result: + raise NotImplementedError("Edge cancellation is not supported for Python <3.11") diff --git a/src/taskiq_cancellation/cancellation_handlers/level.py b/src/taskiq_cancellation/cancellation_handlers/level.py index a068f64..55f740b 100644 --- a/src/taskiq_cancellation/cancellation_handlers/level.py +++ b/src/taskiq_cancellation/cancellation_handlers/level.py @@ -1,4 +1,11 @@ -from typing import Callable, TYPE_CHECKING +import sys +from collections.abc import Coroutine +from typing import Callable, TYPE_CHECKING, Generic, Union, cast, Any + +if sys.version_info >= (3, 11): + from typing import ParamSpec, TypeVar +else: + from typing_extensions import ParamSpec, TypeVar import anyio from anyio.abc import TaskStatus @@ -10,7 +17,11 @@ from taskiq_cancellation.abc.backend import CancellationBackend -class LevelCancellationHandler: +Params = ParamSpec("Params") +Result = TypeVar("Result") + + +class LevelCancellationHandler(Generic[Params, Result]): class ListeningEvent(StartedListeningEvent): def __init__(self, task_status: TaskStatus) -> None: self.task_status = task_status @@ -22,16 +33,21 @@ async def wait(self): # Can ignore, won't execute further before task status is set pass - def __init__(self, backend: "CancellationBackend", task: Callable, task_id: str): + def __init__( + self, + backend: "CancellationBackend", + task: Callable[Params, Coroutine[Any, Any, Result]], + task_id: str, + ): self.backend = backend self.task = task self.task_id = task_id - async def __call__(self, *args, **kwargs): - result = None + async def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Result: + result: Union[Result, None] = None - listener_exception: Exception | None = None - task_exception: Exception | None = None + listener_exception: Union[Exception, None] = None + task_exception: Union[Exception, None] = None cancelled_by_request: bool = False async with anyio.create_task_group() as group: @@ -81,4 +97,6 @@ async def call_task(): elif listener_exception is not None: raise listener_exception else: + # If the task is finished, it is definitely not None + result = cast(Result, result) return result diff --git a/src/taskiq_cancellation/utils.py b/src/taskiq_cancellation/utils.py index 9a9bd4f..a128266 100644 --- a/src/taskiq_cancellation/utils.py +++ b/src/taskiq_cancellation/utils.py @@ -5,7 +5,9 @@ from collections import OrderedDict -def combines(wrapped, add_var_parameters=False): +def combines( + wrapped: typing.Callable, add_var_parameters: bool = False +) -> typing.Callable: """ Combines wrapped and wrapper functions signatures and type hints From bc84268cc17c4cc7de3a2875810b5c491ea6f916 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Mon, 10 Nov 2025 18:33:39 +0300 Subject: [PATCH 23/36] test: add sync function cancellable test and docstrings --- tests/test_backend.py | 18 ++++++++++++++++++ tests/test_cancellation.py | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/tests/test_backend.py b/tests/test_backend.py index 4c4b8c2..459492d 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -12,6 +12,8 @@ def backend(): @pytest.mark.asyncio async def test_decorator_without_parentesis(backend: CancellationBackend): + """Tests that cancellable decorator works without parentesis""" + @backend.cancellable async def test_task(): pass @@ -23,6 +25,8 @@ async def test_task(): @pytest.mark.asyncio async def test_decorator_with_parentesis(backend: CancellationBackend): + """Tests that cancellable decorator works with parentesis""" + @backend.cancellable() async def test_task(): pass @@ -30,3 +34,17 @@ async def test_task(): task = test_task() assert inspect.iscoroutine(task) await task + + +@pytest.mark.asyncio +async def test_decorator_with_sync_function(backend: CancellationBackend): + """ + Tests that cancellable decorator doesn't work with synchronous functions + + To launch a synchronous function we need to know how to do it and only Receiver knows that + """ + + with pytest.raises(ValueError): + @backend.cancellable + def test_task(): + pass diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index 1d9e2ba..892e988 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -34,6 +34,8 @@ async def test_task_direct_call( backend: CancellationBackend, cancellation_type: CancellationType, ): + """Tests that cancellable function can be called directly""" + @broker.task @backend.cancellable(cancellation_type=cancellation_type) async def test_task(): @@ -121,6 +123,8 @@ class TestEdgeCancellation: async def test_non_supported( self, broker: AsyncBroker, backend: CancellationBackend ): + """Tests that edge cancellation raises NotImplementedError in Python <3.11""" + @broker.task @backend.cancellable(cancellation_type=CancellationType.EDGE) async def test_task(): From 5352d11992ba815b086b164e6a156f6ea58daec9 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 00:36:53 +0300 Subject: [PATCH 24/36] fix: close connections in redis and aiopika integrations (how did I miss this) --- src/taskiq_cancellation/notifiers/aiopika.py | 71 ++++++++++--------- src/taskiq_cancellation/notifiers/redis.py | 5 ++ .../state_holders/redis.py | 5 ++ 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/src/taskiq_cancellation/notifiers/aiopika.py b/src/taskiq_cancellation/notifiers/aiopika.py index 8e55ab8..205ede2 100644 --- a/src/taskiq_cancellation/notifiers/aiopika.py +++ b/src/taskiq_cancellation/notifiers/aiopika.py @@ -19,43 +19,48 @@ def __init__(self, url: str, **kwargs): async def cancel(self, task_id: str) -> None: timestamp = time.time() + connection = await aio_pika.connect_robust(self.url) - channel = await connection.channel() - exchange = await channel.declare_exchange( - self.EXCHANGE_NAME, aio_pika.ExchangeType.FANOUT, durable=True - ) + async with connection: + channel = await connection.channel() - await exchange.publish( - aio_pika.Message( - body=self.serializer.dumpb( - model_dump( - CancellationMessage(task_id=task_id, timestamp=timestamp) + exchange = await channel.declare_exchange( + self.EXCHANGE_NAME, aio_pika.ExchangeType.FANOUT, durable=True + ) + + await exchange.publish( + aio_pika.Message( + body=self.serializer.dumpb( + model_dump( + CancellationMessage(task_id=task_id, timestamp=timestamp) + ) ) - ) - ), - routing_key="", - ) + ), + routing_key="", + ) async def _listen(self, started_listening: asyncio.Event): connection = await aio_pika.connect_robust(self.url) - channel = await connection.channel() - - exchange = await channel.declare_exchange( - self.EXCHANGE_NAME, aio_pika.ExchangeType.FANOUT, durable=True - ) - queue = await channel.declare_queue(exclusive=True, auto_delete=True) - await queue.bind(exchange) - - loop = asyncio.get_running_loop() - loop.call_soon_threadsafe(started_listening.set) - - async with queue.iterator() as queue_iter: - async for message in queue_iter: - cancellation_message = model_validate( - CancellationMessage, self.serializer.loadb(message.body) - ) - - for queue in self.queues: - await queue.put(cancellation_message) - await message.ack() + + async with connection: + channel = await connection.channel() + + exchange = await channel.declare_exchange( + self.EXCHANGE_NAME, aio_pika.ExchangeType.FANOUT, durable=True + ) + queue = await channel.declare_queue(exclusive=True, auto_delete=True) + await queue.bind(exchange) + + loop = asyncio.get_running_loop() + loop.call_soon_threadsafe(started_listening.set) + + async with queue.iterator() as queue_iter: + async for message in queue_iter: + cancellation_message = model_validate( + CancellationMessage, self.serializer.loadb(message.body) + ) + + for queue in self.queues: + await queue.put(cancellation_message) + await message.ack() diff --git a/src/taskiq_cancellation/notifiers/redis.py b/src/taskiq_cancellation/notifiers/redis.py index 2efa7c2..51b29bc 100644 --- a/src/taskiq_cancellation/notifiers/redis.py +++ b/src/taskiq_cancellation/notifiers/redis.py @@ -54,3 +54,8 @@ async def _listen(self, started_listening: asyncio.Event): ) for queue in self.queues: await queue.put(cancellation_message) + + async def shutdown(self) -> None: + await super().shutdown() + await self.connection_pool.aclose() + \ No newline at end of file diff --git a/src/taskiq_cancellation/state_holders/redis.py b/src/taskiq_cancellation/state_holders/redis.py index 262f121..be86f08 100644 --- a/src/taskiq_cancellation/state_holders/redis.py +++ b/src/taskiq_cancellation/state_holders/redis.py @@ -17,5 +17,10 @@ async def is_cancelled(self, task_id: str) -> bool: response = await conn.get(self._task_key(task_id)) return bool(response) + async def shutdown(self) -> None: + await super().shutdown() + await self.connection_pool.aclose() + def _task_key(self, task_id: str) -> str: return f"__cancellation_status_{task_id}" + From 9b1d9f79e4aa4ea8b8eae3467c494078ff6a4d96 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 00:40:25 +0300 Subject: [PATCH 25/36] docs: more docstrings --- src/taskiq_cancellation/abc/backend.py | 3 ++- src/taskiq_cancellation/abc/notifier.py | 7 +++---- .../abc/started_listening_event.py | 11 +++++++++++ src/taskiq_cancellation/backends/in_memory.py | 6 ++++++ .../cancellation_handlers/cancellation_type.py | 2 ++ .../cancellation_handlers/edge_3_11.py | 13 +++++++++++-- .../cancellation_handlers/edge_non_supported.py | 7 +++++++ .../cancellation_handlers/level.py | 8 ++++++++ src/taskiq_cancellation/notifiers/aiopika.py | 2 ++ src/taskiq_cancellation/notifiers/queue.py | 1 + src/taskiq_cancellation/notifiers/redis.py | 2 ++ 11 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/taskiq_cancellation/abc/backend.py b/src/taskiq_cancellation/abc/backend.py index 9c4abd7..6f20b5e 100644 --- a/src/taskiq_cancellation/abc/backend.py +++ b/src/taskiq_cancellation/abc/backend.py @@ -155,7 +155,8 @@ def cancellable(self, cancellation_type=None): - Raises :ref:`TaskCancellationException` if listener task receives cancellation message - If listener task raises an exception, task is cancelled and exception is propogated upwards - :param task: Task function to wrap + :param cancellation_type: type of cancellation used + :type cancellation_type: CancellationType :returns: Cancellable task function """ diff --git a/src/taskiq_cancellation/abc/notifier.py b/src/taskiq_cancellation/abc/notifier.py index 938618a..eb0718d 100644 --- a/src/taskiq_cancellation/abc/notifier.py +++ b/src/taskiq_cancellation/abc/notifier.py @@ -39,12 +39,11 @@ async def listen_for_cancellation( receives :ref:`CancellationMessage` with same id as *task_id*. This function is used in :func:`cancellable` decorator of :ref:`ModularCancellationBackend`. - Call `started_listening_task_status.started()` when the listener is ready - to receive messages. + Call `started_listening_event.set()` when the listener is ready to receive messages. :param task_id: id of task that will be listened for :type task_id: str - :param started_listening_task_status: - :type started_listening_task_status: anyio.abc.TaskStatus + :param started_listening_event: "listener started listening" confirmation event + :type started_listening_event: StartedListeningEvent """ pass diff --git a/src/taskiq_cancellation/abc/started_listening_event.py b/src/taskiq_cancellation/abc/started_listening_event.py index 28a51eb..4e43acf 100644 --- a/src/taskiq_cancellation/abc/started_listening_event.py +++ b/src/taskiq_cancellation/abc/started_listening_event.py @@ -2,10 +2,21 @@ class StartedListeningEvent(abc.ABC): + """ + A confirmation event for listeners to mark that they started listening to messages. API is + similar to :ref:`asyncio.Event`. + + This is needed for different cancellation types: + - Level cancellation uses :ref:`anyio.abc.TaskStatus` + - Edge cancellation uses :ref:`asyncio.Event` + """ + @abc.abstractmethod async def set(self): + """Sets the event""" pass @abc.abstractmethod async def wait(self): + """Waits for the event to be set""" pass diff --git a/src/taskiq_cancellation/backends/in_memory.py b/src/taskiq_cancellation/backends/in_memory.py index 6c8f172..795ac6e 100644 --- a/src/taskiq_cancellation/backends/in_memory.py +++ b/src/taskiq_cancellation/backends/in_memory.py @@ -5,6 +5,12 @@ class InMemoryCancellationBackend(ModularCancellationBackend): + """ + Cancellation backend that stores state and notifications in memory + + Useful for testing purposes + """ + def __init__(self, **kwargs): super().__init__( state_holder=InMemoryCancellationStateHolder(**kwargs), diff --git a/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py b/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py index 0dd0d89..9714e67 100644 --- a/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py +++ b/src/taskiq_cancellation/cancellation_handlers/cancellation_type.py @@ -2,5 +2,7 @@ class CancellationType(str, enum.Enum): + """Type of cancellation used by the backend""" + EDGE = "edge" LEVEL = "level" diff --git a/src/taskiq_cancellation/cancellation_handlers/edge_3_11.py b/src/taskiq_cancellation/cancellation_handlers/edge_3_11.py index 441d66e..4bf66b5 100644 --- a/src/taskiq_cancellation/cancellation_handlers/edge_3_11.py +++ b/src/taskiq_cancellation/cancellation_handlers/edge_3_11.py @@ -32,6 +32,16 @@ class EdgeCancellationHandler(Generic[Params, Result]): + """ + Wrapper around a task function that handles cancellation + + Uses edge cancellation provided by asyncio. That means :ref:`asyncio.CancelledError` is + raised only once for the task. + Docs: https://docs.python.org/3/library/asyncio-task.html#task-cancellation + + Currently is supported in Python 3.11+ due to using :ref:`asyncio.TaskGroup`. + """ + class ListeningEvent(StartedListeningEvent): def __init__(self) -> None: self.event = asyncio.Event() @@ -54,8 +64,7 @@ def __init__( self.task_id = task_id async def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Result: - result: Result = None - # type: ignore + result: Result = None # type: ignore listener_exception: Exception | None = None task_exception: Exception | None = None diff --git a/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py b/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py index 7184105..03d06fd 100644 --- a/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py +++ b/src/taskiq_cancellation/cancellation_handlers/edge_non_supported.py @@ -15,6 +15,13 @@ class EdgeCancellationHandler(Generic[Params, Result]): + """ + Wrapper around a task function that handles cancellation + + Uses edge cancellation provided by asyncio. Currently is supported in Python 3.11+ due + to using :ref:`asyncio.TaskGroup`. + """ + def __init__( self, backend: "CancellationBackend", diff --git a/src/taskiq_cancellation/cancellation_handlers/level.py b/src/taskiq_cancellation/cancellation_handlers/level.py index 55f740b..2cfe71a 100644 --- a/src/taskiq_cancellation/cancellation_handlers/level.py +++ b/src/taskiq_cancellation/cancellation_handlers/level.py @@ -22,6 +22,14 @@ class LevelCancellationHandler(Generic[Params, Result]): + """ + Wrapper around a task function that handles cancellation + + Uses level cancellation provided by anyio. That means cancellation exception is raised + on every await in the coroutine. + Docs: https://anyio.readthedocs.io/en/stable/cancellation.html#differences-between-asyncio-and-anyio-cancellation-semantics + """ + class ListeningEvent(StartedListeningEvent): def __init__(self, task_status: TaskStatus) -> None: self.task_status = task_status diff --git a/src/taskiq_cancellation/notifiers/aiopika.py b/src/taskiq_cancellation/notifiers/aiopika.py index 205ede2..945a445 100644 --- a/src/taskiq_cancellation/notifiers/aiopika.py +++ b/src/taskiq_cancellation/notifiers/aiopika.py @@ -10,6 +10,8 @@ class AioPikaNotifier(QueueCancellationNotifier): + """Notifier for RabbitMQ using aio-pika""" + EXCHANGE_NAME = "__taskiq_cancellation" def __init__(self, url: str, **kwargs): diff --git a/src/taskiq_cancellation/notifiers/queue.py b/src/taskiq_cancellation/notifiers/queue.py index f18c083..16ea174 100644 --- a/src/taskiq_cancellation/notifiers/queue.py +++ b/src/taskiq_cancellation/notifiers/queue.py @@ -28,6 +28,7 @@ def __init__(self, **kwargs) -> None: async def shutdown(self) -> None: if self.listener_task is not None: self.listener_task.cancel() + await asyncio.wait([self.listener_task]) async def listen_for_cancellation( self, task_id: str, started_listening_event: StartedListeningEvent diff --git a/src/taskiq_cancellation/notifiers/redis.py b/src/taskiq_cancellation/notifiers/redis.py index 51b29bc..5d7906e 100644 --- a/src/taskiq_cancellation/notifiers/redis.py +++ b/src/taskiq_cancellation/notifiers/redis.py @@ -10,6 +10,8 @@ class PubSubCancellationNotifier(QueueCancellationNotifier): + """Cancellation notifier using Redis pub/sub""" + CHANNEL_NAME = "__taskiq_cancellation_notifications" def __init__(self, url: str, **kwargs) -> None: From d6adb15f39199fefa503305d770d5506c9afb36d Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 15:04:21 +0300 Subject: [PATCH 26/36] test: intergration tests for redis and aiopika --- .github/workflows/run_tests.yaml | 38 +++++++- docker-compose-tests.yml | 20 +++++ pyproject.toml | 2 +- tests/integration/__init__.py | 0 tests/integration/aiopika/__init__.py | 0 tests/integration/aiopika/conftest.py | 14 +++ tests/integration/aiopika/test_notifier.py | 15 ++++ tests/integration/common/cancellations.py | 94 ++++++++++++++++++++ tests/integration/redis/__init__.py | 0 tests/integration/redis/conftest.py | 14 +++ tests/integration/redis/test_backend.py | 15 ++++ tests/integration/redis/test_pubsub.py | 15 ++++ tests/integration/redis/test_state_holder.py | 15 ++++ tests/unit/__init__.py | 0 tests/{ => unit}/test_backend.py | 0 tests/{ => unit}/test_cancellation.py | 0 16 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 docker-compose-tests.yml create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/aiopika/__init__.py create mode 100644 tests/integration/aiopika/conftest.py create mode 100644 tests/integration/aiopika/test_notifier.py create mode 100644 tests/integration/common/cancellations.py create mode 100644 tests/integration/redis/__init__.py create mode 100644 tests/integration/redis/conftest.py create mode 100644 tests/integration/redis/test_backend.py create mode 100644 tests/integration/redis/test_pubsub.py create mode 100644 tests/integration/redis/test_state_holder.py create mode 100644 tests/unit/__init__.py rename tests/{ => unit}/test_backend.py (100%) rename tests/{ => unit}/test_cancellation.py (100%) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index fdd04d6..ca80840 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -2,10 +2,10 @@ name: Testing on: pull_request: - branches: [develop] + branches: [develop, main] jobs: - run-tests: + run-unit-tests: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] @@ -13,6 +13,7 @@ jobs: fail-fast: false runs-on: ${{ matrix.os }} + steps: - name: Checkout uses: actions/checkout@v5 @@ -31,5 +32,34 @@ jobs: - name: Install modules run: uv sync - - name: Run tests for Python ${{ matrix.python-version }} - run: uv run pytest + - name: Run unit tests for Python ${{ matrix.python-version }} + run: uv run pytest tests/unit + + run-integration-tests: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Install modules + run: uv sync --extra redis --extra aiopika + + - name: Setup Docker containers + run: docker compose -f ./docker-compose-tests.yml up --wait + + - name: Run integration tests for Python ${{ matrix.python-version }} + run: uv run pytest tests/integration diff --git a/docker-compose-tests.yml b/docker-compose-tests.yml new file mode 100644 index 0000000..ffa0fbd --- /dev/null +++ b/docker-compose-tests.yml @@ -0,0 +1,20 @@ +services: + redis: + image: redis:latest + ports: + - "6379:6379" + + rabbitmq: + image: rabbitmq:latest + environment: + - RABBITMQ_DEFAULT_USER=guest + - RABBITMQ_DEFAULT_PASSWORD=guest + hostname: localhost + ports: + - "5672:5672" + healthcheck: + test: "rabbitmq-diagnostics check_running -q" + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 677fb5b..1df4f56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ ] [project.optional-dependencies] -redis = ["redis~=3.0"] +redis = ["redis~=6.0"] aiopika = ["aio_pika"] [project.urls] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/aiopika/__init__.py b/tests/integration/aiopika/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/aiopika/conftest.py b/tests/integration/aiopika/conftest.py new file mode 100644 index 0000000..53e27dc --- /dev/null +++ b/tests/integration/aiopika/conftest.py @@ -0,0 +1,14 @@ +import os +import pytest + +from taskiq import InMemoryBroker + + +@pytest.fixture +def rabbitmq_url(): + return os.environ.get("TEST_RABBITMQ_URL", "amqp://guest:guest@localhost:5672") + + +@pytest.fixture +def broker(): + return InMemoryBroker() diff --git a/tests/integration/aiopika/test_notifier.py b/tests/integration/aiopika/test_notifier.py new file mode 100644 index 0000000..11e6553 --- /dev/null +++ b/tests/integration/aiopika/test_notifier.py @@ -0,0 +1,15 @@ +import pytest + +from taskiq_cancellation.notifiers.aiopika import AioPikaNotifier + +from ..common.cancellations import run_notifier_cancellation_test + + +@pytest.fixture +def notifier(rabbitmq_url): + return AioPikaNotifier(url=rabbitmq_url) + + +@pytest.mark.asyncio +async def test_cancellation(notifier: AioPikaNotifier): + await run_notifier_cancellation_test(notifier) diff --git a/tests/integration/common/cancellations.py b/tests/integration/common/cancellations.py new file mode 100644 index 0000000..e495604 --- /dev/null +++ b/tests/integration/common/cancellations.py @@ -0,0 +1,94 @@ +import sys +import uuid +import pytest +import asyncio + +from taskiq import InMemoryBroker + +from taskiq_cancellation.abc import CancellationBackend, CancellationNotifier, CancellationStateHolder +from taskiq_cancellation.backends.modular import ModularCancellationBackend +from taskiq_cancellation.notifiers.null import NullCancellationNotifier +from taskiq_cancellation.state_holders.null import NullCancellationStateHolder +from taskiq_cancellation.exceptions import TaskCancellationException + +if sys.version_info >= (3, 11): + from asyncio import timeout +else: + from async_timeout import timeout + + + +async def run_backend_cancellation_test(backend: CancellationBackend): + broker = InMemoryBroker() + backend = backend.with_broker(broker) + + @broker.task + @backend.cancellable + async def test_task(): + pass + + await broker.startup() + + task = await test_task.kiq() + await backend.cancel(task.task_id) + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() + + +async def run_notifier_cancellation_test(notifier: CancellationNotifier): + broker = InMemoryBroker() + backend = ModularCancellationBackend( + NullCancellationStateHolder(), + notifier + ).with_broker(broker) + + task_started = asyncio.Event() + + @broker.task + @backend.cancellable + async def test_task(): + task_started.set() + await asyncio.sleep(0.2) + + await broker.startup() + + task = await test_task.kiq() + async with timeout(1): + await task_started.wait() + + await backend.cancel(task.task_id) + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() + + +async def run_state_holder_cancellation_test(state_holder: CancellationStateHolder): + broker = InMemoryBroker() + backend = ModularCancellationBackend( + state_holder, + NullCancellationNotifier() + ).with_broker(broker) + + @broker.task + @backend.cancellable + async def test_task(): + # This task is supposed to never start + assert False + + await broker.startup() + + task_id = str(uuid.uuid4()) + await backend.cancel(task_id) + task = await test_task.kicker().with_task_id(task_id).kiq() + + with pytest.raises(TaskCancellationException): + result = await task.wait_result() + result.raise_for_error() + + await broker.shutdown() diff --git a/tests/integration/redis/__init__.py b/tests/integration/redis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/redis/conftest.py b/tests/integration/redis/conftest.py new file mode 100644 index 0000000..1823f42 --- /dev/null +++ b/tests/integration/redis/conftest.py @@ -0,0 +1,14 @@ +import os +import pytest + +from taskiq import InMemoryBroker + + +@pytest.fixture +def redis_url(): + return os.environ.get("TEST_REDIS_URL", "redis://localhost:6379") + + +@pytest.fixture +def broker(): + return InMemoryBroker() diff --git a/tests/integration/redis/test_backend.py b/tests/integration/redis/test_backend.py new file mode 100644 index 0000000..a5bde89 --- /dev/null +++ b/tests/integration/redis/test_backend.py @@ -0,0 +1,15 @@ +import pytest + +from taskiq_cancellation.backends.redis import RedisCancellationBackend + +from ..common.cancellations import run_backend_cancellation_test + + +@pytest.fixture +def redis_backend(redis_url, broker): + return RedisCancellationBackend(url=redis_url).with_broker(broker) + + +@pytest.mark.asyncio +async def test_cancellation(redis_backend: RedisCancellationBackend): + await run_backend_cancellation_test(redis_backend) diff --git a/tests/integration/redis/test_pubsub.py b/tests/integration/redis/test_pubsub.py new file mode 100644 index 0000000..b49720c --- /dev/null +++ b/tests/integration/redis/test_pubsub.py @@ -0,0 +1,15 @@ +import pytest + +from taskiq_cancellation.notifiers.redis import PubSubCancellationNotifier + +from ..common.cancellations import run_notifier_cancellation_test + + +@pytest.fixture +def pubsub_notifier(redis_url): + return PubSubCancellationNotifier(url=redis_url) + + +@pytest.mark.asyncio +async def test_cancellation(pubsub_notifier: PubSubCancellationNotifier): + await run_notifier_cancellation_test(pubsub_notifier) diff --git a/tests/integration/redis/test_state_holder.py b/tests/integration/redis/test_state_holder.py new file mode 100644 index 0000000..f8be37b --- /dev/null +++ b/tests/integration/redis/test_state_holder.py @@ -0,0 +1,15 @@ +import pytest + +from taskiq_cancellation.state_holders.redis import RedisCancellationStateHolder + +from ..common.cancellations import run_state_holder_cancellation_test + + +@pytest.fixture +def state_holder(redis_url): + return RedisCancellationStateHolder(url=redis_url) + + +@pytest.mark.asyncio +async def test_cancellation(state_holder: RedisCancellationStateHolder): + await run_state_holder_cancellation_test(state_holder) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_backend.py b/tests/unit/test_backend.py similarity index 100% rename from tests/test_backend.py rename to tests/unit/test_backend.py diff --git a/tests/test_cancellation.py b/tests/unit/test_cancellation.py similarity index 100% rename from tests/test_cancellation.py rename to tests/unit/test_cancellation.py From 3ba287f1233c151e902dc18e9cc1f1e08b3cc50c Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 15:50:20 +0300 Subject: [PATCH 27/36] feat: better serializer api and redis/aiopika notifier inits --- src/taskiq_cancellation/abc/notifier.py | 22 ++++++++++++++++++-- src/taskiq_cancellation/backends/modular.py | 21 +++++++++++++++++++ src/taskiq_cancellation/notifiers/aiopika.py | 20 ++++++++++++------ src/taskiq_cancellation/notifiers/redis.py | 13 +++++++++--- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/taskiq_cancellation/abc/notifier.py b/src/taskiq_cancellation/abc/notifier.py index eb0718d..60b1d14 100644 --- a/src/taskiq_cancellation/abc/notifier.py +++ b/src/taskiq_cancellation/abc/notifier.py @@ -1,5 +1,11 @@ +import sys import abc +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + from taskiq.abc.serializer import TaskiqSerializer from taskiq.serializers import JSONSerializer @@ -9,8 +15,8 @@ class CancellationNotifier(abc.ABC): """Receives cancellation messages and notifies listeners of these messages""" - def __init__(self, serializer: TaskiqSerializer = JSONSerializer()): - self.serializer = serializer + def __init__(self): + self.serializer = JSONSerializer() async def startup(self) -> None: """Starts up cancellation notifier""" @@ -47,3 +53,15 @@ async def listen_for_cancellation( :type started_listening_event: StartedListeningEvent """ pass + + def with_serializer(self, serializer: TaskiqSerializer) -> Self: + """ + Sets a serializer to be used by the notifier + + :param serializer: serializer for cancellation messages + :type serializer: TaskiqSerializer + :return: self + """ + self.serializer = serializer + + return self diff --git a/src/taskiq_cancellation/backends/modular.py b/src/taskiq_cancellation/backends/modular.py index 2da1ec4..c65c00c 100644 --- a/src/taskiq_cancellation/backends/modular.py +++ b/src/taskiq_cancellation/backends/modular.py @@ -1,3 +1,12 @@ +import sys + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +from taskiq.abc.serializer import TaskiqSerializer + from taskiq_cancellation.abc import ( CancellationBackend, CancellationNotifier, @@ -47,3 +56,15 @@ async def shutdown(self) -> None: await super().shutdown() await self.state_holder.shutdown() await self.notifier.shutdown() + + def with_serializer(self, serializer: TaskiqSerializer) -> Self: + """ + Sets a serializer to be used by the notifier + + :param serializer: serializer for cancellation messages + :type serializer: TaskiqSerializer + :return: self + """ + self.notifier = self.notifier.with_serializer(serializer) + + return self diff --git a/src/taskiq_cancellation/notifiers/aiopika.py b/src/taskiq_cancellation/notifiers/aiopika.py index 945a445..2432e56 100644 --- a/src/taskiq_cancellation/notifiers/aiopika.py +++ b/src/taskiq_cancellation/notifiers/aiopika.py @@ -14,15 +14,23 @@ class AioPikaNotifier(QueueCancellationNotifier): EXCHANGE_NAME = "__taskiq_cancellation" - def __init__(self, url: str, **kwargs): - super().__init__(**kwargs) + def __init__(self, url: str, **connection_kwargs): + """ + Creates AioPika notifier + + :param url: url to rabbitmq + :type url: str + :param connection_kwargs: arguments for :ref:`aio_pika.connect_robust` + """ + super().__init__() self.url: str = url + self.connection_kwargs = connection_kwargs async def cancel(self, task_id: str) -> None: timestamp = time.time() - connection = await aio_pika.connect_robust(self.url) + connection = await aio_pika.connect_robust(self.url, **self.connection_kwargs) async with connection: channel = await connection.channel() @@ -43,7 +51,7 @@ async def cancel(self, task_id: str) -> None: ) async def _listen(self, started_listening: asyncio.Event): - connection = await aio_pika.connect_robust(self.url) + connection = await aio_pika.connect_robust(self.url, **self.connection_kwargs) async with connection: channel = await connection.channel() @@ -63,6 +71,6 @@ async def _listen(self, started_listening: asyncio.Event): CancellationMessage, self.serializer.loadb(message.body) ) - for queue in self.queues: - await queue.put(cancellation_message) + for subscriber_queue in self.queues: + await subscriber_queue.put(cancellation_message) await message.ack() diff --git a/src/taskiq_cancellation/notifiers/redis.py b/src/taskiq_cancellation/notifiers/redis.py index 5d7906e..fe7746e 100644 --- a/src/taskiq_cancellation/notifiers/redis.py +++ b/src/taskiq_cancellation/notifiers/redis.py @@ -14,10 +14,17 @@ class PubSubCancellationNotifier(QueueCancellationNotifier): CHANNEL_NAME = "__taskiq_cancellation_notifications" - def __init__(self, url: str, **kwargs) -> None: - super().__init__(**kwargs) + def __init__(self, url: str, **connection_kwargs) -> None: + """ + Creates AioPika notifier - self.connection_pool = redis.BlockingConnectionPool.from_url(url, **kwargs) + :param url: url to redis + :type url: str + :param connection_kwargs: arguments for :ref:`redis.BlockingConnectionPool.from_url` + """ + super().__init__() + + self.connection_pool = redis.BlockingConnectionPool.from_url(url, **connection_kwargs) async def cancel(self, task_id: str) -> None: timestamp = time.time() From 185d6c05e3bfb9e7940127379626b5930d380bdf Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 22:02:59 +0300 Subject: [PATCH 28/36] feat: more imports in inits --- src/taskiq_cancellation/__init__.py | 9 ++++++++- src/taskiq_cancellation/backends/__init__.py | 8 ++++++++ src/taskiq_cancellation/notifiers/__init__.py | 8 ++++++++ src/taskiq_cancellation/state_holders/__init__.py | 8 ++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/taskiq_cancellation/__init__.py b/src/taskiq_cancellation/__init__.py index db24b56..914f0b2 100644 --- a/src/taskiq_cancellation/__init__.py +++ b/src/taskiq_cancellation/__init__.py @@ -1,5 +1,12 @@ from .abc import CancellationBackend from .backends.modular import ModularCancellationBackend +from .backends.in_memory import InMemoryCancellationBackend +from .cancellation_handlers.cancellation_type import CancellationType +from .exceptions import TaskCancellationException -__all__ = ["CancellationBackend", "ModularCancellationBackend"] +__all__ = [ + "CancellationBackend", "ModularCancellationBackend", + "InMemoryCancellationBackend", "CancellationType", "TaskCancellationException" +] + diff --git a/src/taskiq_cancellation/backends/__init__.py b/src/taskiq_cancellation/backends/__init__.py index e69de29..5c65c46 100644 --- a/src/taskiq_cancellation/backends/__init__.py +++ b/src/taskiq_cancellation/backends/__init__.py @@ -0,0 +1,8 @@ +from .modular import ModularCancellationBackend +from .in_memory import InMemoryCancellationBackend + + +__all__ = [ + "ModularCancellationBackend", + "InMemoryCancellationBackend" +] diff --git a/src/taskiq_cancellation/notifiers/__init__.py b/src/taskiq_cancellation/notifiers/__init__.py index e69de29..9a097d6 100644 --- a/src/taskiq_cancellation/notifiers/__init__.py +++ b/src/taskiq_cancellation/notifiers/__init__.py @@ -0,0 +1,8 @@ +from .null import NullCancellationNotifier +from .in_memory import InMemoryCancellationNotifier + + +__all__ = [ + "NullCancellationNotifier", + "InMemoryCancellationNotifier" +] diff --git a/src/taskiq_cancellation/state_holders/__init__.py b/src/taskiq_cancellation/state_holders/__init__.py index e69de29..9296f10 100644 --- a/src/taskiq_cancellation/state_holders/__init__.py +++ b/src/taskiq_cancellation/state_holders/__init__.py @@ -0,0 +1,8 @@ +from .in_memory import InMemoryCancellationStateHolder +from .null import NullCancellationStateHolder + + +__all__ = [ + "InMemoryCancellationStateHolder", + "NullCancellationStateHolder" +] From f3a6d4e0089feb13fa73440bdd05c17d5b149e0a Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 22:36:36 +0300 Subject: [PATCH 29/36] docs: project's readme --- README.md | 151 ++++++++++++++++++++++++++++++++++++++-- imgs/backend_scheme.png | Bin 0 -> 88800 bytes imgs/header.png | Bin 0 -> 100297 bytes 3 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 imgs/backend_scheme.png create mode 100644 imgs/header.png diff --git a/README.md b/README.md index aa1a64f..ce150c0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,155 @@ -[![PyPI - Version](https://img.shields.io/pypi/v/taskiq-cancellation.svg)](https://pypi.org/project/taskiq-cancellation) -[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/taskiq-cancellation.svg)](https://pypi.org/project/taskiq-cancellation) +
+ taskiq-cancellation logo +
-# Task cancellation for taskiq +[![PyPI - Version](https://img.shields.io/pypi/v/taskiq-cancellation.svg?style=for-the-badge)](https://pypi.org/project/taskiq-cancellation) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/taskiq-cancellation.svg?style=for-the-badge)](https://pypi.org/project/taskiq-cancellation) + +**[taskiq-cancellation](https://pypi.org/project/taskiq-cancellation)** aims to be a drop-in task cancellation solution for taskiq as the original package doesn't provide a cancellation API. + +## Contents: + +- [Installation](#installation) +- [Usage](#usage) + - [What is a cancellation backend?](#what-is-a-cancellation-backend) + - [Modular cancellation backend](#modular-cancellation-backend) + - [Available integrations](#available-integrations) + - [Level and edge cancellation](#level-and-edge-cancellation) +- [Development](#development) +- [Contributing](#contributing) ## Installation -```console +This package can be install from PyPI with your package manager of choice. + +```bash pip install taskiq-cancellation +pipx install taskiq-cancellation +poetry add taskiq-cancellation +uv add taskiq-cancellation +``` + +taskiq-cancellation currently provides integrations with Redis and RabbitMQ that are installable with `redis` and `aiopika` extras respectfully. + +```bash +pip install taskiq-cancellation[redis,aiopika] ``` ## Usage + +To do task cancellation, you need to: + +1. Create a cancellation backend +2. Wrap a function with `cancellable` decorator +3. Cancel the task with `cancel(task_id)` + +```python +broker = PubSubBroker(url).with_result_backend(RedisAsyncResultBackend(url)) +cancellation_backend = RedisCancellationBackend(url).with_broker(broker) + +@broker.task +@cancellation_backend.cancellable +async def sleep(seconds: int): + await asyncio.sleep(seconds) + print("Slept!") # Won't be printed on worker side because of the cancellation + +async def main(): + await broker.startup() + + task = await sleep.kiq(5) + await cancellation_backend.cancel(task.task_id) + + await broker.shutdown() + +asyncio.run(main()) +``` + +### What is a cancellation backend? + +**Cancellation backend** can be seen as combination of a broker and result backend for cancellation messages that works underneath taskiq's broker. Cancellation backend won't run tasks marked as cancelled and will listen for cancellation messages for already running tasks. + +
+ Cancellation backend example scheme +
+ +### Modular cancellation backend + +To easily create cancellation backends taskiq-cancellation provides `ModularCancellationBackend`. Modular cancellation backend consists of two parts: state holder and notifier. + +- State holder is used to check for task cancellation status before running the task. +- Notifier is used to listen for cancellation messages while running the task + +This allows to use any techonology for task cancellation. For example, if one uses SQL database and RabbitMQ message broker, they can make a custom state holder with SQL library of their choice and use provided RabbitMQ notifier. + +```python +from taskiq_cancellation import ModularCancellationBackend +from taskiq_cancellation.state_holders.redis import RedisCancellationStateHolder +from taskiq_cancellation.notifiers.aiopika import AioPikaCancellationNotifier + +backend = ModularCancellationBackend( + RedisCancellationStateHolder("redis://localhost:6379"), + AioPikaCancellationNotifier("amqp://guest:guest@localhost:5672") +) +``` + +### Available integrations + +taskiq-cancellation provides: + +- state holder for Redis (`RedisCancellationStateHolder`) +- notifiers for Redis pub/sub (`PubSubCancellationNotifier`) and RabbitMQ (`AioPikaCancellationNotifier`) + +Also there are `NullCancellationStateHolder` and `NullCancellationNotifier` that do absolutely nothing, if there's no need to not check for task cancellation before starting the task or no need to listen for cancellation of already running tasks. + +### Level and edge cancellation + +By default, taskiq-cancellation uses [`anyio`](https://anyio.readthedocs.io/en/stable/) and its [level cancellation](anyio.readthedocs.io/en/stable/cancellation.html#differences-between-asyncio-and-anyio-cancellation-semantics). Level cancellation raises a cancellation exception on **every** asynchronous wait in a function. + +As external libraries might not support level cancellation, task-cancellation also provides [edge cancellation]() via `asyncio`. Edge cancellation raises an exception only _once_. To enable it, add `cancellation_type=CancellationType.EDGE` parameter to `cancellable` decorator. + +> [!WARNING] +> Currently edge cancellation is supported only for Python 3.11+ because it uses [`asyncio.TaskGroup`](https://docs.python.org/3/library/asyncio-task.html#asyncio.TaskGroup) + +Example: + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from taskiq_cancellation import CancellationType + +@broker.task +@cancellation_backend.cancellable(cancellation_type=CancellationType.EDGE) +async def sleep(seconds: int): + session = AsyncSession(engine) + + try: + async with session.begin(): + await asyncio.sleep(seconds) + session.add(SleptFor(seconds)) + except asyncio.CancelledError: + # Won't raise cancelled exception + await session.close() + raise +``` + +## Development + +For linting, ruff is used + +```bash +ruff check +ruff format +``` + +For testing, pytest is used + +```bash +pytest tests/unit # Unit tests + +# Integration tests +docker compose -f docker-compose-tests.yml up --wait +pytest tests/integration +``` + +## Contributing + +If you have any issues with this package or have an idea for improvement, please don't hesitate to open an issue! This is my first open-source project so I would like to ask to be a little patient with me though 🙏 diff --git a/imgs/backend_scheme.png b/imgs/backend_scheme.png new file mode 100644 index 0000000000000000000000000000000000000000..30fb5cbb371ed00be347aaef638fd628541ea4ae GIT binary patch literal 88800 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

7$`;1t|Op*(Pb> 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 literal 0 HcmV?d00001 From e66cc49a0f8d05d9d14745007b177144bc484cbe Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 22:48:48 +0300 Subject: [PATCH 30/36] docs: add readme for counter example --- examples/counter/README.md | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 examples/counter/README.md diff --git a/examples/counter/README.md b/examples/counter/README.md new file mode 100644 index 0000000..f3bc5be --- /dev/null +++ b/examples/counter/README.md @@ -0,0 +1,63 @@ +# Example: Counter + +This example demonstrates use of Redis cancellation backend with Redis broker. + +`sleep(seconds)` task sleeps for `seconds` seconds. First task will finish fully after 5 seconds and second task will cancel after 2.5 seconds. + +## How to run + +1. Install dependencies + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +2. Launch redis server locally + +3. Launch worker + +```bash +taskiq worker main:broker --workers=1 +``` + +4. Launch client + +```bash +python main.py +``` + +## Expected result + +Client side: + +```console +Sending task and waiting 5 seconds... +Sending task and waiting 2.5 seconds... +Canceling task... +``` + +Worker side: + +```console +[2025-11-11 22:45:12,072][taskiq.receiver.receiver][INFO ][worker-0] Executing task main:count with ID: e2acdc76da94435d963b561b80098c47 +1 Mississippi +2 Mississippi +3 Mississippi +4 Mississippi +5 Mississippi +[2025-11-11 22:45:17,073][taskiq.receiver.receiver][INFO ][worker-0] Executing task main:count with ID: e094778df6de450d811c4701cfff608f +1 Mississippi +2 Mississippi +3 Mississippi +[2025-11-11 22:45:19,589][taskiq.receiver.receiver][ERROR ][worker-0] Exception found while executing function: +Traceback (most recent call last): + File "P:\Scratches\taskiq-cancellation\.venv\lib\site-packages\taskiq\receiver\receiver.py", line 254, in run_task + returned = await target_future + File "P:\Scratches\taskiq-cancellation\src\taskiq_cancellation\abc\backend.py", line 190, in wrapper + return await level_handler(*args, **kwargs) + File "P:\Scratches\taskiq-cancellation\src\taskiq_cancellation\cancellation_handlers\level.py", line 104, in __call__ + raise TaskCancellationException() +taskiq_cancellation.exceptions.TaskCancellationException +``` From 02447f0a47ec88b882ea0890861f3c0c02f72b80 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Tue, 11 Nov 2025 23:15:42 +0300 Subject: [PATCH 31/36] docs: add note about retry middlewares and task cancellation [skip ci] --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index ce150c0..57abe86 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - [Modular cancellation backend](#modular-cancellation-backend) - [Available integrations](#available-integrations) - [Level and edge cancellation](#level-and-edge-cancellation) + - [Retry middlewares with task cancellation](#retry-middlewares-with-task-cancellation) - [Development](#development) - [Contributing](#contributing) @@ -131,6 +132,22 @@ async def sleep(seconds: int): raise ``` +### Retry middlewares with task cancellation + +If you use `SimpleRetryMiddleware` or `SmartRetryMiddleware`, make sure to add `TaskCancellationException` to `types_of_exceptions` parameter to not trigger additional retries. + +```python +from taskiq_cancellation.exceptions import TaskCancellationException + +broker = PubSubBroker(url) + .with_result_backend(RedisAsyncResultBackend(url)) + .with_middlewares( + SimpleRetryMiddleware( + types_of_exceptions=[TaskCancellationException, ] + ) + ) +``` + ## Development For linting, ruff is used From 4305e394e13c52fbcf79a7f02490e5e644cf820b Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Wed, 12 Nov 2025 00:24:44 +0300 Subject: [PATCH 32/36] ci: release on pypi on github release --- .github/workflows/release.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..9473ce2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,28 @@ +name: Release on PyPI + +on: + release: + types: + - released + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: 3.14 + + - name: Setup uv + uses: astral-sh/setup-uv@v6 + + - name: Build package + run: uv build + + - name: Publish package + run: uv publish From 3b62529d266d8cbc6e7824c682fc4fd87854d6e9 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Wed, 12 Nov 2025 00:24:56 +0300 Subject: [PATCH 33/36] chore: decrease version, actually --- src/taskiq_cancellation/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taskiq_cancellation/__about__.py b/src/taskiq_cancellation/__about__.py index d3ec452..f102a9c 100644 --- a/src/taskiq_cancellation/__about__.py +++ b/src/taskiq_cancellation/__about__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.0.1" From 42a907a5b2dd211a4236042c22504e0b089bd569 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Wed, 12 Nov 2025 00:25:22 +0300 Subject: [PATCH 34/36] fix: include only src in sdist --- pyproject.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1df4f56..f55868d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,12 @@ extra-dependencies = ["mypy>=1.0.0"] [tool.hatch.envs.types.scripts] check = "mypy --install-types --non-interactive {args:src/taskiq_cancellation tests}" +[tool.hatch.build.targets.sdist] +only-include = ["src/taskiq_cancellation"] + +[tool.hatch.build.targets.wheel] +packages = ["src/taskiq_cancellation"] + [tool.mypy] ignore_missing_imports = true exclude = [ @@ -83,3 +89,9 @@ dev = [ # Edge cancellation is currently Python 3.11+ which causes linter to freak out # For such versioning cases tests must be written target-version = "py314" + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true From d0a1d3fffabf24b73d88517331fcd3e82060fc96 Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Wed, 12 Nov 2025 00:45:34 +0300 Subject: [PATCH 35/36] fix: tell mypy we're typed [skip ci] --- src/taskiq_cancellation/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/taskiq_cancellation/py.typed diff --git a/src/taskiq_cancellation/py.typed b/src/taskiq_cancellation/py.typed new file mode 100644 index 0000000..e69de29 From 64e2b7715bcec2c09858b8e14cbacef683bd844c Mon Sep 17 00:00:00 2001 From: Alexander Starikov Date: Wed, 12 Nov 2025 00:51:20 +0300 Subject: [PATCH 36/36] ci: lint on pull request to main --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 0e31ea4..cebff2b 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -2,7 +2,7 @@ name: Lint on: pull_request: - branches: [develop] + branches: [develop, main] jobs: lint: