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.
- python_ddd_framework/application/runtime.py +46 -19
- python_ddd_framework/application_services/bindings.py +0 -64
- python_ddd_framework/application_services/dispatcher.py +1 -1
- python_ddd_framework/application_services/invocation.py +7 -500
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/module.py +1 -1
- python_ddd_framework/background_execution/local.py +20 -1
- python_ddd_framework/background_execution/processes.py +11 -1
- python_ddd_framework/background_jobs/execution.py +1 -1
- python_ddd_framework/background_jobs/pgqueuer/enqueue.py +1 -1
- python_ddd_framework/background_workers/execution.py +1 -1
- python_ddd_framework/background_workers/runtime.py +11 -2
- python_ddd_framework/caching/unit_of_work.py +2 -4
- python_ddd_framework/cli/project.py +11 -10
- python_ddd_framework/developer_kit/generation.py +46 -36
- python_ddd_framework/developer_kit/publication.py +442 -0
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +42 -3
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/sqlalchemy/repositories/order_repository.py.jinja +3 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +21 -3
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +18 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +74 -3
- python_ddd_framework/diagnostics/source.py +14 -1
- python_ddd_framework/domain/aggregates.py +5 -12
- python_ddd_framework/domain/event_values.py +124 -0
- python_ddd_framework/events/runtime.py +2 -7
- python_ddd_framework/events/unit_of_work.py +1 -4
- python_ddd_framework/fastapi/action.py +2 -1
- python_ddd_framework/fastapi/adapter.py +11 -117
- python_ddd_framework/fastapi/application_services.py +17 -402
- python_ddd_framework/fastapi/error_handlers.py +128 -0
- python_ddd_framework/fastapi/realtime/runtime.py +1 -1
- python_ddd_framework/fastapi/request_context.py +2 -5
- python_ddd_framework/fastapi/routing.py +4 -10
- python_ddd_framework/fastapi/service_endpoints.py +182 -0
- python_ddd_framework/fastapi/service_routes.py +237 -0
- python_ddd_framework/fastapi/transfer.py +1 -1
- python_ddd_framework/hosted_services/bridge.py +76 -21
- python_ddd_framework/hosted_services/contracts.py +5 -1
- python_ddd_framework/hosted_services/runtime.py +71 -17
- python_ddd_framework/identity/__init__.py +21 -13
- python_ddd_framework/identity/application.py +12 -66
- python_ddd_framework/identity/contracts.py +38 -411
- python_ddd_framework/identity/domain_rules.py +17 -0
- python_ddd_framework/identity/dtos.py +152 -0
- python_ddd_framework/identity/extensions.py +9 -0
- python_ddd_framework/identity/management.py +28 -25
- python_ddd_framework/identity/models.py +86 -0
- python_ddd_framework/identity/module.py +3 -4
- python_ddd_framework/identity/options.py +54 -0
- python_ddd_framework/identity/seeding.py +59 -0
- python_ddd_framework/identity/services.py +1 -1
- python_ddd_framework/identity/sqlalchemy/module.py +9 -6
- python_ddd_framework/identity/sqlalchemy/repository.py +4 -5
- python_ddd_framework/identity/sqlalchemy/session_security.py +1 -3
- python_ddd_framework/identity/sqlalchemy/stores.py +35 -47
- python_ddd_framework/identity/stores.py +120 -0
- python_ddd_framework/identity/tokens.py +1 -1
- python_ddd_framework/invocation/callables.py +1 -1
- python_ddd_framework/invocation/dispatcher.py +1 -1
- python_ddd_framework/invocation/entrypoints.py +2 -1
- python_ddd_framework/invocation/function_runtime.py +2 -2
- python_ddd_framework/invocation/interception.py +1 -1
- python_ddd_framework/invocation/managed_proxy.py +2 -1
- python_ddd_framework/invocation/managed_services.py +8 -14
- python_ddd_framework/invocation/methods.py +2 -7
- python_ddd_framework/invocation/module.py +1 -1
- python_ddd_framework/invocation/runtime.py +483 -0
- python_ddd_framework/invocation/scopes.py +91 -0
- python_ddd_framework/lifecycle/participants.py +4 -0
- python_ddd_framework/messaging/channel.py +35 -8
- python_ddd_framework/messaging/contracts.py +9 -0
- python_ddd_framework/messaging/module.py +13 -0
- python_ddd_framework/messaging/runtime.py +17 -2
- python_ddd_framework/modularity/discovery.py +2 -7
- python_ddd_framework/modularity/graph.py +2 -8
- python_ddd_framework/observability/logging.py +1 -1
- python_ddd_framework/observability/tracing.py +1 -1
- python_ddd_framework/redis/cache.py +2 -0
- python_ddd_framework/redis/notification_runtime.py +1 -1
- python_ddd_framework/services/arbitration.py +1 -20
- python_ddd_framework/services/composition.py +71 -2
- python_ddd_framework/services/provider.py +23 -1
- python_ddd_framework/services/relocation.py +96 -0
- python_ddd_framework/settings/refresh.py +1 -1
- python_ddd_framework/settings/sqlalchemy/store.py +1 -1
- python_ddd_framework/sqlalchemy/metadata.py +11 -413
- python_ddd_framework/sqlalchemy/metadata_builder.py +234 -0
- python_ddd_framework/sqlalchemy/metadata_fingerprint.py +108 -0
- python_ddd_framework/sqlalchemy/metadata_ownership.py +73 -0
- python_ddd_framework/sqlalchemy/migration.py +2 -1
- python_ddd_framework/sqlalchemy/migration_sources.py +38 -0
- python_ddd_framework/sqlalchemy/module.py +2 -1
- python_ddd_framework/sqlalchemy/unit_of_work.py +1 -1
- python_ddd_framework/unit_of_work/contracts.py +68 -7
- python_ddd_framework/unit_of_work/manager.py +4 -7
- python_ddd_framework/unit_of_work/module.py +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +82 -4
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +104 -85
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
"""
|
|
1
|
+
"""应用服务 Contract 的 REQUEST 代理入口;准入与 scope 清理由 Invocation 拥有。"""
|
|
2
2
|
|
|
3
|
-
from
|
|
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
|
|
18
|
-
from ..invocation.
|
|
19
|
-
from
|
|
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
|
|
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
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ...
|
|
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
|