taskq-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskq/__init__.py +147 -0
- taskq/_di/__init__.py +45 -0
- taskq/_di/_utils.py +13 -0
- taskq/_di/_validate.py +456 -0
- taskq/_di/lifecycle.py +52 -0
- taskq/_di/registry.py +342 -0
- taskq/_di/scope.py +12 -0
- taskq/_di/scopes.py +467 -0
- taskq/_di/solver.py +159 -0
- taskq/_di/types.py +121 -0
- taskq/_dsn.py +18 -0
- taskq/_ids.py +128 -0
- taskq/_json.py +105 -0
- taskq/_scope.py +39 -0
- taskq/actor.py +752 -0
- taskq/backend/__init__.py +69 -0
- taskq/backend/_cursor.py +32 -0
- taskq/backend/_dispatch.py +114 -0
- taskq/backend/_dispatch_sql.py +301 -0
- taskq/backend/_enqueue.py +490 -0
- taskq/backend/_notify.py +49 -0
- taskq/backend/_protocol.py +854 -0
- taskq/backend/_reads.py +177 -0
- taskq/backend/_records.py +116 -0
- taskq/backend/_schedules.py +226 -0
- taskq/backend/_sql.py +103 -0
- taskq/backend/_sql_templates.py +543 -0
- taskq/backend/_sweeps.py +487 -0
- taskq/backend/_terminal.py +830 -0
- taskq/backend/clock.py +47 -0
- taskq/backend/postgres.py +656 -0
- taskq/backend/statemachine.py +50 -0
- taskq/batch.py +274 -0
- taskq/cli.py +670 -0
- taskq/client/__init__.py +25 -0
- taskq/client/_args.py +233 -0
- taskq/client/_enqueuer.py +347 -0
- taskq/client/_handle.py +321 -0
- taskq/client/_jobs.py +756 -0
- taskq/client/_taskq.py +647 -0
- taskq/client/_transport.py +112 -0
- taskq/constants.py +152 -0
- taskq/context.py +171 -0
- taskq/contrib/__init__.py +1 -0
- taskq/contrib/kubernetes/__init__.py +1 -0
- taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
- taskq/contrib/prometheus/__init__.py +10 -0
- taskq/contrib/prometheus/_metrics.py +63 -0
- taskq/contrib/prometheus/rules.yaml +113 -0
- taskq/cron.py +332 -0
- taskq/di.py +7 -0
- taskq/exceptions.py +398 -0
- taskq/migrate.py +248 -0
- taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
- taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
- taskq/migrations/__init__.py +13 -0
- taskq/obs/__init__.py +120 -0
- taskq/obs/_otel.py +583 -0
- taskq/obs/_structlog.py +241 -0
- taskq/obs/error_reporter.py +120 -0
- taskq/progress/__init__.py +5 -0
- taskq/progress/_buffer.py +96 -0
- taskq/progress/_events.py +34 -0
- taskq/progress/_flush.py +140 -0
- taskq/progress/_publish.py +200 -0
- taskq/py.typed +0 -0
- taskq/ratelimit/__init__.py +42 -0
- taskq/ratelimit/_decision_log.py +38 -0
- taskq/ratelimit/_provider.py +90 -0
- taskq/ratelimit/_redis_utils.py +79 -0
- taskq/ratelimit/_scripts.py +265 -0
- taskq/ratelimit/_sliding_window_pg.py +432 -0
- taskq/ratelimit/_sliding_window_redis.py +398 -0
- taskq/ratelimit/composition.py +93 -0
- taskq/ratelimit/decision.py +47 -0
- taskq/ratelimit/refs.py +87 -0
- taskq/ratelimit/registry.py +678 -0
- taskq/ratelimit/reservation.py +593 -0
- taskq/ratelimit/sliding_window.py +570 -0
- taskq/ratelimit/token_bucket.py +754 -0
- taskq/retry.py +582 -0
- taskq/scheduler.py +59 -0
- taskq/settings.py +797 -0
- taskq/testing/__init__.py +102 -0
- taskq/testing/_dispatch.py +158 -0
- taskq/testing/_enqueue.py +225 -0
- taskq/testing/_reads.py +101 -0
- taskq/testing/_runner.py +650 -0
- taskq/testing/_slots.py +108 -0
- taskq/testing/_sweeps.py +184 -0
- taskq/testing/_terminal.py +666 -0
- taskq/testing/actor.py +322 -0
- taskq/testing/assertions.py +311 -0
- taskq/testing/asyncpg_chaos.py +157 -0
- taskq/testing/clock.py +49 -0
- taskq/testing/fixtures.py +860 -0
- taskq/testing/in_memory.py +773 -0
- taskq/testing/job_context.py +63 -0
- taskq/testing/jobs.py +190 -0
- taskq/testing/otel.py +251 -0
- taskq/testing/pg.py +310 -0
- taskq/testing/settings.py +71 -0
- taskq/testing/spy.py +15 -0
- taskq/types.py +48 -0
- taskq/web/__init__.py +1 -0
- taskq/web/admin/__init__.py +28 -0
- taskq/web/admin/_constants.py +26 -0
- taskq/web/admin/_factory.py +403 -0
- taskq/web/admin/_history.py +250 -0
- taskq/web/admin/_jsonb.py +23 -0
- taskq/web/admin/_listen.py +107 -0
- taskq/web/admin/_static.py +25 -0
- taskq/web/admin/auth/__init__.py +42 -0
- taskq/web/admin/auth/_session.py +190 -0
- taskq/web/admin/auth/oidc.py +299 -0
- taskq/web/admin/auth/saml.py +213 -0
- taskq/web/admin/auth/token.py +35 -0
- taskq/web/admin/jobs.py +555 -0
- taskq/web/admin/ops.py +658 -0
- taskq/web/admin/queues.py +197 -0
- taskq/web/admin/sse.py +104 -0
- taskq/web/admin/workers.py +105 -0
- taskq/web/health.py +81 -0
- taskq/web/progress.py +445 -0
- taskq/web/static/admin.css +2 -0
- taskq/web/static/admin.js +218 -0
- taskq/web/static/alpine.min.js +5 -0
- taskq/web/static/htmx.min.js +1 -0
- taskq/web/static/realtime.js +246 -0
- taskq/web/static/sse.min.js +1 -0
- taskq/web/static/tailwind.css +3 -0
- taskq/web/templates/_base.html +75 -0
- taskq/web/templates/_partials/job_card.html +97 -0
- taskq/web/templates/_partials/job_table.html +148 -0
- taskq/web/templates/_partials/sse_console.html +4 -0
- taskq/web/templates/_partials/table.html +37 -0
- taskq/web/templates/history.html +98 -0
- taskq/web/templates/job_detail.html +367 -0
- taskq/web/templates/jobs.html +193 -0
- taskq/web/templates/leader.html +73 -0
- taskq/web/templates/queue_detail.html +59 -0
- taskq/web/templates/queues.html +85 -0
- taskq/web/templates/rate_limits.html +104 -0
- taskq/web/templates/reservations.html +93 -0
- taskq/web/templates/schedules.html +76 -0
- taskq/web/templates/workers.html +69 -0
- taskq/worker/__init__.py +69 -0
- taskq/worker/_bootstrap.py +509 -0
- taskq/worker/_consumer.py +896 -0
- taskq/worker/_handlers.py +709 -0
- taskq/worker/_leader_shared.py +390 -0
- taskq/worker/_leader_sweeps.py +441 -0
- taskq/worker/actor_config.py +19 -0
- taskq/worker/budget.py +93 -0
- taskq/worker/cancel.py +438 -0
- taskq/worker/cron_loop.py +300 -0
- taskq/worker/deps.py +296 -0
- taskq/worker/dev.py +160 -0
- taskq/worker/dispatch.py +290 -0
- taskq/worker/health.py +330 -0
- taskq/worker/heartbeat.py +270 -0
- taskq/worker/leader.py +384 -0
- taskq/worker/notify.py +331 -0
- taskq/worker/run.py +576 -0
- taskq/worker/shutdown.py +287 -0
- taskq/worker/startup.py +214 -0
- taskq/worker/workgroup.py +778 -0
- taskq_py-0.1.0.dist-info/METADATA +364 -0
- taskq_py-0.1.0.dist-info/RECORD +172 -0
- taskq_py-0.1.0.dist-info/WHEEL +4 -0
- taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
- taskq_py-0.1.0.dist-info/licenses/LICENSE +21 -0
taskq/testing/actor.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import AbstractAsyncContextManager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Literal, cast
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from taskq._ids import new_job_id, new_uuid
|
|
13
|
+
from taskq.backend._protocol import (
|
|
14
|
+
AttemptOutcome,
|
|
15
|
+
AttemptRow,
|
|
16
|
+
Backend,
|
|
17
|
+
CancelFlag,
|
|
18
|
+
CancelPhase,
|
|
19
|
+
EnqueueArgs,
|
|
20
|
+
ErrorInfo,
|
|
21
|
+
EventRow,
|
|
22
|
+
JobFilter,
|
|
23
|
+
JobRow,
|
|
24
|
+
ScheduleCreateArgs,
|
|
25
|
+
ScheduleUpdateArgs,
|
|
26
|
+
)
|
|
27
|
+
from taskq.retry import OnRetryExhausted, OnSuccess, RetryClassifierHook, RetryPolicy
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"EmptyPayload",
|
|
31
|
+
"FakeBackend",
|
|
32
|
+
"StubActorConfig",
|
|
33
|
+
"as_backend",
|
|
34
|
+
"default_actor_config",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class StubActorConfig:
|
|
40
|
+
retry: RetryPolicy
|
|
41
|
+
non_retryable_exceptions: tuple[type[Exception], ...] = ()
|
|
42
|
+
retry_classifier: RetryClassifierHook | None = None
|
|
43
|
+
on_retry_exhausted: OnRetryExhausted | None = None
|
|
44
|
+
on_retry_exhausted_timeout: float = 3.0
|
|
45
|
+
on_success: OnSuccess | None = None
|
|
46
|
+
on_success_timeout: float = 3.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def default_actor_config() -> StubActorConfig:
|
|
50
|
+
return StubActorConfig(retry=RetryPolicy(kind="transient", max_attempts=3, jitter=0.0))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EmptyPayload(BaseModel):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _make_job_row() -> JobRow:
|
|
58
|
+
return JobRow(
|
|
59
|
+
id=new_job_id(),
|
|
60
|
+
actor="test_actor",
|
|
61
|
+
queue="default",
|
|
62
|
+
identity_key=None,
|
|
63
|
+
fairness_key=None,
|
|
64
|
+
payload={},
|
|
65
|
+
payload_schema_ver=1,
|
|
66
|
+
status="running",
|
|
67
|
+
priority=0,
|
|
68
|
+
attempt=1,
|
|
69
|
+
max_attempts=3,
|
|
70
|
+
retry_kind="transient",
|
|
71
|
+
schedule_to_close=None,
|
|
72
|
+
start_to_close=None,
|
|
73
|
+
heartbeat_timeout=None,
|
|
74
|
+
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
75
|
+
scheduled_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
76
|
+
started_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
77
|
+
finished_at=None,
|
|
78
|
+
last_heartbeat_at=None,
|
|
79
|
+
locked_by_worker=new_uuid(),
|
|
80
|
+
lock_expires_at=None,
|
|
81
|
+
cancel_requested_at=None,
|
|
82
|
+
cancel_phase=CancelPhase.NONE,
|
|
83
|
+
error_class=None,
|
|
84
|
+
error_message=None,
|
|
85
|
+
error_traceback=None,
|
|
86
|
+
progress_state={},
|
|
87
|
+
progress_seq=0,
|
|
88
|
+
result=None,
|
|
89
|
+
result_size_bytes=None,
|
|
90
|
+
result_expires_at=None,
|
|
91
|
+
idempotency_key=None,
|
|
92
|
+
trace_id=None,
|
|
93
|
+
span_id=None,
|
|
94
|
+
metadata={},
|
|
95
|
+
tags=(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class FakeBackend:
|
|
100
|
+
"""Minimal backend recording method calls for assertions."""
|
|
101
|
+
|
|
102
|
+
BACKEND_PROTOCOL_VERSION: int = 2
|
|
103
|
+
supports_transactional_simulation: bool = False
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
mark_snoozed_return: Literal["scheduled", "failed", "noop"] = "scheduled",
|
|
109
|
+
mark_retry_after_return: Literal[
|
|
110
|
+
"scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"
|
|
111
|
+
] = "scheduled",
|
|
112
|
+
) -> None:
|
|
113
|
+
self.mark_succeeded_calls: list[tuple[UUID, UUID, dict[str, object] | None]] = []
|
|
114
|
+
self.mark_cancelled_calls: list[dict[str, object]] = []
|
|
115
|
+
self.mark_snoozed_calls: list[dict[str, object]] = []
|
|
116
|
+
self.mark_retry_after_calls: list[dict[str, object]] = []
|
|
117
|
+
self.mark_failed_or_retry_calls: list[dict[str, object]] = []
|
|
118
|
+
self._mark_snoozed_return: Literal["scheduled", "failed", "noop"] = mark_snoozed_return
|
|
119
|
+
self._mark_retry_after_return: Literal[
|
|
120
|
+
"scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"
|
|
121
|
+
] = mark_retry_after_return
|
|
122
|
+
|
|
123
|
+
async def enqueue(self, args: EnqueueArgs) -> JobRow:
|
|
124
|
+
raise NotImplementedError
|
|
125
|
+
|
|
126
|
+
async def enqueue_with_conn(self, conn: object, args: EnqueueArgs) -> JobRow:
|
|
127
|
+
raise NotImplementedError
|
|
128
|
+
|
|
129
|
+
async def dispatch_batch(
|
|
130
|
+
self, worker_id: UUID, queues: list[str], limit: int, lock_lease: timedelta
|
|
131
|
+
) -> list[JobRow]:
|
|
132
|
+
raise NotImplementedError
|
|
133
|
+
|
|
134
|
+
async def heartbeat_jobs(self, worker_id: UUID, lock_lease: timedelta) -> int:
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
async def extend_reservation_leases(self, worker_id: UUID, lock_lease: timedelta) -> int:
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
async def mark_succeeded(
|
|
141
|
+
self,
|
|
142
|
+
job_id: UUID,
|
|
143
|
+
worker_id: UUID,
|
|
144
|
+
result: dict[str, object] | None,
|
|
145
|
+
progress_seq: int = 0,
|
|
146
|
+
progress_state: dict[str, object] | None = None,
|
|
147
|
+
) -> bool:
|
|
148
|
+
self.mark_succeeded_calls.append((job_id, worker_id, result))
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
async def mark_succeeded_with_conn(
|
|
152
|
+
self,
|
|
153
|
+
conn: object,
|
|
154
|
+
job_id: UUID,
|
|
155
|
+
worker_id: UUID,
|
|
156
|
+
result: dict[str, object] | None,
|
|
157
|
+
progress_seq: int = 0,
|
|
158
|
+
progress_state: dict[str, object] | None = None,
|
|
159
|
+
) -> bool:
|
|
160
|
+
return await self.mark_succeeded(job_id, worker_id, result, progress_seq, progress_state)
|
|
161
|
+
|
|
162
|
+
async def mark_failed_or_retry(
|
|
163
|
+
self,
|
|
164
|
+
job_id: UUID,
|
|
165
|
+
worker_id: UUID,
|
|
166
|
+
error_info: ErrorInfo,
|
|
167
|
+
next_scheduled_at: datetime | None,
|
|
168
|
+
progress_seq: int = 0,
|
|
169
|
+
progress_state: dict[str, object] | None = None,
|
|
170
|
+
) -> JobRow:
|
|
171
|
+
self.mark_failed_or_retry_calls.append(
|
|
172
|
+
{
|
|
173
|
+
"job_id": job_id,
|
|
174
|
+
"worker_id": worker_id,
|
|
175
|
+
"error_info": error_info,
|
|
176
|
+
"next_scheduled_at": next_scheduled_at,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
return _make_job_row()
|
|
180
|
+
|
|
181
|
+
async def mark_cancelled(
|
|
182
|
+
self,
|
|
183
|
+
job_id: UUID,
|
|
184
|
+
worker_id: UUID,
|
|
185
|
+
progress_seq: int = 0,
|
|
186
|
+
progress_state: dict[str, object] | None = None,
|
|
187
|
+
) -> bool:
|
|
188
|
+
self.mark_cancelled_calls.append(
|
|
189
|
+
{
|
|
190
|
+
"job_id": job_id,
|
|
191
|
+
"worker_id": worker_id,
|
|
192
|
+
"progress_seq": progress_seq,
|
|
193
|
+
"progress_state": progress_state,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
async def write_cancel_escalation(
|
|
199
|
+
self, job_id: UUID, worker_id: UUID, phase: Literal[2]
|
|
200
|
+
) -> bool:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
async def mark_abandoned(
|
|
204
|
+
self,
|
|
205
|
+
job_id: UUID,
|
|
206
|
+
progress_seq: int = 0,
|
|
207
|
+
progress_state: dict[str, object] | None = None,
|
|
208
|
+
) -> bool:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
async def mark_snoozed(
|
|
212
|
+
self,
|
|
213
|
+
job_id: UUID,
|
|
214
|
+
worker_id: UUID,
|
|
215
|
+
delay: timedelta,
|
|
216
|
+
*,
|
|
217
|
+
metadata_update: dict[str, object] | None = None,
|
|
218
|
+
progress_seq: int = 0,
|
|
219
|
+
progress_state: dict[str, object] | None = None,
|
|
220
|
+
outcome: AttemptOutcome = "snoozed",
|
|
221
|
+
) -> Literal["scheduled", "failed", "noop"]:
|
|
222
|
+
self.mark_snoozed_calls.append(
|
|
223
|
+
{
|
|
224
|
+
"job_id": job_id,
|
|
225
|
+
"worker_id": worker_id,
|
|
226
|
+
"delay": delay,
|
|
227
|
+
"metadata_update": metadata_update,
|
|
228
|
+
"progress_seq": progress_seq,
|
|
229
|
+
"progress_state": progress_state,
|
|
230
|
+
"outcome": outcome,
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
return self._mark_snoozed_return
|
|
234
|
+
|
|
235
|
+
async def mark_retry_after(
|
|
236
|
+
self,
|
|
237
|
+
job_id: UUID,
|
|
238
|
+
worker_id: UUID,
|
|
239
|
+
delay: timedelta,
|
|
240
|
+
*,
|
|
241
|
+
consume_budget: bool = True,
|
|
242
|
+
progress_seq: int = 0,
|
|
243
|
+
progress_state: dict[str, object] | None = None,
|
|
244
|
+
) -> Literal["scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"]:
|
|
245
|
+
self.mark_retry_after_calls.append(
|
|
246
|
+
{
|
|
247
|
+
"job_id": job_id,
|
|
248
|
+
"worker_id": worker_id,
|
|
249
|
+
"delay": delay,
|
|
250
|
+
"consume_budget": consume_budget,
|
|
251
|
+
"progress_seq": progress_seq,
|
|
252
|
+
"progress_state": progress_state,
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return self._mark_retry_after_return
|
|
256
|
+
|
|
257
|
+
async def write_attempt(self, attempt: AttemptRow) -> None:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
async def get_attempts(self, job_id: UUID) -> list[AttemptRow]:
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
async def get_events(self, job_id: UUID) -> list[EventRow]:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
async def write_cancel_request(self, job_id: UUID, reason: str | None) -> bool:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
async def poll_cancel_flags(self, worker_id: UUID) -> list[CancelFlag]:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
async def scheduled_to_pending(self, now: datetime) -> int:
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
async def deadline_sweep(self, now: datetime) -> int:
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
async def reclaim_expired_locks(
|
|
279
|
+
self, now: datetime, cancel_grace: timedelta, cleanup_grace: timedelta
|
|
280
|
+
) -> int:
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
async def get(self, job_id: UUID) -> JobRow | None:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
async def list_jobs(self, filters: JobFilter) -> list[JobRow]:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
async def count_pending_jobs(self, actors: list[str]) -> dict[str, int]:
|
|
290
|
+
return {}
|
|
291
|
+
|
|
292
|
+
async def enqueue_batch(
|
|
293
|
+
self,
|
|
294
|
+
args_list: list[EnqueueArgs],
|
|
295
|
+
*,
|
|
296
|
+
connection: object = None,
|
|
297
|
+
) -> list[JobRow]:
|
|
298
|
+
raise NotImplementedError
|
|
299
|
+
|
|
300
|
+
def subscribe_wake(self) -> AbstractAsyncContextManager[asyncio.Event]:
|
|
301
|
+
raise NotImplementedError
|
|
302
|
+
|
|
303
|
+
async def create_schedule(self, args: ScheduleCreateArgs) -> object:
|
|
304
|
+
raise NotImplementedError
|
|
305
|
+
|
|
306
|
+
async def list_schedules(
|
|
307
|
+
self,
|
|
308
|
+
*,
|
|
309
|
+
actor: str | None = None,
|
|
310
|
+
enabled: bool | None = None,
|
|
311
|
+
) -> list[object]:
|
|
312
|
+
raise NotImplementedError
|
|
313
|
+
|
|
314
|
+
async def update_schedule(self, schedule_id: UUID, args: ScheduleUpdateArgs) -> object:
|
|
315
|
+
raise NotImplementedError
|
|
316
|
+
|
|
317
|
+
async def delete_schedule(self, schedule_id: UUID) -> None:
|
|
318
|
+
raise NotImplementedError
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def as_backend(fb: FakeBackend) -> Backend:
|
|
322
|
+
return cast(Backend, fb)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Behavioral assertions for TaskQ tests — query observable state, not implementation details."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from taskq._json import loads
|
|
12
|
+
from taskq.backend._protocol import JobId, JobRow
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import asyncpg
|
|
16
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"assert_attempt",
|
|
20
|
+
"assert_has_event",
|
|
21
|
+
"assert_has_otel_event",
|
|
22
|
+
"assert_has_span",
|
|
23
|
+
"assert_job_status",
|
|
24
|
+
"assert_job_terminal",
|
|
25
|
+
"assert_transition_sequence",
|
|
26
|
+
"parse_detail",
|
|
27
|
+
"pg_now",
|
|
28
|
+
"plain_cli_output",
|
|
29
|
+
"wait_for",
|
|
30
|
+
"wait_for_job_status",
|
|
31
|
+
"wait_for_leader",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def plain_cli_output(output: str) -> str:
|
|
38
|
+
"""Strip ANSI escapes and collapse whitespace for CLI-output assertions.
|
|
39
|
+
|
|
40
|
+
Rich/Typer help rendering varies with the detected environment — color
|
|
41
|
+
codes get injected inside words, box-drawing characters wrap lines at
|
|
42
|
+
terminal width — so raw substring assertions on ``result.output`` are
|
|
43
|
+
environment-dependent. Asserting against the plain, whitespace-collapsed
|
|
44
|
+
text is stable in any terminal, CI runner, or width.
|
|
45
|
+
"""
|
|
46
|
+
return " ".join(_ANSI_RE.sub("", output).split())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class _EventLike(Protocol):
|
|
51
|
+
"""Protocol for OTel span Event objects in test assertions."""
|
|
52
|
+
|
|
53
|
+
attributes: dict[str, object] | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@runtime_checkable
|
|
57
|
+
class _SpanExporter(Protocol):
|
|
58
|
+
"""Protocol for OTel InMemorySpanExporter test instances."""
|
|
59
|
+
|
|
60
|
+
def span_named(self, name: str) -> ReadableSpan | None: ...
|
|
61
|
+
|
|
62
|
+
spans: list[ReadableSpan]
|
|
63
|
+
|
|
64
|
+
def events_on(self, span_name: str, event_name: str) -> list[_EventLike]: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@runtime_checkable
|
|
68
|
+
class _AssertBackend(Protocol):
|
|
69
|
+
"""Protocol for Backend instances in test assertions — minimal get-only surface."""
|
|
70
|
+
|
|
71
|
+
async def get(self, job_id: JobId) -> JobRow | None: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@runtime_checkable
|
|
75
|
+
class _LeaderDeps(Protocol):
|
|
76
|
+
"""Protocol for deps with an is_leader event in test assertions."""
|
|
77
|
+
|
|
78
|
+
is_leader: asyncio.Event
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get(row: object, key: str) -> object:
|
|
82
|
+
try:
|
|
83
|
+
return getattr(row, key)
|
|
84
|
+
except AttributeError:
|
|
85
|
+
return row[key] # type: ignore[index] # Why: row is object (asyncpg.Record or dataclass); subscript fallback is intentional duck-typing for test helpers — pyright cannot prove __getitem__ exists on object.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_detail(detail: object) -> dict[str, object]:
|
|
89
|
+
"""Normalize a detail value (dict, JSON string, or other) to a dict."""
|
|
90
|
+
if isinstance(detail, dict):
|
|
91
|
+
return detail # type: ignore[return-value] # Why: isinstance(detail, dict) guarantees a dict at runtime; the value type is object so pyright cannot narrow dict[unknown, unknown] to dict[str, object].
|
|
92
|
+
if isinstance(detail, str):
|
|
93
|
+
return dict(loads(detail))
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def assert_job_status(
|
|
98
|
+
row: asyncpg.Record | None,
|
|
99
|
+
status: str,
|
|
100
|
+
*,
|
|
101
|
+
error_class: str | None = None,
|
|
102
|
+
attempt: int | None = None,
|
|
103
|
+
finished: bool | None = None,
|
|
104
|
+
) -> asyncpg.Record:
|
|
105
|
+
"""Assert a job row has the expected status and optional fields.
|
|
106
|
+
|
|
107
|
+
Returns the row (guaranteed non-None on success) so callers can
|
|
108
|
+
chain attribute/subscript access without pyright narrowing issues.
|
|
109
|
+
"""
|
|
110
|
+
assert row is not None, "Expected a row but got None"
|
|
111
|
+
actual_status = _get(row, "status")
|
|
112
|
+
if actual_status != status:
|
|
113
|
+
raise AssertionError(f"Expected status {status!r}, got {actual_status!r}")
|
|
114
|
+
if error_class is not None:
|
|
115
|
+
actual_ec = _get(row, "error_class")
|
|
116
|
+
if actual_ec != error_class:
|
|
117
|
+
raise AssertionError(f"Expected error_class {error_class!r}, got {actual_ec!r}")
|
|
118
|
+
if attempt is not None:
|
|
119
|
+
actual_attempt = _get(row, "attempt")
|
|
120
|
+
if actual_attempt != attempt:
|
|
121
|
+
raise AssertionError(f"Expected attempt {attempt}, got {actual_attempt}")
|
|
122
|
+
if finished is not None:
|
|
123
|
+
finished_at = _get(row, "finished_at")
|
|
124
|
+
if finished and finished_at is None:
|
|
125
|
+
raise AssertionError("Expected finished_at to be set, but it is None")
|
|
126
|
+
if not finished and finished_at is not None:
|
|
127
|
+
raise AssertionError(f"Expected finished_at to be None, got {finished_at!r}")
|
|
128
|
+
return row
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def assert_attempt(
|
|
132
|
+
attempts: Sequence[object],
|
|
133
|
+
index: int,
|
|
134
|
+
*,
|
|
135
|
+
outcome: str | None = None,
|
|
136
|
+
error_class: str | None = None,
|
|
137
|
+
attempt_num: int | None = None,
|
|
138
|
+
) -> object:
|
|
139
|
+
"""Assert on the attempt row at *index*."""
|
|
140
|
+
if index < 0 or index >= len(attempts):
|
|
141
|
+
raise AssertionError(f"Attempt index {index} out of range (len={len(attempts)})")
|
|
142
|
+
row = attempts[index]
|
|
143
|
+
if outcome is not None:
|
|
144
|
+
actual = _get(row, "outcome")
|
|
145
|
+
if actual != outcome:
|
|
146
|
+
raise AssertionError(f"attempts[{index}]: expected outcome {outcome!r}, got {actual!r}")
|
|
147
|
+
if error_class is not None:
|
|
148
|
+
actual = _get(row, "error_class")
|
|
149
|
+
if actual != error_class:
|
|
150
|
+
raise AssertionError(
|
|
151
|
+
f"attempts[{index}]: expected error_class {error_class!r}, got {actual!r}"
|
|
152
|
+
)
|
|
153
|
+
if attempt_num is not None:
|
|
154
|
+
actual = _get(row, "attempt")
|
|
155
|
+
if actual != attempt_num:
|
|
156
|
+
raise AssertionError(f"attempts[{index}]: expected attempt {attempt_num}, got {actual}")
|
|
157
|
+
return row
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def assert_job_terminal(
|
|
161
|
+
row: asyncpg.Record | None,
|
|
162
|
+
status: str,
|
|
163
|
+
*,
|
|
164
|
+
error_class: str | None = None,
|
|
165
|
+
) -> asyncpg.Record:
|
|
166
|
+
"""Assert a job is in a terminal status with finished_at set."""
|
|
167
|
+
return assert_job_status(row, status, error_class=error_class, finished=True)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def assert_has_event(
|
|
171
|
+
events: Sequence[asyncpg.Record],
|
|
172
|
+
kind: str,
|
|
173
|
+
*,
|
|
174
|
+
from_state: str | None = None,
|
|
175
|
+
to_state: str | None = None,
|
|
176
|
+
) -> asyncpg.Record:
|
|
177
|
+
"""Find at least one event matching kind and optional state filters."""
|
|
178
|
+
for ev in events:
|
|
179
|
+
if _get(ev, "kind") != kind:
|
|
180
|
+
continue
|
|
181
|
+
if from_state is not None or to_state is not None:
|
|
182
|
+
detail = parse_detail(_get(ev, "detail"))
|
|
183
|
+
if from_state is not None and detail.get("from_state") != from_state:
|
|
184
|
+
continue
|
|
185
|
+
if to_state is not None and detail.get("to_state") != to_state:
|
|
186
|
+
continue
|
|
187
|
+
return ev
|
|
188
|
+
available = [(i, _get(e, "kind")) for i, e in enumerate(events)]
|
|
189
|
+
msg = f"No event with kind={kind!r}"
|
|
190
|
+
if from_state is not None or to_state is not None:
|
|
191
|
+
msg += f" (from_state={from_state!r}, to_state={to_state!r})"
|
|
192
|
+
msg += f"; available events: {available}"
|
|
193
|
+
raise AssertionError(msg)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def assert_transition_sequence(
|
|
197
|
+
events: Sequence[object],
|
|
198
|
+
expected: Sequence[tuple[str | None, str | None]],
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Assert the (from_state, to_state) sequence from state_change events matches expected."""
|
|
201
|
+
transitions: list[tuple[object, object]] = []
|
|
202
|
+
for ev in events:
|
|
203
|
+
if _get(ev, "kind") != "state_change":
|
|
204
|
+
continue
|
|
205
|
+
detail = parse_detail(_get(ev, "detail"))
|
|
206
|
+
transitions.append((detail.get("from_state"), detail.get("to_state")))
|
|
207
|
+
if transitions != list(expected):
|
|
208
|
+
raise AssertionError(f"Expected transition sequence {list(expected)}, got {transitions}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def assert_has_span(
|
|
212
|
+
exporter: _SpanExporter,
|
|
213
|
+
name: str,
|
|
214
|
+
*,
|
|
215
|
+
kind: object = None,
|
|
216
|
+
status: object = None,
|
|
217
|
+
) -> ReadableSpan:
|
|
218
|
+
"""Find a span by name on the exporter; assert kind/status if provided."""
|
|
219
|
+
span = exporter.span_named(name)
|
|
220
|
+
if span is None:
|
|
221
|
+
names = [s.name for s in exporter.spans]
|
|
222
|
+
raise AssertionError(f"No span named {name!r}; available: {names}")
|
|
223
|
+
if kind is not None and span.kind != kind:
|
|
224
|
+
raise AssertionError(f"Span {name!r}: expected kind={kind!r}, got {span.kind!r}")
|
|
225
|
+
if status is not None and span.status.status_code != status:
|
|
226
|
+
raise AssertionError(
|
|
227
|
+
f"Span {name!r}: expected status_code={status!r}, got {span.status.status_code!r}"
|
|
228
|
+
)
|
|
229
|
+
return span
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def assert_has_otel_event(
|
|
233
|
+
exporter: _SpanExporter,
|
|
234
|
+
span_name: str,
|
|
235
|
+
event_name: str,
|
|
236
|
+
*,
|
|
237
|
+
from_state: str | None = None,
|
|
238
|
+
to_state: str | None = None,
|
|
239
|
+
) -> object:
|
|
240
|
+
"""Find an OTel event by span and event name; assert state attributes if provided."""
|
|
241
|
+
events = exporter.events_on(span_name, event_name)
|
|
242
|
+
if not events:
|
|
243
|
+
raise AssertionError(f"No OTel event {event_name!r} on span {span_name!r}")
|
|
244
|
+
if from_state is not None or to_state is not None:
|
|
245
|
+
for ev in events:
|
|
246
|
+
attrs = ev.attributes
|
|
247
|
+
if attrs is None:
|
|
248
|
+
continue
|
|
249
|
+
if from_state is not None and attrs.get("from_state") != from_state:
|
|
250
|
+
continue
|
|
251
|
+
if to_state is not None and attrs.get("to_state") != to_state:
|
|
252
|
+
continue
|
|
253
|
+
return ev
|
|
254
|
+
raise AssertionError(
|
|
255
|
+
f"No OTel event {event_name!r} on span {span_name!r} "
|
|
256
|
+
f"with from_state={from_state!r}, to_state={to_state!r}"
|
|
257
|
+
)
|
|
258
|
+
return events[0]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def wait_for(event: asyncio.Event, timeout: float = 2.0) -> None: # noqa: ASYNC109
|
|
262
|
+
"""Wait for an asyncio.Event with test-failure semantics on timeout."""
|
|
263
|
+
try:
|
|
264
|
+
await asyncio.wait_for(event.wait(), timeout=timeout)
|
|
265
|
+
except TimeoutError:
|
|
266
|
+
raise AssertionError(f"Event not set within {timeout}s") from None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def wait_for_job_status(
|
|
270
|
+
backend: _AssertBackend,
|
|
271
|
+
job_id: JobId,
|
|
272
|
+
status: str,
|
|
273
|
+
*,
|
|
274
|
+
timeout: float = 2.0, # noqa: ASYNC109
|
|
275
|
+
poll_interval: float = 0.05,
|
|
276
|
+
) -> JobRow:
|
|
277
|
+
"""Poll backend.get until the job reaches the expected status."""
|
|
278
|
+
loop = asyncio.get_running_loop()
|
|
279
|
+
deadline = loop.time() + timeout
|
|
280
|
+
while True:
|
|
281
|
+
row = await backend.get(job_id)
|
|
282
|
+
if row is not None and _get(row, "status") == status:
|
|
283
|
+
return row
|
|
284
|
+
remaining = deadline - loop.time()
|
|
285
|
+
if remaining <= 0:
|
|
286
|
+
actual = _get(row, "status") if row is not None else None
|
|
287
|
+
raise AssertionError(
|
|
288
|
+
f"Job {job_id} did not reach status {status!r} within {timeout}s "
|
|
289
|
+
f"(actual: {actual!r})"
|
|
290
|
+
)
|
|
291
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def wait_for_leader(deps: _LeaderDeps, timeout: float = 5.0) -> None: # noqa: ASYNC109
|
|
295
|
+
"""Wait for the leader event on WorkerDeps with test-failure semantics."""
|
|
296
|
+
try:
|
|
297
|
+
await asyncio.wait_for(deps.is_leader.wait(), timeout=timeout)
|
|
298
|
+
except TimeoutError:
|
|
299
|
+
raise AssertionError(f"Leader event not set within {timeout}s") from None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def pg_now(conn: asyncpg.Connection) -> datetime:
|
|
303
|
+
"""Return PG's ``clock_timestamp()`` — the realtime clock the server uses.
|
|
304
|
+
|
|
305
|
+
Use this instead of ``datetime.now(UTC)`` when a test needs to compute
|
|
306
|
+
cutoffs/margins that are compared against rows written via SQL: the
|
|
307
|
+
Python wall clock and PG's realtime clock can diverge enough under
|
|
308
|
+
parallel load to make Python-computed margins flaky.
|
|
309
|
+
"""
|
|
310
|
+
value: datetime = await conn.fetchval("SELECT clock_timestamp()")
|
|
311
|
+
return value
|