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:
d3vyce
2026-05-02 14:49:42 +02:00
committed by GitHub
parent 413c4c42f3
commit b4ee3fce45
21 changed files with 3184 additions and 1 deletions
+14
View File
@@ -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:
+29
View File
@@ -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
+93
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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__/
+34
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.14
+21
View File
@@ -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.
+65 -1
View File
@@ -1,2 +1,66 @@
# Taskiq deduplication # Taskiq Deduplication
Redis-backed deduplication middleware for Taskiq that prevents duplicate tasks from being queued or executed concurrently.
[![CI](https://github.com/d3vyce/taskiq-deduplication/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/taskiq-deduplication/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/d3vyce/taskiq-deduplication/graph/badge.svg)](https://codecov.io/gh/d3vyce/taskiq-deduplication)
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
+66
View File
@@ -0,0 +1,66 @@
# Taskiq Deduplication
Redis-backed deduplication middleware for Taskiq that prevents duplicate tasks from being queued or executed concurrently.
[![CI](https://github.com/d3vyce/taskiq-deduplication/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/taskiq-deduplication/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/d3vyce/taskiq-deduplication/graph/badge.svg)](https://codecov.io/gh/d3vyce/taskiq-deduplication)
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
+13
View File
@@ -0,0 +1,13 @@
# API Reference
## Middleware
::: taskiq_deduplication.RedisDeduplicationMiddleware
options:
show_source: false
## Exceptions
::: taskiq_deduplication.DuplicateTaskError
options:
show_source: false
+120
View File
@@ -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:
...
```
+80
View File
@@ -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",
]
+10
View File
@@ -0,0 +1,10 @@
"""FastAPI utilities package."""
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
__version__ = "3.1.1"
__all__ = [
"DuplicateTaskError",
"RedisDeduplicationMiddleware",
]
+135
View File
@@ -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
)
+18
View File
@@ -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)
View File
+43
View File
@@ -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
+242
View File
@@ -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()
Generated
+1843
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -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 &copy; 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"