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.
Files changed (172) hide show
  1. taskq/__init__.py +147 -0
  2. taskq/_di/__init__.py +45 -0
  3. taskq/_di/_utils.py +13 -0
  4. taskq/_di/_validate.py +456 -0
  5. taskq/_di/lifecycle.py +52 -0
  6. taskq/_di/registry.py +342 -0
  7. taskq/_di/scope.py +12 -0
  8. taskq/_di/scopes.py +467 -0
  9. taskq/_di/solver.py +159 -0
  10. taskq/_di/types.py +121 -0
  11. taskq/_dsn.py +18 -0
  12. taskq/_ids.py +128 -0
  13. taskq/_json.py +105 -0
  14. taskq/_scope.py +39 -0
  15. taskq/actor.py +752 -0
  16. taskq/backend/__init__.py +69 -0
  17. taskq/backend/_cursor.py +32 -0
  18. taskq/backend/_dispatch.py +114 -0
  19. taskq/backend/_dispatch_sql.py +301 -0
  20. taskq/backend/_enqueue.py +490 -0
  21. taskq/backend/_notify.py +49 -0
  22. taskq/backend/_protocol.py +854 -0
  23. taskq/backend/_reads.py +177 -0
  24. taskq/backend/_records.py +116 -0
  25. taskq/backend/_schedules.py +226 -0
  26. taskq/backend/_sql.py +103 -0
  27. taskq/backend/_sql_templates.py +543 -0
  28. taskq/backend/_sweeps.py +487 -0
  29. taskq/backend/_terminal.py +830 -0
  30. taskq/backend/clock.py +47 -0
  31. taskq/backend/postgres.py +656 -0
  32. taskq/backend/statemachine.py +50 -0
  33. taskq/batch.py +274 -0
  34. taskq/cli.py +670 -0
  35. taskq/client/__init__.py +25 -0
  36. taskq/client/_args.py +233 -0
  37. taskq/client/_enqueuer.py +347 -0
  38. taskq/client/_handle.py +321 -0
  39. taskq/client/_jobs.py +756 -0
  40. taskq/client/_taskq.py +647 -0
  41. taskq/client/_transport.py +112 -0
  42. taskq/constants.py +152 -0
  43. taskq/context.py +171 -0
  44. taskq/contrib/__init__.py +1 -0
  45. taskq/contrib/kubernetes/__init__.py +1 -0
  46. taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
  47. taskq/contrib/prometheus/__init__.py +10 -0
  48. taskq/contrib/prometheus/_metrics.py +63 -0
  49. taskq/contrib/prometheus/rules.yaml +113 -0
  50. taskq/cron.py +332 -0
  51. taskq/di.py +7 -0
  52. taskq/exceptions.py +398 -0
  53. taskq/migrate.py +248 -0
  54. taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
  55. taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
  56. taskq/migrations/__init__.py +13 -0
  57. taskq/obs/__init__.py +120 -0
  58. taskq/obs/_otel.py +583 -0
  59. taskq/obs/_structlog.py +241 -0
  60. taskq/obs/error_reporter.py +120 -0
  61. taskq/progress/__init__.py +5 -0
  62. taskq/progress/_buffer.py +96 -0
  63. taskq/progress/_events.py +34 -0
  64. taskq/progress/_flush.py +140 -0
  65. taskq/progress/_publish.py +200 -0
  66. taskq/py.typed +0 -0
  67. taskq/ratelimit/__init__.py +42 -0
  68. taskq/ratelimit/_decision_log.py +38 -0
  69. taskq/ratelimit/_provider.py +90 -0
  70. taskq/ratelimit/_redis_utils.py +79 -0
  71. taskq/ratelimit/_scripts.py +265 -0
  72. taskq/ratelimit/_sliding_window_pg.py +432 -0
  73. taskq/ratelimit/_sliding_window_redis.py +398 -0
  74. taskq/ratelimit/composition.py +93 -0
  75. taskq/ratelimit/decision.py +47 -0
  76. taskq/ratelimit/refs.py +87 -0
  77. taskq/ratelimit/registry.py +678 -0
  78. taskq/ratelimit/reservation.py +593 -0
  79. taskq/ratelimit/sliding_window.py +570 -0
  80. taskq/ratelimit/token_bucket.py +754 -0
  81. taskq/retry.py +582 -0
  82. taskq/scheduler.py +59 -0
  83. taskq/settings.py +797 -0
  84. taskq/testing/__init__.py +102 -0
  85. taskq/testing/_dispatch.py +158 -0
  86. taskq/testing/_enqueue.py +225 -0
  87. taskq/testing/_reads.py +101 -0
  88. taskq/testing/_runner.py +650 -0
  89. taskq/testing/_slots.py +108 -0
  90. taskq/testing/_sweeps.py +184 -0
  91. taskq/testing/_terminal.py +666 -0
  92. taskq/testing/actor.py +322 -0
  93. taskq/testing/assertions.py +311 -0
  94. taskq/testing/asyncpg_chaos.py +157 -0
  95. taskq/testing/clock.py +49 -0
  96. taskq/testing/fixtures.py +860 -0
  97. taskq/testing/in_memory.py +773 -0
  98. taskq/testing/job_context.py +63 -0
  99. taskq/testing/jobs.py +190 -0
  100. taskq/testing/otel.py +251 -0
  101. taskq/testing/pg.py +310 -0
  102. taskq/testing/settings.py +71 -0
  103. taskq/testing/spy.py +15 -0
  104. taskq/types.py +48 -0
  105. taskq/web/__init__.py +1 -0
  106. taskq/web/admin/__init__.py +28 -0
  107. taskq/web/admin/_constants.py +26 -0
  108. taskq/web/admin/_factory.py +403 -0
  109. taskq/web/admin/_history.py +250 -0
  110. taskq/web/admin/_jsonb.py +23 -0
  111. taskq/web/admin/_listen.py +107 -0
  112. taskq/web/admin/_static.py +25 -0
  113. taskq/web/admin/auth/__init__.py +42 -0
  114. taskq/web/admin/auth/_session.py +190 -0
  115. taskq/web/admin/auth/oidc.py +299 -0
  116. taskq/web/admin/auth/saml.py +213 -0
  117. taskq/web/admin/auth/token.py +35 -0
  118. taskq/web/admin/jobs.py +555 -0
  119. taskq/web/admin/ops.py +658 -0
  120. taskq/web/admin/queues.py +197 -0
  121. taskq/web/admin/sse.py +104 -0
  122. taskq/web/admin/workers.py +105 -0
  123. taskq/web/health.py +81 -0
  124. taskq/web/progress.py +445 -0
  125. taskq/web/static/admin.css +2 -0
  126. taskq/web/static/admin.js +218 -0
  127. taskq/web/static/alpine.min.js +5 -0
  128. taskq/web/static/htmx.min.js +1 -0
  129. taskq/web/static/realtime.js +246 -0
  130. taskq/web/static/sse.min.js +1 -0
  131. taskq/web/static/tailwind.css +3 -0
  132. taskq/web/templates/_base.html +75 -0
  133. taskq/web/templates/_partials/job_card.html +97 -0
  134. taskq/web/templates/_partials/job_table.html +148 -0
  135. taskq/web/templates/_partials/sse_console.html +4 -0
  136. taskq/web/templates/_partials/table.html +37 -0
  137. taskq/web/templates/history.html +98 -0
  138. taskq/web/templates/job_detail.html +367 -0
  139. taskq/web/templates/jobs.html +193 -0
  140. taskq/web/templates/leader.html +73 -0
  141. taskq/web/templates/queue_detail.html +59 -0
  142. taskq/web/templates/queues.html +85 -0
  143. taskq/web/templates/rate_limits.html +104 -0
  144. taskq/web/templates/reservations.html +93 -0
  145. taskq/web/templates/schedules.html +76 -0
  146. taskq/web/templates/workers.html +69 -0
  147. taskq/worker/__init__.py +69 -0
  148. taskq/worker/_bootstrap.py +509 -0
  149. taskq/worker/_consumer.py +896 -0
  150. taskq/worker/_handlers.py +709 -0
  151. taskq/worker/_leader_shared.py +390 -0
  152. taskq/worker/_leader_sweeps.py +441 -0
  153. taskq/worker/actor_config.py +19 -0
  154. taskq/worker/budget.py +93 -0
  155. taskq/worker/cancel.py +438 -0
  156. taskq/worker/cron_loop.py +300 -0
  157. taskq/worker/deps.py +296 -0
  158. taskq/worker/dev.py +160 -0
  159. taskq/worker/dispatch.py +290 -0
  160. taskq/worker/health.py +330 -0
  161. taskq/worker/heartbeat.py +270 -0
  162. taskq/worker/leader.py +384 -0
  163. taskq/worker/notify.py +331 -0
  164. taskq/worker/run.py +576 -0
  165. taskq/worker/shutdown.py +287 -0
  166. taskq/worker/startup.py +214 -0
  167. taskq/worker/workgroup.py +778 -0
  168. taskq_py-0.1.0.dist-info/METADATA +364 -0
  169. taskq_py-0.1.0.dist-info/RECORD +172 -0
  170. taskq_py-0.1.0.dist-info/WHEEL +4 -0
  171. taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
  172. 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