已合并
feat(service): 增强请求上下文生命周期与数据能力 #411
feat(service): 增强请求上下文生命周期与数据能力 #411
已合并
m0u55e创建于 8月7日
共 25 个文件变更+1748-150
@@ -8,15 +8,27 @@
8from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk8from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk
9from .config import ServiceConfig9from .config import ServiceConfig
10from .errors import (10from .errors import (
11+ DatabaseUnavailable,
12+ DeadlineExceeded,
11 ErrorCode,13 ErrorCode,
12 FrameworkError,14 FrameworkError,
13 IdempotentConflict,15 IdempotentConflict,
14 LockLost,16 LockLost,
15 LockNotAcquired,17 LockNotAcquired,
16 NotFoundError,18 NotFoundError,
19+ Interrupted,
20+ RedisUnavailable,
17 ValidationError,21 ValidationError,
18)22)
19-from .context.system_context import RequestContext, SystemContext23+from .context import (
24+ AuditEvent,
25+ AuditLogger,
26+ LoggingAuditLogger,
27+ NoopAuditLogger,
28+ RequestContext,
29+ SystemContext,
30+ TypedAppContext,
31+)
20from .context.primitives.idempotency import idempotency_guard32from .context.primitives.idempotency import idempotency_guard
21from .server.app import App33from .server.app import App
22 34 
@@ -33,6 +45,10 @@ __all__ = [
33 # errors45 # errors
34 "ErrorCode",46 "ErrorCode",
35 "FrameworkError",47 "FrameworkError",
48+ "Interrupted",
49+ "DeadlineExceeded",
50+ "DatabaseUnavailable",
51+ "RedisUnavailable",
36 "ValidationError",52 "ValidationError",
37 "NotFoundError",53 "NotFoundError",
38 "IdempotentConflict",54 "IdempotentConflict",
@@ -41,6 +57,11 @@ __all__ = [
41 # context57 # context
42 "SystemContext",58 "SystemContext",
43 "RequestContext",59 "RequestContext",
60+ "TypedAppContext",
61+ "AuditEvent",
62+ "AuditLogger",
63+ "LoggingAuditLogger",
64+ "NoopAuditLogger",
44 # middleware65 # middleware
45 "idempotency_guard",66 "idempotency_guard",
46 # server67 # server
@@ -13,9 +13,11 @@
13| ``OPENJIUWEN_SERVICE_REDIS_URL`` | 协调用 redis 连接串 | ``redis://localhost:6379/0`` |13| ``OPENJIUWEN_SERVICE_REDIS_URL`` | 协调用 redis 连接串 | ``redis://localhost:6379/0`` |
14| ``OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX`` | redis 键命名空间前缀 | ``service`` |14| ``OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX`` | redis 键命名空间前缀 | ``service`` |
15| ``OPENJIUWEN_SERVICE_TITLE`` | 服务标题(OpenAPI/日志) | ``service`` |15| ``OPENJIUWEN_SERVICE_TITLE`` | 服务标题(OpenAPI/日志) | ``service`` |
16+| ``OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS`` | 请求超时秒数,0 表示不设置 deadline | ``0`` |
16"""17"""
17from __future__ import annotations18from __future__ import annotations
18 19 
20+import math
19import os21import os
20from dataclasses import dataclass22from dataclasses import dataclass
21 23 
@@ -24,6 +26,7 @@ _DEFAULT_PORT = 8090
24_DEFAULT_REDIS_URL = "redis://localhost:6379/0"26_DEFAULT_REDIS_URL = "redis://localhost:6379/0"
25_DEFAULT_KEY_PREFIX = "service"27_DEFAULT_KEY_PREFIX = "service"
26_DEFAULT_TITLE = "service"28_DEFAULT_TITLE = "service"
29+_DEFAULT_REQUEST_TIMEOUT_SECONDS = 0.0
27 30 
28 31 
29@dataclass(frozen=True)32@dataclass(frozen=True)
@@ -35,6 +38,11 @@ class ServiceConfig:
35 redis_url: str = _DEFAULT_REDIS_URL38 redis_url: str = _DEFAULT_REDIS_URL
36 key_prefix: str = _DEFAULT_KEY_PREFIX39 key_prefix: str = _DEFAULT_KEY_PREFIX
37 title: str = _DEFAULT_TITLE40 title: str = _DEFAULT_TITLE
41+ request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS
42+ 
43+ def __post_init__(self) -> None:
44+ if not math.isfinite(self.request_timeout_seconds) or self.request_timeout_seconds < 0:
45+ raise ValueError("request_timeout_seconds must be a finite non-negative number")
38 46 
39 @classmethod47 @classmethod
40 def from_env(cls) -> "ServiceConfig":48 def from_env(cls) -> "ServiceConfig":
@@ -45,4 +53,8 @@ class ServiceConfig:
45 redis_url=os.getenv("OPENJIUWEN_SERVICE_REDIS_URL", _DEFAULT_REDIS_URL),53 redis_url=os.getenv("OPENJIUWEN_SERVICE_REDIS_URL", _DEFAULT_REDIS_URL),
46 key_prefix=os.getenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", _DEFAULT_KEY_PREFIX),54 key_prefix=os.getenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", _DEFAULT_KEY_PREFIX),
47 title=os.getenv("OPENJIUWEN_SERVICE_TITLE", _DEFAULT_TITLE),55 title=os.getenv("OPENJIUWEN_SERVICE_TITLE", _DEFAULT_TITLE),
56+ request_timeout_seconds=float(os.getenv(
57+ "OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS",
58+ str(_DEFAULT_REQUEST_TIMEOUT_SECONDS),
59+ )),
48 )60 )
@@ -1,2 +1,16 @@
1# coding: utf-81# coding: utf-8
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+from .audit import AuditEvent, AuditLogger, LoggingAuditLogger, NoopAuditLogger
5+from .request_context import RequestContext, TypedAppContext
6+from .system_context import SystemContext
7+ 
8+__all__ = [
9+ "AuditEvent",
10+ "AuditLogger",
11+ "LoggingAuditLogger",
12+ "NoopAuditLogger",
13+ "RequestContext",
14+ "SystemContext",
15+ "TypedAppContext",
16+]
@@ -0,0 +1,89 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Request audit events and the default structured logging sink."""
5+from __future__ import annotations
6+ 
7+import logging
8+import time
9+from dataclasses import asdict, dataclass, field
10+from typing import Any, Protocol, runtime_checkable
11+ 
12+ 
13+@dataclass(slots=True)
14+class AuditEvent:
15+ """A transport-independent audit record."""
16+ 
17+ action: str
18+ outcome: str = "success"
19+ actor: str | None = None
20+ user_id: str | None = None
21+ resource: str | None = None
22+ request_id: str | None = None
23+ trace_id: str | None = None
24+ session_id: str | None = None
25+ msg_type: str | None = None
26+ instance_id: str | None = None
27+ replica_id: str | None = None
28+ details: dict[str, Any] = field(default_factory=dict)
29+ timestamp: float = field(default_factory=time.time)
30+ 
31+ def to_dict(self) -> dict[str, Any]:
32+ return asdict(self)
33+ 
34+ 
35+@runtime_checkable
36+class AuditLogger(Protocol):
37+ """Protocol implemented by an audit event sink."""
38+ 
39+ async def write(self, event: AuditEvent) -> None:
40+ ...
41+ 
42+ 
43+class LoggingAuditLogger:
44+ """Write structured audit records through a standard-library logger."""
45+ 
46+ def __init__(
47+ self,
48+ logger: logging.Logger | None = None,
49+ *,
50+ level: int | str = logging.INFO,
51+ ) -> None:
52+ self.logger = logger or logging.getLogger("openjiuwen_runtime.service.audit")
53+ if isinstance(level, str):
54+ resolved_level = getattr(logging, level.upper(), None)
55+ if not isinstance(resolved_level, int):
56+ raise ValueError(f"unknown audit logging level: {level!r}")
57+ level = resolved_level
58+ if not isinstance(level, int):
59+ raise TypeError("audit logging level must be an integer or level name")
60+ self.level = level
61+ 
62+ async def write(self, event: AuditEvent) -> None:
63+ self.logger.log(
64+ self.level,
65+ "audit action=%s outcome=%s actor=%s resource=%s request_id=%s "
66+ "trace_id=%s user_id=%s session_id=%s msg_type=%s instance_id=%s details=%s",
67+ event.action,
68+ event.outcome,
69+ event.actor,
70+ event.resource,
71+ event.request_id,
72+ event.trace_id,
73+ event.user_id,
74+ event.session_id,
75+ event.msg_type,
76+ event.instance_id,
77+ event.details,
78+ extra={"audit": event.to_dict()},
79+ )
80+ 
81+ 
82+class NoopAuditLogger:
83+ """An explicit no-op sink for applications that disable audit logging."""
84+ 
85+ async def write(self, event: AuditEvent) -> None:
86+ return None
87+ 
88+ 
89+__all__ = ["AuditEvent", "AuditLogger", "LoggingAuditLogger", "NoopAuditLogger"]
@@ -11,7 +11,7 @@ from __future__ import annotations
11 11 
12import asyncio12import asyncio
13import logging13import logging
14-from typing import Any14+from typing import Any, Callable
15 15 
16from redis.exceptions import WatchError16from redis.exceptions import WatchError
17 17 
@@ -35,6 +35,7 @@ class DistributedLock:
35 timeout: float = 0,35 timeout: float = 0,
36 prefix: str = "lock",36 prefix: str = "lock",
37 renew_interval: float | None = None,37 renew_interval: float | None = None,
38+ check_interrupted: Callable[[], None] | None = None,
38 ) -> None:39 ) -> None:
39 self._redis = redis40 self._redis = redis
40 self.key = f"{prefix}:{key}" if prefix else key41 self.key = f"{prefix}:{key}" if prefix else key
@@ -44,9 +45,12 @@ class DistributedLock:
44 self._renew_interval = renew_interval if renew_interval is not None else max(1.0, ttl / 3)45 self._renew_interval = renew_interval if renew_interval is not None else max(1.0, ttl / 3)
45 self._renew_task: asyncio.Task | None = None46 self._renew_task: asyncio.Task | None = None
46 self._lost = False47 self._lost = False
48+ self._check_interrupted = check_interrupted
47 49 
48 # -------------------------------------------------------------- 抢锁50 # -------------------------------------------------------------- 抢锁
49 async def _try_acquire(self) -> bool:51 async def _try_acquire(self) -> bool:
52+ if self._check_interrupted is not None:
53+ self._check_interrupted()
50 ok = await self._redis.set(self.key, self._owner, nx=True, px=self._ttl_ms)54 ok = await self._redis.set(self.key, self._owner, nx=True, px=self._ttl_ms)
51 return bool(ok)55 return bool(ok)
52 56 
@@ -0,0 +1,417 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Request-scoped service context."""
5+from __future__ import annotations
6+ 
7+import asyncio
8+import inspect
9+import logging
10+import math
11+import time
12+from collections.abc import AsyncIterator, Mapping
13+from contextlib import asynccontextmanager
14+from dataclasses import dataclass, field
15+from typing import TYPE_CHECKING, Any, Callable, Generic, TypeAlias, TypeVar
16+ 
17+from sqlalchemy.ext.asyncio import AsyncSession
18+ 
19+from ..envelope import Envelope, Metadata
20+from ..errors import DeadlineExceeded, FrameworkError, Interrupted
21+from .audit import AuditEvent
22+from .primitives.kv_store import KVStore
23+ 
24+if TYPE_CHECKING:
25+ from .system_context import SystemContext
26+ 
27+ 
28+TRequest = TypeVar("TRequest")
29+_logger = logging.getLogger("openjiuwen_runtime.service")
30+ 
31+ 
32+class _RequestLogger(logging.Logger):
33+ """Logger carrying immutable request fields on every emitted record."""
34+ 
35+ def __init__(self, base: logging.Logger, request_id: str, trace_id: str | None) -> None:
36+ super().__init__(base.name, level=logging.NOTSET)
37+ self.parent = base
38+ self.propagate = True
39+ self.extra = {"request_id": request_id, "trace_id": trace_id}
40+ 
41+ # Keep the standard-library override signature required by logging.Logger.
42+ # pylint: disable=huawei-too-many-arguments
43+ def makeRecord( # noqa: N802 - logging.Logger API
44+ self,
45+ name: str,
46+ level: int,
47+ fn: str,
48+ lno: int,
49+ msg: object,
50+ args: tuple[Any, ...],
51+ exc_info: Any,
52+ func: str | None = None,
53+ extra: dict[str, Any] | None = None,
54+ sinfo: str | None = None,
55+ ) -> logging.LogRecord:
56+ bound_extra = dict(self.extra)
57+ if extra:
58+ bound_extra.update(extra)
59+ return super().makeRecord(
60+ name, level, fn, lno, msg, args, exc_info, func, bound_extra, sinfo
61+ )
62+ # pylint: enable=huawei-too-many-arguments
63+ 
64+ 
65+CleanupCallback = Callable[[], Any]
66+ 
67+ 
68+@dataclass
69+class RequestContext(Generic[TRequest]):
70+ """Capabilities and metadata associated with one service request."""
71+ 
72+ sysctx: SystemContext
73+ envelope: Envelope[TRequest] | None = None
74+ _metadata: Metadata | None = field(default=None, repr=False)
75+ lock_owner: str = ""
76+ logger: logging.Logger = field(default_factory=lambda: _logger)
77+ cancel_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
78+ deadline: float | None = None
79+ attrs: dict[str, Any] = field(default_factory=dict)
80+ principal: Any = None
81+ _kv: KVStore | None = field(default=None, repr=False, compare=False)
82+ _idem: Any = field(default=None, repr=False, compare=False)
83+ _queue: Any = field(default=None, repr=False, compare=False)
84+ _pubsub: Any = field(default=None, repr=False, compare=False)
85+ _interrupt_reason: str | None = field(default=None, init=False, repr=False, compare=False)
86+ _cleanup_callbacks: list[CleanupCallback] = field(
87+ default_factory=list, init=False, repr=False, compare=False
88+ )
89+ _close_task: asyncio.Task[None] | None = field(
90+ default=None, init=False, repr=False, compare=False
91+ )
92+ 
93+ def __post_init__(self) -> None:
94+ if self.envelope is None and self._metadata is None:
95+ raise ValueError("envelope or metadata is required")
96+ if self.deadline is not None:
97+ if not isinstance(self.deadline, (int, float)):
98+ raise TypeError("deadline must be an absolute monotonic timestamp or None")
99+ if not math.isfinite(self.deadline):
100+ raise ValueError("deadline must be a finite monotonic timestamp")
101+ if not isinstance(self.logger, _RequestLogger):
102+ self.logger = _RequestLogger(self.logger, self.request_id, self.trace_id)
103+ 
104+ @property
105+ def request(self) -> TRequest:
106+ """Return the validated request object bound to the envelope."""
107+ if self.envelope is None:
108+ raise FrameworkError("request is unavailable for a metadata-only context")
109+ return self.envelope.rawdata
110+ 
111+ @property
112+ def metadata(self) -> Metadata:
113+ if self.envelope is not None:
114+ return self.envelope.metadata
115+ if self._metadata is None: # Guarded by __post_init__.
116+ raise FrameworkError("request metadata is unavailable")
117+ return self._metadata
118+ 
119+ @property
120+ def msg_type(self) -> str:
121+ if self.envelope is None:
122+ raise FrameworkError("message type is unavailable for a metadata-only context")
123+ return self.envelope.type
124+ 
125+ @property
126+ def request_id(self) -> str:
127+ return self.metadata.request_id
128+ 
129+ @property
130+ def user_id(self) -> str | None:
131+ return self.metadata.user_id
132+ 
133+ @property
134+ def chat_id(self) -> str | None:
135+ return self.metadata.chat_id
136+ 
137+ @property
138+ def session_id(self) -> str | None:
139+ return self.metadata.session_id
140+ 
141+ @property
142+ def trace_id(self) -> str | None:
143+ return self.metadata.trace_id
144+ 
145+ @property
146+ def bot_id(self) -> str | None:
147+ return self.metadata.bot_id
148+ 
149+ @property
150+ def channel(self) -> str | None:
151+ return self.metadata.channel
152+ 
153+ @property
154+ def instance_id(self) -> str | None:
155+ """Business instance identifier supplied by the request."""
156+ return self.metadata.instance_id
157+ 
158+ @property
159+ def replica_id(self) -> str:
160+ """Identifier of the service process handling the request."""
161+ return self.sysctx.instance_id
162+ 
163+ def remaining_seconds(self) -> float | None:
164+ """Return the non-negative duration remaining before the deadline."""
165+ if self.deadline is None:
166+ return None
167+ return max(0.0, self.deadline - time.monotonic())
168+ 
169+ def interrupt(self, reason: str | None = None) -> None:
170+ """Mark this request as interrupted and wake interruption waiters."""
171+ if self.cancel_event.is_set():
172+ return
173+ self._interrupt_reason = reason
174+ self.cancel_event.set()
175+ 
176+ def check_interrupted(self) -> None:
177+ """Raise the lifecycle error currently preventing request work."""
178+ if self.cancel_event.is_set():
179+ raise Interrupted(self._interrupt_reason or "request interrupted")
180+ if self.deadline is not None and self.remaining_seconds() == 0:
181+ raise DeadlineExceeded("request deadline exceeded")
182+ 
183+ async def wait_interrupted(self) -> None:
184+ """Wait until explicit interruption or the request deadline."""
185+ while not self.cancel_event.is_set():
186+ remaining = self.remaining_seconds()
187+ if remaining is None:
188+ await self.cancel_event.wait()
189+ return
190+ if remaining == 0:
191+ return
192+ try:
193+ await asyncio.wait_for(self.cancel_event.wait(), timeout=remaining)
194+ except TimeoutError:
195+ continue
196+ 
197+ def add_cleanup(self, callback: CleanupCallback) -> None:
198+ """Register a synchronous or asynchronous request cleanup callback."""
199+ if not callable(callback):
200+ raise TypeError("cleanup callback must be callable")
201+ if self._close_task is not None:
202+ raise RuntimeError("request context is already closing")
203+ self._cleanup_callbacks.append(callback)
204+ 
205+ register_cleanup = add_cleanup
206+ 
207+ @property
208+ def closed(self) -> bool:
209+ return self._close_task is not None and self._close_task.done()
210+ 
211+ async def close(self) -> None:
212+ """Run all request cleanup callbacks once in LIFO order."""
213+ if self._close_task is None:
214+ self._close_task = asyncio.create_task(
215+ self._run_cleanup(), name=f"request-cleanup:{self.request_id}"
216+ )
217+ task = self._close_task
218+ try:
219+ await asyncio.shield(task)
220+ except asyncio.CancelledError:
221+ await asyncio.shield(task)
222+ raise
223+ 
224+ async def _run_cleanup(self) -> None:
225+ while self._cleanup_callbacks:
226+ callback = self._cleanup_callbacks.pop()
227+ try:
228+ result = callback()
229+ if inspect.isawaitable(result):
230+ await result
231+ except BaseException: # noqa: BLE001 - one cleanup cannot skip the rest
232+ self.logger.exception("request cleanup failed: callback=%r", callback)
233+ 
234+ @property
235+ def db(self) -> Any:
236+ """Return the configured DB handler after checking request state."""
237+ return self.require_db()
238+ 
239+ def require_db(self) -> Any:
240+ """Require a DB handler for this active request."""
241+ self.check_interrupted()
242+ return self.sysctx.require_db()
243+ 
244+ async def db_create(self, table_name: str, data: dict[str, Any]) -> Any:
245+ """Create a record using an independent DBHandler operation."""
246+ return await self.require_db().create(table_name, data)
247+ 
248+ async def db_get(self, table_name: str, filters: dict[str, Any]) -> Any:
249+ """Get one record using an independent DBHandler operation."""
250+ return await self.require_db().get(table_name, filters)
251+ 
252+ async def db_update(
253+ self,
254+ table_name: str,
255+ filters: dict[str, Any],
256+ data: dict[str, Any],
257+ ) -> Any:
258+ """Update records using an independent DBHandler operation."""
259+ return await self.require_db().update(table_name, filters, data)
260+ 
261+ async def db_delete(self, table_name: str, filters: dict[str, Any]) -> bool:
262+ """Delete records using an independent DBHandler operation."""
263+ return await self.require_db().delete(table_name, filters)
264+ 
265+ async def db_list(
266+ self,
267+ table_name: str,
268+ filters: dict[str, Any] | None = None,
269+ limit: int = 100,
270+ offset: int = 0,
271+ ) -> list[Any]:
272+ """List records using an independent DBHandler operation."""
273+ return await self.require_db().list_records(
274+ table_name,
275+ filters=filters,
276+ limit=limit,
277+ offset=offset,
278+ )
279+ 
280+ async def db_count(
281+ self,
282+ table_name: str,
283+ filters: dict[str, Any] | None = None,
284+ ) -> int:
285+ """Count records using an independent DBHandler operation."""
286+ return await self.require_db().count_records(table_name, filters=filters)
287+ 
288+ @property
289+ def kv(self) -> KVStore:
290+ """Distributed dictionary and session storage."""
291+ if self._kv is None:
292+ self._kv = KVStore(
293+ self.sysctx.require_redis(), prefix=self.sysctx.namespace("kv")
294+ )
295+ return self._kv
296+ 
297+ @property
298+ def idempotency(self):
299+ """Global request-id deduplication and result replay."""
300+ if self._idem is None:
301+ from .primitives.idempotency import Idempotency
302+ 
303+ self._idem = Idempotency(
304+ self.sysctx.require_redis(), prefix=self.sysctx.namespace("idem")
305+ )
306+ return self._idem
307+ 
308+ @property
309+ def queue(self):
310+ """Durable cross-replica queue backed by Redis Streams."""
311+ if self._queue is None:
312+ from .primitives.stream_queue import StreamQueue
313+ 
314+ self._queue = StreamQueue(
315+ self.sysctx.require_redis(), prefix=self.sysctx.namespace("queue")
316+ )
317+ return self._queue
318+ 
319+ @property
320+ def pubsub(self):
321+ """Transient fan-out backed by Redis Pub/Sub."""
322+ if self._pubsub is None:
323+ from .primitives.pubsub import PubSub
324+ 
325+ self._pubsub = PubSub(
326+ self.sysctx.require_redis(), prefix=self.sysctx.namespace("pubsub")
327+ )
328+ return self._pubsub
329+ 
330+ def lock(
331+ self,
332+ key: str,
333+ *,
334+ ttl: float = 30,
335+ timeout: float = 0,
336+ renew_interval: float | None = None,
337+ ):
338+ """Create a distributed lock bound to this request owner."""
339+ from .primitives.lock import DistributedLock
340+ 
341+ self.check_interrupted()
342+ return DistributedLock(
343+ self.sysctx.require_redis(),
344+ key,
345+ owner=self.lock_owner,
346+ ttl=ttl,
347+ timeout=timeout,
348+ prefix=self.sysctx.namespace("lock"),
349+ renew_interval=renew_interval,
350+ check_interrupted=self.check_interrupted,
351+ )
352+ 
353+ @asynccontextmanager
354+ async def transaction(self) -> AsyncIterator[AsyncSession]:
355+ """Yield one transaction session shared by explicit session operations.
356+ 
357+ The ``db_*`` helpers use DBHandler-owned sessions and remain independent
358+ from this transaction.
359+ """
360+ self.check_interrupted()
361+ async with self.sysctx.transaction() as session:
362+ yield session
363+ 
364+ async def audit(
365+ self,
366+ action: str,
367+ *,
368+ outcome: str = "success",
369+ actor: str | None = None,
370+ resource: str | None = None,
371+ details: dict[str, Any] | None = None,
372+ ) -> None:
373+ """Write an audit event populated from this request context."""
374+ self.check_interrupted()
375+ msg_type = self.envelope.type if self.envelope is not None else None
376+ resolved_actor = actor or self._principal_identifier() or self.user_id
377+ event = AuditEvent(
378+ action=action,
379+ outcome=outcome,
380+ actor=resolved_actor,
381+ user_id=self.user_id,
382+ resource=resource or msg_type,
383+ request_id=self.request_id,
384+ trace_id=self.trace_id,
385+ session_id=self.session_id,
386+ msg_type=msg_type,
387+ instance_id=self.instance_id,
388+ replica_id=self.replica_id,
389+ details=dict(details or {}),
390+ )
391+ await self.sysctx.audit(event)
392+ 
393+ def _principal_identifier(self) -> str | None:
394+ principal = self.principal
395+ if principal is None:
396+ return None
397+ if isinstance(principal, str):
398+ return principal
399+ if isinstance(principal, Mapping):
400+ value = (
401+ principal.get("user_id")
402+ or principal.get("subject")
403+ or principal.get("sub")
404+ or principal.get("id")
405+ )
406+ return str(value) if value is not None else None
407+ for name in ("user_id", "subject", "sub", "id"):
408+ value = getattr(principal, name, None)
409+ if value is not None:
410+ return str(value)
411+ return None
412+ 
413+ 
414+TypedAppContext: TypeAlias = RequestContext
415+ 
416+ 
417+__all__ = ["RequestContext", "TypedAppContext"]
@@ -1,31 +1,34 @@
1# coding: utf-81# coding: utf-8
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4-"""SystemContext / RequestContext(设计 §8)。4+"""SystemContext(设计 §8)。
5 5 
6- 进程级 SystemContext(lifespan 创建/释放):db / redis / settings / logger / 原语工厂。6- 进程级 SystemContext(lifespan 创建/释放):db / redis / settings / logger / 原语工厂。
7-- 请求级 RequestContext(``for_request(metadata)`` 派生):request_id 等 + lock_owner +7+- 请求级 RequestContext 由 ``for_request(envelope)`` 派生。
8- 绑定 request_id 的 logger + 对进程级组件的引用;handler 只通过它访问能力。
9- 事务:``async with ctx.transaction() as s`` 取 SQLAlchemy session(多操作原子)。8- 事务:``async with ctx.transaction() as s`` 取 SQLAlchemy session(多操作原子)。
10- 硬约束:handler 禁止读写模块级可变状态——无内存状态的多副本。9- 硬约束:handler 禁止读写模块级可变状态——无内存状态的多副本。
11"""10"""
12from __future__ import annotations11from __future__ import annotations
13 12 
14import logging13import logging
14+import math
15import socket15import socket
16+import time
16from contextlib import asynccontextmanager17from contextlib import asynccontextmanager
17-from dataclasses import dataclass, field18+from typing import Any, AsyncIterator, TypeVar, overload
18-from typing import Any, Optional
19from uuid import uuid419from uuid import uuid4
20 20 
21import redis.asyncio21import redis.asyncio
22+from sqlalchemy.ext.asyncio import AsyncSession
22 23 
23from ..config import ServiceConfig24from ..config import ServiceConfig
24-from ..envelope import Metadata25+from ..envelope import Envelope, Metadata
25-from ..errors import FrameworkError26+from ..errors import DatabaseUnavailable, FrameworkError, RedisUnavailable
26-from .primitives.kv_store import KVStore27+from .audit import AuditEvent, AuditLogger, LoggingAuditLogger, NoopAuditLogger
28+from .request_context import RequestContext
27 29 
28_logger = logging.getLogger("openjiuwen_runtime.service")30_logger = logging.getLogger("openjiuwen_runtime.service")
31+TRequest = TypeVar("TRequest")
29 32 
30 33 
31class SystemContext:34class SystemContext:
@@ -39,20 +42,70 @@ class SystemContext:
39 *,42 *,
40 key_prefix: str = "service",43 key_prefix: str = "service",
41 instance_id: str | None = None,44 instance_id: str | None = None,
45+ logger: logging.Logger | None = None,
46+ audit_logger: AuditLogger | None = None,
47+ audit: AuditLogger | None = None,
48+ request_timeout_seconds: float | None = None,
42 _owns_redis: bool = False,49 _owns_redis: bool = False,
43 ) -> None:50 ) -> None:
44 self.redis = redis51 self.redis = redis
45 self.db = db52 self.db = db
46 self.settings = settings53 self.settings = settings
54+ if request_timeout_seconds is None:
55+ request_timeout_seconds = self._request_timeout_from_settings(settings)
56+ if not math.isfinite(request_timeout_seconds) or request_timeout_seconds < 0:
57+ raise ValueError("request_timeout_seconds must be a finite non-negative number")
58+ self.request_timeout_seconds = float(request_timeout_seconds)
47 self.key_prefix = key_prefix or ""59 self.key_prefix = key_prefix or ""
48 self.instance_id = instance_id or f"{socket.gethostname()}:{uuid4().hex[:8]}"60 self.instance_id = instance_id or f"{socket.gethostname()}:{uuid4().hex[:8]}"
61+ self.logger = logger or _logger
62+ if audit_logger is not None and audit is not None:
63+ raise ValueError("audit_logger and audit cannot both be provided")
64+ self.audit_logger: AuditLogger = (
65+ audit_logger or audit or LoggingAuditLogger(self.logger)
66+ )
49 self._owns_redis = _owns_redis67 self._owns_redis = _owns_redis
50 self._started = False68 self._started = False
51 69 
70+ @staticmethod
71+ def _request_timeout_from_settings(settings: Any) -> float:
72+ default = ServiceConfig.from_env().request_timeout_seconds
73+ if settings is None:
74+ return default
75+ if isinstance(settings, dict):
76+ value = settings.get("request_timeout_seconds", default)
77+ else:
78+ value = getattr(settings, "request_timeout_seconds", default)
79+ return float(value)
80+ 
52 # -------------------------------------------------------------- 命名空间81 # -------------------------------------------------------------- 命名空间
53- def _ns(self, suffix: str) -> str:82+ def namespace(self, suffix: str) -> str:
83+ """Return the configured namespace for a service capability."""
54 return f"{self.key_prefix}:{suffix}" if self.key_prefix else suffix84 return f"{self.key_prefix}:{suffix}" if self.key_prefix else suffix
55 85 
86+ # -------------------------------------------------------------- 能力检查
87+ def require_db(self) -> Any:
88+ """Return the configured DB handler or raise a framework error."""
89+ if self.db is None:
90+ raise DatabaseUnavailable("database handler is not configured")
91+ return self.db
92+ 
93+ def require_redis(self) -> Any:
94+ """Return the configured Redis client or raise a framework error."""
95+ if self.redis is None:
96+ raise RedisUnavailable("redis client is not configured")
97+ return self.redis
98+ 
99+ def set_audit_logger(self, audit_logger: AuditLogger | None) -> None:
100+ """Replace the process audit sink; ``None`` selects a no-op sink."""
101+ self.audit_logger = audit_logger or NoopAuditLogger()
102+ 
103+ set_audit = set_audit_logger
104+ 
105+ async def audit(self, event: AuditEvent) -> None:
106+ """Write an audit event through the configured sink."""
107+ await self.audit_logger.write(event)
108+ 
56 # -------------------------------------------------------------- 生命周期109 # -------------------------------------------------------------- 生命周期
57 async def start(self) -> None:110 async def start(self) -> None:
58 if self._started:111 if self._started:
@@ -71,35 +124,53 @@ class SystemContext:
71 self._started = False124 self._started = False
72 125 
73 # -------------------------------------------------------------- 请求上下文126 # -------------------------------------------------------------- 请求上下文
74- def for_request(self, metadata: Metadata) -> "RequestContext":127+ @overload
128+ def for_request(self, request: Envelope[TRequest]) -> RequestContext[TRequest]:
129+ ...
130+ 
131+ @overload
132+ def for_request(self, request: Metadata) -> RequestContext[Any]:
133+ ...
134+ 
135+ def for_request(self, request: Envelope[TRequest] | Metadata) -> RequestContext[TRequest] | RequestContext[Any]:
136+ """Create a request context from an envelope or legacy standalone metadata."""
137+ if isinstance(request, Envelope):
138+ envelope = request
139+ metadata = request.metadata
140+ elif isinstance(request, Metadata):
141+ envelope = None
142+ metadata = request
143+ else:
144+ raise TypeError("request must be an Envelope or Metadata")
145+ deadline = None
146+ if self.request_timeout_seconds > 0:
147+ deadline = time.monotonic() + self.request_timeout_seconds
75 return RequestContext(148 return RequestContext(
76 sysctx=self,149 sysctx=self,
77- request_id=metadata.request_id,150+ envelope=envelope,
78- user_id=metadata.user_id,151+ _metadata=metadata,
79- chat_id=metadata.chat_id,
80- session_id=metadata.session_id,
81- trace_id=metadata.trace_id,
82- bot_id=metadata.bot_id,
83- channel=metadata.channel,
84 lock_owner=f"{self.instance_id}:{uuid4().hex}",152 lock_owner=f"{self.instance_id}:{uuid4().hex}",
85- logger=_logger,153+ logger=self.logger,
154+ deadline=deadline,
86 )155 )
87 156 
88 # -------------------------------------------------------------- 事务157 # -------------------------------------------------------------- 事务
89 @asynccontextmanager158 @asynccontextmanager
90- async def transaction(self):159+ async def transaction(self) -> AsyncIterator[AsyncSession]:
91- """多操作原子事务(独立 SQLAlchemy session,不改 foundation)。160+ """Yield one SQLAlchemy session and commit or roll it back on exit.
92 161 
93- ``db.session_factory`` 在 foundation ``SQLAlchemyHandler`` 上已暴露;未连接时为 None。162+ Request-level ``db_*`` operations remain independent DBHandler calls and
163+ do not use the session yielded here.
94 """164 """
95- sf = getattr(self.db, "session_factory", None) if self.db is not None else None165+ db = self.require_db()
96- if sf is None:166+ sf = getattr(db, "session_factory", None)
167+ if not callable(sf):
97 raise FrameworkError("db has no session_factory; transaction() unavailable")168 raise FrameworkError("db has no session_factory; transaction() unavailable")
98- session = sf()169+ session: AsyncSession = sf()
99 try:170 try:
100 yield session171 yield session
101 await session.commit()172 await session.commit()
102- except Exception:173+ except BaseException:
103 await session.rollback()174 await session.rollback()
104 raise175 raise
105 finally:176 finally:
@@ -125,71 +196,13 @@ class SystemContext:
125 url = cfg.redis_url if redis_url is None else redis_url196 url = cfg.redis_url if redis_url is None else redis_url
126 kp = cfg.key_prefix if key_prefix is None else key_prefix197 kp = cfg.key_prefix if key_prefix is None else key_prefix
127 client = redis.asyncio.from_url(url, decode_responses=False)198 client = redis.asyncio.from_url(url, decode_responses=False)
128- return cls(redis=client, db=db, settings=settings,199+ return cls(
129- key_prefix=kp, _owns_redis=True)200+ redis=client,
201+ db=db,
202+ settings=cfg if settings is None else settings,
203+ key_prefix=kp,
204+ _owns_redis=True,
205+ )
130 206 
131 207 
132-@dataclass208+__all__ = ["RequestContext", "SystemContext"]
133-class RequestContext:
134- """每条 Envelope 派生的请求级上下文;handler 经它访问所有能力。"""
135- 
136- sysctx: SystemContext
137- request_id: str
138- user_id: Optional[str] = None
139- chat_id: Optional[str] = None
140- session_id: Optional[str] = None
141- trace_id: Optional[str] = None
142- bot_id: Optional[str] = None
143- channel: Optional[str] = None
144- lock_owner: str = ""
145- logger: logging.Logger = field(default_factory=lambda: _logger)
146- _kv: Optional[KVStore] = field(default=None, repr=False, compare=False)
147- _idem: Any = field(default=None, repr=False, compare=False)
148- _queue: Any = field(default=None, repr=False, compare=False)
149- _pubsub: Any = field(default=None, repr=False, compare=False)
150- 
151- @property
152- def db(self) -> Any:
153- return self.sysctx.db
154- 
155- @property
156- def kv(self) -> KVStore:
157- """分布式字典 / 会话存储(顶替进程内 dict)。"""
158- if self._kv is None:
159- self._kv = KVStore(self.sysctx.redis, prefix=self.sysctx._ns("kv"))
160- return self._kv
161- 
162- @property
163- def idempotency(self):
164- """幂等:按 request_id 全局去重 / 结果回放。"""
165- if self._idem is None:
166- from .primitives.idempotency import Idempotency
167- self._idem = Idempotency(self.sysctx.redis, prefix=self.sysctx._ns("idem"))
168- return self._idem
169- 
170- @property
171- def queue(self):
172- """队列:跨副本有序、副本重启不丢(Redis Streams + 消费组)。"""
173- if self._queue is None:
174- from .primitives.stream_queue import StreamQueue
175- self._queue = StreamQueue(self.sysctx.redis, prefix=self.sysctx._ns("queue"))
176- return self._queue
177- 
178- @property
179- def pubsub(self):
180- """发布订阅:瞬时扇出(Redis Pub/Sub)。"""
181- if self._pubsub is None:
182- from .primitives.pubsub import PubSub
183- self._pubsub = PubSub(self.sysctx.redis, prefix=self.sysctx._ns("pubsub"))
184- return self._pubsub
185- 
186- def lock(self, key: str, *, ttl: float = 30, timeout: float = 0, renew_interval: float | None = None):
187- """分布式锁:``async with ctx.lock(key, ttl=..., timeout=...)``。"""
188- from .primitives.lock import DistributedLock
189- return DistributedLock(
190- self.sysctx.redis, key, owner=self.lock_owner, ttl=ttl, timeout=timeout,
191- prefix=self.sysctx._ns("lock"), renew_interval=renew_interval)
192- 
193- def transaction(self):
194- """多操作原子事务(委托 SystemContext)。"""
195- return self.sysctx.transaction()
@@ -10,7 +10,12 @@ float(直接 JSON 可序列化);``rawdata`` 为任意 JSON 可序列化 di
10from __future__ import annotations10from __future__ import annotations
11 11 
12from dataclasses import dataclass, field12from dataclasses import dataclass, field
13-from typing import Any13+from typing import Any, Generic, TypeVar
14+ 
15+from pydantic import BaseModel
16+ 
17+ 
18+TRequest = TypeVar("TRequest")
14 19 
15 20 
16@dataclass21@dataclass
@@ -25,6 +30,7 @@ class Metadata:
25 channel: str | None = None30 channel: str | None = None
26 timestamp: float | None = None31 timestamp: float | None = None
27 trace_id: str | None = None32 trace_id: str | None = None
33+ instance_id: str | None = None
28 extra: dict = field(default_factory=dict)34 extra: dict = field(default_factory=dict)
29 35 
30 def to_dict(self) -> dict[str, Any]:36 def to_dict(self) -> dict[str, Any]:
@@ -37,6 +43,7 @@ class Metadata:
37 "channel": self.channel,43 "channel": self.channel,
38 "timestamp": self.timestamp,44 "timestamp": self.timestamp,
39 "trace_id": self.trace_id,45 "trace_id": self.trace_id,
46+ "instance_id": self.instance_id,
40 "extra": dict(self.extra),47 "extra": dict(self.extra),
41 }48 }
42 49 
@@ -51,29 +58,34 @@ class Metadata:
51 channel=d.get("channel"),58 channel=d.get("channel"),
52 timestamp=d.get("timestamp"),59 timestamp=d.get("timestamp"),
53 trace_id=d.get("trace_id"),60 trace_id=d.get("trace_id"),
61+ instance_id=d.get("instance_id"),
54 extra=dict(d.get("extra") or {}),62 extra=dict(d.get("extra") or {}),
55 )63 )
56 64 
57 65 
58@dataclass66@dataclass
59-class Envelope:67+class Envelope(Generic[TRequest]):
60 """框架唯一入口消息结构。``type`` 既是路由键又是 REST 路径段。"""68 """框架唯一入口消息结构。``type`` 既是路由键又是 REST 路径段。"""
61 69 
62 type: str70 type: str
63 metadata: Metadata71 metadata: Metadata
64- rawdata: dict72+ rawdata: TRequest
65 version: str = "1"73 version: str = "1"
66 74 
67 def to_dict(self) -> dict[str, Any]:75 def to_dict(self) -> dict[str, Any]:
68 return {76 return {
69 "type": self.type,77 "type": self.type,
70 "metadata": self.metadata.to_dict(),78 "metadata": self.metadata.to_dict(),
71- "rawdata": self.rawdata,79+ "rawdata": (
80+ self.rawdata.model_dump(mode="json")
81+ if isinstance(self.rawdata, BaseModel)
82+ else self.rawdata
83+ ),
72 "version": self.version,84 "version": self.version,
73 }85 }
74 86 
75 @classmethod87 @classmethod
76- def from_dict(cls, d: dict[str, Any]) -> "Envelope":88+ def from_dict(cls, d: dict[str, Any]) -> "Envelope[dict[str, Any]]":
77 return cls(89 return cls(
78 type=d["type"],90 type=d["type"],
79 metadata=Metadata.from_dict(d.get("metadata") or {}),91 metadata=Metadata.from_dict(d.get("metadata") or {}),
@@ -21,6 +21,10 @@ class ErrorCode:
21 TIMEOUT = "timeout"21 TIMEOUT = "timeout"
22 LOCKED = "locked"22 LOCKED = "locked"
23 INTERNAL = "internal"23 INTERNAL = "internal"
24+ INTERRUPTED = "interrupted"
25+ DEADLINE_EXCEEDED = "deadline_exceeded"
26+ DATABASE_UNAVAILABLE = "database_unavailable"
27+ REDIS_UNAVAILABLE = "redis_unavailable"
24 28 
25 29 
26class FrameworkError(Exception):30class FrameworkError(Exception):
@@ -74,6 +78,30 @@ class FrameworkTimeout(FrameworkError):
74 code = ErrorCode.TIMEOUT78 code = ErrorCode.TIMEOUT
75 79 
76 80 
81+class Interrupted(FrameworkError):
82+ """Request processing was interrupted explicitly."""
83+ 
84+ code = ErrorCode.INTERRUPTED
85+ 
86+ 
87+class DeadlineExceeded(FrameworkError):
88+ """The absolute request deadline has elapsed."""
89+ 
90+ code = ErrorCode.DEADLINE_EXCEEDED
91+ 
92+ 
93+class DatabaseUnavailable(FrameworkError):
94+ """The request requires a database handler that is not configured."""
95+ 
96+ code = ErrorCode.DATABASE_UNAVAILABLE
97+ 
98+ 
99+class RedisUnavailable(FrameworkError):
100+ """The request requires a Redis client that is not configured."""
101+ 
102+ code = ErrorCode.REDIS_UNAVAILABLE
103+ 
104+ 
77@runtime_checkable105@runtime_checkable
78class _HasCode(Protocol):106class _HasCode(Protocol):
79 code: str107 code: str
@@ -93,6 +121,10 @@ _HTTP_STATUS = {
93 ErrorCode.IDEMPOTENT: 409,121 ErrorCode.IDEMPOTENT: 409,
94 ErrorCode.LOCKED: 423,122 ErrorCode.LOCKED: 423,
95 ErrorCode.TIMEOUT: 504,123 ErrorCode.TIMEOUT: 504,
124+ ErrorCode.INTERRUPTED: 499,
125+ ErrorCode.DEADLINE_EXCEEDED: 504,
126+ ErrorCode.DATABASE_UNAVAILABLE: 503,
127+ ErrorCode.REDIS_UNAVAILABLE: 503,
96 ErrorCode.INTERNAL: 500,128 ErrorCode.INTERNAL: 500,
97}129}
98 130 
@@ -8,28 +8,34 @@ ctx 为 RequestContext(duck-typed,本模块仅作注解,运行期不强依
8"""8"""
9from __future__ import annotations9from __future__ import annotations
10 10 
11-from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, Union, runtime_checkable11+from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, TypeVar, Union, runtime_checkable
12 12 
13from ..envelope import Envelope, ResponseEnvelope, StreamChunk13from ..envelope import Envelope, ResponseEnvelope, StreamChunk
14 14 
15if TYPE_CHECKING: # 仅注解用,避免运行期循环导入15if TYPE_CHECKING: # 仅注解用,避免运行期循环导入
16- from ..context.system_context import RequestContext16+ from ..context.request_context import RequestContext
17+ 
18+TRequest = TypeVar("TRequest")
17 19 
18# handler 返回:dict(框架包成 ResponseEnvelope)或现成的 ResponseEnvelope20# handler 返回:dict(框架包成 ResponseEnvelope)或现成的 ResponseEnvelope
19UnaryReturn = Union[ResponseEnvelope, dict]21UnaryReturn = Union[ResponseEnvelope, dict]
20-Handler = Callable[[Any, Envelope], Awaitable[UnaryReturn]]22+Handler = Callable[[Any, Envelope[Any]], Awaitable[UnaryReturn]]
21-StreamHandler = Callable[[Any, Envelope], AsyncIterator[StreamChunk]]23+StreamHandler = Callable[[Any, Envelope[Any]], AsyncIterator[StreamChunk]]
22 24 
23# 中间件:洋葱模型,nxt 为链中下一步25# 中间件:洋葱模型,nxt 为链中下一步
24-Middleware = Callable[[Any, Envelope, "Next"], Awaitable[Any]]26+Middleware = Callable[[Any, Envelope[Any], "Next"], Awaitable[Any]]
25-Next = Callable[[Any, Envelope], Awaitable[Any]]27+Next = Callable[[Any, Envelope[Any]], Awaitable[Any]]
26 28 
27 29 
28@runtime_checkable30@runtime_checkable
29class MessageHandler(Protocol):31class MessageHandler(Protocol):
30 """非流式 handler 协议:``async (ctx, env) -> dict | ResponseEnvelope``。"""32 """非流式 handler 协议:``async (ctx, env) -> dict | ResponseEnvelope``。"""
31 33 
32- async def __call__(self, ctx: "RequestContext", env: Envelope) -> UnaryReturn: # pragma: no cover - 协议34+ async def __call__(
35+ self,
36+ ctx: "RequestContext[TRequest]",
37+ env: Envelope[TRequest],
38+ ) -> UnaryReturn: # pragma: no cover - 协议
33 ...39 ...
34 40 
35 41 
@@ -37,5 +43,9 @@ class MessageHandler(Protocol):
37class StreamMessageHandler(Protocol):43class StreamMessageHandler(Protocol):
38 """流式 handler 协议:``(ctx, env) -> AsyncIterator[StreamChunk]``。"""44 """流式 handler 协议:``(ctx, env) -> AsyncIterator[StreamChunk]``。"""
39 45 
40- def __call__(self, ctx: "RequestContext", env: Envelope) -> AsyncIterator[StreamChunk]: # pragma: no cover - 协议46+ def __call__(
47+ self,
48+ ctx: "RequestContext[TRequest]",
49+ env: Envelope[TRequest],
50+ ) -> AsyncIterator[StreamChunk]: # pragma: no cover - 协议
41 ...51 ...
@@ -8,8 +8,9 @@
8"""8"""
9from __future__ import annotations9from __future__ import annotations
10 10 
11+import asyncio
11from dataclasses import dataclass12from dataclasses import dataclass
12-from typing import AsyncIterator, Union13+from typing import Any, AsyncIterator, Union
13 14 
14from ..envelope import ResponseEnvelope, StreamChunk15from ..envelope import ResponseEnvelope, StreamChunk
15 16 
@@ -21,11 +22,72 @@ class UnaryResult:
21 response: ResponseEnvelope22 response: ResponseEnvelope
22 23 
23 24 
25+class ContextBoundStream:
26+ """Async iterator that closes its request context at every terminal path."""
27+ 
28+ def __init__(self, chunks: AsyncIterator[StreamChunk], context: Any) -> None:
29+ self._chunks = chunks.__aiter__()
30+ self._context = context
31+ self._close_task: asyncio.Task[None] | None = None
32+ 
33+ def __aiter__(self) -> "ContextBoundStream":
34+ return self
35+ 
36+ async def __anext__(self) -> StreamChunk:
37+ if self._close_task is not None:
38+ raise StopAsyncIteration
39+ try:
40+ chunk = await self._chunks.__anext__()
41+ except StopAsyncIteration:
42+ await self.aclose()
43+ raise
44+ except BaseException:
45+ await self.aclose()
46+ raise
47+ if chunk.is_final:
48+ await self.aclose()
49+ return chunk
50+ 
51+ async def aclose(self) -> None:
52+ if self._close_task is None:
53+ self._close_task = asyncio.create_task(self._finish())
54+ task = self._close_task
55+ try:
56+ await asyncio.shield(task)
57+ except asyncio.CancelledError:
58+ await asyncio.shield(task)
59+ raise
60+ 
61+ async def _finish(self) -> None:
62+ try:
63+ close_chunks = getattr(self._chunks, "aclose", None)
64+ if callable(close_chunks):
65+ await close_chunks()
66+ finally:
67+ close_context = getattr(self._context, "close", None)
68+ if callable(close_context):
69+ await close_context()
70+ 
71+ 
24@dataclass72@dataclass
25class StreamResult:73class StreamResult:
26- """流式派发结果:``chunks`` 为 StreamChunk 异步迭代器。"""74+ """流式派发结果,绑定创建该流的请求上下文。"""
27 75 
28 chunks: AsyncIterator[StreamChunk]76 chunks: AsyncIterator[StreamChunk]
77+ context: Any = None
78+ 
79+ def __post_init__(self) -> None:
80+ if self.context is not None and not isinstance(self.chunks, ContextBoundStream):
81+ self.chunks = ContextBoundStream(self.chunks, self.context)
82+ 
83+ async def aclose(self) -> None:
84+ close_chunks = getattr(self.chunks, "aclose", None)
85+ if callable(close_chunks):
86+ await close_chunks()
87+ elif self.context is not None:
88+ close_context = getattr(self.context, "close", None)
89+ if callable(close_context):
90+ await close_context()
29 91 
30 92 
31DispatchResult = Union[UnaryResult, StreamResult]93DispatchResult = Union[UnaryResult, StreamResult]
@@ -10,12 +10,22 @@
10"""10"""
11from __future__ import annotations11from __future__ import annotations
12 12 
13+import asyncio
13import logging14import logging
14from dataclasses import dataclass15from dataclasses import dataclass
15from typing import Any, AsyncIterator16from typing import Any, AsyncIterator
16 17 
18+from pydantic import BaseModel, ValidationError as PydanticValidationError
19+ 
17from ..envelope import Envelope, ResponseEnvelope, StreamChunk20from ..envelope import Envelope, ResponseEnvelope, StreamChunk
18-from ..errors import ErrorCode, FrameworkError, NotFoundError, ValidationError, exception_code21+from ..errors import (
22+ DeadlineExceeded,
23+ ErrorCode,
24+ FrameworkError,
25+ NotFoundError,
26+ ValidationError,
27+ exception_code,
28+)
19from .handlers import Middleware29from .handlers import Middleware
20from .result import DispatchResult, StreamResult, UnaryResult30from .result import DispatchResult, StreamResult, UnaryResult
21 31 
@@ -29,6 +39,7 @@ _STREAM = "stream"
29class _Endpoint:39class _Endpoint:
30 handler: Any40 handler: Any
31 kind: str # _UNARY | _STREAM41 kind: str # _UNARY | _STREAM
42+ request_model: type[BaseModel] | None = None
32 43 
33 44 
34class MessageRouter:45class MessageRouter:
@@ -39,20 +50,20 @@ class MessageRouter:
39 self._middleware: list[Middleware] = []50 self._middleware: list[Middleware] = []
40 51 
41 # ------------------------------------------------------------------ 注册52 # ------------------------------------------------------------------ 注册
42- def handle(self, msg_type: str):53+ def handle(self, msg_type: str, *, request_model: type[BaseModel] | None = None):
43 """非流式 handler 装饰器。"""54 """非流式 handler 装饰器。"""
44 55 
45 def decorator(fn):56 def decorator(fn):
46- self._register(msg_type, fn, _UNARY)57+ self._register(msg_type, fn, _UNARY, request_model)
47 return fn58 return fn
48 59 
49 return decorator60 return decorator
50 61 
51- def stream(self, msg_type: str):62+ def stream(self, msg_type: str, *, request_model: type[BaseModel] | None = None):
52 """流式 handler 装饰器。"""63 """流式 handler 装饰器。"""
53 64 
54 def decorator(fn):65 def decorator(fn):
55- self._register(msg_type, fn, _STREAM)66+ self._register(msg_type, fn, _STREAM, request_model)
56 return fn67 return fn
57 68 
58 return decorator69 return decorator
@@ -68,7 +79,13 @@ class MessageRouter:
68 """type → kind 视图(适配器据此区分流式/非流式)。"""79 """type → kind 视图(适配器据此区分流式/非流式)。"""
69 return {t: ep.kind for t, ep in self._endpoints.items()}80 return {t: ep.kind for t, ep in self._endpoints.items()}
70 81 
71- def _register(self, msg_type: str, handler: Any, kind: str) -> None:82+ def _register(
83+ self,
84+ msg_type: str,
85+ handler: Any,
86+ kind: str,
87+ request_model: type[BaseModel] | None,
88+ ) -> None:
72 if not msg_type:89 if not msg_type:
73 raise ValidationError("msg_type must be a non-empty string")90 raise ValidationError("msg_type must be a non-empty string")
74 existing = self._endpoints.get(msg_type)91 existing = self._endpoints.get(msg_type)
@@ -77,7 +94,11 @@ class MessageRouter:
77 f"type {msg_type!r} already registered as {existing.kind}",94 f"type {msg_type!r} already registered as {existing.kind}",
78 code=ErrorCode.CONFLICT,95 code=ErrorCode.CONFLICT,
79 )96 )
80- self._endpoints[msg_type] = _Endpoint(handler=handler, kind=kind)97+ self._endpoints[msg_type] = _Endpoint(
98+ handler=handler,
99+ kind=kind,
100+ request_model=request_model,
101+ )
81 102 
82 # ------------------------------------------------------------------ 派发103 # ------------------------------------------------------------------ 派发
83 async def dispatch(self, env: Envelope, rctx: Any) -> DispatchResult:104 async def dispatch(self, env: Envelope, rctx: Any) -> DispatchResult:
@@ -86,9 +107,10 @@ class MessageRouter:
86 return UnaryResult(response=self._error_response(107 return UnaryResult(response=self._error_response(
87 env, NotFoundError(f"no handler registered for type {env.type!r}")))108 env, NotFoundError(f"no handler registered for type {env.type!r}")))
88 109 
89- core = self._build_core(endpoint)
90- chain = self._compose(self._middleware, core)
91 try:110 try:
111+ self._validate_request(endpoint, env)
112+ core = self._build_core(endpoint)
113+ chain = self._compose(self._middleware, core)
92 return await chain(rctx, env)114 return await chain(rctx, env)
93 except FrameworkError as exc:115 except FrameworkError as exc:
94 return UnaryResult(response=self._error_response(env, exc))116 return UnaryResult(response=self._error_response(env, exc))
@@ -96,6 +118,15 @@ class MessageRouter:
96 logger.exception("dispatch failed: type=%s request_id=%s", env.type, env.metadata.request_id)118 logger.exception("dispatch failed: type=%s request_id=%s", env.type, env.metadata.request_id)
97 return UnaryResult(response=self._error_response(env, FrameworkError(str(exc))))119 return UnaryResult(response=self._error_response(env, FrameworkError(str(exc))))
98 120 
121+ @staticmethod
122+ def _validate_request(endpoint: _Endpoint, env: Envelope[Any]) -> None:
123+ if endpoint.request_model is None:
124+ return
125+ try:
126+ env.rawdata = endpoint.request_model.model_validate(env.rawdata)
127+ except PydanticValidationError as exc:
128+ raise ValidationError(str(exc)) from exc
129+ 
99 # -------------------------------------------------------------- 核心组装130 # -------------------------------------------------------------- 核心组装
100 def _build_core(self, endpoint: _Endpoint):131 def _build_core(self, endpoint: _Endpoint):
101 if endpoint.kind == _UNARY:132 if endpoint.kind == _UNARY:
@@ -104,15 +135,16 @@ class MessageRouter:
104 135 
105 def _unary_core(self, handler):136 def _unary_core(self, handler):
106 async def core(ctx, env):137 async def core(ctx, env):
107- result = await handler(ctx, env)138+ result = await self._await_with_lifecycle(lambda: handler(ctx, env), ctx)
108 return UnaryResult(response=self._normalize_unary(result, env))139 return UnaryResult(response=self._normalize_unary(result, env))
109 140 
110 return core141 return core
111 142 
112 def _stream_core(self, handler):143 def _stream_core(self, handler):
113 async def core(ctx, env):144 async def core(ctx, env):
145+ self._check_interrupted(ctx)
114 ait = handler(ctx, env) # async generator → async iterator(同步调用)146 ait = handler(ctx, env) # async generator → async iterator(同步调用)
115- return StreamResult(chunks=self._wrap_stream(ait, env))147+ return StreamResult(chunks=self._wrap_stream(ait, env, ctx), context=ctx)
116 148 
117 return core149 return core
118 150 
@@ -141,7 +173,9 @@ class MessageRouter:
141 raise FrameworkError(173 raise FrameworkError(
142 f"unary handler must return dict or ResponseEnvelope, got {type(result).__name__}")174 f"unary handler must return dict or ResponseEnvelope, got {type(result).__name__}")
143 175 
144- def _wrap_stream(self, ait: AsyncIterator, env: Envelope) -> AsyncIterator[StreamChunk]:176+ def _wrap_stream(
177+ self, ait: AsyncIterator, env: Envelope, ctx: Any
178+ ) -> AsyncIterator[StreamChunk]:
145 """为流式 handler 的产物分配 sequence、置末帧 is_final;出错发末帧错误分片。"""179 """为流式 handler 的产物分配 sequence、置末帧 is_final;出错发末帧错误分片。"""
146 180 
147 async def gen():181 async def gen():
@@ -149,17 +183,30 @@ class MessageRouter:
149 it = ait.__aiter__()183 it = ait.__aiter__()
150 try:184 try:
151 try:185 try:
152- nxt = await it.__anext__()186+ nxt = await self._await_with_lifecycle(it.__anext__, ctx)
153 except StopAsyncIteration:187 except StopAsyncIteration:
154 return188 return
155 while True:189 while True:
156 cur = nxt190 cur = nxt
157 try:191 try:
158- nxt = await it.__anext__()192+ nxt = await self._await_with_lifecycle(it.__anext__, ctx)
159 except StopAsyncIteration:193 except StopAsyncIteration:
160 seq += 1194 seq += 1
161 yield self._to_chunk(cur, seq, is_final=True, env=env)195 yield self._to_chunk(cur, seq, is_final=True, env=env)
162 return196 return
197+ except FrameworkError as exc:
198+ seq += 1
199+ yield self._to_chunk(cur, seq, is_final=False, env=env)
200+ seq += 1
201+ yield self._error_chunk(seq, env, exc)
202+ return
203+ except Exception as exc: # noqa: BLE001
204+ logger.exception("stream handler failed: type=%s", env.type)
205+ seq += 1
206+ yield self._to_chunk(cur, seq, is_final=False, env=env)
207+ seq += 1
208+ yield self._error_chunk(seq, env, FrameworkError(str(exc)))
209+ return
163 seq += 1210 seq += 1
164 yield self._to_chunk(cur, seq, is_final=False, env=env)211 yield self._to_chunk(cur, seq, is_final=False, env=env)
165 except FrameworkError as exc:212 except FrameworkError as exc:
@@ -172,6 +219,36 @@ class MessageRouter:
172 219 
173 return gen()220 return gen()
174 221 
222+ async def _await_with_lifecycle(self, awaitable_factory, ctx: Any):
223+ self._check_interrupted(ctx)
224+ remaining = self._remaining_seconds(ctx)
225+ if remaining is not None and remaining <= 0:
226+ raise DeadlineExceeded("request deadline exceeded")
227+ awaitable = awaitable_factory()
228+ if remaining is None:
229+ return await awaitable
230+ timeout = asyncio.timeout(remaining)
231+ try:
232+ async with timeout:
233+ return await awaitable
234+ except TimeoutError as exc:
235+ if timeout.expired():
236+ raise DeadlineExceeded("request deadline exceeded") from exc
237+ raise
238+ 
239+ @staticmethod
240+ def _check_interrupted(ctx: Any) -> None:
241+ check = getattr(ctx, "check_interrupted", None)
242+ if callable(check):
243+ check()
244+ 
245+ @staticmethod
246+ def _remaining_seconds(ctx: Any) -> float | None:
247+ remaining = getattr(ctx, "remaining_seconds", None)
248+ if not callable(remaining):
249+ return None
250+ return remaining()
251+ 
175 @staticmethod252 @staticmethod
176 def _to_chunk(item: Any, sequence: int, is_final: bool, env: Envelope) -> StreamChunk:253 def _to_chunk(item: Any, sequence: int, is_final: bool, env: Envelope) -> StreamChunk:
177 if isinstance(item, StreamChunk):254 if isinstance(item, StreamChunk):
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
13from typing import Any, Callable13from typing import Any, Callable
14 14 
15from fastapi import FastAPI15from fastapi import FastAPI
16+from pydantic import BaseModel
16 17 
17from ..context.system_context import SystemContext18from ..context.system_context import SystemContext
18from ..envelope import Envelope19from ..envelope import Envelope
@@ -85,13 +86,13 @@ class App:
85 )86 )
86 87 
87 # ------------------------------------------------------------ 注册委托88 # ------------------------------------------------------------ 注册委托
88- def handle(self, msg_type: str):89+ def handle(self, msg_type: str, *, request_model: type[BaseModel] | None = None):
89 """非流式 handler 装饰器(委托 router)。"""90 """非流式 handler 装饰器(委托 router)。"""
90- return self._router.handle(msg_type)91+ return self._router.handle(msg_type, request_model=request_model)
91 92 
92- def stream(self, msg_type: str):93+ def stream(self, msg_type: str, *, request_model: type[BaseModel] | None = None):
93 """流式 handler 装饰器(委托 router)。"""94 """流式 handler 装饰器(委托 router)。"""
94- return self._router.stream(msg_type)95+ return self._router.stream(msg_type, request_model=request_model)
95 96 
96 def use(self, middleware) -> None:97 def use(self, middleware) -> None:
97 """中间件(委托 router)。"""98 """中间件(委托 router)。"""
@@ -9,7 +9,9 @@ body 中的 ``type`` 为准。仅此层 import fastapi。
9"""9"""
10from __future__ import annotations10from __future__ import annotations
11 11 
12+import asyncio
12import json13import json
14+from contextlib import suppress
13from typing import Any, Awaitable, Callable15from typing import Any, Awaitable, Callable
14 16 
15from fastapi import FastAPI, Request17from fastapi import FastAPI, Request
@@ -17,7 +19,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
17 19 
18from ..envelope import Envelope20from ..envelope import Envelope
19from ..errors import ErrorCode, http_status_for21from ..errors import ErrorCode, http_status_for
20-from ..routing.result import UnaryResult22+from ..routing.result import StreamResult, UnaryResult
21from ..routing.router import MessageRouter23from ..routing.router import MessageRouter
22 24 
23 25 
@@ -34,9 +36,54 @@ def _error_json(msg_type: str, code: str, message: str, request_id: str = "") ->
34 return JSONResponse(body, status_code=http_status_for(code))36 return JSONResponse(body, status_code=http_status_for(code))
35 37 
36 38 
37-async def _sse(chunks):39+async def _sse(result: StreamResult):
38- async for chunk in chunks:40+ try:
39- yield f"data: {json.dumps(chunk.to_dict(), ensure_ascii=False)}\n\n"41+ async for chunk in result.chunks:
42+ yield f"data: {json.dumps(chunk.to_dict(), ensure_ascii=False)}\n\n"
43+ finally:
44+ await result.aclose()
45+ 
46+ 
47+async def _wait_for_disconnect(request: Request) -> None:
48+ while True:
49+ message = await request.receive()
50+ if message["type"] == "http.disconnect":
51+ return
52+ 
53+ 
54+async def _dispatch_with_disconnect(
55+ router: MessageRouter,
56+ env: Envelope,
57+ rctx: Any,
58+ request: Request,
59+):
60+ dispatch_task = asyncio.create_task(
61+ router.dispatch(env, rctx), name=f"rest-dispatch:{rctx.request_id}"
62+ )
63+ disconnect_task = asyncio.create_task(
64+ _wait_for_disconnect(request), name=f"rest-disconnect:{rctx.request_id}"
65+ )
66+ try:
67+ done, _ = await asyncio.wait(
68+ {dispatch_task, disconnect_task},
69+ return_when=asyncio.FIRST_COMPLETED,
70+ )
71+ if disconnect_task in done:
72+ rctx.interrupt("client disconnected")
73+ dispatch_task.cancel()
74+ with suppress(asyncio.CancelledError):
75+ await dispatch_task
76+ raise asyncio.CancelledError("client disconnected")
77+ return dispatch_task.result()
78+ finally:
79+ if not disconnect_task.done():
80+ disconnect_task.cancel()
81+ with suppress(asyncio.CancelledError):
82+ await disconnect_task
83+ if not dispatch_task.done():
84+ dispatch_task.cancel()
85+ with suppress(asyncio.CancelledError):
86+ await dispatch_task
40 87 
41 88 
42def mount_rest(89def mount_rest(
@@ -60,13 +107,26 @@ def mount_rest(
60 return _error_json(msg_type, ErrorCode.VALIDATION, f"invalid envelope: {exc}")107 return _error_json(msg_type, ErrorCode.VALIDATION, f"invalid envelope: {exc}")
61 108 
62 sysctx = await ensure_sysctx(fastapi)109 sysctx = await ensure_sysctx(fastapi)
63- rctx = sysctx.for_request(env.metadata)110+ rctx = sysctx.for_request(env)
64- result = await router.dispatch(env, rctx)111+ try:
112+ result = await _dispatch_with_disconnect(router, env, rctx, request)
113+ except BaseException:
114+ await rctx.close()
115+ raise
65 116 
66 if isinstance(result, UnaryResult):117 if isinstance(result, UnaryResult):
67- resp = result.response118+ try:
68- status = 200 if resp.ok else http_status_for(resp.error_code or ErrorCode.INTERNAL)119+ resp = result.response
69- return JSONResponse(resp.to_dict(), status_code=status)120+ status = 200 if resp.ok else http_status_for(
121+ resp.error_code or ErrorCode.INTERNAL
122+ )
123+ return JSONResponse(resp.to_dict(), status_code=status)
124+ finally:
125+ await rctx.close()
70 126 
71 # 流式 → SSE127 # 流式 → SSE
72- return StreamingResponse(_sse(result.chunks), media_type="text/event-stream")128+ try:
129+ return StreamingResponse(_sse(result), media_type="text/event-stream")
130+ except BaseException:
131+ await result.aclose()
132+ raise
@@ -15,7 +15,7 @@ from typing import Any, Awaitable, Callable
15from fastapi import FastAPI, WebSocket, WebSocketDisconnect15from fastapi import FastAPI, WebSocket, WebSocketDisconnect
16 16 
17from ..envelope import Envelope17from ..envelope import Envelope
18-from ..routing.result import UnaryResult18+from ..routing.result import StreamResult, UnaryResult
19 19 
20 20 
21def _error_frame(message: str, code: str = "validation") -> str:21def _error_frame(message: str, code: str = "validation") -> str:
@@ -50,14 +50,25 @@ def mount_ws(
50 await ws.send_text(_error_frame("invalid envelope frame"))50 await ws.send_text(_error_frame("invalid envelope frame"))
51 continue51 continue
52 52 
53- rctx = sysctx.for_request(env.metadata)53+ rctx = sysctx.for_request(env)
54- result = await router.dispatch(env, rctx)54+ result = None
55- if isinstance(result, UnaryResult):55+ try:
56- async with send_lock:56+ result = await router.dispatch(env, rctx)
57- await ws.send_text(json.dumps(result.response.to_dict(), ensure_ascii=False))57+ if isinstance(result, UnaryResult):
58- else:
59- async for chunk in result.chunks:
60 async with send_lock:58 async with send_lock:
61- await ws.send_text(json.dumps(chunk.to_dict(), ensure_ascii=False))59+ await ws.send_text(json.dumps(
60+ result.response.to_dict(), ensure_ascii=False
61+ ))
62+ else:
63+ async for chunk in result.chunks:
64+ async with send_lock:
65+ await ws.send_text(json.dumps(
66+ chunk.to_dict(), ensure_ascii=False
67+ ))
68+ finally:
69+ if isinstance(result, StreamResult):
70+ await result.aclose()
71+ else:
72+ await rctx.close()
62 except WebSocketDisconnect:73 except WebSocketDisconnect:
63 return74 return
@@ -48,8 +48,11 @@ async def test_global_idx_across_two_replicas():
48 metadata=Metadata(request_id=f"r{i}"),48 metadata=Metadata(request_id=f"r{i}"),
49 rawdata={"message": "hi"})49 rawdata={"message": "hi"})
50 rctx = ctx.for_request(env.metadata) # 派生请求上下文50 rctx = ctx.for_request(env.metadata) # 派生请求上下文
51- res = await app.dispatch(env, rctx) # 非流式 → UnaryResult(ResponseEnvelope)51+ try:
52- assert res.response.rawdata == {"echo": "hi", "idx": i + 1} # 1,2,3,4 全局递增52+ res = await app.dispatch(env, rctx) # 非流式 → UnaryResult(ResponseEnvelope)
53+ assert res.response.rawdata == {"echo": "hi", "idx": i + 1} # 1,2,3,4 全局递增
54+ finally:
55+ await rctx.close()
53 finally:56 finally:
54 await ctx_a.stop()57 await ctx_a.stop()
55 await ctx_b.stop()58 await ctx_b.stop()
@@ -3,13 +3,22 @@
3 3 
4"""适配器集成测试:REST SSE 流式 / 错误状态、WS 流式、REST 幂等。覆盖 echo 验收之外的路径。"""4"""适配器集成测试:REST SSE 流式 / 错误状态、WS 流式、REST 幂等。覆盖 echo 验收之外的路径。"""
5import json5import json
6+import asyncio
6 7 
7import fakeredis.aioredis8import fakeredis.aioredis
8import httpx9import httpx
9import pytest10import pytest
11+from pydantic import BaseModel
10from starlette.testclient import TestClient12from starlette.testclient import TestClient
11 13 
12-from openjiuwen_runtime.service import App, Envelope, SystemContext, ValidationError, idempotency_guard14+from openjiuwen_runtime.service import (
15+ App,
16+ Envelope,
17+ SystemContext,
18+ TypedAppContext,
19+ ValidationError,
20+ idempotency_guard,
21+)
13 22 
14 23 
15def _env(t: str, rid="r1", raw=None):24def _env(t: str, rid="r1", raw=None):
@@ -20,6 +29,17 @@ def _ctx_factory():
20 return SystemContext(redis=fakeredis.aioredis.FakeRedis())29 return SystemContext(redis=fakeredis.aioredis.FakeRedis())
21 30 
22 31 
32+class _TrackingSystemContext(SystemContext):
33+ def __init__(self, closed: list[str]) -> None:
34+ super().__init__(redis=fakeredis.aioredis.FakeRedis())
35+ self._closed_requests = closed
36+ 
37+ def for_request(self, request):
38+ ctx = super().for_request(request)
39+ ctx.add_cleanup(lambda: self._closed_requests.append(ctx.request_id))
40+ return ctx
41+ 
42+ 
23def _build_stream_app():43def _build_stream_app():
24 app = App(_ctx_factory)44 app = App(_ctx_factory)
25 45 
@@ -52,6 +72,27 @@ def _build_idem_app():
52 return app72 return app
53 73 
54 74 
75+class _TypedInput(BaseModel):
76+ value: int
77+ 
78+ 
79+def _build_typed_app():
80+ app = App(_ctx_factory)
81+ 
82+ @app.handle("typed", request_model=_TypedInput)
83+ async def typed(
84+ ctx: TypedAppContext[_TypedInput],
85+ env: Envelope[_TypedInput],
86+ ):
87+ return {
88+ "value": ctx.request.value,
89+ "same_request": ctx.request is env.rawdata,
90+ "same_envelope": ctx.envelope is env,
91+ }
92+ 
93+ return app
94+ 
95+ 
55# ---------- REST 流式(SSE)----------96# ---------- REST 流式(SSE)----------
56@pytest.mark.system97@pytest.mark.system
57async def test_rest_stream_returns_sse_chunks():98async def test_rest_stream_returns_sse_chunks():
@@ -107,3 +148,122 @@ async def test_rest_idempotency_rejects_duplicate():
107 assert r1.json()["rawdata"] == {"sent": True}148 assert r1.json()["rawdata"] == {"sent": True}
108 assert r2.status_code == 409 # idempotent → 409149 assert r2.status_code == 409 # idempotent → 409
109 assert r2.json()["error_code"] == "idempotent"150 assert r2.json()["error_code"] == "idempotent"
151+ 
152+ 
153+@pytest.mark.system
154+async def test_rest_typed_request_context_and_validation():
155+ app = _build_typed_app()
156+ async with httpx.AsyncClient(
157+ transport=httpx.ASGITransport(app=app.asgi),
158+ base_url="http://test",
159+ ) as client:
160+ valid = await client.post("/api/typed", json=_env("typed", raw={"value": "7"}))
161+ invalid = await client.post("/api/typed", json=_env("typed", raw={}))
162+ 
163+ assert valid.status_code == 200
164+ assert valid.json()["rawdata"] == {
165+ "value": 7,
166+ "same_request": True,
167+ "same_envelope": True,
168+ }
169+ assert invalid.status_code == 400
170+ assert invalid.json()["error_code"] == "validation"
171+ 
172+ 
173+@pytest.mark.system
174+def test_ws_typed_request_context():
175+ app = _build_typed_app()
176+ client = TestClient(app.asgi)
177+ with client.websocket_connect("/ws") as ws:
178+ ws.send_text(json.dumps(_env("typed", raw={"value": "9"})))
179+ response = json.loads(ws.receive_text())
180+ 
181+ assert response["ok"] is True
182+ assert response["rawdata"] == {
183+ "value": 9,
184+ "same_request": True,
185+ "same_envelope": True,
186+ }
187+ 
188+ 
189+@pytest.mark.system
190+async def test_rest_closes_unary_validation_error_and_stream_contexts():
191+ closed: list[str] = []
192+ app = App(lambda: _TrackingSystemContext(closed))
193+ 
194+ @app.handle("ok")
195+ async def ok(ctx, env):
196+ return {"ok": True}
197+ 
198+ @app.handle("typed-close", request_model=_TypedInput)
199+ async def typed_close(ctx, env):
200+ return {}
201+ 
202+ @app.stream("stream-close")
203+ async def stream_close(ctx, env):
204+ yield {"done": True}
205+ 
206+ async with httpx.AsyncClient(
207+ transport=httpx.ASGITransport(app=app.asgi), base_url="http://test"
208+ ) as client:
209+ unary = await client.post("/api/ok", json=_env("ok", rid="unary"))
210+ invalid = await client.post(
211+ "/api/typed-close", json=_env("typed-close", rid="invalid")
212+ )
213+ stream = await client.post(
214+ "/api/stream-close", json=_env("stream-close", rid="stream")
215+ )
216+ 
217+ assert unary.status_code == 200
218+ assert invalid.status_code == 400
219+ assert stream.status_code == 200
220+ assert closed == ["unary", "invalid", "stream"]
221+ 
222+ 
223+@pytest.mark.system
224+def test_websocket_closes_one_context_per_envelope():
225+ closed: list[str] = []
226+ app = App(lambda: _TrackingSystemContext(closed))
227+ 
228+ @app.handle("ok")
229+ async def ok(ctx, env):
230+ return {"ok": True}
231+ 
232+ @app.stream("stream-close")
233+ async def stream_close(ctx, env):
234+ yield {"done": True}
235+ 
236+ client = TestClient(app.asgi)
237+ with client.websocket_connect("/ws") as ws:
238+ ws.send_text(json.dumps(_env("ok", rid="ws-unary")))
239+ ws.receive_text()
240+ ws.send_text(json.dumps(_env("stream-close", rid="ws-stream")))
241+ ws.receive_text()
242+ 
243+ assert closed == ["ws-unary", "ws-stream"]
244+ 
245+ 
246+@pytest.mark.system
247+async def test_rest_task_cancellation_closes_context():
248+ closed: list[str] = []
249+ started = asyncio.Event()
250+ app = App(lambda: _TrackingSystemContext(closed))
251+ 
252+ @app.handle("slow")
253+ async def slow(ctx, env):
254+ started.set()
255+ await asyncio.Event().wait()
256+ return {}
257+ 
258+ async with httpx.AsyncClient(
259+ transport=httpx.ASGITransport(app=app.asgi), base_url="http://test"
260+ ) as client:
261+ request_task = asyncio.create_task(
262+ client.post("/api/slow", json=_env("slow", rid="cancelled"))
263+ )
264+ await asyncio.wait_for(started.wait(), timeout=1)
265+ request_task.cancel()
266+ with pytest.raises(asyncio.CancelledError):
267+ await request_task
268+ 
269+ assert closed == ["cancelled"]
@@ -12,6 +12,7 @@ _ENVS = [
12 "OPENJIUWEN_SERVICE_REDIS_URL",12 "OPENJIUWEN_SERVICE_REDIS_URL",
13 "OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX",13 "OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX",
14 "OPENJIUWEN_SERVICE_TITLE",14 "OPENJIUWEN_SERVICE_TITLE",
15+ "OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS",
15]16]
16 17 
17 18 
@@ -25,6 +26,7 @@ def test_defaults_when_env_unset(monkeypatch):
25 assert cfg.redis_url == "redis://localhost:6379/0"26 assert cfg.redis_url == "redis://localhost:6379/0"
26 assert cfg.key_prefix == "service"27 assert cfg.key_prefix == "service"
27 assert cfg.title == "service"28 assert cfg.title == "service"
29+ assert cfg.request_timeout_seconds == 0
28 30 
29 31 
30@pytest.mark.unit32@pytest.mark.unit
@@ -34,12 +36,14 @@ def test_reads_all_env_vars(monkeypatch):
34 monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_URL", "redis://cache.internal:6380/3")36 monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_URL", "redis://cache.internal:6380/3")
35 monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", "prod-ns")37 monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", "prod-ns")
36 monkeypatch.setenv("OPENJIUWEN_SERVICE_TITLE", "echo-app")38 monkeypatch.setenv("OPENJIUWEN_SERVICE_TITLE", "echo-app")
39+ monkeypatch.setenv("OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS", "2.5")
37 cfg = ServiceConfig.from_env()40 cfg = ServiceConfig.from_env()
38 assert cfg.host == "127.0.0.1"41 assert cfg.host == "127.0.0.1"
39 assert cfg.port == 999942 assert cfg.port == 9999
40 assert cfg.redis_url == "redis://cache.internal:6380/3"43 assert cfg.redis_url == "redis://cache.internal:6380/3"
41 assert cfg.key_prefix == "prod-ns"44 assert cfg.key_prefix == "prod-ns"
42 assert cfg.title == "echo-app"45 assert cfg.title == "echo-app"
46+ assert cfg.request_timeout_seconds == 2.5
43 47 
44 48 
45@pytest.mark.unit49@pytest.mark.unit
@@ -49,6 +53,14 @@ def test_invalid_port_fails_fast(monkeypatch):
49 ServiceConfig.from_env()53 ServiceConfig.from_env()
50 54 
51 55 
56+@pytest.mark.unit
57+@pytest.mark.parametrize("value", ["invalid", "-1", "nan", "inf"])
58+def test_invalid_request_timeout_fails_fast(monkeypatch, value):
59+ monkeypatch.setenv("OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS", value)
60+ with pytest.raises(ValueError):
61+ ServiceConfig.from_env()
62+ 
63+ 
52@pytest.mark.unit64@pytest.mark.unit
53def test_app_run_host_port_from_env(monkeypatch):65def test_app_run_host_port_from_env(monkeypatch):
54 import fakeredis.aioredis66 import fakeredis.aioredis
@@ -2,7 +2,10 @@
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4"""Envelope / Metadata / ResponseEnvelope / StreamChunk 序列化往返与默认值单测。"""4"""Envelope / Metadata / ResponseEnvelope / StreamChunk 序列化往返与默认值单测。"""
5+from datetime import datetime, timezone
6+ 
5import pytest7import pytest
8+from pydantic import BaseModel
6 9 
7from openjiuwen_runtime.service.envelope import (10from openjiuwen_runtime.service.envelope import (
8 Envelope,11 Envelope,
@@ -48,6 +51,34 @@ def test_metadata_extra_preserved():
48 assert Metadata.from_dict(d) == md51 assert Metadata.from_dict(d) == md
49 52 
50 53 
54+@pytest.mark.unit
55+def test_metadata_instance_id_preserved():
56+ md = Metadata(request_id="r1", instance_id="workflow-7")
57+ assert Metadata.from_dict(md.to_dict()).instance_id == "workflow-7"
58+ 
59+ 
60+class _TypedRequest(BaseModel):
61+ name: str
62+ created_at: datetime
63+ 
64+ 
65+@pytest.mark.unit
66+def test_typed_envelope_serializes_pydantic_request_as_json_data():
67+ request = _TypedRequest(
68+ name="demo",
69+ created_at=datetime(2026, 8, 6, 12, 30, tzinfo=timezone.utc),
70+ )
71+ env = Envelope(type="typed", metadata=Metadata(request_id="r1"), rawdata=request)
72+ 
73+ data = env.to_dict()
74+ 
75+ assert env.rawdata is request
76+ assert data["rawdata"] == {
77+ "name": "demo",
78+ "created_at": "2026-08-06T12:30:00Z",
79+ }
80+ 
81+ 
51@pytest.mark.unit82@pytest.mark.unit
52def test_response_envelope_to_dict_contract():83def test_response_envelope_to_dict_contract():
53 # 适配器据此序列化为 JSON 响应体84 # 适配器据此序列化为 JSON 响应体
@@ -5,12 +5,16 @@
5import pytest5import pytest
6 6 
7from openjiuwen_runtime.service.errors import (7from openjiuwen_runtime.service.errors import (
8+ DatabaseUnavailable,
9+ DeadlineExceeded,
8 ErrorCode,10 ErrorCode,
9 FrameworkError,11 FrameworkError,
10 IdempotentConflict,12 IdempotentConflict,
13+ Interrupted,
11 LockLost,14 LockLost,
12 LockNotAcquired,15 LockNotAcquired,
13 NotFoundError,16 NotFoundError,
17+ RedisUnavailable,
14 ValidationError,18 ValidationError,
15 exception_code,19 exception_code,
16 http_status_for,20 http_status_for,
@@ -32,6 +36,10 @@ def test_subclass_codes():
32 assert IdempotentConflict("x").code == ErrorCode.IDEMPOTENT36 assert IdempotentConflict("x").code == ErrorCode.IDEMPOTENT
33 assert LockNotAcquired("x").code == ErrorCode.LOCKED37 assert LockNotAcquired("x").code == ErrorCode.LOCKED
34 assert LockLost("x").code == ErrorCode.LOCKED38 assert LockLost("x").code == ErrorCode.LOCKED
39+ assert Interrupted("x").code == ErrorCode.INTERRUPTED
40+ assert DeadlineExceeded("x").code == ErrorCode.DEADLINE_EXCEEDED
41+ assert DatabaseUnavailable("x").code == ErrorCode.DATABASE_UNAVAILABLE
42+ assert RedisUnavailable("x").code == ErrorCode.REDIS_UNAVAILABLE
35 # 都是 FrameworkError 子类 → 中间件可统一捕获43 # 都是 FrameworkError 子类 → 中间件可统一捕获
36 for exc in (ValidationError(""), NotFoundError(""), IdempotentConflict(""),44 for exc in (ValidationError(""), NotFoundError(""), IdempotentConflict(""),
37 LockNotAcquired(""), LockLost("")):45 LockNotAcquired(""), LockLost("")):
@@ -61,6 +69,10 @@ def test_http_status_mapping():
61 assert http_status_for(ErrorCode.IDEMPOTENT) == 40969 assert http_status_for(ErrorCode.IDEMPOTENT) == 409
62 assert http_status_for(ErrorCode.LOCKED) == 42370 assert http_status_for(ErrorCode.LOCKED) == 423
63 assert http_status_for(ErrorCode.TIMEOUT) == 50471 assert http_status_for(ErrorCode.TIMEOUT) == 504
72+ assert http_status_for(ErrorCode.INTERRUPTED) == 499
73+ assert http_status_for(ErrorCode.DEADLINE_EXCEEDED) == 504
74+ assert http_status_for(ErrorCode.DATABASE_UNAVAILABLE) == 503
75+ assert http_status_for(ErrorCode.REDIS_UNAVAILABLE) == 503
64 assert http_status_for(ErrorCode.INTERNAL) == 50076 assert http_status_for(ErrorCode.INTERNAL) == 500
65 # 未知 code → 500(fail-safe)77 # 未知 code → 500(fail-safe)
66 assert http_status_for("totally-unknown") == 50078 assert http_status_for("totally-unknown") == 500
@@ -58,6 +58,7 @@ async def test_guard_reject_mode_runs_handler_once():
58 assert res2.response.ok is False58 assert res2.response.ok is False
59 assert res2.response.error_code == "idempotent" # 重复 → 拒绝59 assert res2.response.error_code == "idempotent" # 重复 → 拒绝
60 assert len(calls) == 1 # handler 只跑一次60 assert len(calls) == 1 # handler 只跑一次
61+ await rctx.close()
61 await ctx.stop()62 await ctx.stop()
62 63 
63 64 
@@ -81,4 +82,5 @@ async def test_guard_cache_mode_replays_result_without_rerunning():
81 assert res2.response.ok is True82 assert res2.response.ok is True
82 assert res2.response.rawdata == {"pong": 1} # 回放首次结果83 assert res2.response.rawdata == {"pong": 1} # 回放首次结果
83 assert len(calls) == 1 # handler 只跑一次84 assert len(calls) == 1 # handler 只跑一次
85+ await rctx.close()
84 await ctx.stop()86 await ctx.stop()
@@ -0,0 +1,245 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""DB, transaction, audit, and lock capabilities on RequestContext."""
5+from __future__ import annotations
6+ 
7+import logging
8+from dataclasses import dataclass
9+from typing import Any
10+ 
11+import fakeredis.aioredis
12+import pytest
13+ 
14+from openjiuwen_runtime.service import (
15+ AuditEvent,
16+ DatabaseUnavailable,
17+ Envelope,
18+ Interrupted,
19+ LoggingAuditLogger,
20+ Metadata,
21+ RedisUnavailable,
22+ SystemContext,
23+)
24+ 
25+ 
26+def _env() -> Envelope[dict[str, Any]]:
27+ return Envelope(
28+ type="users/update",
29+ metadata=Metadata(
30+ request_id="request-1",
31+ trace_id="trace-1",
32+ user_id="metadata-user",
33+ session_id="session-1",
34+ instance_id="workflow-1",
35+ ),
36+ rawdata={"id": 1},
37+ )
38+ 
39+ 
40+class _FakeSession:
41+ def __init__(self) -> None:
42+ self.committed = False
43+ self.rolled_back = False
44+ self.closed = False
45+ 
46+ async def commit(self) -> None:
47+ self.committed = True
48+ 
49+ async def rollback(self) -> None:
50+ self.rolled_back = True
51+ 
52+ async def close(self) -> None:
53+ self.closed = True
54+ 
55+ 
56+class _FakeDb:
57+ def __init__(self) -> None:
58+ self.calls: list[tuple[Any, ...]] = []
59+ self.sessions: list[_FakeSession] = []
60+ 
61+ def session_factory(self) -> _FakeSession:
62+ session = _FakeSession()
63+ self.sessions.append(session)
64+ return session
65+ 
66+ async def create(self, table_name: str, data: dict[str, Any]) -> dict[str, Any]:
67+ self.calls.append(("create", table_name, data))
68+ return dict(data)
69+ 
70+ async def get(self, table_name: str, filters: dict[str, Any]) -> dict[str, Any]:
71+ self.calls.append(("get", table_name, filters))
72+ return dict(filters)
73+ 
74+ async def update(
75+ self,
76+ table_name: str,
77+ filters: dict[str, Any],
78+ data: dict[str, Any],
79+ ) -> dict[str, Any]:
80+ self.calls.append(("update", table_name, filters, data))
81+ return {**filters, **data}
82+ 
83+ async def delete(self, table_name: str, filters: dict[str, Any]) -> bool:
84+ self.calls.append(("delete", table_name, filters))
85+ return True
86+ 
87+ async def list_records(
88+ self,
89+ table_name: str,
90+ filters: dict[str, Any] | None = None,
91+ limit: int = 100,
92+ offset: int = 0,
93+ ) -> list[dict[str, Any]]:
94+ self.calls.append(("list", table_name, filters, limit, offset))
95+ return [dict(filters or {})]
96+ 
97+ async def count_records(
98+ self,
99+ table_name: str,
100+ filters: dict[str, Any] | None = None,
101+ ) -> int:
102+ self.calls.append(("count", table_name, filters))
103+ return 1
104+ 
105+ 
106+class _CaptureAuditLogger:
107+ def __init__(self) -> None:
108+ self.events: list[AuditEvent] = []
109+ 
110+ async def write(self, event: AuditEvent) -> None:
111+ self.events.append(event)
112+ 
113+ 
114+@dataclass
115+class _Principal:
116+ user_id: str
117+ 
118+ 
119+@pytest.mark.unit
120+def test_system_context_requires_configured_resources():
121+ sysctx = SystemContext()
122+ 
123+ with pytest.raises(DatabaseUnavailable):
124+ sysctx.require_db()
125+ with pytest.raises(RedisUnavailable):
126+ sysctx.require_redis()
127+ 
128+ 
129+@pytest.mark.unit
130+async def test_db_helpers_delegate_as_independent_operations():
131+ db = _FakeDb()
132+ ctx = SystemContext(db=db).for_request(_env())
133+ 
134+ assert ctx.db is db
135+ assert ctx.require_db() is db
136+ assert await ctx.db_create("users", {"id": 1}) == {"id": 1}
137+ assert await ctx.db_get("users", {"id": 1}) == {"id": 1}
138+ assert await ctx.db_update("users", {"id": 1}, {"name": "new"}) == {
139+ "id": 1,
140+ "name": "new",
141+ }
142+ assert await ctx.db_delete("users", {"id": 1}) is True
143+ assert await ctx.db_list("users", {"active": True}, limit=5, offset=2) == [
144+ {"active": True}
145+ ]
146+ assert await ctx.db_count("users", {"active": True}) == 1
147+ assert [call[0] for call in db.calls] == [
148+ "create",
149+ "get",
150+ "update",
151+ "delete",
152+ "list",
153+ "count",
154+ ]
155+ 
156+ 
157+@pytest.mark.unit
158+async def test_db_helpers_check_request_state_and_missing_database():
159+ missing = SystemContext().for_request(_env())
160+ with pytest.raises(DatabaseUnavailable):
161+ await missing.db_get("users", {"id": 1})
162+ 
163+ interrupted = SystemContext(db=_FakeDb()).for_request(_env())
164+ interrupted.interrupt("stop database work")
165+ with pytest.raises(Interrupted, match="stop database work"):
166+ await interrupted.db_count("users")
167+ 
168+ 
169+@pytest.mark.unit
170+async def test_transaction_yields_one_session_and_db_helpers_stay_independent():
171+ db = _FakeDb()
172+ ctx = SystemContext(db=db).for_request(_env())
173+ 
174+ async with ctx.transaction() as session:
175+ same_session = session
176+ await ctx.db_create("users", {"id": 1})
177+ 
178+ assert session is same_session is db.sessions[0]
179+ assert session.committed is True
180+ assert session.rolled_back is False
181+ assert session.closed is True
182+ assert db.calls == [("create", "users", {"id": 1})]
183+ 
184+ 
185+@pytest.mark.unit
186+async def test_request_audit_populates_context_fields_and_principal_actor():
187+ audit = _CaptureAuditLogger()
188+ ctx = SystemContext(
189+ audit_logger=audit,
190+ instance_id="replica-1",
191+ ).for_request(_env())
192+ ctx.principal = _Principal(user_id="principal-user")
193+ details = {"changed": ["name"]}
194+ 
195+ await ctx.audit("user.updated", details=details)
196+ 
197+ assert len(audit.events) == 1
198+ event = audit.events[0]
199+ assert event.action == "user.updated"
200+ assert event.actor == "principal-user"
201+ assert event.user_id == "metadata-user"
202+ assert event.resource == "users/update"
203+ assert event.request_id == "request-1"
204+ assert event.trace_id == "trace-1"
205+ assert event.session_id == "session-1"
206+ assert event.msg_type == "users/update"
207+ assert event.instance_id == "workflow-1"
208+ assert event.replica_id == "replica-1"
209+ assert event.details == details
210+ assert event.details is not details
211+ 
212+ 
213+@pytest.mark.unit
214+async def test_default_audit_logger_emits_structured_event(caplog):
215+ logger = logging.getLogger("test.audit")
216+ logger.setLevel(logging.INFO)
217+ ctx = SystemContext(logger=logger).for_request(_env())
218+ 
219+ with caplog.at_level(logging.INFO, logger=logger.name):
220+ await ctx.audit("user.read", actor="explicit-actor", resource="user:1")
221+ 
222+ record = next(record for record in caplog.records if "audit action=user.read" in record.message)
223+ assert record.audit["actor"] == "explicit-actor"
224+ assert record.audit["resource"] == "user:1"
225+ assert record.audit["request_id"] == "request-1"
226+ 
227+ 
228+@pytest.mark.unit
229+def test_logging_audit_logger_accepts_named_level():
230+ audit = LoggingAuditLogger(level="WARNING")
231+ 
232+ assert audit.level == logging.WARNING
233+ 
234+ 
235+@pytest.mark.unit
236+async def test_lock_checks_interruption_at_acquisition_time():
237+ redis = fakeredis.aioredis.FakeRedis()
238+ ctx = SystemContext(redis=redis).for_request(_env())
239+ lock = ctx.lock("user:1")
240+ ctx.interrupt("request cancelled")
241+ 
242+ with pytest.raises(Interrupted, match="request cancelled"):
243+ async with lock:
244+ pass
245+ assert await redis.exists(lock.key) == 0
@@ -0,0 +1,99 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""Request cancellation, deadline, cleanup, and bound logging tests."""
5+ 
6+import asyncio
7+import logging
8+ 
9+import pytest
10+ 
11+from openjiuwen_runtime.service import (
12+ DeadlineExceeded,
13+ Envelope,
14+ Interrupted,
15+ Metadata,
16+ SystemContext,
17+)
18+ 
19+ 
20+def _env(request_id: str = "r1", trace_id: str | None = "t1") -> Envelope:
21+ return Envelope(
22+ type="work",
23+ metadata=Metadata(request_id=request_id, trace_id=trace_id),
24+ rawdata={},
25+ )
26+ 
27+ 
28+@pytest.mark.unit
29+async def test_interrupt_wakes_waiters_and_preserves_first_reason():
30+ ctx = SystemContext().for_request(_env())
31+ waiter = asyncio.create_task(ctx.wait_interrupted())
32+ 
33+ await asyncio.sleep(0)
34+ ctx.interrupt("client disconnected")
35+ ctx.interrupt("later reason")
36+ await asyncio.wait_for(waiter, timeout=1)
37+ 
38+ with pytest.raises(Interrupted, match="client disconnected"):
39+ ctx.check_interrupted()
40+ await ctx.close()
41+ 
42+ 
43+@pytest.mark.unit
44+async def test_deadline_is_absolute_and_wakes_waiters():
45+ ctx = SystemContext(request_timeout_seconds=0.02).for_request(_env())
46+ 
47+ remaining = ctx.remaining_seconds()
48+ assert remaining is not None and 0 < remaining <= 0.02 + 1e-6
49+ await asyncio.wait_for(ctx.wait_interrupted(), timeout=1)
50+ assert ctx.remaining_seconds() == 0
51+ with pytest.raises(DeadlineExceeded):
52+ ctx.check_interrupted()
53+ await ctx.close()
54+ 
55+ 
56+@pytest.mark.unit
57+async def test_close_is_idempotent_lifo_and_continues_after_failure(caplog):
58+ logger = logging.getLogger("test.request.cleanup")
59+ logger.setLevel(logging.ERROR)
60+ ctx = SystemContext(logger=logger).for_request(_env())
61+ calls: list[str] = []
62+ 
63+ async def first() -> None:
64+ calls.append("first")
65+ 
66+ def failing() -> None:
67+ calls.append("failing")
68+ raise RuntimeError("cleanup failed")
69+ 
70+ def last() -> None:
71+ calls.append("last")
72+ 
73+ ctx.add_cleanup(first)
74+ ctx.add_cleanup(failing)
75+ ctx.add_cleanup(last)
76+ 
77+ with caplog.at_level(logging.ERROR, logger=logger.name):
78+ await asyncio.gather(ctx.close(), ctx.close())
79+ await ctx.close()
80+ 
81+ assert calls == ["last", "failing", "first"]
82+ assert ctx.closed is True
83+ assert "request cleanup failed" in caplog.text
84+ 
85+ 
86+@pytest.mark.unit
87+def test_request_logger_adds_request_and_trace_ids(caplog):
88+ logger = logging.getLogger("test.request.bound")
89+ logger.setLevel(logging.INFO)
90+ ctx = SystemContext(logger=logger).for_request(_env("request-7", "trace-9"))
91+ 
92+ with caplog.at_level(logging.INFO, logger=logger.name):
93+ ctx.logger.info("bound message")
94+ 
95+ record = next(record for record in caplog.records if record.message == "bound message")
96+ assert record.request_id == "request-7"
97+ assert record.trace_id == "trace-9"
98+ assert isinstance(ctx.attrs, dict)
99+ assert ctx.principal is None
@@ -2,8 +2,12 @@
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4"""MessageRouter 单测:注册/派发/中间件链/唯一性/流式(设计 §6.2、§6.3)。"""4"""MessageRouter 单测:注册/派发/中间件链/唯一性/流式(设计 §6.2、§6.3)。"""
5-import pytest5+import asyncio
6 6 
7+import pytest
8+from pydantic import BaseModel
9+ 
10+from openjiuwen_runtime.service.context.system_context import SystemContext
7from openjiuwen_runtime.service.envelope import Envelope, Metadata, ResponseEnvelope11from openjiuwen_runtime.service.envelope import Envelope, Metadata, ResponseEnvelope
8from openjiuwen_runtime.service.errors import ValidationError12from openjiuwen_runtime.service.errors import ValidationError
9from openjiuwen_runtime.service.routing.router import MessageRouter13from openjiuwen_runtime.service.routing.router import MessageRouter
@@ -149,3 +153,143 @@ async def test_stream_dispatch_assigns_sequence_and_final():
149 assert chunks[0].is_final is False and chunks[1].is_final is True153 assert chunks[0].is_final is False and chunks[1].is_final is True
150 assert chunks[0].rawdata == {"n": 1}154 assert chunks[0].rawdata == {"n": 1}
151 assert chunks[1].metadata.request_id == "r1"155 assert chunks[1].metadata.request_id == "r1"
156+ 
157+ 
158+class _CreateRequest(BaseModel):
159+ name: str
160+ count: int
161+ 
162+ 
163+@pytest.mark.unit
164+async def test_typed_request_is_validated_once_and_shared_by_context_and_envelope():
165+ router = MessageRouter()
166+ env = _env(t="create", raw={"name": "demo", "count": "2"})
167+ rctx = SystemContext().for_request(env)
168+ 
169+ @router.handle("create", request_model=_CreateRequest)
170+ async def create(ctx, typed_env):
171+ assert isinstance(typed_env.rawdata, _CreateRequest)
172+ assert ctx.request is typed_env.rawdata
173+ return {"name": ctx.request.name, "count": ctx.request.count}
174+ 
175+ res = await router.dispatch(env, rctx)
176+ 
177+ assert res.response.ok is True
178+ assert res.response.rawdata == {"name": "demo", "count": 2}
179+ await rctx.close()
180+ 
181+ 
182+@pytest.mark.unit
183+async def test_typed_request_validation_error_uses_validation_envelope():
184+ router = MessageRouter()
185+ called = False
186+ 
187+ @router.handle("create", request_model=_CreateRequest)
188+ async def create(ctx, env):
189+ nonlocal called
190+ called = True
191+ return {}
192+ 
193+ res = await router.dispatch(_env(t="create", raw={"name": "demo"}), object())
194+ 
195+ assert called is False
196+ assert res.response.ok is False
197+ assert res.response.error_code == "validation"
198+ assert "count" in res.response.error_message
199+ 
200+ 
201+@pytest.mark.unit
202+async def test_stream_request_model_is_applied_before_iteration():
203+ router = MessageRouter()
204+ 
205+ @router.stream("create", request_model=_CreateRequest)
206+ async def create(ctx, env):
207+ yield {"count": env.rawdata.count}
208+ 
209+ res = await router.dispatch(
210+ _env(t="create", raw={"name": "demo", "count": 3}),
211+ object(),
212+ )
213+ chunks = [chunk async for chunk in res.chunks]
214+ 
215+ assert chunks[0].rawdata == {"count": 3}
216+ 
217+ 
218+@pytest.mark.unit
219+async def test_preinterrupted_request_does_not_call_unary_handler():
220+ router = MessageRouter()
221+ ctx = SystemContext().for_request(_env())
222+ ctx.interrupt("cancelled by caller")
223+ called = False
224+ 
225+ @router.handle("echo")
226+ async def echo(handler_ctx, env):
227+ nonlocal called
228+ called = True
229+ return {}
230+ 
231+ result = await router.dispatch(_env(), ctx)
232+ 
233+ assert called is False
234+ assert result.response.error_code == "interrupted"
235+ assert result.response.error_message == "cancelled by caller"
236+ await ctx.close()
237+ 
238+ 
239+@pytest.mark.unit
240+async def test_unary_handler_uses_remaining_deadline():
241+ router = MessageRouter()
242+ ctx = SystemContext(request_timeout_seconds=0.01).for_request(_env())
243+ 
244+ @router.handle("echo")
245+ async def echo(handler_ctx, env):
246+ await asyncio.sleep(1)
247+ return {}
248+ 
249+ result = await router.dispatch(_env(), ctx)
250+ 
251+ assert result.response.ok is False
252+ assert result.response.error_code == "deadline_exceeded"
253+ await ctx.close()
254+ 
255+ 
256+@pytest.mark.unit
257+async def test_stream_deadline_and_close_are_applied_during_iteration():
258+ router = MessageRouter()
259+ ctx = SystemContext(request_timeout_seconds=0.02).for_request(_env(t="gen"))
260+ cleaned: list[str] = []
261+ ctx.add_cleanup(lambda: cleaned.append("closed"))
262+ 
263+ @router.stream("gen")
264+ async def gen(handler_ctx, env):
265+ yield {"n": 1}
266+ await asyncio.sleep(1)
267+ yield {"n": 2}
268+ 
269+ result = await router.dispatch(_env(t="gen"), ctx)
270+ chunks = [chunk async for chunk in result.chunks]
271+ 
272+ assert chunks[0].rawdata == {"n": 1}
273+ assert chunks[0].is_final is False
274+ assert chunks[-1].is_final is True
275+ assert chunks[-1].error_code == "deadline_exceeded"
276+ assert cleaned == ["closed"]
277+ await result.aclose()
278+ assert cleaned == ["closed"]
279+ 
280+ 
281+@pytest.mark.unit
282+async def test_unconsumed_stream_can_be_closed_explicitly():
283+ router = MessageRouter()
284+ ctx = SystemContext().for_request(_env(t="gen"))
285+ cleaned: list[str] = []
286+ ctx.add_cleanup(lambda: cleaned.append("closed"))
287+ 
288+ @router.stream("gen")
289+ async def gen(handler_ctx, env):
290+ yield {"n": 1}
291+ 
292+ result = await router.dispatch(_env(t="gen"), ctx)
293+ await result.aclose()
294+ 
295+ assert cleaned == ["closed"]
@@ -7,8 +7,14 @@ import logging
7import fakeredis.aioredis7import fakeredis.aioredis
8import pytest8import pytest
9 9 
10+from openjiuwen_runtime.service import RequestContext as PublicRequestContext
11+from openjiuwen_runtime.service import TypedAppContext
12+from openjiuwen_runtime.service.context.request_context import RequestContext
13+from openjiuwen_runtime.service.context.system_context import (
14+ RequestContext as LegacyRequestContext,
15+)
10from openjiuwen_runtime.service.context.system_context import SystemContext16from openjiuwen_runtime.service.context.system_context import SystemContext
11-from openjiuwen_runtime.service.envelope import Metadata17+from openjiuwen_runtime.service.envelope import Envelope, Metadata
12 18 
13 19 
14@pytest.mark.unit20@pytest.mark.unit
@@ -40,6 +46,55 @@ async def test_for_request_propagates_metadata_and_lock_owner():
40 await ctx.stop()46 await ctx.stop()
41 47 
42 48 
49+@pytest.mark.unit
50+def test_for_request_binds_complete_envelope_and_exposes_identifiers():
51+ sysctx = SystemContext(instance_id="replica-a")
52+ request = {"name": "typed"}
53+ env = Envelope(
54+ type="users/create",
55+ metadata=Metadata(
56+ request_id="r1",
57+ user_id="u1",
58+ chat_id="c1",
59+ session_id="s1",
60+ trace_id="t1",
61+ bot_id="b1",
62+ channel="rest",
63+ instance_id="workflow-7",
64+ ),
65+ rawdata=request,
66+ )
67+ 
68+ rctx = sysctx.for_request(env)
69+ 
70+ assert rctx.envelope is env
71+ assert rctx.request is request
72+ assert rctx.metadata is env.metadata
73+ assert rctx.msg_type == "users/create"
74+ assert rctx.request_id == "r1"
75+ assert rctx.user_id == "u1"
76+ assert rctx.chat_id == "c1"
77+ assert rctx.session_id == "s1"
78+ assert rctx.trace_id == "t1"
79+ assert rctx.bot_id == "b1"
80+ assert rctx.channel == "rest"
81+ assert rctx.instance_id == "workflow-7"
82+ assert rctx.replica_id == "replica-a"
83+ 
84+ 
85+@pytest.mark.unit
86+def test_namespace_applies_optional_key_prefix():
87+ assert SystemContext(key_prefix="runtime").namespace("kv") == "runtime:kv"
88+ assert SystemContext(key_prefix="").namespace("kv") == "kv"
89+ 
90+ 
91+@pytest.mark.unit
92+def test_request_context_imports_remain_compatible():
93+ assert LegacyRequestContext is RequestContext
94+ assert PublicRequestContext is RequestContext
95+ assert TypedAppContext is RequestContext
96+ 
97+ 
43@pytest.mark.unit98@pytest.mark.unit
44async def test_lock_owner_unique_per_request():99async def test_lock_owner_unique_per_request():
45 ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis())100 ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis())