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
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
7
|
+
from functools import partial
|
|
6
8
|
from typing import Never
|
|
7
9
|
from uuid import UUID
|
|
8
10
|
|
|
@@ -155,27 +157,25 @@ class Application:
|
|
|
155
157
|
|
|
156
158
|
async def _stop_runtimes(self, reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
157
159
|
failures: list[BaseException] = []
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
+
# 每个阶段都是全局屏障;收尾与排空完成前,不释放任何参与者的依赖资源。
|
|
161
|
+
# 用户钩子只交给触达过 start 的参与者;预启动请求仍由独立 drain 收口。
|
|
160
162
|
participants = tuple(reversed(self._root.touched_runtimes))
|
|
161
|
-
for
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
for
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
failures.extend(_call_shutdown_callbacks(item.quiesce for item in participants))
|
|
164
|
+
failures.extend(
|
|
165
|
+
await _await_shutdown_callbacks(
|
|
166
|
+
partial(item.stopping, reason) for item in participants if item.stopping is not None
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
failures.extend(_call_shutdown_callbacks(item.close_admission for item in participants))
|
|
170
|
+
failures.extend(
|
|
171
|
+
await _await_shutdown_callbacks(
|
|
172
|
+
item.drain for item in reversed(self._root.runtimes) if item.drain is not None
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
failures.extend(
|
|
176
|
+
await _await_shutdown_callbacks(partial(item.stop, reason) for item in participants)
|
|
177
|
+
)
|
|
172
178
|
self._root.touched_runtimes.clear()
|
|
173
|
-
for participant in reversed(self._root.runtimes):
|
|
174
|
-
if participant.drain is not None:
|
|
175
|
-
try:
|
|
176
|
-
await participant.drain()
|
|
177
|
-
except BaseException as error:
|
|
178
|
-
failures.append(error)
|
|
179
179
|
return tuple(failures)
|
|
180
180
|
|
|
181
181
|
async def stop(self) -> None:
|
|
@@ -352,3 +352,30 @@ class Application:
|
|
|
352
352
|
if not watcher.done():
|
|
353
353
|
watcher.cancel()
|
|
354
354
|
await asyncio.gather(*watchers, return_exceptions=True)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _call_shutdown_callbacks(
|
|
358
|
+
callbacks: Iterable[Callable[[], None] | None],
|
|
359
|
+
) -> tuple[BaseException, ...]:
|
|
360
|
+
failures: list[BaseException] = []
|
|
361
|
+
for callback in callbacks:
|
|
362
|
+
if callback is not None:
|
|
363
|
+
try:
|
|
364
|
+
callback()
|
|
365
|
+
except BaseException as error:
|
|
366
|
+
failures.append(error)
|
|
367
|
+
return tuple(failures)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def _await_shutdown_callbacks(
|
|
371
|
+
callbacks: Iterable[Callable[[], Awaitable[tuple[BaseException, ...] | None]]],
|
|
372
|
+
) -> tuple[BaseException, ...]:
|
|
373
|
+
failures: list[BaseException] = []
|
|
374
|
+
for callback in callbacks:
|
|
375
|
+
try:
|
|
376
|
+
result = await callback()
|
|
377
|
+
if result is not None:
|
|
378
|
+
failures.extend(result)
|
|
379
|
+
except BaseException as error:
|
|
380
|
+
failures.append(error)
|
|
381
|
+
return tuple(failures)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
+
import threading
|
|
7
8
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
8
9
|
from contextlib import asynccontextmanager, contextmanager
|
|
9
10
|
from contextvars import ContextVar, Token
|
|
@@ -70,20 +71,31 @@ class _InvocationReservation:
|
|
|
70
71
|
executing: bool = False
|
|
71
72
|
|
|
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
|
+
|
|
73
82
|
class _InvocationRuntime:
|
|
74
83
|
"""每个 Application 独占 admission 与 ContextVar,禁止进程级 current app。"""
|
|
75
84
|
|
|
76
85
|
__slots__ = (
|
|
77
86
|
"_active",
|
|
87
|
+
"_admission_lock",
|
|
78
88
|
"_ambient",
|
|
79
89
|
"_application_id",
|
|
80
90
|
"_authenticating",
|
|
81
91
|
"_call_scope",
|
|
82
92
|
"_caller",
|
|
83
|
-
"
|
|
93
|
+
"_changed",
|
|
84
94
|
"_http_method",
|
|
85
95
|
"_is_accepting",
|
|
86
96
|
"_lease",
|
|
97
|
+
"_loop",
|
|
98
|
+
"_permissions",
|
|
87
99
|
"_reservations",
|
|
88
100
|
"_state_getter",
|
|
89
101
|
"tracer",
|
|
@@ -116,7 +128,10 @@ class _InvocationRuntime:
|
|
|
116
128
|
f"python_ddd_framework_invocation_lease_{application_id}",
|
|
117
129
|
default=None,
|
|
118
130
|
)
|
|
119
|
-
self.
|
|
131
|
+
self._admission_lock = threading.RLock()
|
|
132
|
+
self._changed = asyncio.Event()
|
|
133
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
134
|
+
self._permissions: set[_StoppingPermission] = set()
|
|
120
135
|
self._is_accepting = False
|
|
121
136
|
self._active = 0
|
|
122
137
|
self._reservations: set[_InvocationReservation] = set()
|
|
@@ -166,6 +181,7 @@ class _InvocationRuntime:
|
|
|
166
181
|
return self._application_id
|
|
167
182
|
|
|
168
183
|
def start_accepting(self) -> None:
|
|
184
|
+
self._loop = asyncio.get_running_loop()
|
|
169
185
|
self._is_accepting = True
|
|
170
186
|
|
|
171
187
|
def runtime_participant(self) -> RuntimeParticipant:
|
|
@@ -173,27 +189,67 @@ class _InvocationRuntime:
|
|
|
173
189
|
self.start_accepting()
|
|
174
190
|
|
|
175
191
|
async def stop(reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
176
|
-
self.
|
|
192
|
+
self.close_admission()
|
|
177
193
|
return ()
|
|
178
194
|
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
)
|
|
181
202
|
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
def quiesce(self) -> None:
|
|
204
|
+
with self._admission_lock:
|
|
184
205
|
self._is_accepting = False
|
|
185
|
-
await self._condition.wait_for(lambda: self._active == 0)
|
|
186
206
|
|
|
187
|
-
def
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
194
251
|
|
|
195
252
|
def require_accepting_work(self) -> None:
|
|
196
|
-
_current_task()
|
|
197
253
|
if not self._is_accepting or self._state_getter() not in (
|
|
198
254
|
ApplicationState.STARTING,
|
|
199
255
|
ApplicationState.RUNNING,
|
|
@@ -201,13 +257,20 @@ class _InvocationRuntime:
|
|
|
201
257
|
raise ApplicationInvocationRejectedError(reason="Application is not accepting work")
|
|
202
258
|
|
|
203
259
|
async def discard(self, reservation: _InvocationReservation) -> None:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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")
|
|
207
266
|
self._reservations.remove(reservation)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
self.
|
|
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
|
|
211
274
|
|
|
212
275
|
@asynccontextmanager
|
|
213
276
|
async def reserved_lease(self, reservation: _InvocationReservation) -> AsyncIterator[None]:
|
|
@@ -246,7 +309,7 @@ class _InvocationRuntime:
|
|
|
246
309
|
yield
|
|
247
310
|
return
|
|
248
311
|
|
|
249
|
-
|
|
312
|
+
with self._admission_lock:
|
|
250
313
|
state = self._state_getter()
|
|
251
314
|
# HostedService.start 可调用业务入口,但全部后台启动成功前 readiness 仍为 false。
|
|
252
315
|
allows_invocation = self._is_accepting and state in (
|
|
@@ -266,11 +329,11 @@ class _InvocationRuntime:
|
|
|
266
329
|
yield
|
|
267
330
|
finally:
|
|
268
331
|
self._lease.reset(token)
|
|
269
|
-
|
|
332
|
+
with self._admission_lock:
|
|
270
333
|
assert self._active > 0
|
|
271
334
|
self._active -= 1
|
|
272
335
|
if self._active == 0:
|
|
273
|
-
self.
|
|
336
|
+
self._changed.set()
|
|
274
337
|
|
|
275
338
|
def current(self) -> _InvocationState | None:
|
|
276
339
|
return self._ambient.get()
|
|
@@ -30,12 +30,19 @@ class _LocalTask:
|
|
|
30
30
|
self.operation: asyncio.Task[None] | None = None
|
|
31
31
|
self._failed = failed
|
|
32
32
|
self._monitor: asyncio.Task[None] | None = None
|
|
33
|
+
self._quiescing = False
|
|
34
|
+
self._claiming_stopped = False
|
|
33
35
|
|
|
34
36
|
async def start(self) -> None:
|
|
37
|
+
self._claiming_stopped = False
|
|
35
38
|
self.state = BackgroundTaskState.STARTING
|
|
36
39
|
if self.execution is None:
|
|
37
40
|
raise RuntimeError("Task execution provider is unavailable")
|
|
38
41
|
await self.execution.start()
|
|
42
|
+
if self._quiescing:
|
|
43
|
+
# start 可能与关闭重叠;完成后再次请求停止,不能重新打开领取。
|
|
44
|
+
self._claiming_stopped = False
|
|
45
|
+
self._stop_claiming()
|
|
39
46
|
self.state = BackgroundTaskState.RUNNING
|
|
40
47
|
if self.execution.wait_for_failure is not None:
|
|
41
48
|
self._monitor = asyncio.create_task(
|
|
@@ -82,6 +89,9 @@ class _LocalTask:
|
|
|
82
89
|
async def _operate(self, start: bool) -> None:
|
|
83
90
|
try:
|
|
84
91
|
if start:
|
|
92
|
+
if self._quiescing:
|
|
93
|
+
self.state = BackgroundTaskState.STOPPED
|
|
94
|
+
return
|
|
85
95
|
await self.start()
|
|
86
96
|
else:
|
|
87
97
|
await self._drain()
|
|
@@ -91,6 +101,15 @@ class _LocalTask:
|
|
|
91
101
|
self.state = BackgroundTaskState.FAILED
|
|
92
102
|
# 失败转为 Application-owned 状态供查询;task 不留下未观察异常。
|
|
93
103
|
|
|
104
|
+
def quiesce(self) -> None:
|
|
105
|
+
self._quiescing = True
|
|
106
|
+
self._stop_claiming()
|
|
107
|
+
|
|
108
|
+
def _stop_claiming(self) -> None:
|
|
109
|
+
if self.execution is not None and not self._claiming_stopped:
|
|
110
|
+
self._claiming_stopped = True
|
|
111
|
+
self.execution.stop_claiming()
|
|
112
|
+
|
|
94
113
|
async def shutdown(self) -> None:
|
|
95
114
|
if self.operation is not None:
|
|
96
115
|
await self.operation
|
|
@@ -112,7 +131,7 @@ class _LocalTask:
|
|
|
112
131
|
execution = self.execution
|
|
113
132
|
if execution is None:
|
|
114
133
|
return
|
|
115
|
-
|
|
134
|
+
self._stop_claiming()
|
|
116
135
|
draining = asyncio.ensure_future(execution.wait())
|
|
117
136
|
_, pending = await asyncio.wait((draining,), timeout=self.deadline)
|
|
118
137
|
if pending:
|
|
@@ -60,6 +60,7 @@ class _ProcessSlot:
|
|
|
60
60
|
self._exited = asyncio.Event()
|
|
61
61
|
self._exited.set()
|
|
62
62
|
self._stopping = False
|
|
63
|
+
self._shutdown_request: asyncio.Task[None] | None = None
|
|
63
64
|
self._restart_count = 0
|
|
64
65
|
self._wanted: set[str] = set()
|
|
65
66
|
self._serial = 0
|
|
@@ -307,14 +308,23 @@ class _ProcessSlot:
|
|
|
307
308
|
if not self._stopping and self._wanted and self._process is None:
|
|
308
309
|
self._spawn()
|
|
309
310
|
|
|
310
|
-
|
|
311
|
+
def quiesce(self) -> None:
|
|
311
312
|
self._stopping = True
|
|
312
313
|
self._set_enabled_state(BackgroundTaskState.STOPPING)
|
|
314
|
+
if self._shutdown_request is None:
|
|
315
|
+
self._shutdown_request = asyncio.create_task(self._request_shutdown())
|
|
316
|
+
|
|
317
|
+
async def _request_shutdown(self) -> None:
|
|
313
318
|
if self._process is not None and self._process.is_alive():
|
|
314
319
|
try:
|
|
315
320
|
await self._send((_Message.SHUTDOWN,))
|
|
316
321
|
except (EOFError, OSError):
|
|
317
322
|
pass
|
|
323
|
+
|
|
324
|
+
async def shutdown(self) -> None:
|
|
325
|
+
self.quiesce()
|
|
326
|
+
assert self._shutdown_request is not None
|
|
327
|
+
await self._shutdown_request
|
|
318
328
|
if self._monitor is not None:
|
|
319
329
|
await self._monitor
|
|
320
330
|
if self._operations:
|
|
@@ -46,7 +46,6 @@ class _BackgroundCoordinator:
|
|
|
46
46
|
raise _BackgroundExecutionStartFailure(original_error=error) from error
|
|
47
47
|
|
|
48
48
|
async def stop(reason: ShutdownReason) -> tuple[BaseException, ...]:
|
|
49
|
-
await self.stop()
|
|
50
49
|
return ()
|
|
51
50
|
|
|
52
51
|
async def wait() -> ApplicationRuntimeError:
|
|
@@ -62,6 +61,8 @@ class _BackgroundCoordinator:
|
|
|
62
61
|
prepare=prepare,
|
|
63
62
|
start=start,
|
|
64
63
|
stop=stop,
|
|
64
|
+
quiesce=self.quiesce,
|
|
65
|
+
drain=self.stop,
|
|
65
66
|
failure=lambda: wait() if self.has_runtime_failure_source else None,
|
|
66
67
|
)
|
|
67
68
|
|
|
@@ -242,6 +243,14 @@ class _BackgroundCoordinator:
|
|
|
242
243
|
return slot.request(key, start)
|
|
243
244
|
return self._tasks[key].request(start)
|
|
244
245
|
|
|
246
|
+
def quiesce(self) -> None:
|
|
247
|
+
self._started = False
|
|
248
|
+
for key, task in self._tasks.items():
|
|
249
|
+
if key in self._local:
|
|
250
|
+
task.quiesce()
|
|
251
|
+
for slot in self._slots.values():
|
|
252
|
+
slot.quiesce()
|
|
253
|
+
|
|
245
254
|
async def stop(self) -> None:
|
|
246
255
|
# 即使启动前的预占或 Module 初始化失败,也必须释放已取得的位置。
|
|
247
256
|
operations = [task.shutdown() for key, task in self._tasks.items() if key in self._local]
|
|
@@ -23,7 +23,8 @@ This module owns the generated order example. Adapt its business rules from conf
|
|
|
23
23
|
|
|
24
24
|
- Query, management, and approval use separate `{{ cookiecutter.class_prefix }}QueryApplicationService`, `{{ cookiecutter.class_prefix }}ManagementApplicationService`, and `{{ cookiecutter.class_prefix }}ApprovalApplicationService` contracts. The exposure decorator contributes the ContractsModule dependency; overrides retain the sample's deliberate paths and operation IDs. Other conventional methods are automatic, with plain GET Pydantic DTOs bound as query parameters unless explicitly declared otherwise. Reporting uses `OrderReportingService` internally without end-user permissions or HTTP exposure.
|
|
25
25
|
- The aggregate owns pending-to-approved transitions. The domain service reads the approval setting; the caller owns version checks, transaction, and save. Repository writes collect events; AFTER_COMMIT removes the cache before online notification. Notification failure cannot undo a committed write.
|
|
26
|
-
- The approval Job participates in transactional enqueue and ignores missing/already-approved orders. The statistics schedule refers to its Job type and typed payload; the Job definition owns persisted name/version/schema. Generated Workers are code-disabled; HostedService and periodic schedule require explicit Module registration as described in the project development guide.
|
|
26
|
+
- The approval Job participates in transactional enqueue and ignores missing/already-approved orders. The statistics schedule refers to its Job type and typed payload; the Job definition owns persisted name/version/schema. Generated Workers are code-disabled; HostedService and periodic schedule require explicit Module registration as described in the project development guide. Settings refresh and timing interception reuse framework extensions.
|
|
27
|
+
- The hosted example uses `stopping` for an independent business call and an SDK thread callback with the temporary context, then `stop` joins the actual thread after accepted work drains. Startup contexts stay closed during shutdown. To keep message input during this phase, declare this service in the channel's `stopping_owners`, depend on its Module owner, and pass `stopping=context`; no channel opts in by default.
|
|
27
28
|
- File upload/download/stream and `/ws/{{ cookiecutter.module_name }}` call public contracts. Typed messages in [order_messages](domain_shared/messages/order_messages.py) own their versioned names and payload schemas; handlers and the socket explicitly map business values. Sending reports local queue acceptance, with no offline replay, cross-process backplane or client acknowledgment.
|
|
28
29
|
|
|
29
30
|
Startup [Options](application/options/order_options.py) bind to the `{{ cookiecutter.module_name }}` YAML section; `allow_background_approval` defaults to true. Runtime approval settings and seed contributors belong to Domain. Its Module declares `SettingsModule` and `DataSeedingModule`; contributors are independent DI services, not ApplicationServices. The Domain [OrderApprovalService](domain/services/order_approval_service.py) opts into `ValidationEnabled` through the transitive InvocationModule dependency, retains its transient lifetime, and leaves saving and transaction completion to its caller. Capability dependencies and typed declarations are owned by each Module, while Host selects providers. Change declarations at their owners rather than copying names into another registry.
|
|
@@ -4,10 +4,6 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
from threading import Event, Thread
|
|
6
6
|
|
|
7
|
-
from modules.{{ cookiecutter.module_name }}.application_contracts.integration_services.order_reporting_service import (
|
|
8
|
-
OrderReportingService,
|
|
9
|
-
)
|
|
10
|
-
|
|
11
7
|
from python_ddd_framework import (
|
|
12
8
|
HostedService,
|
|
13
9
|
HostedServiceContext,
|
|
@@ -15,6 +11,10 @@ from python_ddd_framework import (
|
|
|
15
11
|
ShutdownReason,
|
|
16
12
|
)
|
|
17
13
|
|
|
14
|
+
from modules.{{ cookiecutter.module_name }}.application_contracts.integration_services.order_reporting_service import (
|
|
15
|
+
OrderReportingService,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
18
|
|
|
19
19
|
async def observe_integration(reporting: OrderReportingService) -> None:
|
|
20
20
|
count = await reporting.get_count()
|
|
@@ -24,6 +24,10 @@ async def observe_integration(reporting: OrderReportingService) -> None:
|
|
|
24
24
|
class OrderIntegrationService(HostedService):
|
|
25
25
|
def __init__(self) -> None:
|
|
26
26
|
self._stop = Event()
|
|
27
|
+
self._prepare = Event()
|
|
28
|
+
self._callback_done = Event()
|
|
29
|
+
self._stopping_context: HostedServiceContext | None = None
|
|
30
|
+
self._callback_error: BaseException | None = None
|
|
27
31
|
self._thread: Thread | None = None
|
|
28
32
|
|
|
29
33
|
async def start(self, context: HostedServiceContext) -> None:
|
|
@@ -34,13 +38,33 @@ class OrderIntegrationService(HostedService):
|
|
|
34
38
|
def _receive(self, context: HostedServiceContext) -> None:
|
|
35
39
|
# submit_call 只能来自外部线程;Future.result 等待同一正式 invocation 完成。
|
|
36
40
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
try:
|
|
42
|
+
context.submit_call(observe_integration).result()
|
|
43
|
+
except HostedServiceInvocationRejectedError:
|
|
44
|
+
pass # 普通准入可能已经关闭;收尾只使用 stopping 新提供的 context。
|
|
45
|
+
self._prepare.wait()
|
|
46
|
+
stopping = self._stopping_context
|
|
47
|
+
if stopping is not None:
|
|
48
|
+
stopping.submit_call(observe_integration).result()
|
|
49
|
+
except BaseException as error: # noqa: BLE001 -- 线程故障转交 stopping 重新抛出。
|
|
50
|
+
self._callback_error = error
|
|
51
|
+
finally:
|
|
52
|
+
self._callback_done.set()
|
|
40
53
|
self._stop.wait()
|
|
41
54
|
|
|
55
|
+
async def stopping(self, context: HostedServiceContext, reason: ShutdownReason) -> None:
|
|
56
|
+
# 独立业务调用完成后再等 SDK 回调,不跨线程传 scope,也不跨等待持有事务。
|
|
57
|
+
await context.call(observe_integration)
|
|
58
|
+
self._stopping_context = context
|
|
59
|
+
self._prepare.set()
|
|
60
|
+
await asyncio.to_thread(self._callback_done.wait)
|
|
61
|
+
if self._callback_error is not None:
|
|
62
|
+
raise RuntimeError("External integration callback failed") from self._callback_error
|
|
63
|
+
|
|
42
64
|
async def stop(self, reason: ShutdownReason) -> None:
|
|
65
|
+
# start 部分失败时 stopping 不会执行;仍须唤醒并等待实际线程退出。
|
|
43
66
|
self._stop.set()
|
|
67
|
+
self._prepare.set()
|
|
44
68
|
if self._thread is not None:
|
|
45
69
|
await asyncio.to_thread(self._thread.join)
|
|
46
70
|
self._thread = None
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
|
+
from python_ddd_framework import MessageHandler, unit_of_work
|
|
6
|
+
|
|
5
7
|
from modules.{{ cookiecutter.module_name }}.domain_shared.messages.order_observation import (
|
|
6
8
|
OrderObservation,
|
|
7
9
|
)
|
|
8
10
|
|
|
9
|
-
from python_ddd_framework import MessageHandler, unit_of_work
|
|
10
|
-
|
|
11
11
|
|
|
12
12
|
class OrderObservationHandler(MessageHandler[OrderObservation]):
|
|
13
13
|
@unit_of_work(disabled=True)
|
python_ddd_framework/developer_kit/templates/project/{{cookiecutter.project_name}}/AGENTS.md
CHANGED
|
@@ -41,6 +41,13 @@ Business modules own message values and coalescing identities. Only replaceable
|
|
|
41
41
|
coalesce. Acceptance, processing, commit and external acknowledgement are separate facts.
|
|
42
42
|
Use the framework channel and hosted bridge; preserve capacity and actual cleanup guarantees.
|
|
43
43
|
|
|
44
|
+
For SDK shutdown, initiate business cleanup in `HostedService.stopping(context, reason)` and
|
|
45
|
+
release resources in `stop(reason)`. Pass the separate stopping context only to this phase's
|
|
46
|
+
callbacks; startup contexts never gain shutdown permissions. Channels opt in with declared
|
|
47
|
+
`stopping_owners` and `send/submit(..., stopping=context)`. Keep the channel Module dependent on
|
|
48
|
+
the service owner, and drain accepted work before releasing resources. See the hosted-service
|
|
49
|
+
and message examples in the project development guide.
|
|
50
|
+
|
|
44
51
|
## Verify and maintain
|
|
45
52
|
|
|
46
53
|
- Preserve unrelated changes. Do not edit the installed framework, generated dependency files by hand, or another repository to make an application change pass.
|
|
@@ -102,8 +102,9 @@ Module initialization precedes selected hosted services and background execution
|
|
|
102
102
|
- Durable jobs use typed, versioned payloads and the PostgreSQL provider. Transactional enqueue must use the same configured connection as the surrounding unit of work. External side effects remain the job's idempotency responsibility.
|
|
103
103
|
- Periodic workers are explicitly registered. Generated example workers are disabled in their class declarations; configuration cannot enable a code-disabled worker.
|
|
104
104
|
- Execution modes are `host`, `shared`, and `exclusive`. Spawned processes compose their own Application; they do not inherit a live Session or container.
|
|
105
|
-
- A `HostedService` owns long-lived SDKs or threads, their failures, recovery, and actual shutdown. It is opt-in; background-process instances need explicit selection. External
|
|
105
|
+
- A `HostedService` owns long-lived SDKs or threads, their failures, recovery, and actual shutdown. It is opt-in; background-process instances need explicit selection. Shutdown has global barriers: close ordinary admission, await `stopping`, close remaining admission, drain accepted work, then release resources in `stop` before Module/container cleanup. Only successfully started services prepare; all touched services clean up. External callbacks use the separate, temporary stopping context, never an upgraded startup context.
|
|
106
106
|
- Redis business locks are leases. Preserve cancellation and cleanup when ownership is lost; a lease is not a transaction, fencing guarantee, or exactly-once guarantee.
|
|
107
|
+
- In-process channels preserve SDK input during stopping only for declared `stopping_owners` and a current hook context passed to `send/submit`. The channel Module depends on the service owner. Admission permissions are Application-local and temporary; accepted work retains its reservation until execution or a reported non-execution outcome, with unchanged capacity and per-message scopes.
|
|
107
108
|
- WebSocket delivery targets currently connected clients. The generated sample has no cross-process backplane, offline replay, or delivery acknowledgment. HTTP streaming must release business transactions before network transmission and clean up producers on disconnect.
|
|
108
109
|
- Liveness and readiness report lifecycle state, not continuous infrastructure availability. Trace export is disabled in the generated development configuration until explicitly configured.
|
|
109
110
|
|
|
@@ -207,7 +207,9 @@ Background execution Options use catalog names to select `host`, `shared`, or `e
|
|
|
207
207
|
|
|
208
208
|
Use the existing `DistributedLock` provider for explicit business leases. `locks.acquire(key, wait_timeout=timedelta(0))` tries without waiting; omit the argument or pass `None` for configured waiting, or pass a positive timedelta for this acquisition only. If acquisition returns false, skip or report the operation as appropriate. Preserve cancellation and cleanup on lease loss. Use database concurrency and idempotency for their separate guarantees.
|
|
209
209
|
|
|
210
|
-
A `HostedService`
|
|
210
|
+
A `HostedService` starts its SDK/thread in `start(context)`, initiates business cleanup and waits for callbacks in optional `stopping(context, reason)`, then releases resources and joins threads in `stop(reason)`. Register it with `hosted_services = HostedServices((ServiceType,))`; the generated integration example is not registered by default. It demonstrates an independent `context.call` and a thread callback using `context.submit_call(...).result()`. Each call creates its own scope; never pass a Session or scope to the thread or hold a transaction while waiting for the SDK.
|
|
211
|
+
|
|
212
|
+
The stopping context is separate from the startup context, which stays closed to new work. Its permission ends when the hook returns, fails, or receives a timeout cancellation request; accepted work still drains before resource stop. Only successfully started services receive `stopping`, while every touched service must tolerate `stop` after partial startup. Each hook uses `hosted_service.shutdown_timeout`, requests cancellation on expiry, and waits for actual cleanup; it is not a total shutdown deadline. HTTP/WebSocket use native transport shutdown.
|
|
211
213
|
|
|
212
214
|
## HTTP, files, and real-time communication
|
|
213
215
|
|
|
@@ -270,6 +272,14 @@ UoW, scope or concurrently mutable payloads between tasks/threads. Host configur
|
|
|
270
272
|
resource exit and reports timeout. Restart requires a new Application. Commands, results and
|
|
271
273
|
events requiring individual processing must not declare a coalescing key.
|
|
272
274
|
|
|
275
|
+
To accept SDK messages during a hosted service's stopping hook, declare
|
|
276
|
+
`MessageChannelDefinition(T, Handler, stopping_owners=(ServiceType,))` and make the channel
|
|
277
|
+
Module depend on the service's owner Module. Send with `await channel.send(value, stopping=context)`
|
|
278
|
+
or `channel.submit(value, stopping=context)` using that hook's current context. The default empty
|
|
279
|
+
tuple keeps input closed. Ordinary, expired, undeclared-owner and foreign-Application contexts
|
|
280
|
+
are rejected. Already accepted messages still drain before resource stop; the permission changes
|
|
281
|
+
neither capacity nor handler validation, authorization, scope/UoW or receipt semantics.
|
|
282
|
+
|
|
273
283
|
## Verification
|
|
274
284
|
|
|
275
285
|
Run native uv/test/build commands from `backend/`. Installed `pddd` commands locate the backend from the application root or any descendant, including nested foreign Python projects. Read fixtures first: Host tests use disposable PostgreSQL and Redis containers, so Docker must be available.
|