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/__init__.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""TaskQ — async-native, Postgres-backed background job library.
|
|
2
|
+
|
|
3
|
+
Canonical imports:
|
|
4
|
+
|
|
5
|
+
from taskq import actor, TaskQ, JobHandle, JobFailed, RetryPolicy
|
|
6
|
+
from taskq import cron, ScheduleHandle
|
|
7
|
+
from taskq.context import JobContext
|
|
8
|
+
from taskq.di import ProviderRegistry, Scope
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
|
|
13
|
+
from taskq.actor import ActorFn, ActorFnWithCtx, ActorHandler, ActorRef, actor
|
|
14
|
+
from taskq.backend._protocol import (
|
|
15
|
+
CancelPhase,
|
|
16
|
+
DstStrategy,
|
|
17
|
+
IdempotencyKey,
|
|
18
|
+
IdentityKey,
|
|
19
|
+
JobFilter,
|
|
20
|
+
JobId,
|
|
21
|
+
JobSortField,
|
|
22
|
+
QueueMode,
|
|
23
|
+
QueueName,
|
|
24
|
+
RateLimitBackend,
|
|
25
|
+
RetryKind,
|
|
26
|
+
ScheduleRecord,
|
|
27
|
+
)
|
|
28
|
+
from taskq.batch import BatchCompletionStatus, BatchHandle, EnqueueItem, wait_for_batch
|
|
29
|
+
from taskq.client import CancelResult, JobEvent, JobHandle, JobsClient, TaskQ
|
|
30
|
+
from taskq.client._enqueuer import SubJobEnqueuer
|
|
31
|
+
from taskq.context import JobContext
|
|
32
|
+
from taskq.cron import CronScheduleSpec, ScheduleHandle, cron
|
|
33
|
+
from taskq.exceptions import (
|
|
34
|
+
ActorConfigDriftError,
|
|
35
|
+
ActorConfigDriftList,
|
|
36
|
+
BackpressureError,
|
|
37
|
+
DependencyCycle,
|
|
38
|
+
DIError,
|
|
39
|
+
IllegalStateTransition,
|
|
40
|
+
JobFailed,
|
|
41
|
+
MaxPendingExceededError,
|
|
42
|
+
MissingProvider,
|
|
43
|
+
PartialBatchError,
|
|
44
|
+
PayloadValidationError,
|
|
45
|
+
ProgressTooLarge,
|
|
46
|
+
ReservationUnavailable,
|
|
47
|
+
ResultTooLarge,
|
|
48
|
+
ResultUnavailable,
|
|
49
|
+
RetryAfter,
|
|
50
|
+
SchemaNotMigratedError,
|
|
51
|
+
ScopeViolation,
|
|
52
|
+
SingletonCollisionError,
|
|
53
|
+
Snooze,
|
|
54
|
+
SubEnqueueError,
|
|
55
|
+
TaskQError,
|
|
56
|
+
WorkerOwnershipMismatch,
|
|
57
|
+
)
|
|
58
|
+
from taskq.obs import ErrorReporter, NullErrorReporter
|
|
59
|
+
from taskq.progress import ProgressEvent
|
|
60
|
+
from taskq.retry import (
|
|
61
|
+
Fail,
|
|
62
|
+
JobRetryState,
|
|
63
|
+
OnSuccess,
|
|
64
|
+
Retry,
|
|
65
|
+
RetryClassifier,
|
|
66
|
+
RetryClassifierHook,
|
|
67
|
+
RetryDecision,
|
|
68
|
+
RetryOverride,
|
|
69
|
+
RetryPolicy,
|
|
70
|
+
)
|
|
71
|
+
from taskq.scheduler import register_cron
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"ActorConfigDriftError",
|
|
75
|
+
"ActorConfigDriftList",
|
|
76
|
+
"ActorFn",
|
|
77
|
+
"ActorFnWithCtx",
|
|
78
|
+
"ActorHandler",
|
|
79
|
+
"ActorRef",
|
|
80
|
+
"BackpressureError",
|
|
81
|
+
"BatchCompletionStatus",
|
|
82
|
+
"BatchHandle",
|
|
83
|
+
"CancelPhase",
|
|
84
|
+
"CancelResult",
|
|
85
|
+
"CronScheduleSpec",
|
|
86
|
+
"DIError",
|
|
87
|
+
"DependencyCycle",
|
|
88
|
+
"DstStrategy",
|
|
89
|
+
"EnqueueItem",
|
|
90
|
+
"ErrorReporter",
|
|
91
|
+
"Fail",
|
|
92
|
+
"IdempotencyKey",
|
|
93
|
+
"IdentityKey",
|
|
94
|
+
"IllegalStateTransition",
|
|
95
|
+
"JobContext",
|
|
96
|
+
"JobEvent",
|
|
97
|
+
"JobFailed",
|
|
98
|
+
"JobFilter",
|
|
99
|
+
"JobHandle",
|
|
100
|
+
"JobId",
|
|
101
|
+
"JobRetryState",
|
|
102
|
+
"JobSortField",
|
|
103
|
+
"JobsClient",
|
|
104
|
+
"MaxPendingExceededError",
|
|
105
|
+
"MissingProvider",
|
|
106
|
+
"NullErrorReporter",
|
|
107
|
+
"OnSuccess",
|
|
108
|
+
"PartialBatchError",
|
|
109
|
+
"PayloadValidationError",
|
|
110
|
+
"ProgressEvent",
|
|
111
|
+
"ProgressTooLarge",
|
|
112
|
+
"QueueMode",
|
|
113
|
+
"QueueName",
|
|
114
|
+
"RateLimitBackend",
|
|
115
|
+
"ReservationUnavailable",
|
|
116
|
+
"ResultTooLarge",
|
|
117
|
+
"ResultUnavailable",
|
|
118
|
+
"Retry",
|
|
119
|
+
"RetryAfter",
|
|
120
|
+
"RetryClassifier",
|
|
121
|
+
"RetryClassifierHook",
|
|
122
|
+
"RetryDecision",
|
|
123
|
+
"RetryKind",
|
|
124
|
+
"RetryOverride",
|
|
125
|
+
"RetryPolicy",
|
|
126
|
+
"ScheduleHandle",
|
|
127
|
+
"ScheduleRecord",
|
|
128
|
+
"SchemaNotMigratedError",
|
|
129
|
+
"ScopeViolation",
|
|
130
|
+
"SingletonCollisionError",
|
|
131
|
+
"Snooze",
|
|
132
|
+
"SubEnqueueError",
|
|
133
|
+
"SubJobEnqueuer",
|
|
134
|
+
"TaskQ",
|
|
135
|
+
"TaskQError",
|
|
136
|
+
"WorkerOwnershipMismatch",
|
|
137
|
+
"__version__",
|
|
138
|
+
"actor",
|
|
139
|
+
"cron",
|
|
140
|
+
"register_cron",
|
|
141
|
+
"wait_for_batch",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
__version__ = importlib.metadata.version("taskq-py")
|
|
146
|
+
except importlib.metadata.PackageNotFoundError:
|
|
147
|
+
__version__ = "0.1.0"
|
taskq/_di/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Internal DI package — resolution engine and type vocabulary."""
|
|
2
|
+
|
|
3
|
+
from taskq._di.lifecycle import detect_factory_lifecycle, detect_lifecycle
|
|
4
|
+
from taskq._di.registry import ProviderRegistry
|
|
5
|
+
from taskq._di.scope import LifecycleDetectionWarning, Scope
|
|
6
|
+
from taskq._di.scopes import (
|
|
7
|
+
LoopScope,
|
|
8
|
+
ProcessScope,
|
|
9
|
+
ResolvedActorScope,
|
|
10
|
+
ScopeContainer,
|
|
11
|
+
ThreadScope,
|
|
12
|
+
build_actor_scope,
|
|
13
|
+
)
|
|
14
|
+
from taskq._di.types import (
|
|
15
|
+
Factory,
|
|
16
|
+
FactoryShape,
|
|
17
|
+
ProviderEntry,
|
|
18
|
+
ProviderLifecycle,
|
|
19
|
+
)
|
|
20
|
+
from taskq._di.types import (
|
|
21
|
+
ProviderRegistry as ProviderRegistryProtocol,
|
|
22
|
+
)
|
|
23
|
+
from taskq._di.types import (
|
|
24
|
+
ScopeContainer as ScopeContainerProtocol,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Factory",
|
|
29
|
+
"FactoryShape",
|
|
30
|
+
"LifecycleDetectionWarning",
|
|
31
|
+
"LoopScope",
|
|
32
|
+
"ProcessScope",
|
|
33
|
+
"ProviderEntry",
|
|
34
|
+
"ProviderLifecycle",
|
|
35
|
+
"ProviderRegistry",
|
|
36
|
+
"ProviderRegistryProtocol",
|
|
37
|
+
"ResolvedActorScope",
|
|
38
|
+
"Scope",
|
|
39
|
+
"ScopeContainer",
|
|
40
|
+
"ScopeContainerProtocol",
|
|
41
|
+
"ThreadScope",
|
|
42
|
+
"build_actor_scope",
|
|
43
|
+
"detect_factory_lifecycle",
|
|
44
|
+
"detect_lifecycle",
|
|
45
|
+
]
|
taskq/_di/_utils.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared DI internal helpers."""
|
|
2
|
+
|
|
3
|
+
from typing import get_origin
|
|
4
|
+
|
|
5
|
+
from taskq.context import JobContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _origin_is_job_context(annotation: object) -> bool: # pyright: ignore[reportUnusedFunction] — used by _validate.py and registry.py via import
|
|
9
|
+
"""Return ``True`` if ``annotation`` resolves to ``JobContext[...]``."""
|
|
10
|
+
origin = get_origin(annotation)
|
|
11
|
+
if origin is JobContext:
|
|
12
|
+
return True
|
|
13
|
+
return annotation is JobContext
|
taskq/_di/_validate.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Five-phase startup-validation algorithm.
|
|
2
|
+
|
|
3
|
+
Pure function — does not mutate any input. Raises ``MissingProvider`` /
|
|
4
|
+
``DependencyCycle`` / ``ScopeViolation`` .
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import (
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Annotated,
|
|
12
|
+
Any,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
from taskq._di._utils import (
|
|
21
|
+
_origin_is_job_context, # pyright: ignore[reportPrivateUsage] — internal helper shared within _di package; the _di prefix itself signals package-level privacy
|
|
22
|
+
)
|
|
23
|
+
from taskq._di.scope import LifecycleDetectionWarning, Scope
|
|
24
|
+
from taskq._di.types import ProviderEntry
|
|
25
|
+
from taskq.exceptions import (
|
|
26
|
+
DependencyCycle,
|
|
27
|
+
DIError,
|
|
28
|
+
MissingProvider,
|
|
29
|
+
ScopeViolation,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from taskq.actor import ActorRef
|
|
34
|
+
from taskq.ratelimit.registry import RateLimitRegistry
|
|
35
|
+
|
|
36
|
+
_log = structlog.get_logger("taskq._di.validate")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _qual(t: type) -> str:
|
|
40
|
+
"""Return the fully-qualified name of ``t`` for diagnostic purposes."""
|
|
41
|
+
return f"{t.__module__}.{t.__qualname__}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _collect_actor_edges(
|
|
45
|
+
actors: list["ActorRef[Any, Any]"],
|
|
46
|
+
) -> list[tuple[str, type, Scope | None]]:
|
|
47
|
+
"""Walk each actor's DI parameter annotations and return edges.
|
|
48
|
+
|
|
49
|
+
Returns ``(actor_name, dep_type, override_or_None)`` tuples.
|
|
50
|
+
``actor_name`` comes from ``ActorRef.name``; ``dep_type`` and
|
|
51
|
+
``override_or_None`` are extracted from the actor handler's
|
|
52
|
+
parameter annotations.
|
|
53
|
+
"""
|
|
54
|
+
edges: list[tuple[str, type, Scope | None]] = []
|
|
55
|
+
for actor in actors:
|
|
56
|
+
fn = actor.fn
|
|
57
|
+
try:
|
|
58
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
59
|
+
except NameError as err:
|
|
60
|
+
raise DIError(f"unresolvable annotation in {fn.__qualname__}: {err}") from err
|
|
61
|
+
|
|
62
|
+
sig = inspect.signature(fn)
|
|
63
|
+
for param_name, param in sig.parameters.items():
|
|
64
|
+
if param_name == "payload":
|
|
65
|
+
continue
|
|
66
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
annotation = hints.get(param_name)
|
|
70
|
+
if annotation is None:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if _origin_is_job_context(annotation) or param_name == "ctx":
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
unwrapped_type: type | None = None
|
|
77
|
+
override_scope: Scope | None = None
|
|
78
|
+
origin = get_origin(annotation)
|
|
79
|
+
|
|
80
|
+
if origin is Annotated:
|
|
81
|
+
args = get_args(annotation)
|
|
82
|
+
if args and isinstance(args[0], type):
|
|
83
|
+
unwrapped_type = args[0]
|
|
84
|
+
scopes_found: list[Scope] = []
|
|
85
|
+
for meta in args[1:]:
|
|
86
|
+
if isinstance(meta, Scope):
|
|
87
|
+
scopes_found.append(meta)
|
|
88
|
+
if len(scopes_found) > 1:
|
|
89
|
+
raise DIError(
|
|
90
|
+
f"parameter '{param_name}' has multiple Scope markers: "
|
|
91
|
+
f"{', '.join(s.name for s in scopes_found)}"
|
|
92
|
+
)
|
|
93
|
+
if scopes_found:
|
|
94
|
+
override_scope = scopes_found[0]
|
|
95
|
+
elif isinstance(annotation, type):
|
|
96
|
+
unwrapped_type = annotation
|
|
97
|
+
|
|
98
|
+
if unwrapped_type is None:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
edges.append((actor.name, unwrapped_type, override_scope))
|
|
102
|
+
return edges
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _emit_redundant_override_warnings( # pyright: ignore[reportUnusedFunction] — used by registry.py via import; the _di prefix signals package-level privacy
|
|
106
|
+
actors: list["ActorRef[Any, Any]"],
|
|
107
|
+
providers: dict[type, ProviderEntry[object]],
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Walk each actor's parameters and emit dual-signal for redundant Scope overrides.
|
|
110
|
+
|
|
111
|
+
Runs after phases 1-4 so it never preempts error reporting. A redundant
|
|
112
|
+
override is one where ``Annotated[T, Scope.X]`` matches the registered
|
|
113
|
+
default scope for ``T`` — the override has no effect.
|
|
114
|
+
"""
|
|
115
|
+
for actor in actors:
|
|
116
|
+
fn = actor.fn
|
|
117
|
+
try:
|
|
118
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
119
|
+
except NameError as err:
|
|
120
|
+
raise DIError(f"unresolvable annotation in {fn.__qualname__}: {err}") from err
|
|
121
|
+
|
|
122
|
+
sig = inspect.signature(fn)
|
|
123
|
+
for param_name, param in sig.parameters.items():
|
|
124
|
+
if param_name == "payload":
|
|
125
|
+
continue
|
|
126
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
annotation = hints.get(param_name)
|
|
130
|
+
if annotation is None:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
if _origin_is_job_context(annotation) or param_name == "ctx":
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
origin = get_origin(annotation)
|
|
137
|
+
if origin is not Annotated:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
args = get_args(annotation)
|
|
141
|
+
if not args:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
unwrapped: type | None = args[0] if isinstance(args[0], type) else None
|
|
145
|
+
scope_override: Scope | None = None
|
|
146
|
+
for meta in args[1:]:
|
|
147
|
+
if isinstance(meta, Scope):
|
|
148
|
+
scope_override = meta
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if scope_override is None:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if unwrapped is None or unwrapped not in providers:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
registered_default = providers[unwrapped].scope
|
|
158
|
+
if scope_override != registered_default:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
t = unwrapped
|
|
162
|
+
scope = scope_override
|
|
163
|
+
warnings.warn(
|
|
164
|
+
LifecycleDetectionWarning(
|
|
165
|
+
f"redundant Scope override on {actor.name}.{param_name}: "
|
|
166
|
+
f"Annotated[..., Scope.{scope.name}] matches the registered "
|
|
167
|
+
f"default for {_qual(t)}; the override has no effect."
|
|
168
|
+
),
|
|
169
|
+
stacklevel=3, # Why: three frames to skip — warn → _emit_redundant_override_warnings → validate() — so the warning points at validate()'s caller
|
|
170
|
+
)
|
|
171
|
+
_log.warning(
|
|
172
|
+
"redundant_scope_override",
|
|
173
|
+
actor=actor.name,
|
|
174
|
+
param=param_name,
|
|
175
|
+
type_name=_qual(t),
|
|
176
|
+
scope=scope.name,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_adjacency(
|
|
181
|
+
edges: list[tuple[type, type, Scope | None]],
|
|
182
|
+
) -> dict[type, list[type]]:
|
|
183
|
+
"""Build adjacency list from dependency edges keyed by provider type."""
|
|
184
|
+
adj: dict[type, list[type]] = {}
|
|
185
|
+
for provider_type, dep_type, _ in edges:
|
|
186
|
+
adj.setdefault(provider_type, []).append(dep_type)
|
|
187
|
+
if dep_type not in adj:
|
|
188
|
+
adj[dep_type] = []
|
|
189
|
+
return adj
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _detect_cycles(adjacency: dict[type, list[type]]) -> None:
|
|
193
|
+
"""Iterative DFS cycle detection over the provider graph.
|
|
194
|
+
|
|
195
|
+
Uses a list as the recursion stack so the cycle path can be
|
|
196
|
+
reconstructed by index. When DFS visits a node whose outgoing
|
|
197
|
+
edge points to a node already in the recursion stack, slice
|
|
198
|
+
the stack from that node's index and append it again to form
|
|
199
|
+
the cycle path.
|
|
200
|
+
"""
|
|
201
|
+
visited: set[type] = set()
|
|
202
|
+
|
|
203
|
+
for start in adjacency:
|
|
204
|
+
if start in visited:
|
|
205
|
+
continue
|
|
206
|
+
stack: list[type] = [start]
|
|
207
|
+
path_set: set[type] = {start}
|
|
208
|
+
children_idx: dict[type, int] = {}
|
|
209
|
+
children_list: dict[type, list[type]] = {}
|
|
210
|
+
|
|
211
|
+
while stack:
|
|
212
|
+
current = stack[-1]
|
|
213
|
+
if current not in children_list:
|
|
214
|
+
children_list[current] = adjacency.get(current, [])
|
|
215
|
+
children_idx[current] = 0
|
|
216
|
+
|
|
217
|
+
idx = children_idx[current]
|
|
218
|
+
neighbors = children_list[current]
|
|
219
|
+
|
|
220
|
+
if idx < len(neighbors):
|
|
221
|
+
neighbor = neighbors[idx]
|
|
222
|
+
children_idx[current] = idx + 1
|
|
223
|
+
if neighbor in path_set:
|
|
224
|
+
cycle_start = stack.index(neighbor)
|
|
225
|
+
cycle_path = [_qual(t) for t in stack[cycle_start:]] + [_qual(neighbor)]
|
|
226
|
+
raise DependencyCycle(cycle_path=cycle_path)
|
|
227
|
+
if neighbor not in visited:
|
|
228
|
+
stack.append(neighbor)
|
|
229
|
+
path_set.add(neighbor)
|
|
230
|
+
else:
|
|
231
|
+
stack.pop()
|
|
232
|
+
path_set.discard(current)
|
|
233
|
+
visited.add(current)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _post_order_dfs(
|
|
237
|
+
node: type,
|
|
238
|
+
adjacency: dict[type, list[type]],
|
|
239
|
+
visited: set[type],
|
|
240
|
+
post_order: list[type],
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Post-order DFS traversal for topological sort."""
|
|
243
|
+
if node in visited:
|
|
244
|
+
return
|
|
245
|
+
visited.add(node)
|
|
246
|
+
for neighbor in adjacency.get(node, []):
|
|
247
|
+
_post_order_dfs(neighbor, adjacency, visited, post_order)
|
|
248
|
+
post_order.append(node)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _actor_deps_at_scope(
|
|
252
|
+
actor: "ActorRef[Any, Any]",
|
|
253
|
+
scope: Scope,
|
|
254
|
+
providers: dict[type, ProviderEntry[object]],
|
|
255
|
+
) -> list[type]:
|
|
256
|
+
"""Return the actor's DI parameter types whose effective scope matches ``scope``."""
|
|
257
|
+
fn = actor.fn
|
|
258
|
+
try:
|
|
259
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
260
|
+
except NameError as err:
|
|
261
|
+
raise DIError(f"unresolvable annotation in {fn.__qualname__}: {err}") from err
|
|
262
|
+
|
|
263
|
+
sig = inspect.signature(fn)
|
|
264
|
+
result: list[type] = []
|
|
265
|
+
|
|
266
|
+
for param_name, param in sig.parameters.items():
|
|
267
|
+
if param_name == "payload":
|
|
268
|
+
continue
|
|
269
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
annotation = hints.get(param_name)
|
|
273
|
+
if annotation is None:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
if _origin_is_job_context(annotation) or param_name == "ctx":
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
unwrapped_type: type | None = None
|
|
280
|
+
override_scope: Scope | None = None
|
|
281
|
+
origin = get_origin(annotation)
|
|
282
|
+
|
|
283
|
+
if origin is Annotated:
|
|
284
|
+
args = get_args(annotation)
|
|
285
|
+
if args and isinstance(args[0], type):
|
|
286
|
+
unwrapped_type = args[0]
|
|
287
|
+
for meta in args[1:]:
|
|
288
|
+
if isinstance(meta, Scope):
|
|
289
|
+
override_scope = meta
|
|
290
|
+
break
|
|
291
|
+
elif isinstance(annotation, type):
|
|
292
|
+
unwrapped_type = annotation
|
|
293
|
+
|
|
294
|
+
if unwrapped_type is None or unwrapped_type not in providers:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
effective_scope = override_scope or providers[unwrapped_type].scope
|
|
298
|
+
if effective_scope == scope:
|
|
299
|
+
result.append(unwrapped_type)
|
|
300
|
+
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _warn_sync_actor_loop_deps(
|
|
305
|
+
actors: list["ActorRef[Any, Any]"],
|
|
306
|
+
providers: dict[type, ProviderEntry[object]],
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Warn when a sync actor declares a LOOP-scoped dependency.
|
|
309
|
+
|
|
310
|
+
LOOP-scoped providers (e.g. an ``asyncpg.Connection``) are not
|
|
311
|
+
thread-safe. Sync actors run via ``asyncio.to_thread()``, so using a
|
|
312
|
+
LOOP-scoped dependency from one is a latent thread-safety bug. This
|
|
313
|
+
is advisory only (does not raise) — see docs/guides/actors.md#sync-actors.
|
|
314
|
+
"""
|
|
315
|
+
for actor in actors:
|
|
316
|
+
if not actor.is_sync:
|
|
317
|
+
continue
|
|
318
|
+
loop_deps = _actor_deps_at_scope(actor, Scope.LOOP, providers)
|
|
319
|
+
for dep_type in loop_deps:
|
|
320
|
+
_log.warning(
|
|
321
|
+
"sync_actor_loop_scoped_dependency",
|
|
322
|
+
actor=actor.name,
|
|
323
|
+
type_name=_qual(dep_type),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _topo_sort_for_actor(
|
|
328
|
+
actor: "ActorRef[Any, Any]",
|
|
329
|
+
scope: Scope,
|
|
330
|
+
edges: list[tuple[type, type, Scope | None]],
|
|
331
|
+
providers: dict[type, ProviderEntry[object]],
|
|
332
|
+
) -> list[type]:
|
|
333
|
+
"""Compute the dependency-first resolution plan for ``actor`` at ``scope``.
|
|
334
|
+
|
|
335
|
+
Walks the actor's transitive dep closure rooted at parameters whose
|
|
336
|
+
effective scope is ``scope``, using post-order DFS on depends-on
|
|
337
|
+
edges, which directly produces dependency-first order (leaf deps
|
|
338
|
+
first, dependents last) without reversal.
|
|
339
|
+
"""
|
|
340
|
+
root_deps = _actor_deps_at_scope(actor, scope, providers)
|
|
341
|
+
if not root_deps:
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
adjacency: dict[type, list[type]] = {}
|
|
345
|
+
for provider_type, dep_type, _ in edges:
|
|
346
|
+
if provider_type in providers:
|
|
347
|
+
adjacency.setdefault(provider_type, []).append(dep_type)
|
|
348
|
+
|
|
349
|
+
visited: set[type] = set()
|
|
350
|
+
post_order: list[type] = []
|
|
351
|
+
|
|
352
|
+
for root in root_deps:
|
|
353
|
+
_post_order_dfs(root, adjacency, visited, post_order)
|
|
354
|
+
|
|
355
|
+
# Why: post-order DFS on "depends-on" edges already produces
|
|
356
|
+
# dependency-first order (leaf deps first, dependents last);
|
|
357
|
+
# no reversal needed — the "reverse" instruction
|
|
358
|
+
# assumes edges point from deps to dependents, but our adjacency
|
|
359
|
+
# has edges from dependents to dependencies.
|
|
360
|
+
return post_order
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def run_validation(
|
|
364
|
+
providers: dict[type, ProviderEntry[object]],
|
|
365
|
+
dep_edges: list[tuple[type, type, Scope | None]],
|
|
366
|
+
actors: list["ActorRef[Any, Any]"] | None,
|
|
367
|
+
rate_limit_registry: "RateLimitRegistry | None" = None,
|
|
368
|
+
) -> dict[tuple[str, Scope], list[type]]:
|
|
369
|
+
"""Run the five-phase algorithm. Return the plan cache.
|
|
370
|
+
|
|
371
|
+
Raises MissingProvider / DependencyCycle / ScopeViolation.
|
|
372
|
+
Pure function — does not mutate any input.
|
|
373
|
+
Never invokes a factory function, calls a resolver, or performs
|
|
374
|
+
await — the entire algorithm is synchronous introspection on
|
|
375
|
+
registration metadata only.
|
|
376
|
+
|
|
377
|
+
When ``rate_limit_registry`` is provided, an additional phase
|
|
378
|
+
after the DI-edge walk checks each actor's ``rate_limits`` and
|
|
379
|
+
``reservations`` string lists against the registry's dicts.
|
|
380
|
+
Unknown names raise ``MissingProvider`` at startup .
|
|
381
|
+
"""
|
|
382
|
+
actor_edges: list[tuple[str, type, Scope | None]] = []
|
|
383
|
+
if actors is not None:
|
|
384
|
+
actor_edges = _collect_actor_edges(actors)
|
|
385
|
+
|
|
386
|
+
# Phase 2 — MissingProvider check
|
|
387
|
+
for provider_type, dep_type, _override in dep_edges:
|
|
388
|
+
if dep_type not in providers:
|
|
389
|
+
raise MissingProvider(
|
|
390
|
+
type_name=_qual(dep_type),
|
|
391
|
+
required_by=provider_type.__qualname__,
|
|
392
|
+
)
|
|
393
|
+
for actor_name, dep_type, _override in actor_edges:
|
|
394
|
+
if dep_type not in providers:
|
|
395
|
+
raise MissingProvider(
|
|
396
|
+
type_name=_qual(dep_type),
|
|
397
|
+
required_by=actor_name,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Phase 2b — Rate-limit / reservation name check
|
|
401
|
+
if rate_limit_registry is not None and actors is not None:
|
|
402
|
+
rl_names = rate_limit_registry.rate_limits
|
|
403
|
+
res_names = rate_limit_registry.reservations
|
|
404
|
+
for actor in actors:
|
|
405
|
+
for rl_name in actor.rate_limits:
|
|
406
|
+
if rl_name not in rl_names:
|
|
407
|
+
raise MissingProvider(
|
|
408
|
+
type_name="RateLimit",
|
|
409
|
+
required_by=f"actor:{actor.name}:rate_limits:{rl_name}",
|
|
410
|
+
)
|
|
411
|
+
for res_name in actor.reservations:
|
|
412
|
+
if res_name not in res_names:
|
|
413
|
+
raise MissingProvider(
|
|
414
|
+
type_name="ConcurrencyReservation",
|
|
415
|
+
required_by=f"actor:{actor.name}:reservations:{res_name}",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Phase 3 — DependencyCycle detection
|
|
419
|
+
adjacency = _build_adjacency(dep_edges)
|
|
420
|
+
_detect_cycles(adjacency)
|
|
421
|
+
|
|
422
|
+
# Phase 4 — ScopeViolation direction check
|
|
423
|
+
for provider_type, dep_type, override in dep_edges:
|
|
424
|
+
provider_scope = providers[provider_type].scope
|
|
425
|
+
effective_dep_scope = override or providers[dep_type].scope
|
|
426
|
+
# Why: direction rule — effective_dep_scope.value <=
|
|
427
|
+
# provider_scope.value is valid (narrower dep is safe)
|
|
428
|
+
if effective_dep_scope.value > provider_scope.value:
|
|
429
|
+
raise ScopeViolation(
|
|
430
|
+
from_scope=provider_scope,
|
|
431
|
+
to_scope=effective_dep_scope,
|
|
432
|
+
type_name=_qual(dep_type),
|
|
433
|
+
dependent=_qual(provider_type),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Why: actors run inside build_actor_scope which creates a
|
|
437
|
+
# per-invocation TRANSIENT container — the actor body is
|
|
438
|
+
# TRANSIENT-scoped by construction. Direction rule
|
|
439
|
+
# applied uniformly: no actor→provider edge can ever raise
|
|
440
|
+
# ScopeViolation because TRANSIENT (value 3) is the narrowest
|
|
441
|
+
# scope (DoD item 5).
|
|
442
|
+
|
|
443
|
+
# Phase 4b — sync actor + LOOP-scoped dependency advisory warning
|
|
444
|
+
if actors is not None:
|
|
445
|
+
_warn_sync_actor_loop_deps(actors, providers)
|
|
446
|
+
|
|
447
|
+
# Phase 5 — topological sort and plan cache
|
|
448
|
+
plan_cache: dict[tuple[str, Scope], list[type]] = {}
|
|
449
|
+
if actors is not None:
|
|
450
|
+
for actor in actors:
|
|
451
|
+
for scope in (Scope.PROCESS, Scope.THREAD, Scope.LOOP, Scope.TRANSIENT):
|
|
452
|
+
plan = _topo_sort_for_actor(actor, scope, dep_edges, providers)
|
|
453
|
+
if plan:
|
|
454
|
+
plan_cache[(actor.name, scope)] = plan
|
|
455
|
+
|
|
456
|
+
return plan_cache
|