-
Notifications
You must be signed in to change notification settings - Fork 8
Debounce and Cooldown admission control dependencies #355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
1b08852
cd38e0e
7ebc656
4138d29
3d3312f
88eece7
15ae145
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """Cooldown (trailing-edge) admission control dependency.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from datetime import timedelta | ||
| from types import TracebackType | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from ._base import AdmissionBlocked, Dependency, current_docket, current_execution | ||
|
|
||
| if TYPE_CHECKING: # pragma: no cover | ||
| from ..execution import Execution | ||
|
|
||
|
|
||
| class CooldownBlocked(AdmissionBlocked): | ||
| """Raised when a task is blocked by cooldown.""" | ||
|
|
||
| reschedule = False | ||
|
|
||
| def __init__(self, execution: Execution, cooldown_key: str, window: timedelta): | ||
| self.cooldown_key = cooldown_key | ||
| self.window = window | ||
| reason = f"cooldown ({window}) on {cooldown_key}" | ||
| super().__init__(execution, reason=reason) | ||
|
|
||
|
|
||
| class Cooldown(Dependency["Cooldown"]): | ||
| """Trailing-edge cooldown: blocks execution if one recently succeeded. | ||
|
|
||
| Checks for a Redis key on entry. If present, the task is blocked. | ||
| The key is only set on *successful* exit, so failed tasks don't | ||
| trigger the cooldown — they can be retried immediately. | ||
|
|
||
| Works both as a default parameter and as ``Annotated`` metadata:: | ||
|
|
||
| # Per-task: don't start if one succeeded in the last 60s | ||
| async def send_digest( | ||
| cooldown: Cooldown = Cooldown(timedelta(seconds=60)), | ||
| ) -> None: ... | ||
|
|
||
| # Per-parameter: don't start for this customer if one succeeded in the last 60s | ||
| async def send_notification( | ||
| customer_id: Annotated[int, Cooldown(timedelta(seconds=60))], | ||
| ) -> None: ... | ||
| """ | ||
|
|
||
| single: bool = True | ||
|
|
||
| def __init__(self, window: timedelta, *, scope: str | None = None) -> None: | ||
| self.window = window | ||
| self.scope = scope | ||
| self._argument_name: str | None = None | ||
| self._argument_value: Any = None | ||
|
|
||
| def bind_to_parameter(self, name: str, value: Any) -> Cooldown: | ||
| bound = Cooldown(self.window, scope=self.scope) | ||
| bound._argument_name = name | ||
| bound._argument_value = value | ||
| return bound | ||
|
|
||
| def _cooldown_key(self, function_name: str) -> str: | ||
| scope = self.scope or current_docket.get().name | ||
| if self._argument_name is not None: | ||
| return f"{scope}:cooldown:{self._argument_name}:{self._argument_value}" | ||
| return f"{scope}:cooldown:{function_name}" | ||
|
|
||
| async def __aenter__(self) -> Cooldown: | ||
| execution = current_execution.get() | ||
| docket = current_docket.get() | ||
|
|
||
| self._key = self._cooldown_key(execution.function_name) | ||
|
|
||
| async with docket.redis() as redis: | ||
| exists = await redis.exists(self._key) | ||
|
|
||
| if exists: | ||
| raise CooldownBlocked(execution, self._key, self.window) | ||
|
|
||
| return self | ||
|
|
||
| async def __aexit__( | ||
| self, | ||
| exc_type: type[BaseException] | None, | ||
| exc_value: BaseException | None, | ||
| traceback: TracebackType | None, | ||
| ) -> None: | ||
| if exc_type is not None: | ||
| return | ||
|
|
||
| docket = current_docket.get() | ||
| window_ms = int(self.window.total_seconds() * 1000) | ||
|
|
||
| async with docket.redis() as redis: | ||
| await redis.set(self._key, 1, px=window_ms) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """Debounce (leading-edge) admission control dependency.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from datetime import timedelta | ||
| from types import TracebackType | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from ._base import AdmissionBlocked, Dependency, current_docket, current_execution | ||
|
|
||
| if TYPE_CHECKING: # pragma: no cover | ||
| from ..execution import Execution | ||
|
|
||
|
|
||
| class DebounceBlocked(AdmissionBlocked): | ||
| """Raised when a task is blocked by debounce.""" | ||
|
|
||
| reschedule = False | ||
|
|
||
| def __init__(self, execution: Execution, debounce_key: str, window: timedelta): | ||
| self.debounce_key = debounce_key | ||
| self.window = window | ||
| reason = f"debounce ({window}) on {debounce_key}" | ||
| super().__init__(execution, reason=reason) | ||
|
|
||
|
|
||
| class Debounce(Dependency["Debounce"]): | ||
| """Leading-edge debounce: blocks execution if one was recently started. | ||
|
|
||
| Sets a Redis key on entry with a TTL equal to the window. If the key | ||
| already exists, the task is blocked via ``AdmissionBlocked``. | ||
|
|
||
| Works both as a default parameter and as ``Annotated`` metadata:: | ||
|
|
||
| # Per-task: don't start if one started in the last 30s | ||
| async def process_webhooks( | ||
| debounce: Debounce = Debounce(timedelta(seconds=30)), | ||
| ) -> None: ... | ||
|
|
||
| # Per-parameter: don't start for this customer if one started in the last 30s | ||
| async def process_customer( | ||
| customer_id: Annotated[int, Debounce(timedelta(seconds=30))], | ||
| ) -> None: ... | ||
| """ | ||
|
|
||
| single: bool = True | ||
|
|
||
| def __init__(self, window: timedelta, *, scope: str | None = None) -> None: | ||
| self.window = window | ||
| self.scope = scope | ||
| self._argument_name: str | None = None | ||
| self._argument_value: Any = None | ||
|
|
||
| def bind_to_parameter(self, name: str, value: Any) -> Debounce: | ||
| bound = Debounce(self.window, scope=self.scope) | ||
| bound._argument_name = name | ||
| bound._argument_value = value | ||
| return bound | ||
|
|
||
| async def __aenter__(self) -> Debounce: | ||
| execution = current_execution.get() | ||
| docket = current_docket.get() | ||
|
|
||
| scope = self.scope or docket.name | ||
| if self._argument_name is not None: | ||
| debounce_key = ( | ||
| f"{scope}:debounce:{self._argument_name}:{self._argument_value}" | ||
|
||
| ) | ||
| else: | ||
| debounce_key = f"{scope}:debounce:{execution.function_name}" | ||
|
|
||
| window_ms = int(self.window.total_seconds() * 1000) | ||
|
|
||
| async with docket.redis() as redis: | ||
| acquired = await redis.set(debounce_key, 1, nx=True, px=window_ms) | ||
|
|
||
| if not acquired: | ||
| raise DebounceBlocked(execution, debounce_key, self.window) | ||
|
|
||
| return self | ||
|
|
||
| async def __aexit__( | ||
| self, | ||
| exc_type: type[BaseException] | None, | ||
| exc_value: BaseException | None, | ||
| traceback: TracebackType | None, | ||
| ) -> None: | ||
| pass | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The per-parameter
Cooldownkey also depends on_argument_value, which is captured from pre-bound values and can beNonefor positional calls, so different positional inputs collapse onto the same Redis key (for example,task(1)andtask(2)both map to...:customer_id:None). That makes cooldown block unrelated argument values and silently drops valid executions.Useful? React with 👍 / 👎.