Skip to content

Commit 9efb05f

Browse files
committed
fix(tasks): protect bound task state from callable metadata
- prevent callable __dict__ from overriding task identity and broker state - preserve non-conflicting custom metadata for backward compatibility - cover callable-object and legacy decorator metadata collisions
1 parent d89d186 commit 9efb05f

3 files changed

Lines changed: 119 additions & 8 deletions

File tree

taskiq/abc/broker.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import warnings
44
from abc import ABC, abstractmethod
55
from collections import defaultdict
6-
from collections.abc import AsyncGenerator, Awaitable, Callable
6+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
77
from functools import WRAPPER_ASSIGNMENTS, update_wrapper
88
from logging import getLogger
99
from typing import (
@@ -49,12 +49,43 @@
4949
_T = TypeVar("_T")
5050
_FuncParams = ParamSpec("_FuncParams")
5151
_ReturnType = TypeVar("_ReturnType")
52+
_DecoratedTask = TypeVar(
53+
"_DecoratedTask",
54+
bound=AsyncTaskiqDecoratedTask[Any, Any],
55+
)
5256

5357
EventHandler: TypeAlias = Callable[[TaskiqState], Awaitable[None] | None]
5458

5559
logger = getLogger("taskiq")
5660

5761

62+
def _copy_callable_metadata(
63+
task: _DecoratedTask,
64+
source: Callable[..., Any],
65+
) -> _DecoratedTask:
66+
"""Preserve callable metadata while keeping task-owned state authoritative."""
67+
assigned_metadata = tuple(
68+
attribute for attribute in WRAPPER_ASSIGNMENTS if hasattr(source, attribute)
69+
)
70+
update_wrapper(
71+
task,
72+
source,
73+
assigned=assigned_metadata,
74+
updated=(),
75+
)
76+
77+
protected_attributes = set(vars(task))
78+
for task_type in type(task).__mro__:
79+
protected_attributes.update(vars(task_type))
80+
81+
source_attributes = getattr(source, "__dict__", None)
82+
if isinstance(source_attributes, Mapping):
83+
for attribute, value in source_attributes.items():
84+
if attribute not in protected_attributes:
85+
setattr(task, attribute, value)
86+
return task
87+
88+
5889
def default_id_generator() -> str:
5990
"""
6091
Default task_id generator.
@@ -541,12 +572,7 @@ def _decorate_task(
541572
if "return" in sign:
542573
return_type = sign["return"]
543574

544-
assigned_metadata = tuple(
545-
attribute
546-
for attribute in WRAPPER_ASSIGNMENTS
547-
if hasattr(metadata_source, attribute)
548-
)
549-
decorated_task = update_wrapper(
575+
decorated_task = _copy_callable_metadata(
550576
task_cls(
551577
broker=self,
552578
original_func=func,
@@ -555,7 +581,6 @@ def _decorate_task(
555581
return_type=return_type, # type: ignore
556582
),
557583
metadata_source,
558-
assigned=assigned_metadata,
559584
)
560585

561586
if register:

tests/abc/test_broker.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ async def test_func() -> None:
6868
}
6969

7070

71+
def test_decorator_metadata_cannot_replace_task_contract() -> None:
72+
"""Task fields must win over colliding wrapped-function attributes."""
73+
broker = _TestBroker()
74+
75+
def source(value: int) -> int:
76+
return value + 1
77+
78+
vars(source).update(
79+
broker="source-broker",
80+
custom_metadata="preserved",
81+
kiq="source-kiq",
82+
labels={"source": "function"},
83+
original_func="source-original-func",
84+
return_type=str,
85+
task_name="source.task-name",
86+
)
87+
88+
registered = broker.task(
89+
task_name="expected.task-name",
90+
queue="expected",
91+
)(source)
92+
93+
assert registered.broker is broker
94+
assert registered.task_name == "expected.task-name"
95+
assert registered.labels == {"queue": "expected"}
96+
assert registered.original_func is source
97+
assert registered.return_type is int
98+
assert callable(registered.kiq)
99+
assert vars(registered)["custom_metadata"] == "preserved"
100+
assert registered(2) == 3
101+
102+
71103
def test_decorator_generates_unique_lambda_task_names() -> None:
72104
"""Anonymous tasks must not collide in the broker task registry."""
73105
broker = _TestBroker()

tests/test_task_builder.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ def __call__(self, value: int) -> int:
5252
return value + self.increment
5353

5454

55+
class CollidingCallable:
56+
"""Callable whose application state overlaps with the task API."""
57+
58+
def __init__(self, increment: int) -> None:
59+
self.broker = "source-broker"
60+
self.increment = increment
61+
self.kiq = "source-kiq"
62+
self.labels = {"source": "callable-object"}
63+
self.original_func = "source-original-func"
64+
self.return_type = str
65+
self.task_name = "source.task-name"
66+
67+
def __call__(self, value: int) -> int:
68+
"""Increment one value without exposing state as task metadata."""
69+
return value + self.increment
70+
71+
5572
@task_builder("shared.process_pool")
5673
def shared_process_pool_add(left: int, right: int) -> int:
5774
"""Add values in a spawned worker process."""
@@ -146,6 +163,43 @@ async def test_callable_object_task_definition_can_be_bound_and_sent() -> None:
146163
assert result.return_value == 5
147164

148165

166+
async def test_callable_object_state_cannot_replace_task_contract() -> None:
167+
source = CollidingCallable(2)
168+
definition = task_builder(
169+
"shared.callable-owned-state",
170+
queue="expected",
171+
)(source)
172+
broker = InMemoryBroker(await_inplace=True)
173+
174+
registered = broker.register_task(definition)
175+
sent_task = await registered.kiq(3)
176+
result = await sent_task.wait_result()
177+
await broker.shutdown()
178+
179+
assert registered.broker is broker
180+
assert registered.task_name == "shared.callable-owned-state"
181+
assert registered.labels == {"queue": "expected"}
182+
assert registered.return_type is int
183+
assert registered.__wrapped__ is source
184+
assert registered.original_func(3) == 5
185+
assert callable(registered.kiq)
186+
assert vars(registered)["increment"] == 2
187+
assert broker.local_task_registry[registered.task_name] is registered
188+
assert result.return_value == 5
189+
190+
191+
def test_task_binding_preserves_non_conflicting_custom_metadata() -> None:
192+
def source(value: int) -> int:
193+
return value + 1
194+
195+
vars(source)["custom_metadata"] = "preserved"
196+
definition = task_builder("shared.custom-metadata")(source)
197+
198+
registered = InMemoryBroker().register_task(definition)
199+
200+
assert vars(registered)["custom_metadata"] == "preserved"
201+
202+
149203
def test_callable_object_binding_rejects_ambiguous_redeclaration() -> None:
150204
task_builder("shared.callable-collision")(CallableIncrement(2))
151205

0 commit comments

Comments
 (0)