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
@@ -0,0 +1,754 @@
1
+ """Token-bucket rate limiter with pluggable backends.
2
+
3
+ The in-memory backend is the reference implementation and arithmetic oracle
4
+ for the Redis Lua script and the PG fallback.
5
+
6
+ Design deviation — always invoke the Lua script instead of a Python
7
+ pre-check ``if self.refill_per_second == 0 and tokens < count: return ...``
8
+ before invoking the script. We deviate by always invoking the Lua script
9
+ and post-processing the result.
10
+ - Why: the pre-check requires local knowledge of ``tokens``.
11
+ The in-memory backend has that knowledge in ``_InMemoryBucket._tokens``
12
+ and implements the pre-check. The Redis backend does NOT
13
+ have local knowledge of ``tokens`` — that state lives in the Redis
14
+ hash and is computed inside the Lua script using ``elapsed * refill``.
15
+ The guard cannot be applied verbatim on the Redis path
16
+ without first issuing an HMGET to read ``tokens``, which would change
17
+ the protocol from "one Lua call" to "HMGET then Lua call." Rather
18
+ than introduce that round-trip, we let the Lua script run
19
+ unconditionally and substitute ``None`` in Python when the denial
20
+ branch produces a ``nan``/``inf`` ``retry_after_seconds``.
21
+ - What we do instead: invoke the Lua script unconditionally; in the
22
+ denial branch with ``refill = 0`` the script's ``retry_after_seconds``
23
+ is ``nan``/``inf`` (division by zero), but the script's ``tokens_remaining``
24
+ (result index 1) IS still valid because the denial branch reports
25
+ the current token count without modification. We discard the Lua
26
+ ``retry_after_seconds`` and substitute ``None`` when
27
+ ``allowed_int == 0 and self.refill_per_second == 0``.
28
+ - Reversibility: fully reversible. Switching to the
29
+ pre-check is a one-method change (add ``_pre_check_redis()`` issuing
30
+ HMGET, branch before ``register_script`` call). No persistent state
31
+ or external contract relies on the deviation.
32
+
33
+ This file exceeds the 500-line soft ceiling (file-size
34
+ decomposition). It co-locates three concern-clusters — (a) the
35
+ ``_InMemoryBucket`` state machine, (b) Lua-result decoding and the Redis
36
+ acquire path, and (c) the PG acquire path — all of which serve the single
37
+ token-bucket primitive. Splitting would move the shared ``RateLimitDecision``
38
+ return contract, the ``capacity``/``refill_per_second`` constructor validation,
39
+ and the ``acquire`` dispatch logic into a fourth module, creating an inner
40
+ platform where every backend module re-imports from a thin orchestrator that
41
+ exists only to satisfy a line-count rule. The three paths share the same
42
+ arithmetic, the same clock/now conventions, and the same logging discipline;
43
+ co-location keeps the arithmetic consistent and the dispatch logic visible
44
+ end-to-end without indirection.
45
+ """
46
+
47
+ import asyncio
48
+ import math
49
+ from dataclasses import dataclass
50
+ from datetime import timedelta
51
+ from typing import TYPE_CHECKING, Final
52
+
53
+ import structlog
54
+
55
+ from taskq.backend._protocol import RateLimitBackend
56
+ from taskq.backend._records import jsonb_param, jsonb_to_dict
57
+ from taskq.backend.clock import Clock
58
+ from taskq.ratelimit._decision_log import log_decision
59
+ from taskq.ratelimit._redis_utils import ensure_redis_script, with_pg_fallback
60
+ from taskq.ratelimit._scripts import REFUND_SCRIPT, TOKEN_BUCKET_SCRIPT
61
+ from taskq.ratelimit.decision import RateLimitDecision, RateLimitState
62
+
63
+ if TYPE_CHECKING:
64
+ import asyncpg
65
+ import redis.asyncio as redis_async
66
+ from redis.commands.core import AsyncScript
67
+
68
+ from taskq.settings import WorkerSettings
69
+
70
+ logger = structlog.get_logger("taskq.ratelimit.token_bucket")
71
+
72
+ _DEFAULT_FIXED_QUOTA_TTL: Final[timedelta] = timedelta(seconds=86400)
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class _LuaResult:
77
+ """Typed decoding of the three-element list returned by the token-bucket Lua script.
78
+
79
+ The Lua script returns ``{allowed, tokens_remaining,
80
+ retry_after_seconds}`` where ``allowed`` is 0 or 1 (Lua integer) and
81
+ ``tokens_remaining`` / ``retry_after_seconds`` are Lua number strings
82
+ (``tostring()``). Redis truncates Lua numbers to integers on return;
83
+ returning floats as strings preserves the fractional part (see Redis
84
+ EVAL docs: "Lua number → RESP2 integer reply — removing the decimal
85
+ part of the number, if any"). This helper normalises them to Python
86
+ types in one place so the rest of the Redis path stays fully typed.
87
+ """
88
+
89
+ allowed: bool
90
+ tokens_remaining: float
91
+ retry_after_seconds: float
92
+
93
+
94
+ def _decode_lua_result(raw: list[object]) -> _LuaResult:
95
+ """Decode the raw Redis response from the token-bucket Lua script.
96
+
97
+ This function is the **only** place where redis-py's untyped
98
+ ``AsyncScript.__call__`` return touches our code. ``raw[0]`` is an
99
+ integer (allowed: 0 or 1). ``raw[1]`` and ``raw[2]`` are strings
100
+ (bytes or str depending on ``decode_responses``) produced by Lua's
101
+ ``tostring()`` — this is required because Redis truncates Lua number
102
+ returns to integers, losing fractional parts. ``int()`` / ``float()``
103
+ accept bytes, int, and str at runtime.
104
+ """
105
+ allowed_int = int(raw[0]) # pyright: ignore[reportArgumentType] # Why: raw[0] is int | bytes from Redis; int() accepts both at runtime but pyright cannot model AsyncScript's untyped return
106
+ tokens_remaining = float(raw[1]) # pyright: ignore[reportArgumentType] # Why: raw[1] is bytes | str from Redis (Lua tostring); float() accepts both
107
+ retry_after_seconds = float(raw[2]) # pyright: ignore[reportArgumentType] # Why: raw[2] is bytes | str from Redis (Lua tostring); float() accepts both
108
+ return _LuaResult(
109
+ allowed=allowed_int == 1,
110
+ tokens_remaining=tokens_remaining,
111
+ retry_after_seconds=retry_after_seconds,
112
+ )
113
+
114
+
115
+ class _InMemoryBucket:
116
+ """Per-bucket state for the in-memory token-bucket algorithm."""
117
+
118
+ __slots__ = ("_capacity", "_lock", "_name", "_refill", "_tokens", "_ts")
119
+
120
+ def __init__(self, name: str, capacity: float, refill_per_second: float) -> None:
121
+ self._name = name
122
+ self._capacity = capacity
123
+ self._refill = refill_per_second
124
+ self._tokens: float = capacity
125
+ self._ts: float | None = None
126
+ self._lock = asyncio.Lock()
127
+
128
+ async def acquire(self, count: float, now_ts: float) -> RateLimitDecision:
129
+ async with self._lock:
130
+ if self._ts is None:
131
+ self._ts = now_ts
132
+
133
+ elapsed = max(0.0, now_ts - self._ts)
134
+ tokens = min(self._capacity, self._tokens + elapsed * self._refill)
135
+
136
+ if tokens >= count:
137
+ tokens -= count
138
+ self._tokens = tokens
139
+ self._ts = now_ts
140
+ return RateLimitDecision(
141
+ allowed=True,
142
+ remaining=tokens,
143
+ retry_after=timedelta(0),
144
+ bucket_name=self._name,
145
+ backend="memory",
146
+ )
147
+
148
+ self._tokens = tokens
149
+ self._ts = now_ts
150
+
151
+ if self._refill == 0.0:
152
+ return RateLimitDecision(
153
+ allowed=False,
154
+ remaining=tokens,
155
+ retry_after=None,
156
+ bucket_name=self._name,
157
+ backend="memory",
158
+ )
159
+
160
+ retry_seconds = (count - tokens) / self._refill
161
+ return RateLimitDecision(
162
+ allowed=False,
163
+ remaining=tokens,
164
+ retry_after=timedelta(seconds=retry_seconds),
165
+ bucket_name=self._name,
166
+ backend="memory",
167
+ )
168
+
169
+ async def refund(self, count: float) -> None:
170
+ async with self._lock:
171
+ self._tokens = min(self._capacity, self._tokens + count)
172
+
173
+ async def peek(self, now_ts: float) -> RateLimitState:
174
+ async with self._lock:
175
+ if self._ts is None:
176
+ tokens = self._capacity
177
+ else:
178
+ elapsed = max(0.0, now_ts - self._ts)
179
+ tokens = min(self._capacity, self._tokens + elapsed * self._refill)
180
+
181
+ is_exhausted = tokens <= 0.0
182
+ retry_after: timedelta | None = None
183
+ if is_exhausted and self._refill > 0.0:
184
+ retry_seconds = (1.0 - tokens) / self._refill
185
+ retry_after = timedelta(seconds=max(0.0, retry_seconds))
186
+
187
+ return RateLimitState(
188
+ bucket_name=self._name,
189
+ backend="memory",
190
+ is_exhausted=is_exhausted,
191
+ tokens_remaining=tokens,
192
+ retry_after=retry_after,
193
+ capacity=self._capacity,
194
+ refill_per_second=self._refill,
195
+ )
196
+
197
+ async def reset(self, now_ts: float) -> None:
198
+ async with self._lock:
199
+ self._tokens = self._capacity
200
+ self._ts = now_ts
201
+
202
+
203
+ class TokenBucket:
204
+ """Token-bucket rate limiter with pluggable backends.
205
+
206
+ Raises :class:`ValueError` if ``capacity <= 0`` or
207
+ ``refill_per_second < 0``.
208
+ """
209
+
210
+ __slots__ = (
211
+ "_backend",
212
+ "_capacity",
213
+ "_mem_bucket",
214
+ "_name",
215
+ "_redis_refund_script",
216
+ "_redis_script",
217
+ "_refill",
218
+ "_script_lock",
219
+ "_ttl",
220
+ )
221
+
222
+ def __init__(
223
+ self,
224
+ name: str,
225
+ capacity: float,
226
+ refill_per_second: float,
227
+ backend: RateLimitBackend = "redis",
228
+ ttl: timedelta | None = None,
229
+ ) -> None:
230
+ if capacity <= 0:
231
+ raise ValueError(f"capacity must be > 0, got {capacity}")
232
+ if refill_per_second < 0:
233
+ raise ValueError(f"refill_per_second must be >= 0, got {refill_per_second}")
234
+
235
+ self._name = name
236
+ self._capacity = capacity
237
+ self._refill = refill_per_second
238
+ self._backend: RateLimitBackend = backend
239
+
240
+ if ttl is not None:
241
+ self._ttl = ttl
242
+ elif refill_per_second == 0.0:
243
+ self._ttl = _DEFAULT_FIXED_QUOTA_TTL
244
+ else:
245
+ self._ttl = timedelta(seconds=math.ceil(capacity / refill_per_second * 2) + 60)
246
+
247
+ self._mem_bucket: _InMemoryBucket | None = None
248
+ if backend == "memory":
249
+ self._mem_bucket = _InMemoryBucket(name, capacity, refill_per_second)
250
+
251
+ self._redis_script: AsyncScript | None = None
252
+ self._redis_refund_script: AsyncScript | None = None
253
+ self._script_lock: asyncio.Lock = asyncio.Lock()
254
+
255
+ @property
256
+ def name(self) -> str:
257
+ return self._name
258
+
259
+ @property
260
+ def capacity(self) -> float:
261
+ return self._capacity
262
+
263
+ @property
264
+ def refill_per_second(self) -> float:
265
+ return self._refill
266
+
267
+ @property
268
+ def backend(self) -> RateLimitBackend:
269
+ return self._backend
270
+
271
+ @property
272
+ def ttl(self) -> timedelta:
273
+ return self._ttl
274
+
275
+ async def acquire(
276
+ self,
277
+ count: float = 1.0,
278
+ *,
279
+ redis_client: "redis_async.Redis | None" = None,
280
+ pg_pool: "asyncpg.Pool | None" = None,
281
+ clock: Clock | None = None,
282
+ settings: "WorkerSettings | None" = None,
283
+ ) -> RateLimitDecision:
284
+ if self._backend == "memory":
285
+ return await self._acquire_memory(count, clock)
286
+ if self._backend == "redis":
287
+ return await self._acquire_redis_wrapped(count, redis_client, pg_pool, clock, settings)
288
+ if self._backend == "postgres":
289
+ return await self._acquire_pg(count, pg_pool, clock, settings)
290
+
291
+ raise RuntimeError(f"unknown backend: {self._backend!r}")
292
+
293
+ async def refund(
294
+ self,
295
+ decision: RateLimitDecision,
296
+ *,
297
+ count: float = 1.0,
298
+ redis_client: "redis_async.Redis | None" = None,
299
+ pg_pool: "asyncpg.Pool | None" = None,
300
+ clock: Clock | None = None,
301
+ settings: "WorkerSettings | None" = None,
302
+ ) -> None:
303
+ if self._backend == "memory":
304
+ await self._refund_memory(count)
305
+ elif self._backend == "redis":
306
+ await self._refund_redis(decision, count, redis_client, clock, settings)
307
+ elif self._backend == "postgres":
308
+ await self._refund_pg(count, pg_pool, clock, settings)
309
+
310
+ async def peek(
311
+ self,
312
+ *,
313
+ redis_client: "redis_async.Redis | None" = None,
314
+ pg_pool: "asyncpg.Pool | None" = None,
315
+ clock: Clock | None = None,
316
+ settings: "WorkerSettings | None" = None,
317
+ ) -> RateLimitState:
318
+ if self._backend == "memory":
319
+ return await self._peek_memory(clock)
320
+ if self._backend == "redis":
321
+ return await self._peek_redis(redis_client, clock, settings)
322
+ if self._backend == "postgres":
323
+ return await self._peek_pg(pg_pool, clock, settings)
324
+
325
+ raise RuntimeError(f"unknown backend: {self._backend!r}")
326
+
327
+ async def reset(
328
+ self,
329
+ *,
330
+ redis_client: "redis_async.Redis | None" = None,
331
+ pg_pool: "asyncpg.Pool | None" = None,
332
+ clock: Clock | None = None,
333
+ settings: "WorkerSettings | None" = None,
334
+ ) -> None:
335
+ if self._backend == "memory":
336
+ await self._reset_memory(clock)
337
+ elif self._backend == "redis":
338
+ await self._reset_redis(redis_client, settings)
339
+ elif self._backend == "postgres":
340
+ await self._reset_pg(pg_pool, settings)
341
+ else:
342
+ raise RuntimeError(f"unknown backend: {self._backend!r}")
343
+
344
+ logger.warning(
345
+ "ratelimit-reset",
346
+ bucket_name=self._name,
347
+ backend=self._backend,
348
+ )
349
+
350
+ async def _peek_memory(self, clock: Clock | None) -> RateLimitState:
351
+ if clock is None:
352
+ raise RuntimeError("clock not injected for memory backend")
353
+ if self._mem_bucket is None:
354
+ raise RuntimeError("memory bucket not initialised")
355
+ now_ts = clock.now().timestamp()
356
+ return await self._mem_bucket.peek(now_ts)
357
+
358
+ async def _reset_memory(self, clock: Clock | None) -> None:
359
+ if clock is None:
360
+ raise RuntimeError("clock not injected for memory backend")
361
+ if self._mem_bucket is None:
362
+ raise RuntimeError("memory bucket not initialised")
363
+ now_ts = clock.now().timestamp()
364
+ await self._mem_bucket.reset(now_ts)
365
+
366
+ async def _peek_redis(
367
+ self,
368
+ redis_client: "redis_async.Redis | None",
369
+ clock: Clock | None,
370
+ settings: "WorkerSettings | None",
371
+ ) -> RateLimitState:
372
+ if redis_client is None:
373
+ raise RuntimeError("redis_client not injected for redis backend")
374
+ if clock is None:
375
+ raise RuntimeError("clock not injected for redis backend")
376
+ if settings is None:
377
+ raise RuntimeError("settings not injected for redis backend")
378
+
379
+ schema_name = settings.schema_name
380
+ key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
381
+ now_seconds = clock.now().timestamp()
382
+
383
+ raw = await redis_client.hmget(key, ["tokens", "ts"]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportGeneralTypeIssues] # Why: redis-py hmget return type is untyped in the stub; all operations reflect correct runtime behavior.
384
+
385
+ tokens_raw = raw[0] if raw else None # pyright: ignore[reportUnknownVariableType] # Why: raw is untyped from redis-py hmget stub; validated at runtime.
386
+ ts_raw = raw[1] if raw else None # pyright: ignore[reportUnknownVariableType] # Why: raw is untyped from redis-py hmget stub; validated at runtime.
387
+
388
+ tokens = self._capacity if tokens_raw is None else float(tokens_raw) # pyright: ignore[reportUnknownArgumentType] # Why: tokens_raw type is unknown due to untyped redis-py stub; validated at runtime.
389
+ ts = now_seconds if ts_raw is None else float(ts_raw) # pyright: ignore[reportUnknownArgumentType] # Why: ts_raw type is unknown due to untyped redis-py stub; validated at runtime.
390
+
391
+ elapsed = max(0.0, now_seconds - ts)
392
+ tokens = min(self._capacity, tokens + elapsed * self._refill)
393
+
394
+ is_exhausted = tokens <= 0.0
395
+ retry_after: timedelta | None = None
396
+ if is_exhausted and self._refill > 0.0:
397
+ retry_seconds = (1.0 - tokens) / self._refill
398
+ retry_after = timedelta(seconds=max(0.0, retry_seconds))
399
+
400
+ return RateLimitState(
401
+ bucket_name=self._name,
402
+ backend="redis",
403
+ is_exhausted=is_exhausted,
404
+ tokens_remaining=tokens,
405
+ retry_after=retry_after,
406
+ capacity=self._capacity,
407
+ refill_per_second=self._refill,
408
+ )
409
+
410
+ async def _reset_redis(
411
+ self,
412
+ redis_client: "redis_async.Redis | None",
413
+ settings: "WorkerSettings | None",
414
+ ) -> None:
415
+ if redis_client is None:
416
+ raise RuntimeError("redis_client not injected for redis backend")
417
+ if settings is None:
418
+ raise RuntimeError("settings not injected for redis backend")
419
+
420
+ schema_name = settings.schema_name
421
+ key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
422
+ await redis_client.delete(key) # pyright: ignore[reportUnknownMemberType] # Why: redis-py delete return type is untyped in the stub
423
+
424
+ async def _peek_pg(
425
+ self,
426
+ pg_pool: "asyncpg.Pool | None",
427
+ clock: Clock | None,
428
+ settings: "WorkerSettings | None",
429
+ ) -> RateLimitState:
430
+ if pg_pool is None:
431
+ raise RuntimeError("pg_pool not injected for postgres backend")
432
+ if settings is None:
433
+ raise RuntimeError("settings not injected for postgres backend")
434
+ if clock is None:
435
+ raise RuntimeError("clock not injected for postgres backend")
436
+
437
+ now = clock.now().timestamp()
438
+ schema = settings.schema_name
439
+
440
+ select_sql = f'SELECT state FROM "{schema}".rate_limit_buckets WHERE bucket_name=$1' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; bucket_name is $1-bound
441
+
442
+ async with pg_pool.acquire() as conn:
443
+ row = await conn.fetchrow(select_sql, self._name)
444
+
445
+ if row is None:
446
+ tokens = self._capacity
447
+ else:
448
+ state = jsonb_to_dict(row["state"])
449
+ tokens = float(state.get("tokens", self._capacity)) # type: ignore[index] # Why: rate_limit_buckets.state is NOT NULL; jsonb_to_dict only returns None for SQL NULL, which cannot occur here; fallback for rows missing keys (e.g. from schema migrations or interop writes)
450
+ ts = float(state.get("ts", now)) # type: ignore[index] # Why: same — state is non-None; fallback to now for rows missing "ts"
451
+ elapsed = max(0.0, now - ts)
452
+ tokens = min(self._capacity, tokens + elapsed * self._refill)
453
+
454
+ is_exhausted = tokens <= 0.0
455
+ retry_after: timedelta | None = None
456
+ if is_exhausted and self._refill > 0.0:
457
+ retry_seconds = (1.0 - tokens) / self._refill
458
+ retry_after = timedelta(seconds=max(0.0, retry_seconds))
459
+
460
+ return RateLimitState(
461
+ bucket_name=self._name,
462
+ backend="postgres",
463
+ is_exhausted=is_exhausted,
464
+ tokens_remaining=tokens,
465
+ retry_after=retry_after,
466
+ capacity=self._capacity,
467
+ refill_per_second=self._refill,
468
+ )
469
+
470
+ async def _reset_pg(
471
+ self,
472
+ pg_pool: "asyncpg.Pool | None",
473
+ settings: "WorkerSettings | None",
474
+ ) -> None:
475
+ if pg_pool is None:
476
+ raise RuntimeError("pg_pool not injected for postgres backend")
477
+ if settings is None:
478
+ raise RuntimeError("settings not injected for postgres backend")
479
+
480
+ schema = settings.schema_name
481
+ delete_sql = f'DELETE FROM "{schema}".rate_limit_buckets WHERE bucket_name = $1' # noqa: S608 # Why: schema_name pre-validated; bucket_name is $1-bound
482
+ await pg_pool.execute(delete_sql, self._name)
483
+
484
+ async def _refund_memory(self, count: float) -> None:
485
+ if self._mem_bucket is None:
486
+ raise RuntimeError("memory bucket not initialised")
487
+ await self._mem_bucket.refund(count)
488
+
489
+ async def _refund_redis(
490
+ self,
491
+ decision: RateLimitDecision,
492
+ count: float,
493
+ redis_client: "redis_async.Redis | None",
494
+ clock: Clock | None,
495
+ settings: "WorkerSettings | None",
496
+ ) -> None:
497
+ if redis_client is None:
498
+ raise RuntimeError("redis_client not injected for redis backend refund")
499
+ if clock is None:
500
+ raise RuntimeError("clock not injected for redis backend refund")
501
+ if settings is None:
502
+ raise RuntimeError("settings not injected for redis backend refund")
503
+
504
+ script = await self._ensure_refund_script(redis_client)
505
+
506
+ schema_name = settings.schema_name
507
+ key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
508
+ now_seconds = clock.now().timestamp()
509
+
510
+ argv: list[float] = [count, now_seconds, self._capacity, self._refill]
511
+ await script(keys=[key], args=argv) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # Why: redis-py AsyncScript.__call__ has no return-type annotation; refund return value is not consumed
512
+
513
+ async def _ensure_refund_script(self, redis_client: "redis_async.Redis") -> "AsyncScript":
514
+ return await ensure_redis_script(
515
+ lambda: self._redis_refund_script,
516
+ lambda s: setattr(self, "_redis_refund_script", s),
517
+ lambda: redis_client.register_script(REFUND_SCRIPT),
518
+ self._script_lock,
519
+ )
520
+
521
+ async def _refund_pg(
522
+ self,
523
+ count: float,
524
+ pg_pool: "asyncpg.Pool | None",
525
+ clock: Clock | None,
526
+ settings: "WorkerSettings | None",
527
+ ) -> None:
528
+ """Refund tokens on the PG backend using FOR UPDATE on rate_limit_buckets.
529
+
530
+ Mirrors the Redis refund script: apply the elapsed-refill step so a
531
+ refund landing after idle time does not lose accrued tokens, then add
532
+ ``count`` capped at ``capacity``. If the bucket row does not exist
533
+ (never created or already reset), this is a no-op.
534
+ """
535
+ if pg_pool is None:
536
+ raise RuntimeError("pg_pool not injected for postgres backend refund")
537
+ if settings is None:
538
+ raise RuntimeError("settings not injected for postgres backend refund")
539
+ if clock is None:
540
+ raise RuntimeError("clock not injected for postgres backend refund")
541
+
542
+ now = clock.now().timestamp()
543
+ schema = settings.schema_name
544
+
545
+ select_sql = (
546
+ f'SELECT state FROM "{schema}".rate_limit_buckets WHERE bucket_name=$1 FOR UPDATE' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; bucket_name is $1-bound
547
+ )
548
+ update_sql = f'UPDATE "{schema}".rate_limit_buckets SET state=$1::jsonb, updated_at=now() WHERE bucket_name=$2' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; values are $1/$2-bound
549
+
550
+ async with pg_pool.acquire() as conn, conn.transaction():
551
+ row = await conn.fetchrow(select_sql, self._name)
552
+
553
+ if row is None:
554
+ return
555
+
556
+ state = jsonb_to_dict(row["state"])
557
+ tokens = float(state.get("tokens", self._capacity)) # type: ignore[index] # Why: rate_limit_buckets.state is NOT NULL; jsonb_to_dict only returns None for SQL NULL, which cannot occur here; fallback for rows missing keys (e.g. from schema migrations or interop writes)
558
+ ts = float(state.get("ts", now)) # type: ignore[index] # Why: same — state is non-None; fallback to now for rows missing "ts"
559
+
560
+ elapsed = max(0.0, now - ts)
561
+ tokens = min(self._capacity, tokens + elapsed * self._refill)
562
+ tokens = min(self._capacity, tokens + count)
563
+
564
+ state_param = jsonb_param({"tokens": tokens, "ts": now})
565
+ await conn.execute(update_sql, state_param, self._name)
566
+
567
+ async def _acquire_memory(self, count: float, clock: Clock | None) -> RateLimitDecision:
568
+ if clock is None:
569
+ raise RuntimeError("clock not injected for memory backend")
570
+ if self._mem_bucket is None:
571
+ raise RuntimeError("memory bucket not initialised")
572
+
573
+ now_ts = clock.now().timestamp()
574
+ result = await self._mem_bucket.acquire(count, now_ts)
575
+ log_decision(result)
576
+ return result
577
+
578
+ async def _acquire_redis(
579
+ self,
580
+ count: float,
581
+ redis_client: "redis_async.Redis | None",
582
+ clock: Clock | None,
583
+ settings: "WorkerSettings | None",
584
+ ) -> RateLimitDecision:
585
+ if redis_client is None:
586
+ raise RuntimeError("redis_client not injected for redis backend")
587
+ if settings is None:
588
+ raise RuntimeError("settings not injected for redis backend")
589
+ if clock is None:
590
+ raise RuntimeError("clock not injected for redis backend")
591
+
592
+ script = await self._ensure_script(redis_client)
593
+
594
+ schema_name = settings.schema_name
595
+ key = f"taskq:{schema_name}:rl:tb:{{{self._name}}}"
596
+
597
+ now_seconds = clock.now().timestamp()
598
+ ttl_seconds = self._compute_ttl_seconds()
599
+
600
+ argv: list[float | int] = [
601
+ now_seconds,
602
+ self._capacity,
603
+ self._refill,
604
+ count,
605
+ ttl_seconds,
606
+ ]
607
+
608
+ raw: list[object] = await script(keys=[key], args=argv) # pyright: ignore[reportAssignmentType, reportUnknownMemberType, reportUnknownVariableType] # Why: redis-py AsyncScript.__call__ has no return-type annotation — pyright cannot model the return shape; the three-element list structure is guaranteed by the Lua script contract
609
+
610
+ lua = _decode_lua_result(raw)
611
+
612
+ retry_after: timedelta | None
613
+ if lua.allowed:
614
+ retry_after = timedelta(0)
615
+ elif self._refill == 0.0:
616
+ retry_after = None
617
+ else:
618
+ retry_after = timedelta(seconds=lua.retry_after_seconds)
619
+
620
+ result = RateLimitDecision(
621
+ allowed=lua.allowed,
622
+ remaining=lua.tokens_remaining,
623
+ retry_after=retry_after,
624
+ bucket_name=self._name,
625
+ backend="redis",
626
+ )
627
+
628
+ log_decision(result)
629
+ return result
630
+
631
+ async def _ensure_script(self, redis_client: "redis_async.Redis") -> "AsyncScript":
632
+ return await ensure_redis_script(
633
+ lambda: self._redis_script,
634
+ lambda s: setattr(self, "_redis_script", s),
635
+ lambda: redis_client.register_script(TOKEN_BUCKET_SCRIPT),
636
+ self._script_lock,
637
+ )
638
+
639
+ async def _acquire_redis_wrapped(
640
+ self,
641
+ count: float,
642
+ redis_client: "redis_async.Redis | None",
643
+ pg_pool: "asyncpg.Pool | None",
644
+ clock: Clock | None,
645
+ settings: "WorkerSettings | None",
646
+ ) -> RateLimitDecision:
647
+ """Redis path with optional PG fallback on ConnectionError/TimeoutError."""
648
+ return await with_pg_fallback(
649
+ self._acquire_redis(count, redis_client, clock, settings),
650
+ lambda: self._acquire_pg(count, pg_pool, clock, settings),
651
+ bucket_name=self._name,
652
+ settings=settings,
653
+ )
654
+
655
+ async def _acquire_pg(
656
+ self,
657
+ count: float,
658
+ pg_pool: "asyncpg.Pool | None",
659
+ clock: Clock | None,
660
+ settings: "WorkerSettings | None",
661
+ ) -> RateLimitDecision:
662
+ """PG fallback path using FOR UPDATE on rate_limit_buckets.
663
+
664
+ Runs in a single transaction: SELECT … FOR UPDATE (blocking, NOT SKIP
665
+ LOCKED), compute token arithmetic in Python, upsert the new state.
666
+ """
667
+ if pg_pool is None:
668
+ raise RuntimeError("pg_pool not injected for postgres backend")
669
+ if settings is None:
670
+ raise RuntimeError("settings not injected for postgres backend")
671
+ if clock is None:
672
+ raise RuntimeError("clock not injected for postgres backend")
673
+
674
+ now = clock.now().timestamp()
675
+ schema = settings.schema_name
676
+
677
+ # Schema-name interpolation ; schema_name is
678
+ # pre-validated against _IDENT_RE at WorkerSettings load time.
679
+ preseed_sql = (
680
+ f'INSERT INTO "{schema}".rate_limit_buckets (bucket_name, kind, state, updated_at) ' # noqa: S608 # Why: schema_name pre-validated; values are $1/$2-bound
681
+ f"VALUES ($1, 'token_bucket', $2::jsonb, now()) "
682
+ f"ON CONFLICT (bucket_name) DO NOTHING"
683
+ )
684
+ select_sql = (
685
+ f'SELECT state FROM "{schema}".rate_limit_buckets WHERE bucket_name=$1 FOR UPDATE' # noqa: S608 # Why: schema_name is pre-validated against _IDENT_RE at settings load time; bucket_name is $1-bound
686
+ )
687
+ upsert_sql = (
688
+ f'INSERT INTO "{schema}".rate_limit_buckets (bucket_name, kind, state, updated_at) ' # noqa: S608 # Why: schema_name pre-validated; values are $1/$2-bound
689
+ f"VALUES ($1, 'token_bucket', $2::jsonb, now()) "
690
+ f"ON CONFLICT (bucket_name) DO UPDATE SET state=EXCLUDED.state, updated_at=now()"
691
+ )
692
+
693
+ async with pg_pool.acquire() as conn, conn.transaction():
694
+ # Cold-start guard: SELECT ... FOR UPDATE cannot lock a row that
695
+ # does not exist yet, so concurrent first acquires would each
696
+ # read `row is None` and independently admit up to `capacity`
697
+ # tokens. Pre-seed a full-capacity row (idempotent — DO NOTHING
698
+ # on conflict) so the very first acquire also serializes on the
699
+ # row lock below.
700
+ preseed_state = jsonb_param({"tokens": self._capacity, "ts": now})
701
+ await conn.execute(preseed_sql, self._name, preseed_state)
702
+ row = await conn.fetchrow(select_sql, self._name)
703
+
704
+ if row is None:
705
+ # Unreachable in the normal path — the preseed above guarantees
706
+ # the row exists before the SELECT. Kept as a defensive fallback
707
+ # (e.g. a concurrent DELETE between preseed and select).
708
+ tokens = self._capacity
709
+ ts = now
710
+ else:
711
+ state = jsonb_to_dict(row["state"])
712
+ tokens = float(state.get("tokens", self._capacity)) # type: ignore[index] # Why: rate_limit_buckets.state is NOT NULL; jsonb_to_dict only returns None for SQL NULL, which cannot occur here; fallback for rows missing keys (e.g. from schema migrations or interop writes)
713
+ ts = float(state.get("ts", now)) # type: ignore[index] # Why: same — state is non-None; fallback to now for rows missing "ts"
714
+
715
+ elapsed = max(0.0, now - ts)
716
+ tokens = min(self._capacity, tokens + elapsed * self._refill)
717
+
718
+ retry_after: timedelta | None
719
+ allowed: bool
720
+
721
+ if tokens >= count:
722
+ tokens -= count
723
+ allowed = True
724
+ retry_after = timedelta(0)
725
+ else:
726
+ allowed = False
727
+ if self._refill == 0.0:
728
+ retry_after = None
729
+ else:
730
+ retry_after_seconds = (count - tokens) / self._refill
731
+ retry_after = timedelta(seconds=retry_after_seconds)
732
+
733
+ # _jsonb_param serializes via orjson — passing a dict directly
734
+ # to conn.execute fails because asyncpg does not auto-encode
735
+ # Python dicts as jsonb.
736
+ state_param = jsonb_param({"tokens": tokens, "ts": now})
737
+ # updated_at uses server-side now(); state ts is client-supplied
738
+ await conn.execute(upsert_sql, self._name, state_param)
739
+
740
+ result = RateLimitDecision(
741
+ allowed=allowed,
742
+ remaining=tokens,
743
+ retry_after=retry_after,
744
+ bucket_name=self._name,
745
+ backend="postgres",
746
+ )
747
+ log_decision(result)
748
+ return result
749
+
750
+ def _compute_ttl_seconds(self) -> int:
751
+ """Compute the TTL for the Redis key based on bucket parameters."""
752
+ if self._refill == 0.0:
753
+ return 86400
754
+ return math.ceil(self._capacity / self._refill * 2) + 60