taskq-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskq/__init__.py +147 -0
- taskq/_di/__init__.py +45 -0
- taskq/_di/_utils.py +13 -0
- taskq/_di/_validate.py +456 -0
- taskq/_di/lifecycle.py +52 -0
- taskq/_di/registry.py +342 -0
- taskq/_di/scope.py +12 -0
- taskq/_di/scopes.py +467 -0
- taskq/_di/solver.py +159 -0
- taskq/_di/types.py +121 -0
- taskq/_dsn.py +18 -0
- taskq/_ids.py +128 -0
- taskq/_json.py +105 -0
- taskq/_scope.py +39 -0
- taskq/actor.py +752 -0
- taskq/backend/__init__.py +69 -0
- taskq/backend/_cursor.py +32 -0
- taskq/backend/_dispatch.py +114 -0
- taskq/backend/_dispatch_sql.py +301 -0
- taskq/backend/_enqueue.py +490 -0
- taskq/backend/_notify.py +49 -0
- taskq/backend/_protocol.py +854 -0
- taskq/backend/_reads.py +177 -0
- taskq/backend/_records.py +116 -0
- taskq/backend/_schedules.py +226 -0
- taskq/backend/_sql.py +103 -0
- taskq/backend/_sql_templates.py +543 -0
- taskq/backend/_sweeps.py +487 -0
- taskq/backend/_terminal.py +830 -0
- taskq/backend/clock.py +47 -0
- taskq/backend/postgres.py +656 -0
- taskq/backend/statemachine.py +50 -0
- taskq/batch.py +274 -0
- taskq/cli.py +670 -0
- taskq/client/__init__.py +25 -0
- taskq/client/_args.py +233 -0
- taskq/client/_enqueuer.py +347 -0
- taskq/client/_handle.py +321 -0
- taskq/client/_jobs.py +756 -0
- taskq/client/_taskq.py +647 -0
- taskq/client/_transport.py +112 -0
- taskq/constants.py +152 -0
- taskq/context.py +171 -0
- taskq/contrib/__init__.py +1 -0
- taskq/contrib/kubernetes/__init__.py +1 -0
- taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
- taskq/contrib/prometheus/__init__.py +10 -0
- taskq/contrib/prometheus/_metrics.py +63 -0
- taskq/contrib/prometheus/rules.yaml +113 -0
- taskq/cron.py +332 -0
- taskq/di.py +7 -0
- taskq/exceptions.py +398 -0
- taskq/migrate.py +248 -0
- taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
- taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
- taskq/migrations/__init__.py +13 -0
- taskq/obs/__init__.py +120 -0
- taskq/obs/_otel.py +583 -0
- taskq/obs/_structlog.py +241 -0
- taskq/obs/error_reporter.py +120 -0
- taskq/progress/__init__.py +5 -0
- taskq/progress/_buffer.py +96 -0
- taskq/progress/_events.py +34 -0
- taskq/progress/_flush.py +140 -0
- taskq/progress/_publish.py +200 -0
- taskq/py.typed +0 -0
- taskq/ratelimit/__init__.py +42 -0
- taskq/ratelimit/_decision_log.py +38 -0
- taskq/ratelimit/_provider.py +90 -0
- taskq/ratelimit/_redis_utils.py +79 -0
- taskq/ratelimit/_scripts.py +265 -0
- taskq/ratelimit/_sliding_window_pg.py +432 -0
- taskq/ratelimit/_sliding_window_redis.py +398 -0
- taskq/ratelimit/composition.py +93 -0
- taskq/ratelimit/decision.py +47 -0
- taskq/ratelimit/refs.py +87 -0
- taskq/ratelimit/registry.py +678 -0
- taskq/ratelimit/reservation.py +593 -0
- taskq/ratelimit/sliding_window.py +570 -0
- taskq/ratelimit/token_bucket.py +754 -0
- taskq/retry.py +582 -0
- taskq/scheduler.py +59 -0
- taskq/settings.py +797 -0
- taskq/testing/__init__.py +102 -0
- taskq/testing/_dispatch.py +158 -0
- taskq/testing/_enqueue.py +225 -0
- taskq/testing/_reads.py +101 -0
- taskq/testing/_runner.py +650 -0
- taskq/testing/_slots.py +108 -0
- taskq/testing/_sweeps.py +184 -0
- taskq/testing/_terminal.py +666 -0
- taskq/testing/actor.py +322 -0
- taskq/testing/assertions.py +311 -0
- taskq/testing/asyncpg_chaos.py +157 -0
- taskq/testing/clock.py +49 -0
- taskq/testing/fixtures.py +860 -0
- taskq/testing/in_memory.py +773 -0
- taskq/testing/job_context.py +63 -0
- taskq/testing/jobs.py +190 -0
- taskq/testing/otel.py +251 -0
- taskq/testing/pg.py +310 -0
- taskq/testing/settings.py +71 -0
- taskq/testing/spy.py +15 -0
- taskq/types.py +48 -0
- taskq/web/__init__.py +1 -0
- taskq/web/admin/__init__.py +28 -0
- taskq/web/admin/_constants.py +26 -0
- taskq/web/admin/_factory.py +403 -0
- taskq/web/admin/_history.py +250 -0
- taskq/web/admin/_jsonb.py +23 -0
- taskq/web/admin/_listen.py +107 -0
- taskq/web/admin/_static.py +25 -0
- taskq/web/admin/auth/__init__.py +42 -0
- taskq/web/admin/auth/_session.py +190 -0
- taskq/web/admin/auth/oidc.py +299 -0
- taskq/web/admin/auth/saml.py +213 -0
- taskq/web/admin/auth/token.py +35 -0
- taskq/web/admin/jobs.py +555 -0
- taskq/web/admin/ops.py +658 -0
- taskq/web/admin/queues.py +197 -0
- taskq/web/admin/sse.py +104 -0
- taskq/web/admin/workers.py +105 -0
- taskq/web/health.py +81 -0
- taskq/web/progress.py +445 -0
- taskq/web/static/admin.css +2 -0
- taskq/web/static/admin.js +218 -0
- taskq/web/static/alpine.min.js +5 -0
- taskq/web/static/htmx.min.js +1 -0
- taskq/web/static/realtime.js +246 -0
- taskq/web/static/sse.min.js +1 -0
- taskq/web/static/tailwind.css +3 -0
- taskq/web/templates/_base.html +75 -0
- taskq/web/templates/_partials/job_card.html +97 -0
- taskq/web/templates/_partials/job_table.html +148 -0
- taskq/web/templates/_partials/sse_console.html +4 -0
- taskq/web/templates/_partials/table.html +37 -0
- taskq/web/templates/history.html +98 -0
- taskq/web/templates/job_detail.html +367 -0
- taskq/web/templates/jobs.html +193 -0
- taskq/web/templates/leader.html +73 -0
- taskq/web/templates/queue_detail.html +59 -0
- taskq/web/templates/queues.html +85 -0
- taskq/web/templates/rate_limits.html +104 -0
- taskq/web/templates/reservations.html +93 -0
- taskq/web/templates/schedules.html +76 -0
- taskq/web/templates/workers.html +69 -0
- taskq/worker/__init__.py +69 -0
- taskq/worker/_bootstrap.py +509 -0
- taskq/worker/_consumer.py +896 -0
- taskq/worker/_handlers.py +709 -0
- taskq/worker/_leader_shared.py +390 -0
- taskq/worker/_leader_sweeps.py +441 -0
- taskq/worker/actor_config.py +19 -0
- taskq/worker/budget.py +93 -0
- taskq/worker/cancel.py +438 -0
- taskq/worker/cron_loop.py +300 -0
- taskq/worker/deps.py +296 -0
- taskq/worker/dev.py +160 -0
- taskq/worker/dispatch.py +290 -0
- taskq/worker/health.py +330 -0
- taskq/worker/heartbeat.py +270 -0
- taskq/worker/leader.py +384 -0
- taskq/worker/notify.py +331 -0
- taskq/worker/run.py +576 -0
- taskq/worker/shutdown.py +287 -0
- taskq/worker/startup.py +214 -0
- taskq/worker/workgroup.py +778 -0
- taskq_py-0.1.0.dist-info/METADATA +364 -0
- taskq_py-0.1.0.dist-info/RECORD +172 -0
- taskq_py-0.1.0.dist-info/WHEEL +4 -0
- taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
- taskq_py-0.1.0.dist-info/licenses/LICENSE +21 -0
taskq/_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
|