python-ddd-framework 0.5.1__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_services/bindings.py +0 -64
- python_ddd_framework/application_services/dispatcher.py +1 -1
- python_ddd_framework/application_services/invocation.py +7 -563
- python_ddd_framework/application_services/module.py +16 -5
- python_ddd_framework/authorization/module.py +1 -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 +1 -1
- 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 +40 -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 +14 -3
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +16 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +63 -2
- 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 +2 -6
- python_ddd_framework/hosted_services/runtime.py +1 -1
- 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/messaging/channel.py +1 -1
- python_ddd_framework/messaging/runtime.py +1 -1
- 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.5.1.dist-info → python_ddd_framework-0.6.0.dist-info}/METADATA +74 -4
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.6.0.dist-info}/RECORD +95 -76
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.6.0.dist-info}/WHEEL +1 -1
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.5.1.dist-info → python_ddd_framework-0.6.0.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -9,15 +9,12 @@ from typing import get_origin
|
|
|
9
9
|
from dishka import (
|
|
10
10
|
DEFAULT_COMPONENT,
|
|
11
11
|
DependencyKey,
|
|
12
|
-
Provider,
|
|
13
12
|
Scope,
|
|
14
13
|
)
|
|
15
14
|
|
|
16
15
|
from ..diagnostics import SourceLocation
|
|
17
16
|
from ..modularity import ModuleRegistry
|
|
18
17
|
from ..services.arbitration import (
|
|
19
|
-
_ArbitrationResult,
|
|
20
|
-
_provider_with_active_sources,
|
|
21
18
|
_RegistrationDraft,
|
|
22
19
|
)
|
|
23
20
|
from ..services.native_graph import _NativeServiceGraph
|
|
@@ -201,64 +198,3 @@ def _validate_application_service_bindings(
|
|
|
201
198
|
"or private implementation binding"
|
|
202
199
|
),
|
|
203
200
|
)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _runtime_providers(
|
|
207
|
-
arbitration: _ArbitrationResult,
|
|
208
|
-
services: tuple[_BoundApplicationService, ...],
|
|
209
|
-
) -> tuple[Provider, ...]:
|
|
210
|
-
"""仅重定位 Application Service factories;其他 Dishka provider 保持原生形状。"""
|
|
211
|
-
|
|
212
|
-
related_draft_ids = frozenset(
|
|
213
|
-
draft_id for service in services for draft_id in service.related_draft_ids
|
|
214
|
-
)
|
|
215
|
-
drafts_by_entry: dict[int, list[_RegistrationDraft]] = {}
|
|
216
|
-
for draft in arbitration.drafts:
|
|
217
|
-
drafts_by_entry.setdefault(draft.entry.entry_id, []).append(draft)
|
|
218
|
-
|
|
219
|
-
providers: list[Provider] = []
|
|
220
|
-
for entry in arbitration.ordered_entries:
|
|
221
|
-
entry_drafts = drafts_by_entry[entry.entry_id]
|
|
222
|
-
active = tuple(
|
|
223
|
-
draft
|
|
224
|
-
for draft in entry_drafts
|
|
225
|
-
if draft.decision is not RegistrationDecision.BACKED_OFF
|
|
226
|
-
and id(draft) not in related_draft_ids
|
|
227
|
-
)
|
|
228
|
-
if not active:
|
|
229
|
-
continue
|
|
230
|
-
if len(active) == len(entry_drafts):
|
|
231
|
-
providers.append(entry.contribution.provider)
|
|
232
|
-
else:
|
|
233
|
-
providers.append(_provider_with_active_sources(entry.contribution.provider, active))
|
|
234
|
-
|
|
235
|
-
# default component 只发布 REQUEST proxy;ACTION implementation 统一藏入框架私有 component。
|
|
236
|
-
implementation_provider = Provider(component=_APPLICATION_SERVICE_COMPONENT)
|
|
237
|
-
for service in services:
|
|
238
|
-
winner = service.winner
|
|
239
|
-
assert winner.factory is not None
|
|
240
|
-
# internal contributor 被 override 时仍以原声明 key 调度;
|
|
241
|
-
# winner class 只是实现事实,不能改写契约 key。
|
|
242
|
-
contract = (
|
|
243
|
-
service.binding.discovery.definition.contract or service.binding.discovery.service_type
|
|
244
|
-
)
|
|
245
|
-
implementation_provider.factories.append(
|
|
246
|
-
winner.factory.replace(
|
|
247
|
-
dependencies=tuple(
|
|
248
|
-
dependency.with_component(DEFAULT_COMPONENT)
|
|
249
|
-
for dependency in winner.factory.dependencies
|
|
250
|
-
),
|
|
251
|
-
kw_dependencies={
|
|
252
|
-
name: dependency.with_component(DEFAULT_COMPONENT)
|
|
253
|
-
for name, dependency in winner.factory.kw_dependencies.items()
|
|
254
|
-
},
|
|
255
|
-
provides=DependencyKey(contract, None),
|
|
256
|
-
when_override=None,
|
|
257
|
-
when_component=DEFAULT_COMPONENT,
|
|
258
|
-
)
|
|
259
|
-
)
|
|
260
|
-
# ServiceCatalog 的 accepted row 必须描述 relocated native binding,而非旧 default key。
|
|
261
|
-
winner.key = DependencyKey(contract, _APPLICATION_SERVICE_COMPONENT)
|
|
262
|
-
if services:
|
|
263
|
-
providers.append(implementation_provider)
|
|
264
|
-
return tuple(providers)
|
|
@@ -13,6 +13,7 @@ from dishka import AsyncContainer
|
|
|
13
13
|
from ..invocation.dispatcher import _MethodDispatcher
|
|
14
14
|
from ..invocation.execution import _InterceptorPipeline
|
|
15
15
|
from ..invocation.methods import _CompiledMethod
|
|
16
|
+
from ..invocation.runtime import _current_task, _InvocationRuntime
|
|
16
17
|
from ..settings.provider import SettingProvider
|
|
17
18
|
from .catalog import (
|
|
18
19
|
_APPLICATION_SERVICE_COMPONENT,
|
|
@@ -22,7 +23,6 @@ from .contracts import ApplicationService
|
|
|
22
23
|
from .errors import (
|
|
23
24
|
CrossTaskInvocationError,
|
|
24
25
|
)
|
|
25
|
-
from .invocation import _current_task, _InvocationRuntime
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@dataclass(slots=True)
|
|
@@ -1,563 +1,14 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""应用服务 Contract 的 REQUEST 代理入口;准入与 scope 清理由 Invocation 拥有。"""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import logging
|
|
7
|
-
import threading
|
|
8
|
-
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
9
|
-
from contextlib import asynccontextmanager, contextmanager
|
|
10
|
-
from contextvars import ContextVar, Token
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
from typing import TYPE_CHECKING, cast
|
|
13
|
-
from uuid import UUID, uuid4
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import cast
|
|
14
5
|
|
|
15
6
|
from dishka import AsyncContainer, Scope
|
|
16
7
|
|
|
17
8
|
from ..authorization import CurrentUser
|
|
18
|
-
from ..invocation import
|
|
19
|
-
from ..invocation.
|
|
20
|
-
from
|
|
21
|
-
from ..unit_of_work import UnitOfWork, UnitOfWorkManager
|
|
22
|
-
from ..unit_of_work.options import _DEFAULT_CONNECTION_NAME, UnitOfWorkDeclaration
|
|
23
|
-
from .catalog import (
|
|
24
|
-
ApplicationServiceCatalog,
|
|
25
|
-
)
|
|
26
|
-
from .errors import (
|
|
27
|
-
ApplicationInvocationCleanupError,
|
|
28
|
-
ApplicationInvocationRejectedError,
|
|
29
|
-
CrossTaskInvocationError,
|
|
30
|
-
NestedConnectionError,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
_LOGGER = logging.getLogger(__name__)
|
|
34
|
-
|
|
35
|
-
if TYPE_CHECKING:
|
|
36
|
-
from opentelemetry.trace import Tracer
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@dataclass(slots=True)
|
|
40
|
-
class _InvocationState:
|
|
41
|
-
context: ApplicationInvocationContext
|
|
42
|
-
owner_task: asyncio.Task[object]
|
|
43
|
-
action_container: AsyncContainer
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@dataclass(frozen=True, slots=True)
|
|
47
|
-
class _CallerContext:
|
|
48
|
-
current_user: CurrentUser
|
|
49
|
-
correlation_id: str
|
|
50
|
-
owner_task: asyncio.Task[object]
|
|
51
|
-
transferable: bool = False
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@dataclass(frozen=True, slots=True)
|
|
55
|
-
class _OuterInvocationLease:
|
|
56
|
-
owner_task: asyncio.Task[object]
|
|
57
|
-
allows_invocation: bool
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@dataclass(frozen=True, slots=True)
|
|
61
|
-
class _CallScope:
|
|
62
|
-
container: AsyncContainer
|
|
63
|
-
owner_task: asyncio.Task[object]
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@dataclass(eq=False, slots=True)
|
|
67
|
-
class _InvocationReservation:
|
|
68
|
-
"""只携带准入事实,不携带 caller、DI scope 或事务;由接受工作的 owner 收口。"""
|
|
69
|
-
|
|
70
|
-
runtime: _InvocationRuntime
|
|
71
|
-
executing: bool = False
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@dataclass(eq=False, frozen=True, slots=True)
|
|
75
|
-
class _StoppingPermission:
|
|
76
|
-
"""单 Application、服务 owner 与单次收尾钩子的准入身份,不携带业务 scope。"""
|
|
77
|
-
|
|
78
|
-
runtime: _InvocationRuntime
|
|
79
|
-
owner: type[object]
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class _InvocationRuntime:
|
|
83
|
-
"""每个 Application 独占 admission 与 ContextVar,禁止进程级 current app。"""
|
|
84
|
-
|
|
85
|
-
__slots__ = (
|
|
86
|
-
"_active",
|
|
87
|
-
"_admission_lock",
|
|
88
|
-
"_ambient",
|
|
89
|
-
"_application_id",
|
|
90
|
-
"_authenticating",
|
|
91
|
-
"_call_scope",
|
|
92
|
-
"_caller",
|
|
93
|
-
"_changed",
|
|
94
|
-
"_http_method",
|
|
95
|
-
"_is_accepting",
|
|
96
|
-
"_lease",
|
|
97
|
-
"_loop",
|
|
98
|
-
"_permissions",
|
|
99
|
-
"_reservations",
|
|
100
|
-
"_state_getter",
|
|
101
|
-
"tracer",
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
def __init__(
|
|
105
|
-
self,
|
|
106
|
-
*,
|
|
107
|
-
application_id: UUID,
|
|
108
|
-
state_getter: Callable[[], ApplicationState],
|
|
109
|
-
) -> None:
|
|
110
|
-
self._application_id = application_id
|
|
111
|
-
self._authenticating: ContextVar[bool] = ContextVar(
|
|
112
|
-
f"python_ddd_framework_authenticating_{application_id}", default=False
|
|
113
|
-
)
|
|
114
|
-
self._http_method: ContextVar[str | None] = ContextVar(
|
|
115
|
-
f"python_ddd_framework_http_method_{application_id}", default=None
|
|
116
|
-
)
|
|
117
|
-
self.tracer: Tracer | None = None
|
|
118
|
-
self._state_getter = state_getter
|
|
119
|
-
self._ambient: ContextVar[_InvocationState | None] = ContextVar(
|
|
120
|
-
f"python_ddd_framework_invocation_{application_id}",
|
|
121
|
-
default=None,
|
|
122
|
-
)
|
|
123
|
-
self._caller: ContextVar[_CallerContext | None] = ContextVar(
|
|
124
|
-
f"python_ddd_framework_caller_{application_id}",
|
|
125
|
-
default=None,
|
|
126
|
-
)
|
|
127
|
-
self._lease: ContextVar[_OuterInvocationLease | None] = ContextVar(
|
|
128
|
-
f"python_ddd_framework_invocation_lease_{application_id}",
|
|
129
|
-
default=None,
|
|
130
|
-
)
|
|
131
|
-
self._admission_lock = threading.RLock()
|
|
132
|
-
self._changed = asyncio.Event()
|
|
133
|
-
self._loop: asyncio.AbstractEventLoop | None = None
|
|
134
|
-
self._permissions: set[_StoppingPermission] = set()
|
|
135
|
-
self._is_accepting = False
|
|
136
|
-
self._active = 0
|
|
137
|
-
self._reservations: set[_InvocationReservation] = set()
|
|
138
|
-
self._call_scope: ContextVar[_CallScope | None] = ContextVar(
|
|
139
|
-
f"python_ddd_framework_call_scope_{application_id}",
|
|
140
|
-
default=None,
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
@contextmanager
|
|
144
|
-
def call_scope(self, container: AsyncContainer) -> Iterator[None]:
|
|
145
|
-
token = self._call_scope.set(_CallScope(container, _current_task()))
|
|
146
|
-
try:
|
|
147
|
-
yield
|
|
148
|
-
finally:
|
|
149
|
-
self._call_scope.reset(token)
|
|
150
|
-
|
|
151
|
-
def current_call_scope(self) -> AsyncContainer | None:
|
|
152
|
-
scope = self._call_scope.get()
|
|
153
|
-
if scope is None:
|
|
154
|
-
return None
|
|
155
|
-
if scope.owner_task is not _current_task():
|
|
156
|
-
raise CrossTaskInvocationError
|
|
157
|
-
return scope.container
|
|
158
|
-
|
|
159
|
-
def http_method(self) -> str | None:
|
|
160
|
-
return self._http_method.get()
|
|
161
|
-
|
|
162
|
-
@contextmanager
|
|
163
|
-
def http_scope(self, method: str) -> Iterator[None]:
|
|
164
|
-
token = self._http_method.set(method)
|
|
165
|
-
try:
|
|
166
|
-
yield
|
|
167
|
-
finally:
|
|
168
|
-
self._http_method.reset(token)
|
|
169
|
-
|
|
170
|
-
@contextmanager
|
|
171
|
-
def detached_execution(self, container: AsyncContainer) -> Iterator[None]:
|
|
172
|
-
token = self._ambient.set(None)
|
|
173
|
-
try:
|
|
174
|
-
with self.call_scope(container):
|
|
175
|
-
yield
|
|
176
|
-
finally:
|
|
177
|
-
self._ambient.reset(token)
|
|
178
|
-
|
|
179
|
-
@property
|
|
180
|
-
def application_id(self) -> UUID:
|
|
181
|
-
return self._application_id
|
|
182
|
-
|
|
183
|
-
def start_accepting(self) -> None:
|
|
184
|
-
self._loop = asyncio.get_running_loop()
|
|
185
|
-
self._is_accepting = True
|
|
186
|
-
|
|
187
|
-
def runtime_participant(self) -> RuntimeParticipant:
|
|
188
|
-
async def start() -> None:
|
|
189
|
-
self.start_accepting()
|
|
190
|
-
|
|
191
|
-
async def stop(reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
192
|
-
self.close_admission()
|
|
193
|
-
return ()
|
|
194
|
-
|
|
195
|
-
return RuntimeParticipant(
|
|
196
|
-
start=start,
|
|
197
|
-
stop=stop,
|
|
198
|
-
quiesce=self.quiesce,
|
|
199
|
-
close_admission=self.close_admission,
|
|
200
|
-
drain=self.stop_accepting_and_wait,
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
def quiesce(self) -> None:
|
|
204
|
-
with self._admission_lock:
|
|
205
|
-
self._is_accepting = False
|
|
206
|
-
|
|
207
|
-
def close_admission(self) -> None:
|
|
208
|
-
with self._admission_lock:
|
|
209
|
-
self._is_accepting = False
|
|
210
|
-
self._permissions.clear()
|
|
211
|
-
|
|
212
|
-
def grant_stopping(self, owner: type[object]) -> _StoppingPermission:
|
|
213
|
-
with self._admission_lock:
|
|
214
|
-
if self._state_getter() not in (
|
|
215
|
-
ApplicationState.STOPPING,
|
|
216
|
-
ApplicationState.ROLLING_BACK,
|
|
217
|
-
):
|
|
218
|
-
raise ApplicationInvocationRejectedError(reason="Not in the stopping phase")
|
|
219
|
-
permission = _StoppingPermission(self, owner)
|
|
220
|
-
self._permissions.add(permission)
|
|
221
|
-
return permission
|
|
222
|
-
|
|
223
|
-
def revoke_stopping(self, permission: _StoppingPermission) -> None:
|
|
224
|
-
with self._admission_lock:
|
|
225
|
-
self._permissions.discard(permission)
|
|
226
|
-
|
|
227
|
-
def require_stopping(self, permission: _StoppingPermission) -> None:
|
|
228
|
-
with self._admission_lock:
|
|
229
|
-
if permission.runtime is not self or permission not in self._permissions:
|
|
230
|
-
raise ApplicationInvocationRejectedError(reason="Invalid stopping permission")
|
|
231
|
-
|
|
232
|
-
async def stop_accepting_and_wait(self) -> None:
|
|
233
|
-
self.close_admission()
|
|
234
|
-
while True:
|
|
235
|
-
self._changed.clear()
|
|
236
|
-
with self._admission_lock:
|
|
237
|
-
if self._active == 0 and not self._reservations:
|
|
238
|
-
return
|
|
239
|
-
await self._changed.wait()
|
|
240
|
-
|
|
241
|
-
def reserve(self, permission: _StoppingPermission | None = None) -> _InvocationReservation:
|
|
242
|
-
# SDK thread 与权限撤销在同一锁下线性化;已接受许可独立于钩子权限的寿命。
|
|
243
|
-
with self._admission_lock:
|
|
244
|
-
if permission is None:
|
|
245
|
-
self.require_accepting_work()
|
|
246
|
-
else:
|
|
247
|
-
self.require_stopping(permission)
|
|
248
|
-
reservation = _InvocationReservation(self)
|
|
249
|
-
self._reservations.add(reservation)
|
|
250
|
-
return reservation
|
|
251
|
-
|
|
252
|
-
def require_accepting_work(self) -> None:
|
|
253
|
-
if not self._is_accepting or self._state_getter() not in (
|
|
254
|
-
ApplicationState.STARTING,
|
|
255
|
-
ApplicationState.RUNNING,
|
|
256
|
-
):
|
|
257
|
-
raise ApplicationInvocationRejectedError(reason="Application is not accepting work")
|
|
258
|
-
|
|
259
|
-
async def discard(self, reservation: _InvocationReservation) -> None:
|
|
260
|
-
self.release_reservation(reservation)
|
|
261
|
-
|
|
262
|
-
def release_reservation(self, reservation: _InvocationReservation) -> None:
|
|
263
|
-
with self._admission_lock:
|
|
264
|
-
if reservation.runtime is not self or reservation not in self._reservations:
|
|
265
|
-
raise ApplicationInvocationRejectedError(reason="Invalid or released reservation")
|
|
266
|
-
self._reservations.remove(reservation)
|
|
267
|
-
if self._loop is not None and not self._loop.is_closed():
|
|
268
|
-
try:
|
|
269
|
-
self._loop.call_soon_threadsafe(self._changed.set)
|
|
270
|
-
except RuntimeError:
|
|
271
|
-
# 外部 submit 可能在 loop 关闭竞争中退回许可;此时已无 loop 排空等待者。
|
|
272
|
-
if not self._loop.is_closed():
|
|
273
|
-
raise
|
|
274
|
-
|
|
275
|
-
@asynccontextmanager
|
|
276
|
-
async def reserved_lease(self, reservation: _InvocationReservation) -> AsyncIterator[None]:
|
|
277
|
-
if (
|
|
278
|
-
reservation.runtime is not self
|
|
279
|
-
or reservation not in self._reservations
|
|
280
|
-
or reservation.executing
|
|
281
|
-
or self._lease.get() is not None
|
|
282
|
-
or self.current() is not None
|
|
283
|
-
or self.current_call_scope() is not None
|
|
284
|
-
):
|
|
285
|
-
raise ApplicationInvocationRejectedError(reason="Invalid reservation execution owner")
|
|
286
|
-
reservation.executing = True
|
|
287
|
-
token = self._lease.set(_OuterInvocationLease(_current_task(), True))
|
|
288
|
-
try:
|
|
289
|
-
yield
|
|
290
|
-
finally:
|
|
291
|
-
self._lease.reset(token)
|
|
292
|
-
await self.discard(reservation)
|
|
293
|
-
|
|
294
|
-
@asynccontextmanager
|
|
295
|
-
async def outer_lease(self, *, allow_composed_request: bool = False) -> AsyncIterator[None]:
|
|
296
|
-
owner_task = _current_task()
|
|
297
|
-
inherited = self._lease.get()
|
|
298
|
-
if inherited is not None:
|
|
299
|
-
if inherited.owner_task is not owner_task:
|
|
300
|
-
# asyncio.create_task 会复制 ContextVar;child task 不得继承 parent admission。
|
|
301
|
-
raise CrossTaskInvocationError
|
|
302
|
-
if not inherited.allows_invocation and not allow_composed_request:
|
|
303
|
-
raise ApplicationInvocationRejectedError(
|
|
304
|
-
reason=(
|
|
305
|
-
"Application must be running and accepting invocations, got "
|
|
306
|
-
f"{self._state_getter().value}"
|
|
307
|
-
)
|
|
308
|
-
)
|
|
309
|
-
yield
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
with self._admission_lock:
|
|
313
|
-
state = self._state_getter()
|
|
314
|
-
# HostedService.start 可调用业务入口,但全部后台启动成功前 readiness 仍为 false。
|
|
315
|
-
allows_invocation = self._is_accepting and state in (
|
|
316
|
-
ApplicationState.STARTING,
|
|
317
|
-
ApplicationState.RUNNING,
|
|
318
|
-
)
|
|
319
|
-
allows_composed_request = allow_composed_request and state is ApplicationState.COMPOSED
|
|
320
|
-
if not allows_invocation and not allows_composed_request:
|
|
321
|
-
raise ApplicationInvocationRejectedError(
|
|
322
|
-
reason=(
|
|
323
|
-
f"Application must be running and accepting invocations, got {state.value}"
|
|
324
|
-
)
|
|
325
|
-
)
|
|
326
|
-
self._active += 1
|
|
327
|
-
token = self._lease.set(_OuterInvocationLease(owner_task, allows_invocation))
|
|
328
|
-
try:
|
|
329
|
-
yield
|
|
330
|
-
finally:
|
|
331
|
-
self._lease.reset(token)
|
|
332
|
-
with self._admission_lock:
|
|
333
|
-
assert self._active > 0
|
|
334
|
-
self._active -= 1
|
|
335
|
-
if self._active == 0:
|
|
336
|
-
self._changed.set()
|
|
337
|
-
|
|
338
|
-
def current(self) -> _InvocationState | None:
|
|
339
|
-
return self._ambient.get()
|
|
340
|
-
|
|
341
|
-
def bind(self, state: _InvocationState) -> Token[_InvocationState | None]:
|
|
342
|
-
return self._ambient.set(state)
|
|
343
|
-
|
|
344
|
-
def reset(self, token: Token[_InvocationState | None]) -> None:
|
|
345
|
-
self._ambient.reset(token)
|
|
346
|
-
|
|
347
|
-
def current_user(self) -> CurrentUser:
|
|
348
|
-
if self._authenticating.get():
|
|
349
|
-
raise ApplicationInvocationRejectedError(
|
|
350
|
-
reason="authentication cannot depend on the final CurrentUser"
|
|
351
|
-
)
|
|
352
|
-
caller = self._caller.get()
|
|
353
|
-
if caller is None:
|
|
354
|
-
return CurrentUser.anonymous()
|
|
355
|
-
if not caller.transferable and caller.owner_task is not _current_task():
|
|
356
|
-
raise CrossTaskInvocationError
|
|
357
|
-
return caller.current_user
|
|
358
|
-
|
|
359
|
-
def correlation_id(self) -> str | None:
|
|
360
|
-
caller = self._caller.get()
|
|
361
|
-
if caller is None:
|
|
362
|
-
return None
|
|
363
|
-
if not caller.transferable and caller.owner_task is not _current_task():
|
|
364
|
-
raise CrossTaskInvocationError
|
|
365
|
-
return caller.correlation_id
|
|
366
|
-
|
|
367
|
-
def diagnostic_correlation_id(self) -> str | None:
|
|
368
|
-
# 日志允许只读关联复制到线程的上下文;这不授予 child task/线程调用业务的 lease。
|
|
369
|
-
caller = self._caller.get()
|
|
370
|
-
return None if caller is None else caller.correlation_id
|
|
371
|
-
|
|
372
|
-
def bind_caller(
|
|
373
|
-
self,
|
|
374
|
-
current_user: CurrentUser,
|
|
375
|
-
correlation_id: str,
|
|
376
|
-
) -> Token[_CallerContext | None]:
|
|
377
|
-
if not isinstance(current_user, CurrentUser):
|
|
378
|
-
raise TypeError("current_user must be CurrentUser")
|
|
379
|
-
if not _is_safe_correlation_id(correlation_id):
|
|
380
|
-
raise ValueError("correlation_id must be a safe non-empty identifier")
|
|
381
|
-
return self._caller.set(
|
|
382
|
-
_CallerContext(
|
|
383
|
-
current_user=current_user,
|
|
384
|
-
correlation_id=correlation_id,
|
|
385
|
-
owner_task=_current_task(),
|
|
386
|
-
)
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
def reset_caller(self, token: Token[_CallerContext | None]) -> None:
|
|
390
|
-
self._caller.reset(token)
|
|
391
|
-
|
|
392
|
-
@contextmanager
|
|
393
|
-
def authenticating(self) -> Iterator[None]:
|
|
394
|
-
token = self._authenticating.set(True)
|
|
395
|
-
try:
|
|
396
|
-
yield
|
|
397
|
-
finally:
|
|
398
|
-
self._authenticating.reset(token)
|
|
399
|
-
|
|
400
|
-
@contextmanager
|
|
401
|
-
def trusted_http_task(
|
|
402
|
-
self, container: AsyncContainer, user: CurrentUser, correlation_id: str, method: str
|
|
403
|
-
) -> Iterator[None]:
|
|
404
|
-
# 只有 adapter 的受监督入口可交接许可;普通 create_task 仍受原有 task guard 约束。
|
|
405
|
-
lease = self._lease.get()
|
|
406
|
-
if lease is None or self.current() is not None:
|
|
407
|
-
raise CrossTaskInvocationError
|
|
408
|
-
task = _current_task()
|
|
409
|
-
lease_token = self._lease.set(_OuterInvocationLease(task, lease.allows_invocation))
|
|
410
|
-
caller_token = self._caller.set(_CallerContext(user, correlation_id, task, True))
|
|
411
|
-
try:
|
|
412
|
-
with self.call_scope(container), self.http_scope(method):
|
|
413
|
-
yield
|
|
414
|
-
finally:
|
|
415
|
-
self._caller.reset(caller_token)
|
|
416
|
-
self._lease.reset(lease_token)
|
|
417
|
-
|
|
418
|
-
def context_for_action(self, container: AsyncContainer) -> ApplicationInvocationContext:
|
|
419
|
-
"""由原生 ACTION factory 缓存上下文,DI 与调用管线共享同一身份。"""
|
|
420
|
-
# Dishka 1.10 的公开 context 属性已弃用;这里只读取原生 scope 的输入,
|
|
421
|
-
# 不写 context/cache,也不复制容器的解析或缓存。第三方字段访问集中于此。
|
|
422
|
-
native_context = container._context
|
|
423
|
-
if native_context is None or ApplicationInvocationContext not in native_context:
|
|
424
|
-
return self._new_context(_DEFAULT_CONNECTION_NAME)
|
|
425
|
-
supplied: object = native_context[ApplicationInvocationContext]
|
|
426
|
-
if not isinstance(supplied, ApplicationInvocationContext):
|
|
427
|
-
raise ApplicationInvocationRejectedError(reason="invalid ACTION invocation context")
|
|
428
|
-
if supplied.application_id != self.application_id:
|
|
429
|
-
raise ApplicationInvocationRejectedError(
|
|
430
|
-
reason="ACTION invocation context belongs to another Application"
|
|
431
|
-
)
|
|
432
|
-
return supplied
|
|
433
|
-
|
|
434
|
-
def _new_context(self, connection_name: str) -> ApplicationInvocationContext:
|
|
435
|
-
return ApplicationInvocationContext(
|
|
436
|
-
application_id=self.application_id,
|
|
437
|
-
invocation_id=uuid4(),
|
|
438
|
-
parent_invocation_id=None,
|
|
439
|
-
connection_name=connection_name,
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
async def run_action(
|
|
443
|
-
self,
|
|
444
|
-
container: AsyncContainer,
|
|
445
|
-
*,
|
|
446
|
-
connection_name: str,
|
|
447
|
-
callback: Callable[[AsyncContainer], Awaitable[object]],
|
|
448
|
-
declaration: UnitOfWorkDeclaration,
|
|
449
|
-
method_name: str = "execute",
|
|
450
|
-
unit_of_work_enabled: bool = True,
|
|
451
|
-
) -> object:
|
|
452
|
-
"""执行 scope 独立于数据库 UoW;自动边界只完成自己创建或加入的工作。"""
|
|
453
|
-
context = (
|
|
454
|
-
await container.get(ApplicationInvocationContext)
|
|
455
|
-
if container.scope is Scope.ACTION
|
|
456
|
-
else self._new_context(connection_name)
|
|
457
|
-
)
|
|
458
|
-
if context.connection_name != connection_name:
|
|
459
|
-
raise NestedConnectionError(outer=context.connection_name, requested=connection_name)
|
|
460
|
-
work: UnitOfWork | None = None
|
|
461
|
-
async with _managed_action(
|
|
462
|
-
container, context, committed=lambda: work is not None and work.committed
|
|
463
|
-
) as action:
|
|
464
|
-
state = _InvocationState(context, _current_task(), action)
|
|
465
|
-
token = self.bind(state)
|
|
466
|
-
try:
|
|
467
|
-
if not unit_of_work_enabled:
|
|
468
|
-
return await callback(action)
|
|
469
|
-
manager = await action.get(UnitOfWorkManager)
|
|
470
|
-
if declaration.disabled:
|
|
471
|
-
manager._check_current(connection_name)
|
|
472
|
-
return await callback(action)
|
|
473
|
-
options = manager._resolve_options(connection_name, declaration, method_name)
|
|
474
|
-
async with manager._begin(options) as work:
|
|
475
|
-
result = await callback(action)
|
|
476
|
-
await work.complete()
|
|
477
|
-
return result
|
|
478
|
-
finally:
|
|
479
|
-
self.reset(token)
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
@asynccontextmanager
|
|
483
|
-
async def _managed_request(parent: AsyncContainer) -> AsyncIterator[AsyncContainer]:
|
|
484
|
-
"""排空入口自己创建的 REQUEST;不推断业务事务是否已经提交。"""
|
|
485
|
-
request = parent(scope=Scope.REQUEST)
|
|
486
|
-
primary: BaseException | None = None
|
|
487
|
-
try:
|
|
488
|
-
yield request
|
|
489
|
-
except BaseException as error:
|
|
490
|
-
primary = error
|
|
491
|
-
primary, failures = await _close_action(request, primary)
|
|
492
|
-
if failures:
|
|
493
|
-
# REQUEST 无事务 owner;保留业务原异常及已有 cause,不伪造 committed=False。
|
|
494
|
-
inherited = (primary.__cause__,) if primary is not None and primary.__cause__ else ()
|
|
495
|
-
cleanup = BaseExceptionGroup("REQUEST cleanup failed", (*inherited, *failures))
|
|
496
|
-
if primary is not None:
|
|
497
|
-
raise primary from cleanup
|
|
498
|
-
raise cleanup
|
|
499
|
-
if primary is not None:
|
|
500
|
-
raise primary
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
@asynccontextmanager
|
|
504
|
-
async def _managed_action(
|
|
505
|
-
parent: AsyncContainer,
|
|
506
|
-
context: ApplicationInvocationContext,
|
|
507
|
-
*,
|
|
508
|
-
committed: Callable[[], bool],
|
|
509
|
-
) -> AsyncIterator[AsyncContainer]:
|
|
510
|
-
if parent.scope is Scope.ACTION:
|
|
511
|
-
# 普通服务可能已经由调用方的 ACTION 解析;资源仍由该 scope 的 owner 关闭。
|
|
512
|
-
yield parent
|
|
513
|
-
return
|
|
514
|
-
action = parent(context={ApplicationInvocationContext: context}, scope=Scope.ACTION)
|
|
515
|
-
primary: BaseException | None = None
|
|
516
|
-
try:
|
|
517
|
-
yield action
|
|
518
|
-
except BaseException as error:
|
|
519
|
-
primary = error
|
|
520
|
-
primary, failures = await _close_action(action, primary)
|
|
521
|
-
_raise_scope_failure(primary, failures, committed())
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
async def _close_action(
|
|
525
|
-
action: AsyncContainer, primary: BaseException | None
|
|
526
|
-
) -> tuple[BaseException | None, tuple[BaseException, ...]]:
|
|
527
|
-
# Dishka generator 可能拥有 ContextVar token,进入与退出必须保持同一 task。
|
|
528
|
-
# 具体 provider 负责保护自己的原生 I/O(SQL UoW 已在 scope 关闭前回收);
|
|
529
|
-
# finalizer 的 BaseException 仍需继续原生 close,穷尽其余资源。
|
|
530
|
-
failures: list[BaseException] = []
|
|
531
|
-
while True:
|
|
532
|
-
try:
|
|
533
|
-
await action.close(primary)
|
|
534
|
-
return primary, tuple(failures)
|
|
535
|
-
except asyncio.CancelledError as cancellation:
|
|
536
|
-
if primary is None:
|
|
537
|
-
primary = cancellation
|
|
538
|
-
else:
|
|
539
|
-
failures.append(cancellation)
|
|
540
|
-
except BaseException as error:
|
|
541
|
-
failures.append(error)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
def _raise_scope_failure(
|
|
545
|
-
primary: BaseException | None, failures: tuple[BaseException, ...], committed: bool
|
|
546
|
-
) -> None:
|
|
547
|
-
if failures:
|
|
548
|
-
inherited = primary.__cause__ if isinstance(primary, asyncio.CancelledError) else primary
|
|
549
|
-
if isinstance(inherited, ApplicationInvocationCleanupError):
|
|
550
|
-
failures = (*inherited.cleanup_failures, *failures)
|
|
551
|
-
if not isinstance(primary, asyncio.CancelledError):
|
|
552
|
-
primary = inherited.primary
|
|
553
|
-
failure = ApplicationInvocationCleanupError(
|
|
554
|
-
primary=primary, cleanup_failures=failures, committed=committed
|
|
555
|
-
)
|
|
556
|
-
if isinstance(primary, asyncio.CancelledError):
|
|
557
|
-
raise primary from failure
|
|
558
|
-
raise failure from (primary or failures[0])
|
|
559
|
-
if primary is not None:
|
|
560
|
-
raise primary
|
|
9
|
+
from ..invocation.runtime import _InvocationRuntime
|
|
10
|
+
from ..invocation.scopes import _managed_request
|
|
11
|
+
from .catalog import ApplicationServiceCatalog
|
|
561
12
|
|
|
562
13
|
|
|
563
14
|
class _ScopedApplicationServiceInvoker:
|
|
@@ -589,7 +40,7 @@ class _ScopedApplicationServiceInvoker:
|
|
|
589
40
|
async with self._runtime.outer_lease():
|
|
590
41
|
caller_token = self._runtime.bind_caller(current_user, correlation_id)
|
|
591
42
|
try:
|
|
592
|
-
async with parent
|
|
43
|
+
async with _managed_request(parent) as request_container:
|
|
593
44
|
proxy = await request_container.get(service.registration.contract)
|
|
594
45
|
call = cast(
|
|
595
46
|
Callable[..., Awaitable[object]],
|
|
@@ -604,10 +55,3 @@ class _ScopedApplicationServiceInvoker:
|
|
|
604
55
|
return
|
|
605
56
|
if parent.scope is not Scope.SESSION or parent.parent_container is not self._root_container:
|
|
606
57
|
raise RuntimeError("scoped invocation parent is not owned by this Application")
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
def _current_task() -> asyncio.Task[object]:
|
|
610
|
-
task = asyncio.current_task()
|
|
611
|
-
if task is None:
|
|
612
|
-
raise RuntimeError("Application Service invocation requires an asyncio Task")
|
|
613
|
-
return cast(asyncio.Task[object], task)
|