Skip to content

Commit fe92683

Browse files
authored
Add per-task ack type support (#636)
1 parent 44ef89f commit fe92683

6 files changed

Lines changed: 307 additions & 5 deletions

File tree

docs/guide/cli.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ This can be configured using `--ack-type` parameter. For example:
5858
taskiq worker --ack-type when_executed mybroker:broker
5959
```
6060

61+
It can also be overridden per task using the `ack_type` task label:
62+
63+
```python
64+
@broker.task(ack_type="when_received")
65+
async def best_effort_task() -> None:
66+
...
67+
68+
69+
@broker.task(ack_type="when_saved")
70+
async def critical_task() -> None:
71+
...
72+
```
73+
74+
If a task has no `ack_type` label, the worker-level `--ack-type` value is used.
75+
6176
### Type casts
6277

6378
One of features taskiq have is automatic type casts. For example you have a type-hinted task like this:

taskiq/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from taskiq.abc.middleware import TaskiqMiddleware
1010
from taskiq.abc.result_backend import AsyncResultBackend
1111
from taskiq.abc.schedule_source import ScheduleSource
12-
from taskiq.acks import AckableMessage
12+
from taskiq.acks import AckableMessage, AcknowledgeType
1313
from taskiq.brokers.inmemory_broker import InMemoryBroker
1414
from taskiq.brokers.shared_broker import async_shared_broker
1515
from taskiq.brokers.zmq_broker import ZeroMQBroker
@@ -41,6 +41,7 @@
4141

4242
__all__ = [
4343
"AckableMessage",
44+
"AcknowledgeType",
4445
"AsyncBroker",
4546
"AsyncResultBackend",
4647
"AsyncTaskiqDecoratedTask",

taskiq/acks.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import enum
22
from collections.abc import Awaitable, Callable
3+
from typing import Any
34

45
from pydantic import BaseModel
56

@@ -20,6 +21,20 @@ class AcknowledgeType(str, enum.Enum):
2021
WHEN_SAVED = "when_saved"
2122

2223

24+
def parse_acknowledge_type(value: Any) -> AcknowledgeType:
25+
"""Parse acknowledge type from a task label value."""
26+
if isinstance(value, AcknowledgeType):
27+
return value
28+
if isinstance(value, str):
29+
try:
30+
return AcknowledgeType(value.lower())
31+
except ValueError as exc:
32+
raise ValueError(f"Unknown acknowledge type value: {value}.") from exc
33+
raise ValueError(
34+
f"Unsupported acknowledge type: {value} use str or AcknowledgeType",
35+
)
36+
37+
2338
class AckableMessage(BaseModel):
2439
"""
2540
Message that can be acknowledged.

taskiq/receiver/receiver.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from taskiq.abc.broker import AckableMessage, AsyncBroker
1717
from taskiq.abc.middleware import TaskiqMiddleware
18-
from taskiq.acks import AcknowledgeType
18+
from taskiq.acks import AcknowledgeType, parse_acknowledge_type
1919
from taskiq.context import Context
2020
from taskiq.exceptions import NoResultError
2121
from taskiq.message import TaskiqMessage
@@ -148,13 +148,14 @@ async def callback( # noqa: C901, PLR0912
148148
),
149149
)
150150

151+
ack_time = self._get_ack_time(taskiq_msg)
151152
logger.info(
152153
"Executing task %s with ID: %s",
153154
taskiq_msg.task_name,
154155
taskiq_msg.task_id,
155156
)
156157

157-
if self.ack_time == AcknowledgeType.WHEN_RECEIVED and isinstance(
158+
if ack_time == AcknowledgeType.WHEN_RECEIVED and isinstance(
158159
message,
159160
AckableMessage,
160161
):
@@ -165,7 +166,7 @@ async def callback( # noqa: C901, PLR0912
165166
message=taskiq_msg,
166167
)
167168

168-
if self.ack_time == AcknowledgeType.WHEN_EXECUTED and isinstance(
169+
if ack_time == AcknowledgeType.WHEN_EXECUTED and isinstance(
169170
message,
170171
AckableMessage,
171172
):
@@ -192,12 +193,28 @@ async def callback( # noqa: C901, PLR0912
192193
if raise_err:
193194
raise exc
194195

195-
if self.ack_time == AcknowledgeType.WHEN_SAVED and isinstance(
196+
if ack_time == AcknowledgeType.WHEN_SAVED and isinstance(
196197
message,
197198
AckableMessage,
198199
):
199200
await maybe_awaitable(message.ack())
200201

202+
def _get_ack_time(self, message: TaskiqMessage) -> AcknowledgeType:
203+
"""
204+
Get acknowledge time for a task.
205+
206+
Task-level `ack_type` label overrides worker-level configuration.
207+
"""
208+
ack_type = message.labels.get("ack_type")
209+
if ack_type is None:
210+
return self.ack_time
211+
try:
212+
return parse_acknowledge_type(ack_type)
213+
except ValueError as exc:
214+
raise ValueError(
215+
f"Invalid ack_type label {ack_type!r} for task {message.task_name}.",
216+
) from exc
217+
201218
async def run_task( # noqa: C901, PLR0912, PLR0915
202219
self,
203220
target: Callable[..., Any],

tests/receiver/test_receiver.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from taskiq.abc.broker import AckableMessage, AsyncBroker
1515
from taskiq.abc.middleware import TaskiqMiddleware
16+
from taskiq.abc.result_backend import AsyncResultBackend
17+
from taskiq.acks import AcknowledgeType
1618
from taskiq.brokers.inmemory_broker import InMemoryBroker
1719
from taskiq.exceptions import NoResultError, TaskiqResultTimeoutError
1820
from taskiq.message import TaskiqMessage
@@ -26,6 +28,7 @@ def get_receiver(
2628
no_parse: bool = False,
2729
max_async_tasks: int | None = None,
2830
max_async_tasks_jitter: int = 0,
31+
ack_type: AcknowledgeType | None = None,
2932
) -> Receiver:
3033
"""
3134
Returns receiver with custom broker and args.
@@ -44,9 +47,50 @@ def get_receiver(
4447
validate_params=not no_parse,
4548
max_async_tasks=max_async_tasks,
4649
max_async_tasks_jitter=max_async_tasks_jitter,
50+
ack_type=ack_type,
4751
)
4852

4953

54+
class _EventResultBackend(AsyncResultBackend[Any]):
55+
def __init__(self, events: list[str]) -> None:
56+
self.events = events
57+
self.results: dict[str, TaskiqResult[Any]] = {}
58+
59+
async def set_result(self, task_id: str, result: TaskiqResult[Any]) -> None:
60+
self.events.append("save")
61+
self.results[task_id] = result
62+
63+
async def is_result_ready(self, task_id: str) -> bool:
64+
return task_id in self.results
65+
66+
async def get_result(
67+
self,
68+
task_id: str,
69+
with_logs: bool = False,
70+
) -> TaskiqResult[Any]:
71+
return self.results[task_id]
72+
73+
74+
class _EventMiddleware(TaskiqMiddleware):
75+
def __init__(self, events: list[str]) -> None:
76+
super().__init__()
77+
self.events = events
78+
79+
def post_execute(
80+
self,
81+
message: TaskiqMessage,
82+
result: TaskiqResult[Any],
83+
) -> None:
84+
self.events.append("post_execute")
85+
86+
def post_save(
87+
self,
88+
message: TaskiqMessage,
89+
result: TaskiqResult[Any],
90+
) -> None:
91+
self.events.append("post_save")
92+
93+
5094
async def test_run_task_successful_async() -> None:
5195
"""Tests that run_task can run async tasks."""
5296

@@ -327,6 +371,192 @@ async def ack_callback() -> None:
327371
assert acked
328372

329373

374+
async def test_task_ack_type_when_received_overrides_worker_ack_type() -> None:
375+
"""Task ack_type label overrides worker-level ack type."""
376+
events: list[str] = []
377+
broker = (
378+
InMemoryBroker()
379+
.with_result_backend(
380+
_EventResultBackend(events),
381+
)
382+
.with_middlewares(_EventMiddleware(events))
383+
)
384+
385+
@broker.task
386+
async def my_task() -> int:
387+
events.append("task")
388+
return 1
389+
390+
def ack_callback() -> None:
391+
events.append("ack")
392+
393+
receiver = get_receiver(broker, ack_type=AcknowledgeType.WHEN_SAVED)
394+
broker_message = broker.formatter.dumps(
395+
TaskiqMessage(
396+
task_id="task_id",
397+
task_name=my_task.task_name,
398+
labels={"ack_type": "when_received"},
399+
args=[],
400+
kwargs={},
401+
),
402+
)
403+
404+
await receiver.callback(
405+
AckableMessage(
406+
data=broker_message.message,
407+
ack=ack_callback,
408+
),
409+
)
410+
411+
assert events == ["ack", "task", "post_execute", "save", "post_save"]
412+
413+
414+
async def test_task_ack_type_when_executed_overrides_worker_ack_type() -> None:
415+
"""Task ack_type label can delay a worker-level when_received ack."""
416+
events: list[str] = []
417+
broker = (
418+
InMemoryBroker()
419+
.with_result_backend(
420+
_EventResultBackend(events),
421+
)
422+
.with_middlewares(_EventMiddleware(events))
423+
)
424+
425+
@broker.task(ack_type="when_executed")
426+
async def my_task() -> int:
427+
events.append("task")
428+
return 1
429+
430+
def ack_callback() -> None:
431+
events.append("ack")
432+
433+
receiver = get_receiver(broker, ack_type=AcknowledgeType.WHEN_RECEIVED)
434+
broker_message = broker.formatter.dumps(my_task.kicker()._prepare_message())
435+
436+
await receiver.callback(
437+
AckableMessage(
438+
data=broker_message.message,
439+
ack=ack_callback,
440+
),
441+
)
442+
443+
assert events == ["task", "ack", "post_execute", "save", "post_save"]
444+
445+
446+
async def test_task_ack_type_when_saved_overrides_worker_ack_type() -> None:
447+
"""Task ack_type label can use when_saved with an early-ack worker."""
448+
events: list[str] = []
449+
broker = (
450+
InMemoryBroker()
451+
.with_result_backend(
452+
_EventResultBackend(events),
453+
)
454+
.with_middlewares(_EventMiddleware(events))
455+
)
456+
457+
@broker.task
458+
async def my_task() -> int:
459+
events.append("task")
460+
return 1
461+
462+
def ack_callback() -> None:
463+
events.append("ack")
464+
465+
receiver = get_receiver(broker, ack_type=AcknowledgeType.WHEN_RECEIVED)
466+
broker_message = broker.formatter.dumps(
467+
TaskiqMessage(
468+
task_id="task_id",
469+
task_name=my_task.task_name,
470+
labels={"ack_type": "when_saved"},
471+
args=[],
472+
kwargs={},
473+
),
474+
)
475+
476+
await receiver.callback(
477+
AckableMessage(
478+
data=broker_message.message,
479+
ack=ack_callback,
480+
),
481+
)
482+
483+
assert events == ["task", "post_execute", "save", "post_save", "ack"]
484+
485+
486+
async def test_worker_ack_type_is_used_without_task_ack_type() -> None:
487+
"""Worker-level ack_type is used when the task has no ack_type label."""
488+
events: list[str] = []
489+
broker = (
490+
InMemoryBroker()
491+
.with_result_backend(
492+
_EventResultBackend(events),
493+
)
494+
.with_middlewares(_EventMiddleware(events))
495+
)
496+
497+
@broker.task
498+
async def my_task() -> int:
499+
events.append("task")
500+
return 1
501+
502+
def ack_callback() -> None:
503+
events.append("ack")
504+
505+
receiver = get_receiver(broker, ack_type=AcknowledgeType.WHEN_RECEIVED)
506+
broker_message = broker.formatter.dumps(
507+
TaskiqMessage(
508+
task_id="task_id",
509+
task_name=my_task.task_name,
510+
labels={},
511+
args=[],
512+
kwargs={},
513+
),
514+
)
515+
516+
await receiver.callback(
517+
AckableMessage(
518+
data=broker_message.message,
519+
ack=ack_callback,
520+
),
521+
)
522+
523+
assert events == ["ack", "task", "post_execute", "save", "post_save"]
524+
525+
526+
async def test_invalid_task_ack_type_raises_before_execution() -> None:
527+
"""Invalid task ack_type fails before task execution and acknowledgement."""
528+
events: list[str] = []
529+
broker = InMemoryBroker()
530+
531+
@broker.task
532+
async def my_task() -> None:
533+
events.append("task")
534+
535+
def ack_callback() -> None:
536+
events.append("ack")
537+
538+
receiver = get_receiver(broker, ack_type=AcknowledgeType.WHEN_RECEIVED)
539+
broker_message = broker.formatter.dumps(
540+
TaskiqMessage(
541+
task_id="task_id",
542+
task_name=my_task.task_name,
543+
labels={"ack_type": "when_save"},
544+
args=[],
545+
kwargs={},
546+
),
547+
)
548+
549+
with pytest.raises(ValueError, match="Invalid ack_type label"):
550+
await receiver.callback(
551+
AckableMessage(
552+
data=broker_message.message,
553+
ack=ack_callback,
554+
),
555+
)
556+
557+
assert events == []
558+
559+
330560
async def test_callback_wrong_format() -> None:
331561
"""Test that wrong format of a message won't throw an error."""
332562
receiver = get_receiver()

0 commit comments

Comments
 (0)