mirror of
https://github.com/d3vyce/taskiq-deduplication.git
synced 2026-08-04 19:14:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb99e358e
|
||
|
|
66b418bff1 | ||
|
|
84fff031df | ||
|
|
bede6f8a40 | ||
|
|
446071924e |
@@ -50,8 +50,8 @@ except DuplicateTaskError:
|
||||
|
||||
## 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.
|
||||
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis lock, before they reach the broker.
|
||||
- **Atomic lock release** — lock is released on completion or error via a Lua check-and-delete; only the owning task can release its lock.
|
||||
- **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.
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ except DuplicateTaskError:
|
||||
|
||||
## 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.
|
||||
- **Sender-side deduplication** — rejects duplicate tasks at dispatch time via a Redis lock, before they reach the broker.
|
||||
- **Atomic lock release** — lock is released on completion or error via a Lua check-and-delete; only the owning task can release its lock.
|
||||
- **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.
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "taskiq-deduplication"
|
||||
version = "1.0.1"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
version = "1.0.2"
|
||||
description = "Redis-backed deduplication middleware for Taskiq"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -9,7 +9,7 @@ requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "d3vyce", email = "contact@d3vyce.fr" }
|
||||
]
|
||||
keywords = ["fastapi", "sqlalchemy", "postgresql"]
|
||||
keywords = ["taskiq", "redis", "deduplication", "middleware", "task-queue"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Framework :: AsyncIO",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FastAPI utilities package."""
|
||||
"""Redis-backed deduplication middleware for Taskiq."""
|
||||
|
||||
from .middleware import DuplicateTaskError, RedisDeduplicationMiddleware
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.0.2"
|
||||
|
||||
__all__ = [
|
||||
"DuplicateTaskError",
|
||||
|
||||
@@ -56,7 +56,7 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
if self._redis is not None:
|
||||
await self._redis.aclose()
|
||||
|
||||
def _build_deduplication_key(self, message: TaskiqMessage) -> str:
|
||||
def _build_deduplication_key(self, message: TaskiqMessage) -> str | None:
|
||||
explicit_key: str | None = message.labels.get(DEDUP_EXPLICIT_KEY_LABEL)
|
||||
if explicit_key is not None:
|
||||
return f"{self.key_prefix}:{explicit_key}"
|
||||
@@ -67,10 +67,13 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
if key_fields is not None
|
||||
else message.kwargs
|
||||
)
|
||||
payload = json.dumps(
|
||||
{"task": message.task_name, "kwargs": kwargs},
|
||||
sort_keys=True,
|
||||
)
|
||||
try:
|
||||
payload = json.dumps(
|
||||
{"task": message.task_name, "kwargs": kwargs},
|
||||
sort_keys=True,
|
||||
)
|
||||
except TypeError:
|
||||
return None
|
||||
fingerprint = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
return f"{self.key_prefix}:{fingerprint}"
|
||||
|
||||
@@ -81,7 +84,10 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
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
|
||||
if self._redis is None:
|
||||
raise RuntimeError(
|
||||
"RedisDeduplicationMiddleware.startup() was never called."
|
||||
)
|
||||
released = await check_and_delete(self._redis, key, task_id)
|
||||
if released:
|
||||
logger.debug("Released lock %s", key)
|
||||
@@ -92,8 +98,18 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
if not self._is_enabled(message.labels):
|
||||
return message
|
||||
|
||||
assert self._redis is not None
|
||||
if self._redis is None:
|
||||
raise RuntimeError(
|
||||
"RedisDeduplicationMiddleware.startup() was never called."
|
||||
)
|
||||
key = self._build_deduplication_key(message)
|
||||
if key is None:
|
||||
logger.warning(
|
||||
"Task %s has non-JSON-serializable kwargs; deduplication skipped."
|
||||
" Use the deduplication_key label to deduplicate this task.",
|
||||
message.task_name,
|
||||
)
|
||||
return message
|
||||
ttl = self._get_ttl(message.labels)
|
||||
|
||||
logger.debug("Acquiring lock %s for task %s", key, message.task_name)
|
||||
@@ -118,9 +134,10 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
) -> None:
|
||||
if not self._is_enabled(message.labels):
|
||||
return
|
||||
await self._release_if_owned(
|
||||
self._build_deduplication_key(message), message.task_id
|
||||
)
|
||||
key = self._build_deduplication_key(message)
|
||||
if key is None:
|
||||
return
|
||||
await self._release_if_owned(key, message.task_id)
|
||||
|
||||
async def on_error(
|
||||
self,
|
||||
@@ -130,6 +147,7 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
|
||||
) -> None:
|
||||
if not self._is_enabled(message.labels):
|
||||
return
|
||||
await self._release_if_owned(
|
||||
self._build_deduplication_key(message), message.task_id
|
||||
)
|
||||
key = self._build_deduplication_key(message)
|
||||
if key is None:
|
||||
return
|
||||
await self._release_if_owned(key, message.task_id)
|
||||
|
||||
@@ -92,7 +92,45 @@ class TestDefaultBuildDeduplicationKey:
|
||||
mw._redis = None
|
||||
m = make_message()
|
||||
key = mw._build_deduplication_key(m)
|
||||
assert key.startswith("myapp:locks:")
|
||||
assert key is not None and key.startswith("myapp:locks:")
|
||||
|
||||
def test_empty_kwargs_produces_consistent_key(self, middleware, make_message):
|
||||
m1 = make_message(kwargs={})
|
||||
m2 = make_message(kwargs={})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_key_fields_empty_list_ignores_all_kwargs(self, middleware, make_message):
|
||||
m1 = make_message(kwargs={"a": 1}, labels={DEDUP_KEY_FIELDS_LABEL: []})
|
||||
m2 = make_message(kwargs={"a": 999}, labels={DEDUP_KEY_FIELDS_LABEL: []})
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_key_fields_absent_from_kwargs_are_ignored(self, middleware, make_message):
|
||||
m1 = make_message(
|
||||
kwargs={"order_id": 1}, labels={DEDUP_KEY_FIELDS_LABEL: ["user_id"]}
|
||||
)
|
||||
m2 = make_message(
|
||||
kwargs={"order_id": 999}, labels={DEDUP_KEY_FIELDS_LABEL: ["user_id"]}
|
||||
)
|
||||
assert middleware._build_deduplication_key(
|
||||
m1
|
||||
) == middleware._build_deduplication_key(m2)
|
||||
|
||||
def test_explicit_key_takes_precedence_over_key_fields(
|
||||
self, middleware, make_message
|
||||
):
|
||||
m = make_message(
|
||||
kwargs={"a": 1},
|
||||
labels={DEDUP_EXPLICIT_KEY_LABEL: "my-lock", DEDUP_KEY_FIELDS_LABEL: ["a"]},
|
||||
)
|
||||
assert middleware._build_deduplication_key(m) == "taskiq:deduplication:my-lock"
|
||||
|
||||
def test_non_serializable_kwargs_returns_none(self, middleware, make_message):
|
||||
m = make_message(kwargs={"dt": object()})
|
||||
assert middleware._build_deduplication_key(m) is None
|
||||
|
||||
|
||||
class TestPreSend:
|
||||
@@ -140,6 +178,25 @@ class TestPreSend:
|
||||
await middleware.pre_send(make_message(kwargs={"x": 1}))
|
||||
await middleware.pre_send(make_message(kwargs={"x": 2}))
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_non_serializable_kwargs_skips_deduplication(
|
||||
self, middleware, make_message
|
||||
):
|
||||
msg1 = make_message(kwargs={"dt": object()})
|
||||
msg2 = make_message(kwargs={"dt": object()})
|
||||
await middleware.pre_send(msg1)
|
||||
await middleware.pre_send(msg2) # should not raise
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_ttl_applied(self, fake_redis, make_message):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost", default_ttl=77)
|
||||
mw._redis = fake_redis
|
||||
msg = make_message()
|
||||
await mw.pre_send(msg)
|
||||
key = mw._build_deduplication_key(msg)
|
||||
ttl = await fake_redis.ttl(key)
|
||||
assert 0 < ttl <= 77
|
||||
|
||||
|
||||
class TestPostExecute:
|
||||
@pytest.mark.anyio
|
||||
@@ -166,6 +223,15 @@ class TestPostExecute:
|
||||
await middleware.post_execute(disabled_msg, make_result())
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_post_execute_after_ttl_expiry_is_safe(
|
||||
self, middleware, fake_redis, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
await fake_redis.delete(middleware._build_deduplication_key(msg))
|
||||
await middleware.post_execute(msg, make_result())
|
||||
|
||||
|
||||
class TestOnError:
|
||||
@pytest.mark.anyio
|
||||
@@ -195,6 +261,26 @@ class TestOnError:
|
||||
assert await fake_redis.exists(key)
|
||||
|
||||
|
||||
class TestRedispatchAfterRelease:
|
||||
@pytest.mark.anyio
|
||||
async def test_redispatch_after_post_execute(
|
||||
self, middleware, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
await middleware.post_execute(msg, make_result())
|
||||
await middleware.pre_send(make_message())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_redispatch_after_on_error(
|
||||
self, middleware, make_message, make_result
|
||||
):
|
||||
msg = make_message()
|
||||
await middleware.pre_send(msg)
|
||||
await middleware.on_error(msg, make_result(is_err=True), RuntimeError("boom"))
|
||||
await middleware.pre_send(make_message())
|
||||
|
||||
|
||||
class TestAtomicRelease:
|
||||
@pytest.mark.anyio
|
||||
async def test_only_owner_can_release(self, middleware, fake_redis, make_message):
|
||||
@@ -240,3 +326,9 @@ class TestLifecycle:
|
||||
async def test_shutdown_without_startup_is_safe(self):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||
await mw.shutdown()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pre_send_without_startup_raises_runtime_error(self, make_message):
|
||||
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
|
||||
with pytest.raises(RuntimeError, match="startup"):
|
||||
await mw.pre_send(make_message())
|
||||
|
||||
Reference in New Issue
Block a user