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/_di/scopes.py ADDED
@@ -0,0 +1,467 @@
1
+ """Concrete ScopeContainer with LIFO teardown and factory-shape dispatch.
2
+
3
+ Implements the log-and-continue teardown policy: each teardown callback
4
+ runs in its own try/except; failures are logged at ERROR; remaining
5
+ teardowns always fire. A parallel ``_teardowns`` list replaces
6
+ ``AsyncExitStack.aclose()`` which re-raises the first exception and
7
+ swallows the rest (research line 743-758).
8
+ """
9
+
10
+ import asyncio
11
+ import contextlib
12
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from contextlib import AsyncExitStack, asynccontextmanager
15
+ from dataclasses import dataclass
16
+ from types import MappingProxyType
17
+ from typing import Any, assert_never, cast
18
+
19
+ import structlog
20
+ from pydantic import BaseModel
21
+
22
+ from taskq._di.registry import ProviderRegistry
23
+ from taskq._di.scope import Scope
24
+ from taskq._di.solver import solve_dependencies
25
+ from taskq._di.types import FactoryShape, ProviderEntry, ProviderLifecycle
26
+ from taskq._di.types import ScopeContainer as ScopeContainerProtocol
27
+ from taskq.context import JobContext
28
+ from taskq.settings import WorkerSettings
29
+
30
+ logger = structlog.get_logger("taskq._di.scopes")
31
+
32
+ # Why: resolver accepts object (erasure boundary — entry.impl is object per
33
+ # ) and returns Any (the kwargs dict shape depends
34
+ # on the factory's signature, which the resolver inspects at runtime).
35
+ _Resolver = Callable[[object], Any]
36
+
37
+
38
+ def make_resolver(
39
+ registry: ProviderRegistry,
40
+ scope_containers: dict[Scope, ScopeContainerProtocol],
41
+ ) -> _Resolver:
42
+ """Construct the resolver callable expected by ScopeContainer.__init__.
43
+
44
+ The resolver invokes solve_dependencies with the registry and scope
45
+ containers. The closure captures scope_containers by reference so the
46
+ resolver sees containers added after its construction (the dict is
47
+ populated incrementally as each scope container is bootstrapped).
48
+ """
49
+
50
+ def _resolver(func: object) -> Any:
51
+ async def _resolve() -> dict[str, object]:
52
+ return await solve_dependencies(
53
+ func=func,
54
+ registry=registry,
55
+ scope_containers=scope_containers,
56
+ )
57
+
58
+ return _resolve()
59
+
60
+ return _resolver
61
+
62
+
63
+ class ScopeContainer:
64
+ """Concrete scope-lifetime container owning a cache, teardown list, and AsyncExitStack.
65
+
66
+ The container is responsible for ALL factory invocation, caching, and
67
+ teardown registration. The solver engine NEVER calls a factory directly
68
+ and NEVER touches an AsyncExitStack.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ scope: Scope,
75
+ resolver: _Resolver,
76
+ ) -> None:
77
+ self._scope: Scope = scope
78
+ self._cache: dict[type, object] = {}
79
+ self._stack: AsyncExitStack = AsyncExitStack()
80
+ self._teardowns: list[Callable[[], Any]] = []
81
+ self._resolver: _Resolver = resolver
82
+ self._sync_gen_executor: ThreadPoolExecutor | None = None
83
+ self._last_cache_hit: bool = False
84
+
85
+ @property
86
+ def last_cache_hit(self) -> bool:
87
+ """Whether the most recent ``get_or_create`` returned a cached value."""
88
+ return self._last_cache_hit
89
+
90
+ async def get_or_create[T](self, type_: type[T], entry: ProviderEntry[T]) -> T:
91
+ """Resolve *type_* via *entry*, creating and caching the instance if needed."""
92
+ if self._scope is not Scope.TRANSIENT:
93
+ cached = self._cache.get(type_)
94
+ if cached is not None:
95
+ self._last_cache_hit = True
96
+ return cached # type: ignore[return-value] # Why: _cache is dict[type, object]; caller's T is recovered via the ProviderEntry[T] that selected this branch
97
+ self._last_cache_hit = False
98
+
99
+ match entry.factory_shape:
100
+ case FactoryShape.VALUE:
101
+ result: object = entry.impl
102
+ case FactoryShape.SYNC_CALLABLE:
103
+ kwargs = await self._resolver(entry.impl)
104
+ result = cast(Callable[..., Any], entry.impl)(**kwargs)
105
+ case FactoryShape.ASYNC_CALLABLE:
106
+ kwargs = await self._resolver(entry.impl)
107
+ result = await cast(Callable[..., Any], entry.impl)(**kwargs)
108
+ case FactoryShape.SYNC_GENERATOR:
109
+ result = await self._resolve_sync_generator(entry)
110
+ case FactoryShape.ASYNC_GENERATOR:
111
+ result = await self._resolve_async_generator(entry)
112
+ case FactoryShape.CLASS:
113
+ kwargs = await self._resolver(cast(type[Any], entry.impl).__init__)
114
+ instance = cast(type[Any], entry.impl)(**kwargs)
115
+ match entry.lifecycle:
116
+ case ProviderLifecycle.AsyncContextManager:
117
+ value = await instance.__aenter__()
118
+
119
+ async def _acm_teardown() -> None:
120
+ await instance.__aexit__(None, None, None)
121
+
122
+ self._teardowns.append(_acm_teardown)
123
+ result = value
124
+ case ProviderLifecycle.AsyncCloseable:
125
+ self._teardowns.append(instance.aclose)
126
+ result = instance
127
+ case ProviderLifecycle.SyncCloseable:
128
+ self._teardowns.append(lambda inst=instance: asyncio.to_thread(inst.close))
129
+ result = instance
130
+ case ProviderLifecycle.Plain | None:
131
+ result = instance
132
+ case (
133
+ ProviderLifecycle.AsyncGenerator
134
+ | ProviderLifecycle.SyncGenerator
135
+ | ProviderLifecycle.PlainFactory
136
+ ):
137
+ msg = f"factory lifecycle {entry.lifecycle!r} reached CLASS arm"
138
+ raise RuntimeError(msg)
139
+ case _:
140
+ assert_never(entry.lifecycle)
141
+ case _:
142
+ assert_never(entry.factory_shape)
143
+
144
+ if self._scope is not Scope.TRANSIENT:
145
+ self._cache[type_] = result
146
+
147
+ return result # type: ignore[return-value] # Why: same recovery as cache-hit branch — erasure boundary documented
148
+
149
+ async def _resolve_sync_generator(self, entry: ProviderEntry[object]) -> object:
150
+ """Resolve a SYNC_GENERATOR provider via a pinned single-thread executor."""
151
+ if self._sync_gen_executor is None:
152
+ self._sync_gen_executor = ThreadPoolExecutor(max_workers=1)
153
+ logger.info("sync-generator-executor-created", scope=self._scope.name)
154
+
155
+ kwargs = await self._resolver(entry.impl)
156
+ factory = cast(Callable[..., Generator[Any, None, None]], entry.impl)
157
+ cm = contextlib.contextmanager(factory)(**kwargs)
158
+
159
+ loop = asyncio.get_running_loop()
160
+ value = await loop.run_in_executor(self._sync_gen_executor, cm.__enter__)
161
+
162
+ def _teardown() -> Any:
163
+ return loop.run_in_executor(self._sync_gen_executor, cm.__exit__, None, None, None)
164
+
165
+ self._teardowns.append(_teardown)
166
+ return value
167
+
168
+ async def _resolve_async_generator(self, entry: ProviderEntry[object]) -> object:
169
+ """Resolve an ASYNC_GENERATOR provider via asynccontextmanager, entering manually."""
170
+ kwargs = await self._resolver(entry.impl)
171
+ factory = cast(Callable[..., AsyncGenerator[Any]], entry.impl)
172
+ cm = asynccontextmanager(factory)(**kwargs)
173
+ value = await cm.__aenter__()
174
+
175
+ async def _teardown() -> None:
176
+ await cm.__aexit__(None, None, None)
177
+
178
+ self._teardowns.append(_teardown)
179
+ return value
180
+
181
+ async def aclose(self) -> None:
182
+ """Close the container with the log-and-continue teardown policy."""
183
+ pending_cancel: BaseException | None = None
184
+ while self._teardowns:
185
+ cb = self._teardowns.pop()
186
+ try:
187
+ await cb()
188
+ except BaseException as exc:
189
+ logger.error(
190
+ "provider-teardown-error",
191
+ scope=self._scope.name,
192
+ exc_info=True,
193
+ )
194
+ # Why: remember a CancelledError so we can re-raise it after
195
+ # all remaining teardowns have run; non-cancel exceptions
196
+ # are logged and dropped log-and-continue.
197
+ if isinstance(exc, asyncio.CancelledError):
198
+ pending_cancel = exc
199
+
200
+ # Why: shut down the pinned SYNC_GENERATOR executor AFTER all
201
+ # per-provider teardown callbacks have run. shutdown(wait=True) is
202
+ # blocking; running it inline would block the loop .
203
+ if self._sync_gen_executor is not None:
204
+ executor = self._sync_gen_executor
205
+ self._sync_gen_executor = None
206
+ try:
207
+ await asyncio.to_thread(executor.shutdown, True)
208
+ except BaseException as exc:
209
+ # Why: parallels the per-callback BaseException pattern above.
210
+ logger.error(
211
+ "sync-generator-executor-shutdown-error",
212
+ scope=self._scope.name,
213
+ exc_info=True,
214
+ )
215
+ if isinstance(exc, asyncio.CancelledError):
216
+ pending_cancel = exc
217
+ else:
218
+ logger.info(
219
+ "sync_generator_executor_shutdown",
220
+ scope=self._scope.name,
221
+ )
222
+
223
+ # Why: after every teardown attempted, re-raise any deferred
224
+ # CancelledError so the outer task's cancellation contract is honored.
225
+ if pending_cancel is not None:
226
+ raise pending_cancel
227
+
228
+
229
+ class ProcessScope(ScopeContainer):
230
+ """PROCESS-lifetime scope — worker process startup → exit."""
231
+
232
+ def __init__(self, *, resolver: _Resolver) -> None:
233
+ super().__init__(scope=Scope.PROCESS, resolver=resolver)
234
+
235
+ async def bootstrap(
236
+ self,
237
+ registry: ProviderRegistry,
238
+ settings: WorkerSettings, # Why: accepted for API symmetry with the public surface; bootstrap doesn't use it directly — registration is a separate concern owned by worker bootstrap
239
+ ) -> None:
240
+ """Resolve all PROCESS-scoped providers.
241
+
242
+ Why: no try/except around get_or_create — earlier providers'
243
+ teardowns are already registered on self._teardowns; the
244
+ caller's AsyncExitStack runs aclose() on unwind, which
245
+ iterates whatever teardowns were registered before the
246
+ failure. Partial-bootstrap leaks are not possible: every
247
+ successful get_or_create has its teardown queued before
248
+ the next get_or_create starts.
249
+ """
250
+ process_providers = [t for t, e in registry.providers.items() if e.scope is Scope.PROCESS]
251
+ for t in process_providers:
252
+ await self.get_or_create(t, registry.get(t)) # type: ignore[reportUnknownArgumentType] # Why: registry.get() returns ProviderEntry[Unknown] when called with plain type; runtime guarantee from providers dict iteration
253
+ logger.info(
254
+ "process-scope-opened",
255
+ provider_count=len(process_providers),
256
+ )
257
+
258
+ async def shutdown(self) -> None:
259
+ await self.aclose()
260
+ logger.info("process-scope-closed")
261
+
262
+ def get(self, type_: type) -> object | None:
263
+ return self._cache.get(type_)
264
+
265
+
266
+ class ThreadScope(ScopeContainer):
267
+ """THREAD-lifetime scope — placeholder for multi-thread workers (trivially empty in M3)."""
268
+
269
+ def __init__(self, *, resolver: _Resolver) -> None:
270
+ super().__init__(scope=Scope.THREAD, resolver=resolver)
271
+
272
+ async def bootstrap(
273
+ self,
274
+ registry: ProviderRegistry,
275
+ process_scope: ProcessScope,
276
+ ) -> None:
277
+ """Resolve all THREAD-scoped providers (empty in M3 single-thread deployment)."""
278
+ thread_providers = [t for t, e in registry.providers.items() if e.scope is Scope.THREAD]
279
+ for t in thread_providers:
280
+ await self.get_or_create(t, registry.get(t)) # type: ignore[reportUnknownArgumentType] # Why: registry.get() returns ProviderEntry[Unknown] when called with plain type; runtime guarantee from providers dict iteration
281
+ logger.info(
282
+ "thread-scope-opened",
283
+ provider_count=len(thread_providers),
284
+ )
285
+
286
+ async def shutdown(self) -> None:
287
+ await self.aclose()
288
+ logger.info("thread-scope-closed")
289
+
290
+ def get(self, type_: type) -> object | None:
291
+ return self._cache.get(type_)
292
+
293
+
294
+ class LoopScope(ScopeContainer):
295
+ """LOOP-lifetime scope — worker loop start → loop close."""
296
+
297
+ def __init__(self, *, resolver: _Resolver) -> None:
298
+ super().__init__(scope=Scope.LOOP, resolver=resolver)
299
+ self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
300
+
301
+ async def bootstrap(
302
+ self,
303
+ registry: ProviderRegistry,
304
+ process_scope: ProcessScope,
305
+ thread_scope: ThreadScope | None = None,
306
+ ) -> None:
307
+ """Resolve all LOOP-scoped providers.
308
+
309
+ Why: process_scope / thread_scope parameters are accepted for
310
+ API completeness even though bootstrap doesn't read them
311
+ directly — wider scopes' instances are reachable through the
312
+ _resolver callable (which holds the registry + container map).
313
+ """
314
+ loop_providers = [t for t, e in registry.providers.items() if e.scope is Scope.LOOP]
315
+ for t in loop_providers:
316
+ await self.get_or_create(t, registry.get(t)) # type: ignore[reportUnknownArgumentType] # Why: registry.get() returns ProviderEntry[Unknown] when called with plain type; runtime guarantee from providers dict iteration
317
+ logger.info(
318
+ "loop-scope-opened",
319
+ provider_count=len(loop_providers),
320
+ )
321
+
322
+ async def shutdown(self) -> None:
323
+ await self.aclose()
324
+ logger.info("loop-scope-closed")
325
+
326
+ def resolved_cache(self) -> Mapping[type, object]:
327
+ """Return a read-only view of the LOOP-scope resolved values.
328
+
329
+ The returned mapping is a live view onto the underlying cache —
330
+ providers resolved after this method is called (e.g., lazy
331
+ providers) become visible without re-fetching. Callers MUST NOT
332
+ attempt to mutate the mapping; doing so raises ``TypeError``.
333
+
334
+ Stability invariant: once ``bootstrap`` returns, the registered
335
+ set of LOOP-scope provider types is FROZEN for the lifetime of
336
+ the loop. The values stored under each type key (e.g. an
337
+ ``asyncpg.Connection``) are expected to be stable references —
338
+ callers that read the mapping repeatedly (such as
339
+ ``SubJobEnqueuer.enqueue``) assume the connection identity does
340
+ not change between dispatches. If a future feature needs to
341
+ swap a LOOP-scope value mid-loop (e.g., reconnect after a pool
342
+ reset), it must coordinate with consumers of this mapping
343
+ because the consumer's per-dispatch transaction lifecycle
344
+ requires the same connection across both transaction-open and
345
+ transaction-close.
346
+ """
347
+ return MappingProxyType(self._cache)
348
+
349
+ async def get_or_create[T](self, type_: type[T], entry: ProviderEntry[T]) -> T:
350
+ running = asyncio.get_running_loop()
351
+ if running is not self._loop:
352
+ raise RuntimeError(f"LoopScope created on {self._loop!r} but accessed from {running!r}")
353
+ return await super().get_or_create(type_, entry)
354
+
355
+ def get(self, type_: type) -> object | None:
356
+ return self._cache.get(type_)
357
+
358
+
359
+ @dataclass(frozen=True, slots=True)
360
+ class ResolvedActorScope:
361
+ """The yielded value of build_actor_scope.
362
+
363
+ ctx — the JobContext supplied by the consumer (passthrough).
364
+ di_kwargs — the DI-resolved kwargs dict for the actor function.
365
+
366
+ Usage::
367
+
368
+ async with build_actor_scope(...) as resolved:
369
+ await run_actor(job, resolved.ctx, **resolved.di_kwargs)
370
+ """
371
+
372
+ ctx: JobContext[BaseModel]
373
+ di_kwargs: dict[str, object]
374
+
375
+
376
+ @asynccontextmanager
377
+ async def build_actor_scope(
378
+ *,
379
+ registry: ProviderRegistry,
380
+ process_scope: ProcessScope,
381
+ thread_scope: ThreadScope,
382
+ loop_scope: LoopScope,
383
+ actor_func: Callable[..., Awaitable[object]],
384
+ actor_name: str,
385
+ passthrough_kwargs: dict[str, object],
386
+ ) -> AsyncGenerator[ResolvedActorScope, None]:
387
+ """Per-invocation actor scope: opens TRANSIENT stack, resolves DI kwargs.
388
+
389
+ Yields ResolvedActorScope(ctx, di_kwargs). On exit (regardless of
390
+ outcome), closes the TRANSIENT stack in LIFO order via the
391
+ log-and-continue teardown policy.
392
+
393
+ passthrough_kwargs MUST contain the JobContext (key "ctx") and the
394
+ validated payload (key "payload"). The consumer constructs both
395
+ per-job and supplies them as passthrough; the registry's graph walk
396
+ is configured to skip these parameter names, so they are never
397
+ resolved from providers.
398
+ """
399
+ scope_containers: dict[Scope, ScopeContainerProtocol] = {}
400
+
401
+ def _resolver(func: object) -> Any:
402
+ async def _resolve() -> dict[str, object]:
403
+ return await solve_dependencies(
404
+ func=func,
405
+ registry=registry,
406
+ scope_containers=scope_containers,
407
+ )
408
+
409
+ return _resolve()
410
+
411
+ transient_scope = ScopeContainer(scope=Scope.TRANSIENT, resolver=_resolver)
412
+
413
+ scope_containers = {
414
+ Scope.PROCESS: process_scope,
415
+ Scope.THREAD: thread_scope,
416
+ Scope.LOOP: loop_scope,
417
+ Scope.TRANSIENT: transient_scope,
418
+ }
419
+
420
+ # Why: the resolver closure captured scope_containers before TRANSIENT
421
+ # was added; re-bind so the resolver sees all four containers.
422
+ def _resolver_with_all(func: object) -> Any:
423
+ async def _resolve() -> dict[str, object]:
424
+ return await solve_dependencies(
425
+ func=func,
426
+ registry=registry,
427
+ scope_containers=scope_containers,
428
+ )
429
+
430
+ return _resolve()
431
+
432
+ transient_scope._resolver = _resolver_with_all # pyright: ignore[reportPrivateUsage] # Why: build_actor_scope constructs the TRANSIENT container and must wire its resolver to see all four scope containers; the resolver is a closure detail owned by this call site
433
+
434
+ logger.info("transient-scope-opened", actor_name=actor_name)
435
+ try:
436
+ di_kwargs = await solve_dependencies(
437
+ func=actor_func,
438
+ registry=registry,
439
+ scope_containers=scope_containers,
440
+ passthrough_kwargs=passthrough_kwargs,
441
+ )
442
+ # Why: solve_dependencies includes passthrough keys in its result
443
+ # dict; build_actor_scope yields ctx separately and the consumer
444
+ # spreads di_kwargs — so passthrough keys must not appear in
445
+ # di_kwargs to avoid duplicate keyword arguments at the call site.
446
+ for _key in passthrough_kwargs:
447
+ di_kwargs.pop(_key, None)
448
+ ctx = passthrough_kwargs["ctx"]
449
+ if not isinstance(ctx, JobContext):
450
+ raise TypeError(
451
+ f"passthrough_kwargs['ctx'] must be a JobContext, got {type(ctx).__name__}"
452
+ )
453
+ yield ResolvedActorScope(ctx=ctx, di_kwargs=di_kwargs) # type: ignore[reportUnknownArgumentType] # Why: ctx is narrowed to JobContext[Any] by isinstance but pyright cannot recover the BaseModel bound from the passthrough_kwargs dict[str, object] — the consumer that built passthrough_kwargs guarantees the correct P at the call site
454
+ finally:
455
+ # Why: shield the TRANSIENT teardown so cancellation /
456
+ # asyncio.wait_for timeouts in the with-body do not
457
+ # short-circuit the teardown mid-way.
458
+ # ("wrap terminal writes in asyncio.shield") applies to
459
+ # scope teardown too — losing teardown of an opened
460
+ # resource leaks it. CancelledError after the shielded
461
+ # aclose finishes is re-raised to honor the outer cancel.
462
+ try:
463
+ await asyncio.shield(transient_scope.aclose())
464
+ except asyncio.CancelledError:
465
+ raise
466
+ finally:
467
+ logger.info("transient-scope-closed", actor_name=actor_name)
taskq/_di/solver.py ADDED
@@ -0,0 +1,159 @@
1
+ """DI dependency-resolution engine.
2
+
3
+ Inspects a callable's signature, resolves each parameter through the
4
+ provider registry and scope containers, and returns a kwargs dict
5
+ suitable for ``**kwargs`` injection. The engine never calls factories
6
+ directly and never touches an AsyncExitStack — that is the container's
7
+ responsibility.
8
+ """
9
+
10
+ import inspect
11
+ import sys
12
+ from typing import Annotated, get_args, get_origin, get_type_hints
13
+
14
+ import structlog
15
+
16
+ from taskq._di.scope import Scope
17
+ from taskq._di.types import ProviderRegistry, ScopeContainer
18
+ from taskq.exceptions import DIError
19
+
20
+ logger = structlog.get_logger("taskq._di.solver")
21
+
22
+
23
+ def _unwrap_scope_override(
24
+ param_name: str,
25
+ annotation: object,
26
+ ) -> tuple[type | None, Scope | None]:
27
+ """Return ``(unwrapped_type, scope_override)`` for an annotation.
28
+
29
+ - ``(None, None)`` — annotation is not ``Annotated[...]``; caller uses
30
+ ``annotation`` directly as the registry lookup key.
31
+ - ``(T, None)`` — ``Annotated[T, ...]`` with no ``Scope`` in metadata;
32
+ caller uses ``T`` as lookup key and the registered default scope.
33
+ - ``(T, scope)`` — ``Annotated[T, ...]`` with exactly one ``Scope``;
34
+ caller uses ``T`` as lookup key and ``scope`` as call-site override.
35
+ - Raises ``DIError`` if multiple ``Scope`` members appear.
36
+ """
37
+ origin = get_origin(annotation)
38
+ if origin is not Annotated:
39
+ return (None, None)
40
+
41
+ args = get_args(annotation)
42
+ if not args:
43
+ return (None, None)
44
+
45
+ unwrapped: type | None = args[0] if isinstance(args[0], type) else None
46
+ scopes: list[Scope] = []
47
+ for meta in args[1:]:
48
+ if isinstance(meta, Scope):
49
+ scopes.append(meta)
50
+
51
+ if len(scopes) > 1:
52
+ raise DIError(
53
+ f"parameter '{param_name}' has multiple Scope markers: "
54
+ f"{', '.join(s.name for s in scopes)}"
55
+ )
56
+
57
+ override_scope: Scope | None = scopes[0] if scopes else None
58
+ return (unwrapped, override_scope)
59
+
60
+
61
+ async def solve_dependencies(
62
+ *,
63
+ func: object,
64
+ registry: ProviderRegistry,
65
+ scope_containers: dict[Scope, ScopeContainer],
66
+ passthrough_kwargs: dict[str, object] | None = None,
67
+ ) -> dict[str, object]:
68
+ """Resolve all DI parameters for *func* and return a kwargs dict.
69
+
70
+ Parameters
71
+ ----------
72
+ func:
73
+ The callable whose parameters will be resolved.
74
+ registry:
75
+ Read-only provider registry for type→entry lookups.
76
+ scope_containers:
77
+ One container per scope; the engine selects by effective scope.
78
+ passthrough_kwargs:
79
+ Optional concrete values injected before registry lookup.
80
+
81
+ Raises
82
+ ------
83
+ DIError:
84
+ Malformed annotation (multiple Scope markers, non-type
85
+ annotation, unresolvable forward reference).
86
+ MissingProvider:
87
+ Propagated from ``registry.get()`` when a type has no provider
88
+ and is not in ``passthrough_kwargs``.
89
+ """
90
+ if not callable(func):
91
+ raise DIError(f"solve_dependencies requires a callable, got {type(func)!r}")
92
+
93
+ try:
94
+ module = sys.modules.get(func.__module__)
95
+ globalns = vars(module) if module is not None else {}
96
+ hints = get_type_hints(
97
+ func,
98
+ include_extras=True,
99
+ globalns=globalns,
100
+ )
101
+ except NameError as name_error:
102
+ raise DIError(
103
+ f"unresolvable annotation in {func.__module__}.{func.__qualname__}: {name_error}"
104
+ ) from name_error
105
+
106
+ passthrough = passthrough_kwargs or {}
107
+ kwargs: dict[str, object] = {}
108
+
109
+ for param_name, annotation in hints.items():
110
+ if param_name == "return":
111
+ continue
112
+ if param_name in passthrough:
113
+ kwargs[param_name] = passthrough[param_name]
114
+ continue
115
+
116
+ unwrapped, override_scope = _unwrap_scope_override(param_name, annotation)
117
+
118
+ lookup_type: object = unwrapped if unwrapped is not None else annotation
119
+
120
+ if not isinstance(lookup_type, type):
121
+ raise DIError(
122
+ f"parameter '{param_name}' has a non-type annotation "
123
+ f"{lookup_type!r}; registry lookup requires a concrete type"
124
+ )
125
+
126
+ entry = registry.get(lookup_type)
127
+
128
+ effective_scope = override_scope if override_scope is not None else entry.scope
129
+
130
+ container = scope_containers[effective_scope]
131
+ value = await container.get_or_create(lookup_type, entry)
132
+
133
+ kwargs[param_name] = value
134
+
135
+ logger.debug(
136
+ "dep-resolved",
137
+ param_name=param_name,
138
+ dep_type=lookup_type.__qualname__,
139
+ scope=effective_scope,
140
+ cache_hit=container.last_cache_hit,
141
+ )
142
+
143
+ sig = inspect.signature(func)
144
+ for pname, param in sig.parameters.items():
145
+ if pname == "self":
146
+ continue
147
+ if pname in kwargs or pname in passthrough:
148
+ continue
149
+ if param.default is inspect.Parameter.empty and param.kind in (
150
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
151
+ inspect.Parameter.KEYWORD_ONLY,
152
+ ):
153
+ raise DIError(
154
+ f"parameter '{pname}' of {func.__module__}.{func.__qualname__} "
155
+ f"has no type annotation and no default value; "
156
+ f"DI cannot resolve unannotated required parameters"
157
+ )
158
+
159
+ return kwargs