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/__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