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.
Files changed (25) hide show
  1. python_ddd_framework/application/runtime.py +46 -19
  2. python_ddd_framework/application_services/invocation.py +88 -25
  3. python_ddd_framework/background_execution/local.py +20 -1
  4. python_ddd_framework/background_execution/processes.py +11 -1
  5. python_ddd_framework/background_workers/runtime.py +10 -1
  6. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/README.md +2 -1
  7. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_integration_service.py.jinja +31 -7
  8. python_ddd_framework/developer_kit/templates/module/{{cookiecutter.module_name}}/application/hosted_services/order_observation_handler.py.jinja +2 -2
  9. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md +7 -0
  10. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/architecture.md +2 -1
  11. python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/docs/development.md +11 -1
  12. python_ddd_framework/hosted_services/bridge.py +75 -16
  13. python_ddd_framework/hosted_services/contracts.py +5 -1
  14. python_ddd_framework/hosted_services/runtime.py +71 -17
  15. python_ddd_framework/lifecycle/participants.py +4 -0
  16. python_ddd_framework/messaging/channel.py +34 -7
  17. python_ddd_framework/messaging/contracts.py +9 -0
  18. python_ddd_framework/messaging/module.py +13 -0
  19. python_ddd_framework/messaging/runtime.py +16 -1
  20. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/METADATA +10 -2
  21. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/RECORD +25 -25
  22. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/WHEEL +0 -0
  23. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/entry_points.txt +0 -0
  24. {python_ddd_framework-0.4.0.dist-info → python_ddd_framework-0.5.1.dist-info}/licenses/LICENSE +0 -0
  25. {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, external_future=None)
104
+ self._admit(token, operation, permission=permission)
94
105
  task = asyncio.create_task(
95
- self._invoke_pipeline(
96
- correlation_id, method, args, kwargs, callable_entry=callable_entry
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, external_future=None)
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 = asyncio.run_coroutine_threadsafe(
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._pending.pop(token, None)
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
- external_future: concurrent.futures.Future[object] | None,
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
- self._pending[token] = _PendingInvocation(external_future=external_future)
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._invoke_pipeline(
222
- correlation_id, method, args, kwargs, callable_entry=callable_entry
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._pending.pop(token, None)
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._pending.pop(token, None)
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._pending.pop(token, None)
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(str(uuid4()), function, args, kwargs, callable_entry=True)
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(str(uuid4()), function, args, kwargs, callable_entry=True)
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 if name in failed_names else HostedServiceStatus.STOPPED,
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
- callback = cast(
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
- return await callback(reason)
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-stop:{name}"
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="stop",
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="stop", error_type="TimeoutError"
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="stop",
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(self, message: T) -> MessageReceipt:
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
- self._runtime.require_accepting_work()
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(self, message: T) -> concurrent.futures.Future[MessageReceipt]:
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 _close(self) -> None:
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
- return (*failures, *self._failures)
137
+ failures.extend(self._failures)
138
+ if failures:
139
+ raise BaseExceptionGroup("Message drain failed", failures)