python-ddd-framework 0.4.0__py3-none-any.whl → 0.6.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 (104) hide show
  1. python_ddd_framework/application/runtime.py +46 -19
  2. python_ddd_framework/application_services/bindings.py +0 -64
  3. python_ddd_framework/application_services/dispatcher.py +1 -1
  4. python_ddd_framework/application_services/invocation.py +7 -500
  5. python_ddd_framework/application_services/module.py +16 -5
  6. python_ddd_framework/authorization/module.py +1 -1
  7. python_ddd_framework/background_execution/local.py +20 -1
  8. python_ddd_framework/background_execution/processes.py +11 -1
  9. python_ddd_framework/background_jobs/execution.py +1 -1
  10. python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
  11. python_ddd_framework/background_workers/execution.py +1 -1
  12. python_ddd_framework/background_workers/runtime.py +11 -2
  13. python_ddd_framework/caching/unit_of_work.py +2 -4
  14. python_ddd_framework/cli/project.py +11 -10
  15. python_ddd_framework/developer_kit/generation.py +46 -36
  16. python_ddd_framework/developer_kit/publication.py +442 -0
  17. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
  18. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
  19. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
  20. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
  21. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
  22. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
  23. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
  24. python_ddd_framework/diagnostics/source.py +14 -1
  25. python_ddd_framework/domain/aggregates.py +5 -12
  26. python_ddd_framework/domain/event_values.py +124 -0
  27. python_ddd_framework/events/runtime.py +2 -7
  28. python_ddd_framework/events/unit_of_work.py +1 -4
  29. python_ddd_framework/fastapi/action.py +2 -1
  30. python_ddd_framework/fastapi/adapter.py +11 -117
  31. python_ddd_framework/fastapi/application_services.py +17 -402
  32. python_ddd_framework/fastapi/error_handlers.py +128 -0
  33. python_ddd_framework/fastapi/realtime/runtime.py +1 -1
  34. python_ddd_framework/fastapi/request_context.py +2 -5
  35. python_ddd_framework/fastapi/routing.py +4 -10
  36. python_ddd_framework/fastapi/service_endpoints.py +182 -0
  37. python_ddd_framework/fastapi/service_routes.py +237 -0
  38. python_ddd_framework/fastapi/transfer.py +1 -1
  39. python_ddd_framework/hosted_services/bridge.py +76 -21
  40. python_ddd_framework/hosted_services/contracts.py +5 -1
  41. python_ddd_framework/hosted_services/runtime.py +71 -17
  42. python_ddd_framework/identity/__init__.py +21 -13
  43. python_ddd_framework/identity/application.py +12 -66
  44. python_ddd_framework/identity/contracts.py +38 -411
  45. python_ddd_framework/identity/domain_rules.py +17 -0
  46. python_ddd_framework/identity/dtos.py +152 -0
  47. python_ddd_framework/identity/extensions.py +9 -0
  48. python_ddd_framework/identity/management.py +28 -25
  49. python_ddd_framework/identity/models.py +86 -0
  50. python_ddd_framework/identity/module.py +3 -4
  51. python_ddd_framework/identity/options.py +54 -0
  52. python_ddd_framework/identity/seeding.py +59 -0
  53. python_ddd_framework/identity/services.py +1 -1
  54. python_ddd_framework/identity/sqlalchemy/module.py +9 -6
  55. python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
  56. python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
  57. python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
  58. python_ddd_framework/identity/stores.py +120 -0
  59. python_ddd_framework/identity/tokens.py +1 -1
  60. python_ddd_framework/invocation/callables.py +1 -1
  61. python_ddd_framework/invocation/dispatcher.py +1 -1
  62. python_ddd_framework/invocation/entrypoints.py +2 -1
  63. python_ddd_framework/invocation/function_runtime.py +2 -2
  64. python_ddd_framework/invocation/interception.py +1 -1
  65. python_ddd_framework/invocation/managed_proxy.py +2 -1
  66. python_ddd_framework/invocation/managed_services.py +8 -14
  67. python_ddd_framework/invocation/methods.py +2 -7
  68. python_ddd_framework/invocation/module.py +1 -1
  69. python_ddd_framework/invocation/runtime.py +483 -0
  70. python_ddd_framework/invocation/scopes.py +91 -0
  71. python_ddd_framework/lifecycle/participants.py +4 -0
  72. python_ddd_framework/messaging/channel.py +35 -8
  73. python_ddd_framework/messaging/contracts.py +9 -0
  74. python_ddd_framework/messaging/module.py +13 -0
  75. python_ddd_framework/messaging/runtime.py +17 -2
  76. python_ddd_framework/modularity/discovery.py +2 -7
  77. python_ddd_framework/modularity/graph.py +2 -8
  78. python_ddd_framework/observability/logging.py +1 -1
  79. python_ddd_framework/observability/tracing.py +1 -1
  80. python_ddd_framework/redis/cache.py +2 -0
  81. python_ddd_framework/redis/notification_runtime.py +1 -1
  82. python_ddd_framework/services/arbitration.py +1 -20
  83. python_ddd_framework/services/composition.py +71 -2
  84. python_ddd_framework/services/provider.py +23 -1
  85. python_ddd_framework/services/relocation.py +96 -0
  86. python_ddd_framework/settings/refresh.py +1 -1
  87. python_ddd_framework/settings/sqlalchemy/store.py +1 -1
  88. python_ddd_framework/sqlalchemy/metadata.py +11 -413
  89. python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
  90. python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
  91. python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
  92. python_ddd_framework/sqlalchemy/migration.py +2 -1
  93. python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
  94. python_ddd_framework/sqlalchemy/module.py +2 -1
  95. python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
  96. python_ddd_framework/unit_of_work/contracts.py +68 -7
  97. python_ddd_framework/unit_of_work/manager.py +4 -7
  98. python_ddd_framework/unit_of_work/module.py +1 -1
  99. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
  100. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
  101. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
  102. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
  103. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
  104. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
@@ -1,500 +1,14 @@
1
- """Application-local admission、调用身份、REQUEST 入场与现有 ACTION/UoW owner。"""
1
+ """应用服务 Contract 的 REQUEST 代理入口;准入与 scope 清理由 Invocation 拥有。"""
2
2
 
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import logging
7
- from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
8
- from contextlib import asynccontextmanager, contextmanager
9
- from contextvars import ContextVar, Token
10
- from dataclasses import dataclass
11
- from typing import TYPE_CHECKING, cast
12
- from uuid import UUID, uuid4
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import cast
13
5
 
14
6
  from dishka import AsyncContainer, Scope
15
7
 
16
8
  from ..authorization import CurrentUser
17
- from ..invocation import ApplicationInvocationContext
18
- from ..invocation.contracts import _is_safe_correlation_id
19
- from ..lifecycle import ApplicationState, RuntimeParticipant, ShutdownReason
20
- from ..unit_of_work import UnitOfWork, UnitOfWorkManager
21
- from ..unit_of_work.options import _DEFAULT_CONNECTION_NAME, UnitOfWorkDeclaration
22
- from .catalog import (
23
- ApplicationServiceCatalog,
24
- )
25
- from .errors import (
26
- ApplicationInvocationCleanupError,
27
- ApplicationInvocationRejectedError,
28
- CrossTaskInvocationError,
29
- NestedConnectionError,
30
- )
31
-
32
- _LOGGER = logging.getLogger(__name__)
33
-
34
- if TYPE_CHECKING:
35
- from opentelemetry.trace import Tracer
36
-
37
-
38
- @dataclass(slots=True)
39
- class _InvocationState:
40
- context: ApplicationInvocationContext
41
- owner_task: asyncio.Task[object]
42
- action_container: AsyncContainer
43
-
44
-
45
- @dataclass(frozen=True, slots=True)
46
- class _CallerContext:
47
- current_user: CurrentUser
48
- correlation_id: str
49
- owner_task: asyncio.Task[object]
50
- transferable: bool = False
51
-
52
-
53
- @dataclass(frozen=True, slots=True)
54
- class _OuterInvocationLease:
55
- owner_task: asyncio.Task[object]
56
- allows_invocation: bool
57
-
58
-
59
- @dataclass(frozen=True, slots=True)
60
- class _CallScope:
61
- container: AsyncContainer
62
- owner_task: asyncio.Task[object]
63
-
64
-
65
- @dataclass(eq=False, slots=True)
66
- class _InvocationReservation:
67
- """只携带准入事实,不携带 caller、DI scope 或事务;由接受工作的 owner 收口。"""
68
-
69
- runtime: _InvocationRuntime
70
- executing: bool = False
71
-
72
-
73
- class _InvocationRuntime:
74
- """每个 Application 独占 admission 与 ContextVar,禁止进程级 current app。"""
75
-
76
- __slots__ = (
77
- "_active",
78
- "_ambient",
79
- "_application_id",
80
- "_authenticating",
81
- "_call_scope",
82
- "_caller",
83
- "_condition",
84
- "_http_method",
85
- "_is_accepting",
86
- "_lease",
87
- "_reservations",
88
- "_state_getter",
89
- "tracer",
90
- )
91
-
92
- def __init__(
93
- self,
94
- *,
95
- application_id: UUID,
96
- state_getter: Callable[[], ApplicationState],
97
- ) -> None:
98
- self._application_id = application_id
99
- self._authenticating: ContextVar[bool] = ContextVar(
100
- f"python_ddd_framework_authenticating_{application_id}", default=False
101
- )
102
- self._http_method: ContextVar[str | None] = ContextVar(
103
- f"python_ddd_framework_http_method_{application_id}", default=None
104
- )
105
- self.tracer: Tracer | None = None
106
- self._state_getter = state_getter
107
- self._ambient: ContextVar[_InvocationState | None] = ContextVar(
108
- f"python_ddd_framework_invocation_{application_id}",
109
- default=None,
110
- )
111
- self._caller: ContextVar[_CallerContext | None] = ContextVar(
112
- f"python_ddd_framework_caller_{application_id}",
113
- default=None,
114
- )
115
- self._lease: ContextVar[_OuterInvocationLease | None] = ContextVar(
116
- f"python_ddd_framework_invocation_lease_{application_id}",
117
- default=None,
118
- )
119
- self._condition = asyncio.Condition()
120
- self._is_accepting = False
121
- self._active = 0
122
- self._reservations: set[_InvocationReservation] = set()
123
- self._call_scope: ContextVar[_CallScope | None] = ContextVar(
124
- f"python_ddd_framework_call_scope_{application_id}",
125
- default=None,
126
- )
127
-
128
- @contextmanager
129
- def call_scope(self, container: AsyncContainer) -> Iterator[None]:
130
- token = self._call_scope.set(_CallScope(container, _current_task()))
131
- try:
132
- yield
133
- finally:
134
- self._call_scope.reset(token)
135
-
136
- def current_call_scope(self) -> AsyncContainer | None:
137
- scope = self._call_scope.get()
138
- if scope is None:
139
- return None
140
- if scope.owner_task is not _current_task():
141
- raise CrossTaskInvocationError
142
- return scope.container
143
-
144
- def http_method(self) -> str | None:
145
- return self._http_method.get()
146
-
147
- @contextmanager
148
- def http_scope(self, method: str) -> Iterator[None]:
149
- token = self._http_method.set(method)
150
- try:
151
- yield
152
- finally:
153
- self._http_method.reset(token)
154
-
155
- @contextmanager
156
- def detached_execution(self, container: AsyncContainer) -> Iterator[None]:
157
- token = self._ambient.set(None)
158
- try:
159
- with self.call_scope(container):
160
- yield
161
- finally:
162
- self._ambient.reset(token)
163
-
164
- @property
165
- def application_id(self) -> UUID:
166
- return self._application_id
167
-
168
- def start_accepting(self) -> None:
169
- self._is_accepting = True
170
-
171
- def runtime_participant(self) -> RuntimeParticipant:
172
- async def start() -> None:
173
- self.start_accepting()
174
-
175
- async def stop(reason: ShutdownReason) -> tuple[BaseException, ...]:
176
- self._is_accepting = False
177
- return ()
178
-
179
- # 未 start 的 COMPOSED HTTP 也可持有许可;所有消费者停止后、容器关闭前统一排空。
180
- return RuntimeParticipant(start=start, stop=stop, drain=self.stop_accepting_and_wait)
181
-
182
- async def stop_accepting_and_wait(self) -> None:
183
- async with self._condition:
184
- self._is_accepting = False
185
- await self._condition.wait_for(lambda: self._active == 0)
186
-
187
- def reserve(self) -> _InvocationReservation:
188
- # 与 loop 上的接受/关闭无 await 地线性化;等待执行不打开 REQUEST/ACTION。
189
- self.require_accepting_work()
190
- reservation = _InvocationReservation(self)
191
- self._reservations.add(reservation)
192
- self._active += 1
193
- return reservation
194
-
195
- def require_accepting_work(self) -> None:
196
- _current_task()
197
- if not self._is_accepting or self._state_getter() not in (
198
- ApplicationState.STARTING,
199
- ApplicationState.RUNNING,
200
- ):
201
- raise ApplicationInvocationRejectedError(reason="Application is not accepting work")
202
-
203
- async def discard(self, reservation: _InvocationReservation) -> None:
204
- if reservation.runtime is not self or reservation not in self._reservations:
205
- raise ApplicationInvocationRejectedError(reason="Invalid or released reservation")
206
- async with self._condition:
207
- self._reservations.remove(reservation)
208
- self._active -= 1
209
- if self._active == 0:
210
- self._condition.notify_all()
211
-
212
- @asynccontextmanager
213
- async def reserved_lease(self, reservation: _InvocationReservation) -> AsyncIterator[None]:
214
- if (
215
- reservation.runtime is not self
216
- or reservation not in self._reservations
217
- or reservation.executing
218
- or self._lease.get() is not None
219
- or self.current() is not None
220
- or self.current_call_scope() is not None
221
- ):
222
- raise ApplicationInvocationRejectedError(reason="Invalid reservation execution owner")
223
- reservation.executing = True
224
- token = self._lease.set(_OuterInvocationLease(_current_task(), True))
225
- try:
226
- yield
227
- finally:
228
- self._lease.reset(token)
229
- await self.discard(reservation)
230
-
231
- @asynccontextmanager
232
- async def outer_lease(self, *, allow_composed_request: bool = False) -> AsyncIterator[None]:
233
- owner_task = _current_task()
234
- inherited = self._lease.get()
235
- if inherited is not None:
236
- if inherited.owner_task is not owner_task:
237
- # asyncio.create_task 会复制 ContextVar;child task 不得继承 parent admission。
238
- raise CrossTaskInvocationError
239
- if not inherited.allows_invocation and not allow_composed_request:
240
- raise ApplicationInvocationRejectedError(
241
- reason=(
242
- "Application must be running and accepting invocations, got "
243
- f"{self._state_getter().value}"
244
- )
245
- )
246
- yield
247
- return
248
-
249
- async with self._condition:
250
- state = self._state_getter()
251
- # HostedService.start 可调用业务入口,但全部后台启动成功前 readiness 仍为 false。
252
- allows_invocation = self._is_accepting and state in (
253
- ApplicationState.STARTING,
254
- ApplicationState.RUNNING,
255
- )
256
- allows_composed_request = allow_composed_request and state is ApplicationState.COMPOSED
257
- if not allows_invocation and not allows_composed_request:
258
- raise ApplicationInvocationRejectedError(
259
- reason=(
260
- f"Application must be running and accepting invocations, got {state.value}"
261
- )
262
- )
263
- self._active += 1
264
- token = self._lease.set(_OuterInvocationLease(owner_task, allows_invocation))
265
- try:
266
- yield
267
- finally:
268
- self._lease.reset(token)
269
- async with self._condition:
270
- assert self._active > 0
271
- self._active -= 1
272
- if self._active == 0:
273
- self._condition.notify_all()
274
-
275
- def current(self) -> _InvocationState | None:
276
- return self._ambient.get()
277
-
278
- def bind(self, state: _InvocationState) -> Token[_InvocationState | None]:
279
- return self._ambient.set(state)
280
-
281
- def reset(self, token: Token[_InvocationState | None]) -> None:
282
- self._ambient.reset(token)
283
-
284
- def current_user(self) -> CurrentUser:
285
- if self._authenticating.get():
286
- raise ApplicationInvocationRejectedError(
287
- reason="authentication cannot depend on the final CurrentUser"
288
- )
289
- caller = self._caller.get()
290
- if caller is None:
291
- return CurrentUser.anonymous()
292
- if not caller.transferable and caller.owner_task is not _current_task():
293
- raise CrossTaskInvocationError
294
- return caller.current_user
295
-
296
- def correlation_id(self) -> str | None:
297
- caller = self._caller.get()
298
- if caller is None:
299
- return None
300
- if not caller.transferable and caller.owner_task is not _current_task():
301
- raise CrossTaskInvocationError
302
- return caller.correlation_id
303
-
304
- def diagnostic_correlation_id(self) -> str | None:
305
- # 日志允许只读关联复制到线程的上下文;这不授予 child task/线程调用业务的 lease。
306
- caller = self._caller.get()
307
- return None if caller is None else caller.correlation_id
308
-
309
- def bind_caller(
310
- self,
311
- current_user: CurrentUser,
312
- correlation_id: str,
313
- ) -> Token[_CallerContext | None]:
314
- if not isinstance(current_user, CurrentUser):
315
- raise TypeError("current_user must be CurrentUser")
316
- if not _is_safe_correlation_id(correlation_id):
317
- raise ValueError("correlation_id must be a safe non-empty identifier")
318
- return self._caller.set(
319
- _CallerContext(
320
- current_user=current_user,
321
- correlation_id=correlation_id,
322
- owner_task=_current_task(),
323
- )
324
- )
325
-
326
- def reset_caller(self, token: Token[_CallerContext | None]) -> None:
327
- self._caller.reset(token)
328
-
329
- @contextmanager
330
- def authenticating(self) -> Iterator[None]:
331
- token = self._authenticating.set(True)
332
- try:
333
- yield
334
- finally:
335
- self._authenticating.reset(token)
336
-
337
- @contextmanager
338
- def trusted_http_task(
339
- self, container: AsyncContainer, user: CurrentUser, correlation_id: str, method: str
340
- ) -> Iterator[None]:
341
- # 只有 adapter 的受监督入口可交接许可;普通 create_task 仍受原有 task guard 约束。
342
- lease = self._lease.get()
343
- if lease is None or self.current() is not None:
344
- raise CrossTaskInvocationError
345
- task = _current_task()
346
- lease_token = self._lease.set(_OuterInvocationLease(task, lease.allows_invocation))
347
- caller_token = self._caller.set(_CallerContext(user, correlation_id, task, True))
348
- try:
349
- with self.call_scope(container), self.http_scope(method):
350
- yield
351
- finally:
352
- self._caller.reset(caller_token)
353
- self._lease.reset(lease_token)
354
-
355
- def context_for_action(self, container: AsyncContainer) -> ApplicationInvocationContext:
356
- """由原生 ACTION factory 缓存上下文,DI 与调用管线共享同一身份。"""
357
- # Dishka 1.10 的公开 context 属性已弃用;这里只读取原生 scope 的输入,
358
- # 不写 context/cache,也不复制容器的解析或缓存。第三方字段访问集中于此。
359
- native_context = container._context
360
- if native_context is None or ApplicationInvocationContext not in native_context:
361
- return self._new_context(_DEFAULT_CONNECTION_NAME)
362
- supplied: object = native_context[ApplicationInvocationContext]
363
- if not isinstance(supplied, ApplicationInvocationContext):
364
- raise ApplicationInvocationRejectedError(reason="invalid ACTION invocation context")
365
- if supplied.application_id != self.application_id:
366
- raise ApplicationInvocationRejectedError(
367
- reason="ACTION invocation context belongs to another Application"
368
- )
369
- return supplied
370
-
371
- def _new_context(self, connection_name: str) -> ApplicationInvocationContext:
372
- return ApplicationInvocationContext(
373
- application_id=self.application_id,
374
- invocation_id=uuid4(),
375
- parent_invocation_id=None,
376
- connection_name=connection_name,
377
- )
378
-
379
- async def run_action(
380
- self,
381
- container: AsyncContainer,
382
- *,
383
- connection_name: str,
384
- callback: Callable[[AsyncContainer], Awaitable[object]],
385
- declaration: UnitOfWorkDeclaration,
386
- method_name: str = "execute",
387
- unit_of_work_enabled: bool = True,
388
- ) -> object:
389
- """执行 scope 独立于数据库 UoW;自动边界只完成自己创建或加入的工作。"""
390
- context = (
391
- await container.get(ApplicationInvocationContext)
392
- if container.scope is Scope.ACTION
393
- else self._new_context(connection_name)
394
- )
395
- if context.connection_name != connection_name:
396
- raise NestedConnectionError(outer=context.connection_name, requested=connection_name)
397
- work: UnitOfWork | None = None
398
- async with _managed_action(
399
- container, context, committed=lambda: work is not None and work.committed
400
- ) as action:
401
- state = _InvocationState(context, _current_task(), action)
402
- token = self.bind(state)
403
- try:
404
- if not unit_of_work_enabled:
405
- return await callback(action)
406
- manager = await action.get(UnitOfWorkManager)
407
- if declaration.disabled:
408
- manager._check_current(connection_name)
409
- return await callback(action)
410
- options = manager._resolve_options(connection_name, declaration, method_name)
411
- async with manager._begin(options) as work:
412
- result = await callback(action)
413
- await work.complete()
414
- return result
415
- finally:
416
- self.reset(token)
417
-
418
-
419
- @asynccontextmanager
420
- async def _managed_request(parent: AsyncContainer) -> AsyncIterator[AsyncContainer]:
421
- """排空入口自己创建的 REQUEST;不推断业务事务是否已经提交。"""
422
- request = parent(scope=Scope.REQUEST)
423
- primary: BaseException | None = None
424
- try:
425
- yield request
426
- except BaseException as error:
427
- primary = error
428
- primary, failures = await _close_action(request, primary)
429
- if failures:
430
- # REQUEST 无事务 owner;保留业务原异常及已有 cause,不伪造 committed=False。
431
- inherited = (primary.__cause__,) if primary is not None and primary.__cause__ else ()
432
- cleanup = BaseExceptionGroup("REQUEST cleanup failed", (*inherited, *failures))
433
- if primary is not None:
434
- raise primary from cleanup
435
- raise cleanup
436
- if primary is not None:
437
- raise primary
438
-
439
-
440
- @asynccontextmanager
441
- async def _managed_action(
442
- parent: AsyncContainer,
443
- context: ApplicationInvocationContext,
444
- *,
445
- committed: Callable[[], bool],
446
- ) -> AsyncIterator[AsyncContainer]:
447
- if parent.scope is Scope.ACTION:
448
- # 普通服务可能已经由调用方的 ACTION 解析;资源仍由该 scope 的 owner 关闭。
449
- yield parent
450
- return
451
- action = parent(context={ApplicationInvocationContext: context}, scope=Scope.ACTION)
452
- primary: BaseException | None = None
453
- try:
454
- yield action
455
- except BaseException as error:
456
- primary = error
457
- primary, failures = await _close_action(action, primary)
458
- _raise_scope_failure(primary, failures, committed())
459
-
460
-
461
- async def _close_action(
462
- action: AsyncContainer, primary: BaseException | None
463
- ) -> tuple[BaseException | None, tuple[BaseException, ...]]:
464
- # Dishka generator 可能拥有 ContextVar token,进入与退出必须保持同一 task。
465
- # 具体 provider 负责保护自己的原生 I/O(SQL UoW 已在 scope 关闭前回收);
466
- # finalizer 的 BaseException 仍需继续原生 close,穷尽其余资源。
467
- failures: list[BaseException] = []
468
- while True:
469
- try:
470
- await action.close(primary)
471
- return primary, tuple(failures)
472
- except asyncio.CancelledError as cancellation:
473
- if primary is None:
474
- primary = cancellation
475
- else:
476
- failures.append(cancellation)
477
- except BaseException as error:
478
- failures.append(error)
479
-
480
-
481
- def _raise_scope_failure(
482
- primary: BaseException | None, failures: tuple[BaseException, ...], committed: bool
483
- ) -> None:
484
- if failures:
485
- inherited = primary.__cause__ if isinstance(primary, asyncio.CancelledError) else primary
486
- if isinstance(inherited, ApplicationInvocationCleanupError):
487
- failures = (*inherited.cleanup_failures, *failures)
488
- if not isinstance(primary, asyncio.CancelledError):
489
- primary = inherited.primary
490
- failure = ApplicationInvocationCleanupError(
491
- primary=primary, cleanup_failures=failures, committed=committed
492
- )
493
- if isinstance(primary, asyncio.CancelledError):
494
- raise primary from failure
495
- raise failure from (primary or failures[0])
496
- if primary is not None:
497
- raise primary
9
+ from ..invocation.runtime import _InvocationRuntime
10
+ from ..invocation.scopes import _managed_request
11
+ from .catalog import ApplicationServiceCatalog
498
12
 
499
13
 
500
14
  class _ScopedApplicationServiceInvoker:
@@ -526,7 +40,7 @@ class _ScopedApplicationServiceInvoker:
526
40
  async with self._runtime.outer_lease():
527
41
  caller_token = self._runtime.bind_caller(current_user, correlation_id)
528
42
  try:
529
- async with parent(scope=Scope.REQUEST) as request_container:
43
+ async with _managed_request(parent) as request_container:
530
44
  proxy = await request_container.get(service.registration.contract)
531
45
  call = cast(
532
46
  Callable[..., Awaitable[object]],
@@ -541,10 +55,3 @@ class _ScopedApplicationServiceInvoker:
541
55
  return
542
56
  if parent.scope is not Scope.SESSION or parent.parent_container is not self._root_container:
543
57
  raise RuntimeError("scoped invocation parent is not owned by this Application")
544
-
545
-
546
- def _current_task() -> asyncio.Task[object]:
547
- task = asyncio.current_task()
548
- if task is None:
549
- raise RuntimeError("Application Service invocation requires an asyncio Task")
550
- return cast(asyncio.Task[object], task)
@@ -25,15 +25,16 @@ from ..services.convention_contracts import (
25
25
  ConventionDiscoveryKind,
26
26
  _ServiceDefinition,
27
27
  )
28
+ from ..services.relocation import _BindingRelocation
28
29
  from ..settings.module import SettingsModule
29
30
  from ..unit_of_work.module import UnitOfWorkModule
30
31
  from .bindings import (
31
32
  _bind_application_services,
32
- _runtime_providers,
33
33
  _validate_application_service_bindings,
34
34
  _validate_application_service_dependencies,
35
35
  )
36
36
  from .catalog import (
37
+ _APPLICATION_SERVICE_COMPONENT,
37
38
  ApplicationServiceCatalog,
38
39
  _build_application_service_catalog,
39
40
  _DiscoveredApplicationService,
@@ -130,10 +131,20 @@ class ApplicationServicesModule(AppModule):
130
131
  context.modules.instance(InvocationModule).rules,
131
132
  context.modules.instance(ExtensionsModule).catalog,
132
133
  )
133
- # 只重定位仲裁后的原生绑定,保留其他能力已经生成的目录 Provider。
134
- for provider in context.arbitration.providers:
135
- context._providers.remove(provider)
136
- context._providers[0:0] = _runtime_providers(context.arbitration, bound)
134
+ # 能力只决定私有实现 key;来源、工厂搬迁和贡献指纹仍由 services 管理。
135
+ context._relocate_bindings(
136
+ tuple(
137
+ _BindingRelocation(
138
+ item.winner,
139
+ DependencyKey(
140
+ item.binding.discovery.definition.contract
141
+ or item.binding.discovery.service_type,
142
+ _APPLICATION_SERVICE_COMPONENT,
143
+ ),
144
+ )
145
+ for item in bound
146
+ )
147
+ )
137
148
  provider = Provider()
138
149
  provider.provide(lambda: self.catalog, provides=ApplicationServiceCatalog, scope=Scope.APP)
139
150
  provider.provide(_ApplicationServiceDispatcher, scope=Scope.REQUEST)
@@ -2,8 +2,8 @@
2
2
 
3
3
  from dishka import Provider, Scope
4
4
 
5
- from ..application_services.invocation import _InvocationRuntime
6
5
  from ..invocation.module import InvocationModule
6
+ from ..invocation.runtime import _InvocationRuntime
7
7
  from ..lifecycle import ConfigureContext
8
8
  from ..modularity import AppModule
9
9
  from ..services.composition import (
@@ -30,12 +30,19 @@ class _LocalTask:
30
30
  self.operation: asyncio.Task[None] | None = None
31
31
  self._failed = failed
32
32
  self._monitor: asyncio.Task[None] | None = None
33
+ self._quiescing = False
34
+ self._claiming_stopped = False
33
35
 
34
36
  async def start(self) -> None:
37
+ self._claiming_stopped = False
35
38
  self.state = BackgroundTaskState.STARTING
36
39
  if self.execution is None:
37
40
  raise RuntimeError("Task execution provider is unavailable")
38
41
  await self.execution.start()
42
+ if self._quiescing:
43
+ # start 可能与关闭重叠;完成后再次请求停止,不能重新打开领取。
44
+ self._claiming_stopped = False
45
+ self._stop_claiming()
39
46
  self.state = BackgroundTaskState.RUNNING
40
47
  if self.execution.wait_for_failure is not None:
41
48
  self._monitor = asyncio.create_task(
@@ -82,6 +89,9 @@ class _LocalTask:
82
89
  async def _operate(self, start: bool) -> None:
83
90
  try:
84
91
  if start:
92
+ if self._quiescing:
93
+ self.state = BackgroundTaskState.STOPPED
94
+ return
85
95
  await self.start()
86
96
  else:
87
97
  await self._drain()
@@ -91,6 +101,15 @@ class _LocalTask:
91
101
  self.state = BackgroundTaskState.FAILED
92
102
  # 失败转为 Application-owned 状态供查询;task 不留下未观察异常。
93
103
 
104
+ def quiesce(self) -> None:
105
+ self._quiescing = True
106
+ self._stop_claiming()
107
+
108
+ def _stop_claiming(self) -> None:
109
+ if self.execution is not None and not self._claiming_stopped:
110
+ self._claiming_stopped = True
111
+ self.execution.stop_claiming()
112
+
94
113
  async def shutdown(self) -> None:
95
114
  if self.operation is not None:
96
115
  await self.operation
@@ -112,7 +131,7 @@ class _LocalTask:
112
131
  execution = self.execution
113
132
  if execution is None:
114
133
  return
115
- execution.stop_claiming()
134
+ self._stop_claiming()
116
135
  draining = asyncio.ensure_future(execution.wait())
117
136
  _, pending = await asyncio.wait((draining,), timeout=self.deadline)
118
137
  if pending:
@@ -60,6 +60,7 @@ class _ProcessSlot:
60
60
  self._exited = asyncio.Event()
61
61
  self._exited.set()
62
62
  self._stopping = False
63
+ self._shutdown_request: asyncio.Task[None] | None = None
63
64
  self._restart_count = 0
64
65
  self._wanted: set[str] = set()
65
66
  self._serial = 0
@@ -307,14 +308,23 @@ class _ProcessSlot:
307
308
  if not self._stopping and self._wanted and self._process is None:
308
309
  self._spawn()
309
310
 
310
- async def shutdown(self) -> None:
311
+ def quiesce(self) -> None:
311
312
  self._stopping = True
312
313
  self._set_enabled_state(BackgroundTaskState.STOPPING)
314
+ if self._shutdown_request is None:
315
+ self._shutdown_request = asyncio.create_task(self._request_shutdown())
316
+
317
+ async def _request_shutdown(self) -> None:
313
318
  if self._process is not None and self._process.is_alive():
314
319
  try:
315
320
  await self._send((_Message.SHUTDOWN,))
316
321
  except (EOFError, OSError):
317
322
  pass
323
+
324
+ async def shutdown(self) -> None:
325
+ self.quiesce()
326
+ assert self._shutdown_request is not None
327
+ await self._shutdown_request
318
328
  if self._monitor is not None:
319
329
  await self._monitor
320
330
  if self._operations:
@@ -12,9 +12,9 @@ from uuid import uuid4
12
12
  from dishka import AsyncContainer, Scope
13
13
  from pydantic import BaseModel, ValidationError
14
14
 
15
- from ..application_services.invocation import _InvocationRuntime
16
15
  from ..authorization import CurrentUser
17
16
  from ..invocation import BackgroundSystemCaller
17
+ from ..invocation.runtime import _InvocationRuntime
18
18
  from ..observability.context import invocation_span
19
19
  from .catalog import BackgroundJobCatalog
20
20
  from .contracts import BackgroundJobContext, BackgroundJobDefinition
@@ -8,7 +8,7 @@ from typing import Any, TypeVar
8
8
  import asyncpg # type: ignore[import-untyped]
9
9
  from pydantic import BaseModel
10
10
 
11
- from ...application_services.invocation import _InvocationRuntime
11
+ from ...invocation.runtime import _InvocationRuntime
12
12
  from ...observability.context import capture_trace_context
13
13
  from ...options import Options
14
14
  from ...sqlalchemy import SqlAlchemySessionProvider