mirror of
https://github.com/d3vyce/taskiq-deduplication.git
synced 2026-08-04 19:14:07 +00:00
Version 1.0.0 (#2)
* feat: add taskiq deduplication * doc: rework class comment + update README * fix: make build_deduplication_key private * fix: raise_on_duplicate is now False by default * chore: remove pre_execute * chore: add documentation
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: ⬆
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: ⬆
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Build Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-package:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Build
|
||||
run: uv build
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -0,0 +1,93 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint (Ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Run Ruff linter
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run Ruff formatter check
|
||||
run: uv run ruff format --check .
|
||||
|
||||
typecheck:
|
||||
name: Type Check (ty)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Run ty
|
||||
run: uv run ty check
|
||||
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: uv run pytest --cov --cov-report=xml --cov-report=term-missing --junitxml=junit.xml -o junit_family=legacy
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: coverage
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: test_results
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Documentation
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- run: uv sync --group dev
|
||||
|
||||
- run: uv run zensical build --clean
|
||||
|
||||
- uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
|
||||
- uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
#poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
#pdm.lock
|
||||
#pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
#pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
@@ -0,0 +1,34 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=750"]
|
||||
exclude: ^uv.lock$
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: local-ruff-check
|
||||
name: ruff check
|
||||
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix .
|
||||
require_serial: true
|
||||
language: unsupported
|
||||
types: [python]
|
||||
|
||||
- id: local-ruff-format
|
||||
name: ruff format
|
||||
entry: uv run ruff format --force-exclude --exit-non-zero-on-format .
|
||||
require_serial: true
|
||||
language: unsupported
|
||||
types: [python]
|
||||
|
||||
- id: local-ty
|
||||
name: ty check
|
||||
entry: uv run ty check
|
||||
require_serial: true
|
||||
language: unsupported
|
||||
pass_filenames: false
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 d3vyce
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,2 +1,66 @@
|
||||
# Taskiq deduplication
|
||||
# Taskiq Deduplication
|
||||
|
||||
Redis-backed deduplication middleware for Taskiq that prevents duplicate tasks from being queued or executed concurrently.
|
||||
|
||||
[](https://github.com/d3vyce/taskiq-deduplication/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/d3vyce/taskiq-deduplication)
|
||||
[](https://github.com/astral-sh/ty)
|
||||
[](https://github.com/astral-sh/uv)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
---
|
||||
|
||||
**Documentation**: [https://taskiq-deduplication.d3vyce.fr](https://taskiq-deduplication.d3vyce.fr)
|
||||
|
||||
**Source Code**: [https://github.com/d3vyce/taskiq-deduplication](https://github.com/d3vyce/taskiq-deduplication)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
uv add taskiq-deduplication
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from taskiq_redis import ListQueueBroker
|
||||
from taskiq_deduplication import RedisDeduplicationMiddleware, DuplicateTaskError
|
||||
|
||||
broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
||||
RedisDeduplicationMiddleware(redis_url="redis://localhost:6379"),
|
||||
)
|
||||
|
||||
@broker.task
|
||||
async def send_report(user_id: int) -> None:
|
||||
...
|
||||
|
||||
# First dispatch acquires the lock — succeeds.
|
||||
await send_report.kiq(user_id=42)
|
||||
|
||||
# Second dispatch while the first is queued or running — raises.
|
||||
try:
|
||||
await send_report.kiq(user_id=42)
|
||||
except DuplicateTaskError:
|
||||
pass # already queued or running
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis queue lock, before they reach the broker.
|
||||
- **Worker-side detection** — logs concurrent duplicate executions without raising, keeping `SmartRetryMiddleware` safe from retry storms.
|
||||
- **Configurable TTL** — set a global default or override per task with the `deduplication_ttl` label.
|
||||
- **Explicit lock key** — pin any task to a fixed Redis key with `deduplication_key`, bypassing fingerprint computation entirely.
|
||||
- **Partial fingerprint** — deduplicate on a subset of kwargs with `deduplication_key_fields`, ignoring irrelevant arguments.
|
||||
- **Per-task opt-out** — disable deduplication for individual tasks with the `deduplication` label.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues and pull requests.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Taskiq Deduplication
|
||||
|
||||
Redis-backed deduplication middleware for Taskiq that prevents duplicate tasks from being queued or executed concurrently.
|
||||
|
||||
[](https://github.com/d3vyce/taskiq-deduplication/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/d3vyce/taskiq-deduplication)
|
||||
[](https://github.com/astral-sh/ty)
|
||||
[](https://github.com/astral-sh/uv)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
---
|
||||
|
||||
**Documentation**: [https://taskiq-deduplication.d3vyce.fr](https://taskiq-deduplication.d3vyce.fr)
|
||||
|
||||
**Source Code**: [https://github.com/d3vyce/taskiq-deduplication](https://github.com/d3vyce/taskiq-deduplication)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
uv add "taskiq-deduplication"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from taskiq_redis import ListQueueBroker
|
||||
from taskiq_deduplication import RedisDeduplicationMiddleware, DuplicateTaskError
|
||||
|
||||
broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
||||
RedisDeduplicationMiddleware(redis_url="redis://localhost:6379"),
|
||||
)
|
||||
|
||||
@broker.task
|
||||
async def send_report(user_id: int) -> None:
|
||||
...
|
||||
|
||||
# First dispatch acquires the lock — succeeds.
|
||||
await send_report.kiq(user_id=42)
|
||||
|
||||
# Second dispatch while the first is queued or running — raises.
|
||||
try:
|
||||
await send_report.kiq(user_id=42)
|
||||
except DuplicateTaskError:
|
||||
pass # already queued or running
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis queue lock, before they reach the broker.
|
||||
- **Worker-side detection** — logs concurrent duplicate executions without raising, keeping `SmartRetryMiddleware` safe from retry storms.
|
||||
- **Configurable TTL** — set a global default or override per task with the `deduplication_ttl` label.
|
||||
- **Explicit lock key** — pin any task to a fixed Redis key with `deduplication_key`, bypassing fingerprint computation entirely.
|
||||
- **Partial fingerprint** — deduplicate on a subset of kwargs with `deduplication_key_fields`, ignoring irrelevant arguments.
|
||||
- **Per-task opt-out** — disable deduplication for individual tasks with the `deduplication` label.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues and pull requests.
|
||||
@@ -0,0 +1,13 @@
|
||||
# API Reference
|
||||
|
||||
## Middleware
|
||||
|
||||
::: taskiq_deduplication.RedisDeduplicationMiddleware
|
||||
options:
|
||||
show_source: false
|
||||
|
||||
## Exceptions
|
||||
|
||||
::: taskiq_deduplication.DuplicateTaskError
|
||||
options:
|
||||
show_source: false
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# Usage
|
||||
|
||||
## Setup
|
||||
|
||||
Register `RedisDeduplicationMiddleware` on your broker before the application starts:
|
||||
|
||||
```python
|
||||
from taskiq_redis import ListQueueBroker
|
||||
from taskiq_deduplication import RedisDeduplicationMiddleware
|
||||
|
||||
broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
||||
RedisDeduplicationMiddleware(redis_url="redis://localhost:6379"),
|
||||
)
|
||||
```
|
||||
|
||||
## Middleware options
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `redis_url` | `str` | — | Redis connection URL passed to `Redis.from_url`. |
|
||||
| `default_deduplication` | `bool` | `True` | Whether deduplication is enabled for all tasks by default. Set `False` to opt-in per task instead of opting out. |
|
||||
| `default_ttl` | `int` | `300` | Default lock TTL in seconds. Overridden per task with the `deduplication_ttl` label. |
|
||||
| `key_prefix` | `str` | `"taskiq:deduplication"` | Prefix for all Redis lock keys. |
|
||||
|
||||
```python
|
||||
broker = ListQueueBroker("redis://localhost:6379").with_middlewares(
|
||||
RedisDeduplicationMiddleware(
|
||||
redis_url="redis://localhost:6379",
|
||||
default_deduplication=True,
|
||||
default_ttl=60,
|
||||
key_prefix="myapp:dedup",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
When a task is dispatched, the middleware acquires a Redis lock keyed on the task's
|
||||
fingerprint. Any subsequent dispatch with the same fingerprint raises
|
||||
`DuplicateTaskError` while the lock is held. The lock is released automatically when
|
||||
the task completes or fails.
|
||||
|
||||
## Handling duplicates
|
||||
|
||||
When a duplicate is detected, the middleware logs a warning and raises
|
||||
`DuplicateTaskError`, which prevents the task from reaching the broker.
|
||||
Catch it at the call site if you need to handle it explicitly:
|
||||
|
||||
```python
|
||||
from taskiq_deduplication import DuplicateTaskError
|
||||
|
||||
try:
|
||||
await my_task.kiq(user_id=42)
|
||||
except DuplicateTaskError:
|
||||
pass # task is already queued or running
|
||||
```
|
||||
|
||||
## Per-task label overrides
|
||||
|
||||
Labels can be set at the task level (applied to every call) or at call time.
|
||||
|
||||
### Task-level (decorator)
|
||||
|
||||
```python
|
||||
@broker.task(deduplication_ttl=60)
|
||||
async def my_task(user_id: int) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
### Call-level (kicker)
|
||||
|
||||
```python
|
||||
await my_task.kicker().with_labels(deduplication_ttl=60).kiq(user_id=42)
|
||||
```
|
||||
|
||||
### Available labels
|
||||
|
||||
| Label | Type | Description |
|
||||
|---|---|---|
|
||||
| `deduplication` | `bool` | Set `False` to opt out of deduplication entirely for this task. |
|
||||
| `deduplication_ttl` | `int` | Lock TTL in seconds. Overrides the middleware `default_ttl`. |
|
||||
| `deduplication_key` | `str` | Explicit lock key. Skips fingerprint computation entirely. |
|
||||
| `deduplication_key_fields` | `list[str]` | Subset of kwargs to include in the fingerprint. Ignored if `deduplication_key` is set. |
|
||||
|
||||
## Fingerprint and key customisation
|
||||
|
||||
By default the lock key is a SHA-256 fingerprint of the task name and all kwargs.
|
||||
|
||||
### Explicit key
|
||||
|
||||
Use `deduplication_key` when you want full control over the lock key, regardless of
|
||||
the kwargs:
|
||||
|
||||
```python
|
||||
@broker.task(deduplication_key="send-welcome-email")
|
||||
async def send_welcome_email(user_id: int, locale: str) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
All calls to this task share a single lock, no matter what arguments are passed.
|
||||
|
||||
### Partial key (key fields)
|
||||
|
||||
Use `deduplication_key_fields` to deduplicate only on a subset of kwargs.
|
||||
Here, two calls with the same `user_id` but different `locale` are treated as
|
||||
duplicates:
|
||||
|
||||
```python
|
||||
@broker.task(deduplication_key_fields=["user_id"])
|
||||
async def send_welcome_email(user_id: int, locale: str) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
## Opting out per task
|
||||
|
||||
```python
|
||||
@broker.task(deduplication=False)
|
||||
async def always_run(payload: str) -> None:
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
[project]
|
||||
name = "taskiq-deduplication"
|
||||
version = "0.1.0"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "d3vyce", email = "contact@d3vyce.fr" }
|
||||
]
|
||||
keywords = ["fastapi", "sqlalchemy", "postgresql"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Framework :: AsyncIO",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"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",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Software Development",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"redis>=7.0.0",
|
||||
"taskiq>=0.12.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/d3vyce/taskiq-deduplication"
|
||||
Documentation = "https://taskiq-deduplication.d3vyce.fr/"
|
||||
Repository = "https://github.com/d3vyce/taskiq-deduplication"
|
||||
Issues = "https://github.com/d3vyce/taskiq-deduplication/issues"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
{include-group = "tests"},
|
||||
{include-group = "docs"},
|
||||
"taskiq_deduplication",
|
||||
"prek>=0.3.8",
|
||||
"ruff>=0.1.0",
|
||||
"ty>=0.0.1a0",
|
||||
]
|
||||
tests = [
|
||||
"coverage>=7.0.0",
|
||||
"fakeredis[lua]>=2.0.0",
|
||||
"pytest-anyio>=0.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
docs = [
|
||||
"mkdocstrings-python>=2.0.2",
|
||||
"zensical>=0.0.30",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.10,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/taskiq_deduplication"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""FastAPI utilities package."""
|
||||
|
||||
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||
|
||||
__version__ = "3.1.1"
|
||||
|
||||
__all__ = [
|
||||
"DuplicateTaskError",
|
||||
"RedisDeduplicationMiddleware",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from redis.asyncio import Redis
|
||||
from taskiq import TaskiqMessage, TaskiqResult
|
||||
from taskiq.abc.middleware import TaskiqMiddleware
|
||||
|
||||
from .utils import check_and_delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEDUP_LABEL = "deduplication"
|
||||
DEDUP_TTL_LABEL = "deduplication_ttl"
|
||||
DEDUP_KEY_FIELDS_LABEL = "deduplication_key_fields"
|
||||
DEDUP_EXPLICIT_KEY_LABEL = "deduplication_key"
|
||||
|
||||
|
||||
class DuplicateTaskError(Exception):
|
||||
"""Raised when a task with identical name and kwargs is already queued or running."""
|
||||
|
||||
|
||||
class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
"""Prevents duplicate tasks from being queued.
|
||||
|
||||
When a task is dispatched, a Redis lock is acquired for the duration of its
|
||||
execution. Any subsequent task with the same fingerprint is rejected with
|
||||
``DuplicateTaskError`` while the lock is held. The lock is released automatically
|
||||
on completion or error.
|
||||
|
||||
Attributes:
|
||||
redis_url: Redis connection URL passed to ``Redis.from_url``.
|
||||
default_deduplication: Whether deduplication is enabled by default.
|
||||
default_ttl: Default lock TTL in seconds.
|
||||
key_prefix: Prefix for all Redis lock keys.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str,
|
||||
default_deduplication: bool = True,
|
||||
default_ttl: int = 300,
|
||||
key_prefix: str = "taskiq:deduplication",
|
||||
) -> None:
|
||||
self.redis_url = redis_url
|
||||
self.default_deduplication = default_deduplication
|
||||
self.default_ttl = default_ttl
|
||||
self.key_prefix = key_prefix
|
||||
self._redis: Redis | None = None
|
||||
|
||||
async def startup(self) -> None:
|
||||
self._redis = Redis.from_url(self.redis_url)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._redis is not None:
|
||||
await self._redis.aclose()
|
||||
|
||||
def _build_deduplication_key(self, message: TaskiqMessage) -> str:
|
||||
explicit_key: str | None = message.labels.get(DEDUP_EXPLICIT_KEY_LABEL)
|
||||
if explicit_key is not None:
|
||||
return f"{self.key_prefix}:{explicit_key}"
|
||||
|
||||
key_fields: list[str] | None = message.labels.get(DEDUP_KEY_FIELDS_LABEL)
|
||||
kwargs = (
|
||||
{k: v for k, v in message.kwargs.items() if k in key_fields}
|
||||
if key_fields is not None
|
||||
else message.kwargs
|
||||
)
|
||||
payload = json.dumps(
|
||||
{"task": message.task_name, "kwargs": kwargs},
|
||||
sort_keys=True,
|
||||
)
|
||||
fingerprint = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
return f"{self.key_prefix}:{fingerprint}"
|
||||
|
||||
def _is_enabled(self, labels: dict[str, Any]) -> bool:
|
||||
return bool(labels.get(DEDUP_LABEL, self.default_deduplication))
|
||||
|
||||
def _get_ttl(self, labels: dict[str, Any]) -> int:
|
||||
return int(labels.get(DEDUP_TTL_LABEL, self.default_ttl))
|
||||
|
||||
async def _release_if_owned(self, key: str, task_id: str) -> None:
|
||||
assert self._redis is not None
|
||||
released = await check_and_delete(self._redis, key, task_id)
|
||||
if released:
|
||||
logger.debug("Released lock %s", key)
|
||||
else:
|
||||
logger.debug("Skipped release of lock %s: not owned by this task", key)
|
||||
|
||||
async def pre_send(self, message: TaskiqMessage) -> TaskiqMessage:
|
||||
if not self._is_enabled(message.labels):
|
||||
return message
|
||||
|
||||
assert self._redis is not None
|
||||
key = self._build_deduplication_key(message)
|
||||
ttl = self._get_ttl(message.labels)
|
||||
|
||||
logger.debug("Acquiring lock %s for task %s", key, message.task_name)
|
||||
acquired = await self._redis.set(key, message.task_id, ex=ttl, nx=True)
|
||||
if not acquired:
|
||||
logger.warning(
|
||||
"Duplicate task %s dropped (key=%s).",
|
||||
message.task_name,
|
||||
key,
|
||||
)
|
||||
raise DuplicateTaskError(
|
||||
f"Task {message.task_name!r} with the same arguments is already queued or running."
|
||||
)
|
||||
|
||||
logger.debug("Lock %s acquired for task %s", key, message.task_name)
|
||||
return message
|
||||
|
||||
async def post_execute(
|
||||
self,
|
||||
message: TaskiqMessage,
|
||||
result: TaskiqResult,
|
||||
) -> None:
|
||||
if not self._is_enabled(message.labels):
|
||||
return
|
||||
await self._release_if_owned(
|
||||
self._build_deduplication_key(message), message.task_id
|
||||
)
|
||||
|
||||
async def on_error(
|
||||
self,
|
||||
message: TaskiqMessage,
|
||||
result: TaskiqResult,
|
||||
exception: BaseException,
|
||||
) -> None:
|
||||
if not self._is_enabled(message.labels):
|
||||
return
|
||||
await self._release_if_owned(
|
||||
self._build_deduplication_key(message), message.task_id
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from collections.abc import Awaitable
|
||||
from typing import cast
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
async def check_and_delete(redis: Redis, key: str, owner: str) -> bool:
|
||||
"""Delete *key* only if its value equals *owner*. Returns True if deleted."""
|
||||
release_script = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
|
||||
released = await cast(Awaitable[int], redis.eval(release_script, 1, key, owner))
|
||||
return bool(released)
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
import fakeredis.aioredis
|
||||
from taskiq import TaskiqMessage, TaskiqResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_redis():
|
||||
client = fakeredis.aioredis.FakeRedis()
|
||||
yield client
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_message():
|
||||
def _make(task_name="my_task", task_id="task-1", labels=None, kwargs=None):
|
||||
return TaskiqMessage(
|
||||
task_id=task_id,
|
||||
task_name=task_name,
|
||||
labels=labels or {},
|
||||
labels_types={},
|
||||
args=[],
|
||||
kwargs=kwargs or {},
|
||||
)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_result():
|
||||
def _make(is_err=False):
|
||||
return TaskiqResult(
|
||||
is_err=is_err,
|
||||
log="",
|
||||
return_value=None,
|
||||
execution_time=0.0,
|
||||
)
|
||||
|
||||
return _make
|
||||
@@ -0,0 +1,242 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from taskiq_deduplication import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||
from taskiq_deduplication.middleware import (
|
||||
DEDUP_EXPLICIT_KEY_LABEL,
|
||||
DEDUP_KEY_FIELDS_LABEL,
|
||||
DEDUP_LABEL,
|
||||
DEDUP_TTL_LABEL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def middleware(fake_redis):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||
mw._redis = fake_redis
|
||||
return mw
|
||||
|
||||
|
||||
class TestDefaultBuildDeduplicationKey:
|
||||
def test_same_kwargs_same_key(self, middleware, make_message):
|
||||
m1 = make_message(kwargs={"a": 1, "b": 2})
|
||||
m2 = make_message(kwargs={"a": 1, "b": 2})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_different_kwargs_different_key(self, middleware, make_message):
|
||||
m1 = make_message(kwargs={"a": 1})
|
||||
m2 = make_message(kwargs={"a": 2})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) != middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_kwarg_order_invariant(self, middleware, make_message):
|
||||
m1 = make_message(kwargs={"a": 1, "b": 2})
|
||||
m2 = make_message(kwargs={"b": 2, "a": 1})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_different_task_names_different_keys(self, middleware, make_message):
|
||||
m1 = make_message(task_name="task_a", kwargs={"x": 1})
|
||||
m2 = make_message(task_name="task_b", kwargs={"x": 1})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) != middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_explicit_key_label(self, middleware, make_message):
|
||||
m = make_message(labels={DEDUP_EXPLICIT_KEY_LABEL: "my-lock"})
|
||||
key = middleware._build_deduplication_key(m)
|
||||
assert key == "taskiq:deduplication:my-lock"
|
||||
|
||||
def test_explicit_key_ignores_kwargs(self, middleware, make_message):
|
||||
m1 = make_message(kwargs={"a": 1}, labels={DEDUP_EXPLICIT_KEY_LABEL: "fixed"})
|
||||
m2 = make_message(kwargs={"a": 99}, labels={DEDUP_EXPLICIT_KEY_LABEL: "fixed"})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_key_fields_filters_kwargs(self, middleware, make_message):
|
||||
m1 = make_message(
|
||||
kwargs={"a": 1, "b": 2, "c": 3},
|
||||
labels={DEDUP_KEY_FIELDS_LABEL: ["a", "b"]},
|
||||
)
|
||||
m2 = make_message(
|
||||
kwargs={"a": 1, "b": 2, "c": 999},
|
||||
labels={DEDUP_KEY_FIELDS_LABEL: ["a", "b"]},
|
||||
)
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_key_fields_different_included_fields(self, middleware, make_message):
|
||||
m1 = make_message(
|
||||
kwargs={"a": 1, "b": 2},
|
||||
labels={DEDUP_KEY_FIELDS_LABEL: ["a"]},
|
||||
)
|
||||
m2 = make_message(
|
||||
kwargs={"a": 1, "b": 99},
|
||||
labels={DEDUP_KEY_FIELDS_LABEL: ["a"]},
|
||||
)
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_key_prefix_in_output(self, make_message):
|
||||
mw = RedisDeduplicationMiddleware(
|
||||
redis_url="redis://localhost", key_prefix="myapp:locks"
|
||||
)
|
||||
mw._redis = None
|
||||
m = make_message()
|
||||
key = mw._build_deduplication_key(m)
|
||||
assert key.startswith("myapp:locks:")
|
||||
|
||||
|
||||
class TestPreSend:
|
||||
@pytest.mark.anyio
|
||||
async def test_first_send_passes(self, middleware, make_message):
|
||||
msg = make_message()
|
||||
result = await middleware.pre_send(msg)
|
||||
assert result is msg
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_duplicate_raises(self, middleware, make_message):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
with pytest.raises(DuplicateTaskError):
|
||||
await middleware.pre_send(make_message())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deduplication_disabled_label(self, middleware, make_message):
|
||||
msg1 = make_message(labels={DEDUP_LABEL: False})
|
||||
msg2 = make_message(labels={DEDUP_LABEL: False})
|
||||
await middleware.pre_send(msg1)
|
||||
await middleware.pre_send(msg2) # should not raise
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deduplication_disabled_by_default_init(
|
||||
self, fake_redis, make_message
|
||||
):
|
||||
mw = RedisDeduplicationMiddleware(
|
||||
redis_url="redis://localhost", default_deduplication=False
|
||||
)
|
||||
mw._redis = fake_redis
|
||||
await mw.pre_send(make_message())
|
||||
await mw.pre_send(make_message()) # should not raise
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ttl_applied(self, middleware, fake_redis, make_message):
|
||||
msg = make_message(labels={DEDUP_TTL_LABEL: 42})
|
||||
await middleware.pre_send(msg)
|
||||
key = middleware._build_deduplication_key(msg)
|
||||
ttl = await fake_redis.ttl(key)
|
||||
assert 0 < ttl <= 42
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_different_kwargs_both_pass(self, middleware, make_message):
|
||||
await middleware.pre_send(make_message(kwargs={"x": 1}))
|
||||
await middleware.pre_send(make_message(kwargs={"x": 2}))
|
||||
|
||||
|
||||
class TestPostExecute:
|
||||
@pytest.mark.anyio
|
||||
async def test_releases_lock(
|
||||
self, middleware, fake_redis, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
key = middleware._build_deduplication_key(msg)
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
await middleware.post_execute(msg, make_result())
|
||||
assert not await fake_redis.exists(key)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deduplication_disabled_noop(
|
||||
self, middleware, fake_redis, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
key = middleware._build_deduplication_key(msg)
|
||||
|
||||
disabled_msg = make_message(labels={DEDUP_LABEL: False})
|
||||
await middleware.post_execute(disabled_msg, make_result())
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
|
||||
class TestOnError:
|
||||
@pytest.mark.anyio
|
||||
async def test_releases_lock_on_error(
|
||||
self, middleware, fake_redis, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
key = middleware._build_deduplication_key(msg)
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
await middleware.on_error(msg, make_result(is_err=True), RuntimeError("boom"))
|
||||
assert not await fake_redis.exists(key)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deduplication_disabled_noop(
|
||||
self, middleware, fake_redis, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
key = middleware._build_deduplication_key(msg)
|
||||
|
||||
disabled_msg = make_message(labels={DEDUP_LABEL: False})
|
||||
await middleware.on_error(
|
||||
disabled_msg, make_result(is_err=True), RuntimeError("x")
|
||||
)
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
|
||||
class TestAtomicRelease:
|
||||
@pytest.mark.anyio
|
||||
async def test_only_owner_can_release(self, middleware, fake_redis, make_message):
|
||||
owner_msg = make_message(task_id="owner-task")
|
||||
key = middleware._build_deduplication_key(owner_msg)
|
||||
|
||||
await fake_redis.set(key, "owner-task", ex=300)
|
||||
|
||||
await middleware._release_if_owned(key, "other-task")
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
await middleware._release_if_owned(key, "owner-task")
|
||||
assert not await fake_redis.exists(key)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_missing_key_is_noop(self, middleware, fake_redis):
|
||||
await middleware._release_if_owned(
|
||||
"taskiq:deduplication:nonexistent", "some-task"
|
||||
)
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
@pytest.mark.anyio
|
||||
async def test_startup_creates_redis_client(self):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||
assert mw._redis is None
|
||||
with patch("redis.asyncio.Redis.from_url") as mock_from_url:
|
||||
mock_client = AsyncMock()
|
||||
mock_from_url.return_value = mock_client
|
||||
await mw.startup()
|
||||
mock_from_url.assert_called_once_with("redis://localhost")
|
||||
assert mw._redis is mock_client
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shutdown_closes_redis_client(self):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||
mock_client = AsyncMock()
|
||||
mw._redis = mock_client
|
||||
await mw.shutdown()
|
||||
mock_client.aclose.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shutdown_without_startup_is_safe(self):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||
await mw.shutdown()
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
[project]
|
||||
site_name = "Taskiq Deduplication"
|
||||
site_description = "Redis-backed deduplication middleware for Taskiq."
|
||||
site_author = "d3vyce"
|
||||
site_url = "https://taskiq-deduplication.d3vyce.fr/"
|
||||
copyright = "Copyright © 2026 d3vyce"
|
||||
repo_url = "https://github.com/d3vyce/taskiq-deduplication"
|
||||
|
||||
[project.theme]
|
||||
language = "en"
|
||||
features = [
|
||||
"announce.dismiss",
|
||||
"content.action.view",
|
||||
"content.code.annotate",
|
||||
"content.code.copy",
|
||||
"content.code.select",
|
||||
"content.footnote.tooltips",
|
||||
"content.tabs.link",
|
||||
"content.tooltips",
|
||||
"navigation.footer",
|
||||
"navigation.indexes",
|
||||
"navigation.instant",
|
||||
"navigation.instant.prefetch",
|
||||
"navigation.path",
|
||||
"navigation.sections",
|
||||
"navigation.tabs",
|
||||
"navigation.top",
|
||||
"navigation.tracking",
|
||||
"search.highlight",
|
||||
]
|
||||
|
||||
[[project.theme.palette]]
|
||||
scheme = "default"
|
||||
toggle.icon = "lucide/sun"
|
||||
toggle.name = "Switch to dark mode"
|
||||
|
||||
[[project.theme.palette]]
|
||||
scheme = "slate"
|
||||
toggle.icon = "lucide/moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
|
||||
[project.theme.font]
|
||||
text = "Inter"
|
||||
code = "Jetbrains Mono"
|
||||
|
||||
[project.theme.icon]
|
||||
repo = "fontawesome/brands/github"
|
||||
|
||||
[project.plugins.mkdocstrings.handlers.python]
|
||||
inventories = ["https://docs.python.org/3/objects.inv"]
|
||||
paths = ["src"]
|
||||
|
||||
[project.plugins.mkdocstrings.handlers.python.options]
|
||||
docstring_style = "google"
|
||||
inherited_members = true
|
||||
show_source = false
|
||||
show_root_heading = true
|
||||
|
||||
[project.markdown_extensions]
|
||||
abbr = {}
|
||||
admonition = {}
|
||||
attr_list = {}
|
||||
def_list = {}
|
||||
footnotes = {}
|
||||
md_in_html = {}
|
||||
"pymdownx.arithmatex" = {generic = true}
|
||||
"pymdownx.betterem" = {}
|
||||
"pymdownx.caret" = {}
|
||||
"pymdownx.details" = {}
|
||||
"pymdownx.emoji" = {}
|
||||
"pymdownx.inlinehilite" = {}
|
||||
"pymdownx.keys" = {}
|
||||
"pymdownx.magiclink" = {}
|
||||
"pymdownx.mark" = {}
|
||||
"pymdownx.smartsymbols" = {}
|
||||
"pymdownx.tasklist" = {custom_checkbox = true}
|
||||
"pymdownx.tilde" = {}
|
||||
|
||||
[project.markdown_extensions.pymdownx.emoji]
|
||||
emoji_index = "zensical.extensions.emoji.twemoji"
|
||||
emoji_generator = "zensical.extensions.emoji.to_svg"
|
||||
|
||||
[project.markdown_extensions."pymdownx.highlight"]
|
||||
anchor_linenums = true
|
||||
line_spans = "__span"
|
||||
pygments_lang_class = true
|
||||
|
||||
[project.markdown_extensions."pymdownx.superfences"]
|
||||
custom_fences = [{name = "mermaid", class = "mermaid"}]
|
||||
|
||||
[project.markdown_extensions."pymdownx.tabbed"]
|
||||
alternate_style = true
|
||||
combine_header_slug = true
|
||||
|
||||
[project.markdown_extensions."toc"]
|
||||
permalink = true
|
||||
|
||||
[project.markdown_extensions."pymdownx.snippets"]
|
||||
base_path = ["."]
|
||||
check_paths = true
|
||||
|
||||
[[project.nav]]
|
||||
Home = "index.md"
|
||||
|
||||
[[project.nav]]
|
||||
Usage = "usage.md"
|
||||
|
||||
[[project.nav]]
|
||||
"API Reference" = "reference.md"
|
||||
|
||||
[[project.nav]]
|
||||
"Changelog ↗" = "https://github.com/d3vyce/taskiq-deduplication/releases"
|
||||
Reference in New Issue
Block a user