Skip to content

Commit 4f8030b

Browse files
committed
fix(scheduler): preserve custom task IDs for interval schedules
Forward explicit task IDs to interval ScheduledTask payloads, matching cron and time scheduling semantics. Add regression coverage for recurring IDs, generated dispatch IDs, and schedule serialization. Refs: #271
1 parent 9c339d6 commit 4f8030b

3 files changed

Lines changed: 129 additions & 0 deletions

File tree

‎docs/guide/scheduling-tasks.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ If you want to pass additional labels, you can call these methods on the `Kicker
159159
)
160160
```
161161

162+
An explicit `.with_task_id(...)` is stored for cron, interval and time
163+
schedules and is reused for every dispatch of a recurring schedule. Without an
164+
explicit task id, Taskiq generates a new invocation id for each dispatch.
165+
162166
::: warning Cool warning!
163167

164168
The `with_broker` method won't do anything in this case, since we have a broker assigned to each scheduler.

‎taskiq/kicker.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ async def schedule_by_interval(
242242
labels=message.labels,
243243
args=message.args,
244244
kwargs=message.kwargs,
245+
task_id=self.custom_task_id,
245246
interval=interval,
246247
)
247248
await source.add_schedule(scheduled)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from datetime import datetime, timezone
2+
from typing import Any, Literal
3+
4+
import pytest
5+
6+
from taskiq.abc.schedule_source import ScheduleSource
7+
from taskiq.compat import model_dump
8+
from taskiq.kicker import AsyncKicker
9+
from taskiq.message import BrokerMessage
10+
from taskiq.scheduler.scheduled_task import ScheduledTask
11+
from taskiq.scheduler.scheduler import TaskiqScheduler
12+
from tests.utils import AsyncQueueBroker
13+
14+
ScheduleKind = Literal["cron", "interval", "time"]
15+
16+
17+
class RecordingBroker(AsyncQueueBroker):
18+
"""Record scheduler dispatch without using a transport."""
19+
20+
def __init__(self) -> None:
21+
super().__init__()
22+
self.sent: list[BrokerMessage] = []
23+
24+
async def kick(self, message: BrokerMessage) -> None:
25+
self.sent.append(message)
26+
27+
28+
class RecordingScheduleSource(ScheduleSource):
29+
"""Store schedules created through the public kicker API."""
30+
31+
def __init__(self) -> None:
32+
self.schedules: list[ScheduledTask] = []
33+
34+
async def get_schedules(self) -> list[ScheduledTask]:
35+
return self.schedules
36+
37+
async def add_schedule(self, schedule: ScheduledTask) -> None:
38+
self.schedules.append(schedule)
39+
40+
41+
async def create_schedule(
42+
kind: ScheduleKind,
43+
kicker: AsyncKicker[Any, Any],
44+
source: RecordingScheduleSource,
45+
) -> ScheduledTask:
46+
"""Create one schedule through the selected public kicker method."""
47+
if kind == "cron":
48+
created = await kicker.schedule_by_cron(source, "* * * * *")
49+
elif kind == "interval":
50+
created = await kicker.schedule_by_interval(source, 10)
51+
else:
52+
created = await kicker.schedule_by_time(
53+
source,
54+
datetime(2030, 1, 1, tzinfo=timezone.utc),
55+
)
56+
return created.task
57+
58+
59+
@pytest.mark.parametrize("kind", ["cron", "interval", "time"])
60+
async def test_all_schedule_methods_preserve_custom_task_id(
61+
kind: ScheduleKind,
62+
) -> None:
63+
broker = RecordingBroker()
64+
source = RecordingScheduleSource()
65+
kicker: AsyncKicker[Any, Any] = AsyncKicker("demo.task", broker, {})
66+
67+
scheduled = await create_schedule(
68+
kind,
69+
kicker.with_task_id("custom-task-id"),
70+
source,
71+
)
72+
73+
assert scheduled.task_id == "custom-task-id"
74+
assert source.schedules == [scheduled]
75+
76+
77+
async def test_interval_without_custom_task_id_keeps_deferred_generation() -> None:
78+
broker = RecordingBroker()
79+
source = RecordingScheduleSource()
80+
kicker: AsyncKicker[Any, Any] = AsyncKicker("demo.task", broker, {})
81+
82+
scheduled = await create_schedule("interval", kicker, source)
83+
84+
assert scheduled.task_id is None
85+
86+
87+
async def test_scheduler_reuses_stored_interval_task_id() -> None:
88+
broker = RecordingBroker()
89+
source = RecordingScheduleSource()
90+
kicker: AsyncKicker[Any, Any] = AsyncKicker("demo.task", broker, {})
91+
scheduled = await create_schedule(
92+
"interval",
93+
kicker.with_task_id("custom-task-id"),
94+
source,
95+
)
96+
restored = ScheduledTask(**model_dump(scheduled))
97+
98+
scheduler = TaskiqScheduler(broker, [source])
99+
await scheduler.on_ready(source, restored)
100+
await scheduler.on_ready(source, restored)
101+
102+
assert [message.task_id for message in broker.sent] == [
103+
"custom-task-id",
104+
"custom-task-id",
105+
]
106+
107+
108+
async def test_scheduler_generates_task_id_for_each_interval_dispatch() -> None:
109+
broker = RecordingBroker()
110+
source = RecordingScheduleSource()
111+
kicker: AsyncKicker[Any, Any] = AsyncKicker("demo.task", broker, {})
112+
scheduled = await create_schedule("interval", kicker, source)
113+
restored = ScheduledTask(**model_dump(scheduled))
114+
generated_task_ids = iter(("generated-task-id-1", "generated-task-id-2"))
115+
broker.with_id_generator(lambda: next(generated_task_ids))
116+
117+
scheduler = TaskiqScheduler(broker, [source])
118+
await scheduler.on_ready(source, restored)
119+
await scheduler.on_ready(source, restored)
120+
121+
assert [message.task_id for message in broker.sent] == [
122+
"generated-task-id-1",
123+
"generated-task-id-2",
124+
]

0 commit comments

Comments
 (0)