python-ddd-framework 0.4.0__py3-none-any.whl → 0.5.1__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/invocation.py +88 -25
- python_ddd_framework/background_execution/local.py +20 -1
- python_ddd_framework/background_execution/processes.py +11 -1
- python_ddd_framework/background_workers/runtime.py +10 -1
- python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +2 -1
- 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/project/{{cookiecutter.project_name}}/AGENTS.md +7 -0
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +2 -1
- python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +11 -1
- python_ddd_framework/hosted_services/bridge.py +75 -16
- python_ddd_framework/hosted_services/contracts.py +5 -1
- python_ddd_framework/hosted_services/runtime.py +71 -17
- python_ddd_framework/lifecycle/participants.py +4 -0
- python_ddd_framework/messaging/channel.py +34 -7
- python_ddd_framework/messaging/contracts.py +9 -0
- python_ddd_framework/messaging/module.py +13 -0
- python_ddd_framework/messaging/runtime.py +16 -1
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/METADATA +10 -2
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/RECORD +25 -25
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/WHEEL +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/entry_points.txt +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/licenses/src/python_ddd_framework/background_jobs/pgqueuer/UPSTREAM_LICENSE.txt +0 -0
|
@@ -8,6 +8,7 @@ import functools
|
|
|
8
8
|
import inspect
|
|
9
9
|
import threading
|
|
10
10
|
from collections.abc import Awaitable, Callable
|
|
11
|
+
from contextvars import Context
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
from typing import Concatenate, ParamSpec, TypeVar, cast
|
|
13
14
|
from uuid import uuid4
|
|
@@ -15,10 +16,12 @@ from uuid import uuid4
|
|
|
15
16
|
from dishka import AsyncContainer
|
|
16
17
|
|
|
17
18
|
from ..application_services import ApplicationServiceCatalog, ApplicationServiceContract
|
|
19
|
+
from ..application_services.errors import ApplicationInvocationRejectedError
|
|
18
20
|
from ..application_services.invocation import (
|
|
19
21
|
_InvocationReservation,
|
|
20
22
|
_InvocationRuntime,
|
|
21
23
|
_ScopedApplicationServiceInvoker,
|
|
24
|
+
_StoppingPermission,
|
|
22
25
|
)
|
|
23
26
|
from ..authorization import CurrentUser
|
|
24
27
|
from ..invocation.contracts import _is_safe_correlation_id
|
|
@@ -33,6 +36,7 @@ _TContract = TypeVar("_TContract", bound=ApplicationServiceContract)
|
|
|
33
36
|
@dataclass(slots=True)
|
|
34
37
|
class _PendingInvocation:
|
|
35
38
|
external_future: concurrent.futures.Future[object] | None
|
|
39
|
+
reservation: _InvocationReservation
|
|
36
40
|
task: asyncio.Task[object] | None = None
|
|
37
41
|
|
|
38
42
|
|
|
@@ -48,6 +52,8 @@ class _HostedServiceBridge:
|
|
|
48
52
|
"_loop",
|
|
49
53
|
"_name",
|
|
50
54
|
"_pending",
|
|
55
|
+
"_runtime",
|
|
56
|
+
"_stopping_owners",
|
|
51
57
|
)
|
|
52
58
|
|
|
53
59
|
def __init__(
|
|
@@ -58,6 +64,8 @@ class _HostedServiceBridge:
|
|
|
58
64
|
container: AsyncContainer,
|
|
59
65
|
catalog: ApplicationServiceCatalog,
|
|
60
66
|
limit: int,
|
|
67
|
+
runtime: _InvocationRuntime,
|
|
68
|
+
stopping_owners: tuple[type[object], ...] = (),
|
|
61
69
|
) -> None:
|
|
62
70
|
self._name = name
|
|
63
71
|
self._loop = loop
|
|
@@ -67,6 +75,8 @@ class _HostedServiceBridge:
|
|
|
67
75
|
self._lock = threading.RLock()
|
|
68
76
|
self._pending: dict[object, _PendingInvocation] = {}
|
|
69
77
|
self._accepting = False
|
|
78
|
+
self._runtime = runtime
|
|
79
|
+
self._stopping_owners = stopping_owners
|
|
70
80
|
|
|
71
81
|
def open(self) -> None:
|
|
72
82
|
with self._lock:
|
|
@@ -84,18 +94,25 @@ class _HostedServiceBridge:
|
|
|
84
94
|
kwargs: dict[str, object],
|
|
85
95
|
*,
|
|
86
96
|
callable_entry: bool = False,
|
|
97
|
+
permission: _StoppingPermission | None = None,
|
|
87
98
|
) -> _R:
|
|
88
99
|
operation = "invoke"
|
|
89
100
|
self._require_application_loop(operation)
|
|
90
101
|
self._validate_call(operation, correlation_id, method, callable_entry=callable_entry)
|
|
91
102
|
token = object()
|
|
92
103
|
with self._lock:
|
|
93
|
-
self._admit(token, operation,
|
|
104
|
+
self._admit(token, operation, permission=permission)
|
|
94
105
|
task = asyncio.create_task(
|
|
95
|
-
self.
|
|
96
|
-
|
|
106
|
+
self._invoke_accepted(
|
|
107
|
+
self._pending[token].reservation,
|
|
108
|
+
correlation_id,
|
|
109
|
+
method,
|
|
110
|
+
args,
|
|
111
|
+
kwargs,
|
|
112
|
+
callable_entry=callable_entry,
|
|
97
113
|
),
|
|
98
114
|
name=f"python-ddd-framework-hosted-service-invoke:{self._name}",
|
|
115
|
+
context=Context(),
|
|
99
116
|
)
|
|
100
117
|
self._pending[token].task = task
|
|
101
118
|
task.add_done_callback(functools.partial(self._release_task, token))
|
|
@@ -109,24 +126,26 @@ class _HostedServiceBridge:
|
|
|
109
126
|
kwargs: dict[str, object],
|
|
110
127
|
*,
|
|
111
128
|
callable_entry: bool = False,
|
|
129
|
+
permission: _StoppingPermission | None = None,
|
|
112
130
|
) -> concurrent.futures.Future[_R]:
|
|
113
131
|
operation = "submit"
|
|
114
132
|
self._require_external_thread(operation)
|
|
115
133
|
self._validate_call(operation, correlation_id, method, callable_entry=callable_entry)
|
|
116
134
|
token = object()
|
|
117
135
|
with self._lock:
|
|
118
|
-
self._admit(token, operation,
|
|
136
|
+
self._admit(token, operation, permission=permission)
|
|
119
137
|
invocation = self._invoke_submitted(
|
|
120
138
|
token, correlation_id, method, args, kwargs, callable_entry=callable_entry
|
|
121
139
|
)
|
|
122
140
|
try:
|
|
123
|
-
external =
|
|
141
|
+
external = Context().run(
|
|
142
|
+
asyncio.run_coroutine_threadsafe,
|
|
124
143
|
invocation,
|
|
125
144
|
self._loop,
|
|
126
145
|
)
|
|
127
146
|
except RuntimeError:
|
|
128
147
|
invocation.close()
|
|
129
|
-
self.
|
|
148
|
+
self._release_pending(token)
|
|
130
149
|
raise HostedServiceInvocationRejectedError(
|
|
131
150
|
service=self._name,
|
|
132
151
|
operation=operation,
|
|
@@ -185,9 +204,11 @@ class _HostedServiceBridge:
|
|
|
185
204
|
token: object,
|
|
186
205
|
operation: str,
|
|
187
206
|
*,
|
|
188
|
-
|
|
207
|
+
permission: _StoppingPermission | None,
|
|
189
208
|
) -> None:
|
|
190
|
-
if not self._accepting
|
|
209
|
+
if (permission is None and not self._accepting) or (
|
|
210
|
+
permission is not None and permission.owner not in self._stopping_owners
|
|
211
|
+
):
|
|
191
212
|
raise HostedServiceInvocationRejectedError(
|
|
192
213
|
service=self._name,
|
|
193
214
|
operation=operation,
|
|
@@ -199,7 +220,13 @@ class _HostedServiceBridge:
|
|
|
199
220
|
operation=operation,
|
|
200
221
|
error_type="CapacityExceeded",
|
|
201
222
|
)
|
|
202
|
-
|
|
223
|
+
try:
|
|
224
|
+
reservation = self._runtime.reserve(permission)
|
|
225
|
+
except ApplicationInvocationRejectedError:
|
|
226
|
+
raise HostedServiceInvocationRejectedError(
|
|
227
|
+
service=self._name, operation=operation, error_type="NotAccepting"
|
|
228
|
+
) from None
|
|
229
|
+
self._pending[token] = _PendingInvocation(None, reservation)
|
|
203
230
|
|
|
204
231
|
async def _invoke_submitted(
|
|
205
232
|
self,
|
|
@@ -218,18 +245,28 @@ class _HostedServiceBridge:
|
|
|
218
245
|
assert pending is not None
|
|
219
246
|
pending.task = cast(asyncio.Task[object], task)
|
|
220
247
|
try:
|
|
221
|
-
return await self.
|
|
222
|
-
|
|
248
|
+
return await self._invoke_accepted(
|
|
249
|
+
pending.reservation,
|
|
250
|
+
correlation_id,
|
|
251
|
+
method,
|
|
252
|
+
args,
|
|
253
|
+
kwargs,
|
|
254
|
+
callable_entry=callable_entry,
|
|
223
255
|
)
|
|
224
256
|
finally:
|
|
225
257
|
# concurrent Future 可先进入 cancelled;只有 loop Task finalizer 完成才释放容量。
|
|
226
258
|
with self._lock:
|
|
227
|
-
self.
|
|
259
|
+
self._release_pending(token)
|
|
228
260
|
|
|
229
261
|
def _release_task(self, token: object, task: asyncio.Task[object]) -> None:
|
|
230
262
|
del task
|
|
231
263
|
with self._lock:
|
|
232
|
-
self.
|
|
264
|
+
self._release_pending(token)
|
|
265
|
+
|
|
266
|
+
def _release_pending(self, token: object) -> None:
|
|
267
|
+
pending = self._pending.pop(token, None)
|
|
268
|
+
if pending is not None and not pending.reservation.executing:
|
|
269
|
+
self._runtime.release_reservation(pending.reservation)
|
|
233
270
|
|
|
234
271
|
def _observe_external_completion(
|
|
235
272
|
self,
|
|
@@ -269,7 +306,22 @@ class _HostedServiceBridge:
|
|
|
269
306
|
and pending.task is None
|
|
270
307
|
and external.cancelled()
|
|
271
308
|
):
|
|
272
|
-
self.
|
|
309
|
+
self._release_pending(token)
|
|
310
|
+
|
|
311
|
+
async def _invoke_accepted(
|
|
312
|
+
self,
|
|
313
|
+
reservation: _InvocationReservation,
|
|
314
|
+
correlation_id: str,
|
|
315
|
+
method: Callable[..., Awaitable[object]],
|
|
316
|
+
args: tuple[object, ...],
|
|
317
|
+
kwargs: dict[str, object],
|
|
318
|
+
*,
|
|
319
|
+
callable_entry: bool,
|
|
320
|
+
) -> object:
|
|
321
|
+
async with self._runtime.reserved_lease(reservation):
|
|
322
|
+
return await self._invoke_pipeline(
|
|
323
|
+
correlation_id, method, args, kwargs, callable_entry=callable_entry
|
|
324
|
+
)
|
|
273
325
|
|
|
274
326
|
async def _invoke_pipeline(
|
|
275
327
|
self,
|
|
@@ -370,6 +422,7 @@ class HostedServiceContext:
|
|
|
370
422
|
|
|
371
423
|
name: str
|
|
372
424
|
_bridge: _HostedServiceBridge = field(repr=False, compare=False)
|
|
425
|
+
_permission: _StoppingPermission | None = field(default=None, repr=False, compare=False)
|
|
373
426
|
|
|
374
427
|
async def call(
|
|
375
428
|
self,
|
|
@@ -378,7 +431,9 @@ class HostedServiceContext:
|
|
|
378
431
|
*args: object,
|
|
379
432
|
**kwargs: object,
|
|
380
433
|
) -> _R:
|
|
381
|
-
return await self._bridge.invoke(
|
|
434
|
+
return await self._bridge.invoke(
|
|
435
|
+
str(uuid4()), function, args, kwargs, callable_entry=True, permission=self._permission
|
|
436
|
+
)
|
|
382
437
|
|
|
383
438
|
def submit_call(
|
|
384
439
|
self,
|
|
@@ -388,7 +443,9 @@ class HostedServiceContext:
|
|
|
388
443
|
**kwargs: object,
|
|
389
444
|
) -> concurrent.futures.Future[_R]:
|
|
390
445
|
"""外部线程继续经过同一 admission/drain bridge,不直接接触异步容器。"""
|
|
391
|
-
return self._bridge.submit(
|
|
446
|
+
return self._bridge.submit(
|
|
447
|
+
str(uuid4()), function, args, kwargs, callable_entry=True, permission=self._permission
|
|
448
|
+
)
|
|
392
449
|
|
|
393
450
|
async def invoke(
|
|
394
451
|
self,
|
|
@@ -412,6 +469,7 @@ class HostedServiceContext:
|
|
|
412
469
|
method,
|
|
413
470
|
cast(tuple[object, ...], args),
|
|
414
471
|
cast(dict[str, object], kwargs),
|
|
472
|
+
permission=self._permission,
|
|
415
473
|
)
|
|
416
474
|
|
|
417
475
|
def submit(
|
|
@@ -436,4 +494,5 @@ class HostedServiceContext:
|
|
|
436
494
|
method,
|
|
437
495
|
cast(tuple[object, ...], args),
|
|
438
496
|
cast(dict[str, object], kwargs),
|
|
497
|
+
permission=self._permission,
|
|
439
498
|
)
|
|
@@ -27,6 +27,10 @@ class HostedService(ABC):
|
|
|
27
27
|
async def start(self, context: HostedServiceContext) -> None:
|
|
28
28
|
raise NotImplementedError
|
|
29
29
|
|
|
30
|
+
async def stopping(self, context: HostedServiceContext, reason: ShutdownReason) -> None:
|
|
31
|
+
"""在依赖仍存活时发起业务收尾并等待回调;context 只在本钩子内有效。"""
|
|
32
|
+
return None
|
|
33
|
+
|
|
30
34
|
@abstractmethod
|
|
31
35
|
async def stop(self, reason: ShutdownReason) -> None:
|
|
32
36
|
raise NotImplementedError
|
|
@@ -52,7 +56,7 @@ def _validate_service_type(candidate: type[HostedService]) -> None:
|
|
|
52
56
|
)
|
|
53
57
|
if inspect.isabstract(candidate):
|
|
54
58
|
raise HostedServiceDefinitionError(service=name, reason="service type must be concrete")
|
|
55
|
-
for operation in ("start", "stop"):
|
|
59
|
+
for operation in ("start", "stopping", "stop"):
|
|
56
60
|
if not inspect.iscoroutinefunction(getattr(candidate, operation, None)):
|
|
57
61
|
raise HostedServiceDefinitionError(
|
|
58
62
|
service=name, reason=f"{operation} must use async def"
|
|
@@ -10,6 +10,7 @@ from typing import cast
|
|
|
10
10
|
from dishka import AsyncContainer
|
|
11
11
|
|
|
12
12
|
from ..application_services import ApplicationServiceCatalog
|
|
13
|
+
from ..application_services.invocation import _InvocationRuntime
|
|
13
14
|
from ..lifecycle import ApplicationState, RuntimeParticipant, ShutdownReason
|
|
14
15
|
from .bridge import HostedServiceContext, _HostedServiceBridge
|
|
15
16
|
from .catalog import HostedServiceCatalog, HostedServiceRegistration
|
|
@@ -25,6 +26,8 @@ class _HostedServiceEntry:
|
|
|
25
26
|
service: HostedService
|
|
26
27
|
context: HostedServiceContext
|
|
27
28
|
bridge: _HostedServiceBridge
|
|
29
|
+
started: bool = False
|
|
30
|
+
failed: bool = False
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
class _HostedServiceCoordinator:
|
|
@@ -97,7 +100,10 @@ class _HostedServiceCoordinator:
|
|
|
97
100
|
prepare=prepare,
|
|
98
101
|
start=start,
|
|
99
102
|
stop=self.stop,
|
|
103
|
+
quiesce=self.close_admission,
|
|
104
|
+
stopping=self.stopping,
|
|
100
105
|
close_admission=self.close_admission,
|
|
106
|
+
drain=self.drain,
|
|
101
107
|
ready=lambda: self.readiness().ready,
|
|
102
108
|
)
|
|
103
109
|
|
|
@@ -126,6 +132,7 @@ class _HostedServiceCoordinator:
|
|
|
126
132
|
if container is None or application_service_catalog is None:
|
|
127
133
|
raise RuntimeError("Hosted Service coordinator container is unavailable")
|
|
128
134
|
self._started = True
|
|
135
|
+
runtime = await container.get(_InvocationRuntime)
|
|
129
136
|
for registration in self._registrations:
|
|
130
137
|
self._state_owner.set_status(registration.name, HostedServiceStatus.STARTING)
|
|
131
138
|
try:
|
|
@@ -150,17 +157,55 @@ class _HostedServiceCoordinator:
|
|
|
150
157
|
container=container,
|
|
151
158
|
catalog=application_service_catalog,
|
|
152
159
|
limit=self._options.max_pending_invocations,
|
|
160
|
+
runtime=runtime,
|
|
161
|
+
stopping_owners=(registration.service_type,),
|
|
153
162
|
)
|
|
154
163
|
context = HostedServiceContext(registration.name, bridge)
|
|
155
164
|
entry = _HostedServiceEntry(registration, resolved, context, bridge)
|
|
156
165
|
self._entries.append(entry)
|
|
157
166
|
bridge.open()
|
|
158
167
|
await self._start_entry(entry)
|
|
168
|
+
entry.started = True
|
|
159
169
|
|
|
160
170
|
def close_admission(self) -> None:
|
|
161
171
|
for entry in self._entries:
|
|
162
172
|
entry.bridge.close()
|
|
163
173
|
|
|
174
|
+
async def stopping(self, reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
175
|
+
failures: list[BaseException] = []
|
|
176
|
+
for entry in reversed(self._entries):
|
|
177
|
+
self._state_owner.set_status(entry.registration.name, HostedServiceStatus.STOPPING)
|
|
178
|
+
if not entry.started:
|
|
179
|
+
continue
|
|
180
|
+
assert self._container is not None
|
|
181
|
+
runtime = await self._container.get(_InvocationRuntime)
|
|
182
|
+
permission = runtime.grant_stopping(entry.registration.service_type)
|
|
183
|
+
context = HostedServiceContext(entry.registration.name, entry.bridge, permission)
|
|
184
|
+
try:
|
|
185
|
+
failure = await self._stop_entry(entry, reason, context=context)
|
|
186
|
+
except BaseException as error:
|
|
187
|
+
failures.append(error)
|
|
188
|
+
entry.failed = True
|
|
189
|
+
else:
|
|
190
|
+
if failure is not None:
|
|
191
|
+
failures.append(failure)
|
|
192
|
+
entry.failed = True
|
|
193
|
+
finally:
|
|
194
|
+
runtime.revoke_stopping(permission)
|
|
195
|
+
return tuple(failures)
|
|
196
|
+
|
|
197
|
+
async def drain(self) -> None:
|
|
198
|
+
outcomes = await asyncio.gather(
|
|
199
|
+
*(
|
|
200
|
+
entry.bridge.drain(self._options.shutdown_timeout.total_seconds())
|
|
201
|
+
for entry in self._entries
|
|
202
|
+
),
|
|
203
|
+
return_exceptions=True,
|
|
204
|
+
)
|
|
205
|
+
failures = tuple(item for item in outcomes if isinstance(item, BaseException))
|
|
206
|
+
if failures:
|
|
207
|
+
raise BaseExceptionGroup("Hosted Service invocation drain failed", failures)
|
|
208
|
+
|
|
164
209
|
async def stop(
|
|
165
210
|
self,
|
|
166
211
|
reason: ShutdownReason,
|
|
@@ -176,19 +221,14 @@ class _HostedServiceCoordinator:
|
|
|
176
221
|
if failure is not None:
|
|
177
222
|
failures.append(failure)
|
|
178
223
|
|
|
179
|
-
if self._entries:
|
|
180
|
-
await asyncio.gather(
|
|
181
|
-
*(
|
|
182
|
-
entry.bridge.drain(self._options.shutdown_timeout.total_seconds())
|
|
183
|
-
for entry in self._entries
|
|
184
|
-
)
|
|
185
|
-
)
|
|
186
224
|
failed_names = {failure.service for failure in failures}
|
|
187
225
|
for entry in self._entries:
|
|
188
226
|
name = entry.registration.name
|
|
189
227
|
self._state_owner.set_status(
|
|
190
228
|
name,
|
|
191
|
-
HostedServiceStatus.FAILED
|
|
229
|
+
HostedServiceStatus.FAILED
|
|
230
|
+
if name in failed_names or entry.failed
|
|
231
|
+
else HostedServiceStatus.STOPPED,
|
|
192
232
|
)
|
|
193
233
|
self._started = False
|
|
194
234
|
return tuple(failures)
|
|
@@ -253,18 +293,29 @@ class _HostedServiceCoordinator:
|
|
|
253
293
|
self,
|
|
254
294
|
entry: _HostedServiceEntry,
|
|
255
295
|
reason: ShutdownReason,
|
|
296
|
+
*,
|
|
297
|
+
context: HostedServiceContext | None = None,
|
|
256
298
|
) -> HostedServiceLifecycleError | None:
|
|
257
299
|
name = entry.registration.name
|
|
258
|
-
|
|
259
|
-
Callable[[ShutdownReason], Awaitable[object]],
|
|
260
|
-
entry.service.stop,
|
|
261
|
-
)
|
|
300
|
+
operation = "stopping" if context is not None else "stop"
|
|
262
301
|
|
|
263
302
|
async def invoke_stop() -> object:
|
|
264
|
-
|
|
303
|
+
if context is None:
|
|
304
|
+
callback = cast(Callable[[ShutdownReason], Awaitable[object]], entry.service.stop)
|
|
305
|
+
return await callback(reason)
|
|
306
|
+
stopping_callback = cast(
|
|
307
|
+
Callable[[HostedServiceContext, ShutdownReason], Awaitable[object]],
|
|
308
|
+
entry.service.stopping,
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
return await stopping_callback(context, reason)
|
|
312
|
+
finally:
|
|
313
|
+
# 返回和失败都在用户钩子的 task 内撤权,不留下一轮调度的准入窗口。
|
|
314
|
+
assert context._permission is not None
|
|
315
|
+
context._permission.runtime.revoke_stopping(context._permission)
|
|
265
316
|
|
|
266
317
|
stop_task = asyncio.create_task(
|
|
267
|
-
invoke_stop(), name=f"python-ddd-framework-hosted-service-
|
|
318
|
+
invoke_stop(), name=f"python-ddd-framework-hosted-service-{operation}:{name}"
|
|
268
319
|
)
|
|
269
320
|
done, _ = await asyncio.wait(
|
|
270
321
|
(stop_task,), timeout=self._options.shutdown_timeout.total_seconds()
|
|
@@ -272,25 +323,28 @@ class _HostedServiceCoordinator:
|
|
|
272
323
|
timed_out = not done
|
|
273
324
|
if timed_out:
|
|
274
325
|
# 取消只是一项请求。实现可能在 finally 中异步释放 SDK 资源;完成前不能释放容器。
|
|
326
|
+
if context is not None:
|
|
327
|
+
assert context._permission is not None
|
|
328
|
+
context._permission.runtime.revoke_stopping(context._permission)
|
|
275
329
|
stop_task.cancel()
|
|
276
330
|
try:
|
|
277
331
|
result = await stop_task
|
|
278
332
|
except BaseException as original_error:
|
|
279
333
|
return HostedServiceLifecycleError(
|
|
280
334
|
service=name,
|
|
281
|
-
operation=
|
|
335
|
+
operation=operation,
|
|
282
336
|
error_type="TimeoutError"
|
|
283
337
|
if timed_out and isinstance(original_error, asyncio.CancelledError)
|
|
284
338
|
else type(original_error).__name__,
|
|
285
339
|
)
|
|
286
340
|
if timed_out:
|
|
287
341
|
return HostedServiceLifecycleError(
|
|
288
|
-
service=name, operation=
|
|
342
|
+
service=name, operation=operation, error_type="TimeoutError"
|
|
289
343
|
)
|
|
290
344
|
if result is not None:
|
|
291
345
|
return HostedServiceLifecycleError(
|
|
292
346
|
service=name,
|
|
293
|
-
operation=
|
|
347
|
+
operation=operation,
|
|
294
348
|
error_type="InvalidReturnValue",
|
|
295
349
|
)
|
|
296
350
|
return None
|
|
@@ -16,6 +16,8 @@ class RuntimeParticipant:
|
|
|
16
16
|
ready: Callable[[], bool] | None = None
|
|
17
17
|
failure: Callable[[], Awaitable[ApplicationRuntimeError] | None] | None = None
|
|
18
18
|
drain: Callable[[], Awaitable[None]] | None = None
|
|
19
|
+
quiesce: Callable[[], None] | None = None
|
|
20
|
+
stopping: Callable[[ShutdownReason], Awaitable[tuple[BaseException, ...]]] | None = None
|
|
19
21
|
|
|
20
22
|
def __post_init__(self) -> None:
|
|
21
23
|
if not callable(self.start) or not callable(self.stop):
|
|
@@ -28,6 +30,8 @@ class RuntimeParticipant:
|
|
|
28
30
|
self.ready,
|
|
29
31
|
self.failure,
|
|
30
32
|
self.drain,
|
|
33
|
+
self.quiesce,
|
|
34
|
+
self.stopping,
|
|
31
35
|
)
|
|
32
36
|
):
|
|
33
37
|
raise TypeError("runtime callbacks must be callable")
|
|
@@ -38,9 +38,11 @@ class MessageChannel(Generic[T]):
|
|
|
38
38
|
|
|
39
39
|
def __init__(self, definition: MessageChannelDefinition[T]) -> None:
|
|
40
40
|
self._definition = definition
|
|
41
|
+
self._stopping_owners = definition.stopping_owners
|
|
41
42
|
self._pending: dict[Hashable, _Delivery[T]] = {}
|
|
42
43
|
self._active: _Delivery[T] | None = None
|
|
43
44
|
self._accepting = False
|
|
45
|
+
self._closed = True
|
|
44
46
|
self._failure: BaseException | None = None
|
|
45
47
|
self._context: HostedServiceContext | None = None
|
|
46
48
|
self._bridge: _HostedServiceBridge | None = None
|
|
@@ -49,16 +51,24 @@ class MessageChannel(Generic[T]):
|
|
|
49
51
|
self._send: MemoryObjectSendStream[Hashable] | None = None
|
|
50
52
|
self._receive: MemoryObjectReceiveStream[Hashable] | None = None
|
|
51
53
|
|
|
52
|
-
async def send(
|
|
54
|
+
async def send(
|
|
55
|
+
self, message: T, *, stopping: HostedServiceContext | None = None
|
|
56
|
+
) -> MessageReceipt:
|
|
53
57
|
"""返回只表示接受;本入口无等待发送者、无后台发送 task。"""
|
|
54
58
|
self._require_loop()
|
|
55
59
|
if self._failure is not None:
|
|
56
60
|
raise MessageRejectedError("consumer_failed")
|
|
57
|
-
if not self._accepting:
|
|
61
|
+
if self._closed or (stopping is None and not self._accepting):
|
|
58
62
|
raise MessageRejectedError("closed")
|
|
59
63
|
assert self._runtime is not None
|
|
64
|
+
permission = stopping._permission if stopping is not None else None
|
|
60
65
|
try:
|
|
61
|
-
|
|
66
|
+
if stopping is None:
|
|
67
|
+
self._runtime.require_accepting_work()
|
|
68
|
+
elif permission is not None and permission.owner in self._stopping_owners:
|
|
69
|
+
self._runtime.require_stopping(permission)
|
|
70
|
+
else:
|
|
71
|
+
raise MessageRejectedError("closed")
|
|
62
72
|
except ApplicationInvocationRejectedError:
|
|
63
73
|
raise MessageRejectedError("closed") from None
|
|
64
74
|
if not isinstance(message, self._definition.message_type):
|
|
@@ -76,18 +86,24 @@ class MessageChannel(Generic[T]):
|
|
|
76
86
|
raise MessageRejectedError("capacity_exceeded")
|
|
77
87
|
assert self._runtime is not None and self._send is not None
|
|
78
88
|
# 接受与许可预留在同一 loop turn 完成;slot 的许可随最终快照转交,不携带 scope。
|
|
79
|
-
reservation = self._runtime.reserve()
|
|
89
|
+
reservation = self._runtime.reserve(permission)
|
|
80
90
|
self._pending[key] = _Delivery(message, receipt, reservation)
|
|
81
91
|
self._send.send_nowait(key)
|
|
82
92
|
return receipt
|
|
83
93
|
|
|
84
|
-
def submit(
|
|
94
|
+
def submit(
|
|
95
|
+
self, message: T, *, stopping: HostedServiceContext | None = None
|
|
96
|
+
) -> concurrent.futures.Future[MessageReceipt]:
|
|
85
97
|
"""外部线程入口;bridge pending 上限等于通道容量,Future 结果才表示接受。"""
|
|
86
98
|
context = self._context
|
|
87
99
|
if context is None:
|
|
88
100
|
raise MessageRejectedError("closed")
|
|
101
|
+
if stopping is not None:
|
|
102
|
+
if stopping._permission is None:
|
|
103
|
+
raise MessageRejectedError("closed")
|
|
104
|
+
context = HostedServiceContext(context.name, context._bridge, stopping._permission)
|
|
89
105
|
# to_thread 可能复制生产者的 ContextVar;线程交接只传消息值,不传调用 scope。
|
|
90
|
-
return Context().run(context.submit_call, self.send, message)
|
|
106
|
+
return Context().run(context.submit_call, self.send, message, stopping=stopping)
|
|
91
107
|
|
|
92
108
|
def _start(
|
|
93
109
|
self,
|
|
@@ -110,17 +126,28 @@ class MessageChannel(Generic[T]):
|
|
|
110
126
|
container=container,
|
|
111
127
|
catalog=catalog,
|
|
112
128
|
limit=self._definition.capacity,
|
|
129
|
+
runtime=runtime,
|
|
130
|
+
stopping_owners=self._stopping_owners,
|
|
113
131
|
)
|
|
114
132
|
self._context = HostedServiceContext(
|
|
115
133
|
self._definition.message_type.__qualname__, self._bridge
|
|
116
134
|
)
|
|
117
135
|
self._bridge.open()
|
|
118
136
|
self._accepting = True
|
|
137
|
+
self._closed = False
|
|
119
138
|
|
|
120
|
-
def
|
|
139
|
+
def _quiesce(self) -> None:
|
|
121
140
|
self._accepting = False
|
|
122
141
|
if self._bridge is not None:
|
|
123
142
|
self._bridge.close()
|
|
143
|
+
|
|
144
|
+
def _assert_unchanged(self) -> None:
|
|
145
|
+
if self._definition.stopping_owners != self._stopping_owners:
|
|
146
|
+
raise TypeError("Message stopping owners changed after build")
|
|
147
|
+
|
|
148
|
+
def _close(self) -> None:
|
|
149
|
+
self._quiesce()
|
|
150
|
+
self._closed = True
|
|
124
151
|
if self._send is not None:
|
|
125
152
|
# 发送端仅由此 owner 持有,不向生产者暴露 clone;因此关闭即关闭全部发送端。
|
|
126
153
|
self._send.close()
|
|
@@ -9,6 +9,7 @@ from dataclasses import dataclass
|
|
|
9
9
|
from enum import Enum
|
|
10
10
|
from typing import Generic, TypeVar
|
|
11
11
|
|
|
12
|
+
from ..hosted_services.contracts import HostedService
|
|
12
13
|
from ..modularity import ModuleDeclaration, ModuleDependency, ModuleRef
|
|
13
14
|
|
|
14
15
|
T = TypeVar("T")
|
|
@@ -25,8 +26,16 @@ class MessageChannelDefinition(ModuleDeclaration, Generic[T]):
|
|
|
25
26
|
handler: type[MessageHandler[T]]
|
|
26
27
|
capacity: int = 64
|
|
27
28
|
snapshot_key: Callable[[T], Hashable] | None = None
|
|
29
|
+
stopping_owners: tuple[type[HostedService], ...] = ()
|
|
28
30
|
|
|
29
31
|
def __post_init__(self) -> None:
|
|
32
|
+
if not isinstance(self.stopping_owners, tuple) or any(
|
|
33
|
+
not isinstance(owner, type) or not issubclass(owner, HostedService)
|
|
34
|
+
for owner in self.stopping_owners
|
|
35
|
+
):
|
|
36
|
+
raise TypeError("stopping_owners must be an immutable tuple of HostedService types")
|
|
37
|
+
if len(set(self.stopping_owners)) != len(self.stopping_owners):
|
|
38
|
+
raise ValueError("stopping_owners must not contain duplicates")
|
|
30
39
|
if not isinstance(self.message_type, type):
|
|
31
40
|
raise TypeError("message_type must be a concrete type")
|
|
32
41
|
if (
|
|
@@ -35,10 +35,23 @@ class MessagingModule(AppModule):
|
|
|
35
35
|
def _bind(self, context: ServiceBindingContext) -> None:
|
|
36
36
|
invocation = context.modules.instance(InvocationModule)
|
|
37
37
|
definitions = context.modules.declarations(MessageChannelDefinition)
|
|
38
|
+
hosted = context.modules.instance(HostedServicesModule).catalog
|
|
38
39
|
channels: dict[type[object], MessageChannel[object]] = {}
|
|
39
40
|
provider = Provider()
|
|
40
41
|
for owner, raw_definition in definitions:
|
|
41
42
|
definition = cast(MessageChannelDefinition[object], raw_definition)
|
|
43
|
+
for service_type in definition.stopping_owners:
|
|
44
|
+
registration = next(
|
|
45
|
+
(item for item in hosted.registrations if item.service_type is service_type),
|
|
46
|
+
None,
|
|
47
|
+
)
|
|
48
|
+
if registration is None:
|
|
49
|
+
raise TypeError("stopping owner must be declared in HostedServiceCatalog")
|
|
50
|
+
context.modules.require_dependency(
|
|
51
|
+
owner.key,
|
|
52
|
+
context.modules[registration.owner].module_type,
|
|
53
|
+
source=owner.declaration_source,
|
|
54
|
+
)
|
|
42
55
|
if definition.message_type in channels:
|
|
43
56
|
raise ValueError("A message type must have exactly one channel owner")
|
|
44
57
|
factory = context.arbitration.single_factory(DependencyKey(definition.handler, ""))
|
|
@@ -40,12 +40,16 @@ class _MessagingRuntime:
|
|
|
40
40
|
prepare=self._prepare,
|
|
41
41
|
start=self.start,
|
|
42
42
|
stop=self.stop,
|
|
43
|
+
quiesce=self.quiesce,
|
|
43
44
|
close_admission=self.close,
|
|
45
|
+
drain=self.drain,
|
|
44
46
|
ready=self.ready,
|
|
45
47
|
failure=self.failure,
|
|
46
48
|
)
|
|
47
49
|
|
|
48
50
|
def _prepare(self, enabled: bool) -> None:
|
|
51
|
+
for channel in self.channels:
|
|
52
|
+
channel._assert_unchanged()
|
|
49
53
|
self._enabled = enabled
|
|
50
54
|
|
|
51
55
|
def ready(self) -> bool:
|
|
@@ -97,6 +101,11 @@ class _MessagingRuntime:
|
|
|
97
101
|
for channel in self.channels:
|
|
98
102
|
channel._close()
|
|
99
103
|
|
|
104
|
+
def quiesce(self) -> None:
|
|
105
|
+
self._stopping = True
|
|
106
|
+
for channel in self.channels:
|
|
107
|
+
channel._quiesce()
|
|
108
|
+
|
|
100
109
|
def failure(self) -> Awaitable[ApplicationRuntimeError] | None:
|
|
101
110
|
return self._wait_failure() if self._enabled else None
|
|
102
111
|
|
|
@@ -105,6 +114,10 @@ class _MessagingRuntime:
|
|
|
105
114
|
return self._failures[0]
|
|
106
115
|
|
|
107
116
|
async def stop(self, reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
117
|
+
self.close()
|
|
118
|
+
return ()
|
|
119
|
+
|
|
120
|
+
async def drain(self) -> None:
|
|
108
121
|
import anyio
|
|
109
122
|
|
|
110
123
|
self._stopping = True
|
|
@@ -121,4 +134,6 @@ class _MessagingRuntime:
|
|
|
121
134
|
await self._task
|
|
122
135
|
for channel in self.channels:
|
|
123
136
|
await channel._drain_bridge(timeout)
|
|
124
|
-
|
|
137
|
+
failures.extend(self._failures)
|
|
138
|
+
if failures:
|
|
139
|
+
raise BaseExceptionGroup("Message drain failed", failures)
|