已合并
feat(service): 增强请求上下文生命周期与数据能力 #411
m0u55e创建于 8月7日
feat(service): 增强请求上下文生命周期与数据能力 #411
已合并
共 25 个文件变更+1748-150
| @@ -8,15 +8,27 @@ | |||
| 8 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk | 8 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk |
| 9 | from .config import ServiceConfig | 9 | from .config import ServiceConfig |
| 10 | from .errors import ( | 10 | from .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, SystemContext | 23 | +from .context import ( |
| 24 | + AuditEvent, | ||
| 25 | + AuditLogger, | ||
| 26 | + LoggingAuditLogger, | ||
| 27 | + NoopAuditLogger, | ||
| 28 | + RequestContext, | ||
| 29 | + SystemContext, | ||
| 30 | + TypedAppContext, | ||
| 31 | +) | ||
| 20 | from .context.primitives.idempotency import idempotency_guard | 32 | from .context.primitives.idempotency import idempotency_guard |
| 21 | from .server.app import App | 33 | from .server.app import App |
| 22 | 34 | ||
| @@ -33,6 +45,10 @@ __all__ = [ | |||
| 33 | # errors | 45 | # 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 | # context | 57 | # context |
| 42 | "SystemContext", | 58 | "SystemContext", |
| 43 | "RequestContext", | 59 | "RequestContext", |
| 60 | + "TypedAppContext", | ||
| 61 | + "AuditEvent", | ||
| 62 | + "AuditLogger", | ||
| 63 | + "LoggingAuditLogger", | ||
| 64 | + "NoopAuditLogger", | ||
| 44 | # middleware | 65 | # middleware |
| 45 | "idempotency_guard", | 66 | "idempotency_guard", |
| 46 | # server | 67 | # 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 | """ |
| 17 | from __future__ import annotations | 18 | from __future__ import annotations |
| 18 | 19 | ||
| 20 | +import math | ||
| 19 | import os | 21 | import os |
| 20 | from dataclasses import dataclass | 22 | from 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_URL | 38 | redis_url: str = _DEFAULT_REDIS_URL |
| 36 | key_prefix: str = _DEFAULT_KEY_PREFIX | 39 | key_prefix: str = _DEFAULT_KEY_PREFIX |
| 37 | title: str = _DEFAULT_TITLE | 40 | 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 | @classmethod | 47 | @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-8 | 1 | # coding: utf-8 |
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # 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 | ||
| 12 | import asyncio | 12 | import asyncio |
| 13 | import logging | 13 | import logging |
| 14 | -from typing import Any | 14 | +from typing import Any, Callable |
| 15 | 15 | ||
| 16 | from redis.exceptions import WatchError | 16 | from 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 = redis | 40 | self._redis = redis |
| 40 | self.key = f"{prefix}:{key}" if prefix else key | 41 | 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 = None | 46 | self._renew_task: asyncio.Task | None = None |
| 46 | self._lost = False | 47 | 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-8 | 1 | # coding: utf-8 |
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # 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 | """ |
| 12 | from __future__ import annotations | 11 | from __future__ import annotations |
| 13 | 12 | ||
| 14 | import logging | 13 | import logging |
| 14 | +import math | ||
| 15 | import socket | 15 | import socket |
| 16 | +import time | ||
| 16 | from contextlib import asynccontextmanager | 17 | from contextlib import asynccontextmanager |
| 17 | -from dataclasses import dataclass, field | 18 | +from typing import Any, AsyncIterator, TypeVar, overload |
| 18 | -from typing import Any, Optional | ||
| 19 | from uuid import uuid4 | 19 | from uuid import uuid4 |
| 20 | 20 | ||
| 21 | import redis.asyncio | 21 | import redis.asyncio |
| 22 | +from sqlalchemy.ext.asyncio import AsyncSession | ||
| 22 | 23 | ||
| 23 | from ..config import ServiceConfig | 24 | from ..config import ServiceConfig |
| 24 | -from ..envelope import Metadata | 25 | +from ..envelope import Envelope, Metadata |
| 25 | -from ..errors import FrameworkError | 26 | +from ..errors import DatabaseUnavailable, FrameworkError, RedisUnavailable |
| 26 | -from .primitives.kv_store import KVStore | 27 | +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 | ||
| 31 | class SystemContext: | 34 | class 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 = redis | 51 | self.redis = redis |
| 45 | self.db = db | 52 | self.db = db |
| 46 | self.settings = settings | 53 | 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_redis | 67 | self._owns_redis = _owns_redis |
| 50 | self._started = False | 68 | 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 suffix | 84 | 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 = False | 124 | 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 | @asynccontextmanager | 158 | @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 None | 165 | + 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 session | 171 | 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 | raise | 175 | raise |
| 105 | finally: | 176 | finally: |
| @@ -125,71 +196,13 @@ class SystemContext: | |||
| 125 | url = cfg.redis_url if redis_url is None else redis_url | 196 | url = cfg.redis_url if redis_url is None else redis_url |
| 126 | kp = cfg.key_prefix if key_prefix is None else key_prefix | 197 | 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 | -@dataclass | 208 | +__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 | |||
| 10 | from __future__ import annotations | 10 | from __future__ import annotations |
| 11 | 11 | ||
| 12 | from dataclasses import dataclass, field | 12 | from dataclasses import dataclass, field |
| 13 | -from typing import Any | 13 | +from typing import Any, Generic, TypeVar |
| 14 | + | ||
| 15 | +from pydantic import BaseModel | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +TRequest = TypeVar("TRequest") | ||
| 14 | 19 | ||
| 15 | 20 | ||
| 16 | @dataclass | 21 | @dataclass |
| @@ -25,6 +30,7 @@ class Metadata: | |||
| 25 | channel: str | None = None | 30 | channel: str | None = None |
| 26 | timestamp: float | None = None | 31 | timestamp: float | None = None |
| 27 | trace_id: str | None = None | 32 | 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 | @dataclass | 66 | @dataclass |
| 59 | -class Envelope: | 67 | +class Envelope(Generic[TRequest]): |
| 60 | """框架唯一入口消息结构。``type`` 既是路由键又是 REST 路径段。""" | 68 | """框架唯一入口消息结构。``type`` 既是路由键又是 REST 路径段。""" |
| 61 | 69 | ||
| 62 | type: str | 70 | type: str |
| 63 | metadata: Metadata | 71 | metadata: Metadata |
| 64 | - rawdata: dict | 72 | + 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 | @classmethod | 87 | @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 | ||
| 26 | class FrameworkError(Exception): | 30 | class FrameworkError(Exception): |
| @@ -74,6 +78,30 @@ class FrameworkTimeout(FrameworkError): | |||
| 74 | code = ErrorCode.TIMEOUT | 78 | 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_checkable | 105 | @runtime_checkable |
| 78 | class _HasCode(Protocol): | 106 | class _HasCode(Protocol): |
| 79 | code: str | 107 | 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 | """ |
| 9 | from __future__ import annotations | 9 | from __future__ import annotations |
| 10 | 10 | ||
| 11 | -from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, Union, runtime_checkable | 11 | +from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, TypeVar, Union, runtime_checkable |
| 12 | 12 | ||
| 13 | from ..envelope import Envelope, ResponseEnvelope, StreamChunk | 13 | from ..envelope import Envelope, ResponseEnvelope, StreamChunk |
| 14 | 14 | ||
| 15 | if TYPE_CHECKING: # 仅注解用,避免运行期循环导入 | 15 | if TYPE_CHECKING: # 仅注解用,避免运行期循环导入 |
| 16 | - from ..context.system_context import RequestContext | 16 | + from ..context.request_context import RequestContext |
| 17 | + | ||
| 18 | +TRequest = TypeVar("TRequest") | ||
| 17 | 19 | ||
| 18 | # handler 返回:dict(框架包成 ResponseEnvelope)或现成的 ResponseEnvelope | 20 | # handler 返回:dict(框架包成 ResponseEnvelope)或现成的 ResponseEnvelope |
| 19 | UnaryReturn = Union[ResponseEnvelope, dict] | 21 | UnaryReturn = 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_checkable | 30 | @runtime_checkable |
| 29 | class MessageHandler(Protocol): | 31 | class 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): | |||
| 37 | class StreamMessageHandler(Protocol): | 43 | class 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 | """ |
| 9 | from __future__ import annotations | 9 | from __future__ import annotations |
| 10 | 10 | ||
| 11 | +import asyncio | ||
| 11 | from dataclasses import dataclass | 12 | from dataclasses import dataclass |
| 12 | -from typing import AsyncIterator, Union | 13 | +from typing import Any, AsyncIterator, Union |
| 13 | 14 | ||
| 14 | from ..envelope import ResponseEnvelope, StreamChunk | 15 | from ..envelope import ResponseEnvelope, StreamChunk |
| 15 | 16 | ||
| @@ -21,11 +22,72 @@ class UnaryResult: | |||
| 21 | response: ResponseEnvelope | 22 | 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 | @dataclass | 72 | @dataclass |
| 25 | class StreamResult: | 73 | class 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 | ||
| 31 | DispatchResult = Union[UnaryResult, StreamResult] | 93 | DispatchResult = Union[UnaryResult, StreamResult] |
| @@ -10,12 +10,22 @@ | |||
| 10 | """ | 10 | """ |
| 11 | from __future__ import annotations | 11 | from __future__ import annotations |
| 12 | 12 | ||
| 13 | +import asyncio | ||
| 13 | import logging | 14 | import logging |
| 14 | from dataclasses import dataclass | 15 | from dataclasses import dataclass |
| 15 | from typing import Any, AsyncIterator | 16 | from typing import Any, AsyncIterator |
| 16 | 17 | ||
| 18 | +from pydantic import BaseModel, ValidationError as PydanticValidationError | ||
| 19 | + | ||
| 17 | from ..envelope import Envelope, ResponseEnvelope, StreamChunk | 20 | from ..envelope import Envelope, ResponseEnvelope, StreamChunk |
| 18 | -from ..errors import ErrorCode, FrameworkError, NotFoundError, ValidationError, exception_code | 21 | +from ..errors import ( |
| 22 | + DeadlineExceeded, | ||
| 23 | + ErrorCode, | ||
| 24 | + FrameworkError, | ||
| 25 | + NotFoundError, | ||
| 26 | + ValidationError, | ||
| 27 | + exception_code, | ||
| 28 | +) | ||
| 19 | from .handlers import Middleware | 29 | from .handlers import Middleware |
| 20 | from .result import DispatchResult, StreamResult, UnaryResult | 30 | from .result import DispatchResult, StreamResult, UnaryResult |
| 21 | 31 | ||
| @@ -29,6 +39,7 @@ _STREAM = "stream" | |||
| 29 | class _Endpoint: | 39 | class _Endpoint: |
| 30 | handler: Any | 40 | handler: Any |
| 31 | kind: str # _UNARY | _STREAM | 41 | kind: str # _UNARY | _STREAM |
| 42 | + request_model: type[BaseModel] | None = None | ||
| 32 | 43 | ||
| 33 | 44 | ||
| 34 | class MessageRouter: | 45 | class 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 fn | 58 | return fn |
| 48 | 59 | ||
| 49 | return decorator | 60 | 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 fn | 67 | return fn |
| 57 | 68 | ||
| 58 | return decorator | 69 | 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 core | 141 | 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 core | 149 | 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 | return | 188 | return |
| 155 | while True: | 189 | while True: |
| 156 | cur = nxt | 190 | 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 += 1 | 194 | 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 | return | 196 | 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 += 1 | 210 | 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 | @staticmethod | 252 | @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 | |||
| 13 | from typing import Any, Callable | 13 | from typing import Any, Callable |
| 14 | 14 | ||
| 15 | from fastapi import FastAPI | 15 | from fastapi import FastAPI |
| 16 | +from pydantic import BaseModel | ||
| 16 | 17 | ||
| 17 | from ..context.system_context import SystemContext | 18 | from ..context.system_context import SystemContext |
| 18 | from ..envelope import Envelope | 19 | from ..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 | """ |
| 10 | from __future__ import annotations | 10 | from __future__ import annotations |
| 11 | 11 | ||
| 12 | +import asyncio | ||
| 12 | import json | 13 | import json |
| 14 | +from contextlib import suppress | ||
| 13 | from typing import Any, Awaitable, Callable | 15 | from typing import Any, Awaitable, Callable |
| 14 | 16 | ||
| 15 | from fastapi import FastAPI, Request | 17 | from fastapi import FastAPI, Request |
| @@ -17,7 +19,7 @@ from fastapi.responses import JSONResponse, StreamingResponse | |||
| 17 | 19 | ||
| 18 | from ..envelope import Envelope | 20 | from ..envelope import Envelope |
| 19 | from ..errors import ErrorCode, http_status_for | 21 | from ..errors import ErrorCode, http_status_for |
| 20 | -from ..routing.result import UnaryResult | 22 | +from ..routing.result import StreamResult, UnaryResult |
| 21 | from ..routing.router import MessageRouter | 23 | from ..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 | ||
| 42 | def mount_rest( | 89 | def 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.response | 118 | + 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 | # 流式 → SSE | 127 | # 流式 → 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 | |||
| 15 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect | 15 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect |
| 16 | 16 | ||
| 17 | from ..envelope import Envelope | 17 | from ..envelope import Envelope |
| 18 | -from ..routing.result import UnaryResult | 18 | +from ..routing.result import StreamResult, UnaryResult |
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | def _error_frame(message: str, code: str = "validation") -> str: | 21 | def _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 | continue | 51 | 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 | return | 74 | 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 验收之外的路径。""" |
| 5 | import json | 5 | import json |
| 6 | +import asyncio | ||
| 6 | 7 | ||
| 7 | import fakeredis.aioredis | 8 | import fakeredis.aioredis |
| 8 | import httpx | 9 | import httpx |
| 9 | import pytest | 10 | import pytest |
| 11 | +from pydantic import BaseModel | ||
| 10 | from starlette.testclient import TestClient | 12 | from starlette.testclient import TestClient |
| 11 | 13 | ||
| 12 | -from openjiuwen_runtime.service import App, Envelope, SystemContext, ValidationError, idempotency_guard | 14 | +from openjiuwen_runtime.service import ( |
| 15 | + App, | ||
| 16 | + Envelope, | ||
| 17 | + SystemContext, | ||
| 18 | + TypedAppContext, | ||
| 19 | + ValidationError, | ||
| 20 | + idempotency_guard, | ||
| 21 | +) | ||
| 13 | 22 | ||
| 14 | 23 | ||
| 15 | def _env(t: str, rid="r1", raw=None): | 24 | def _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 | + | ||
| 23 | def _build_stream_app(): | 43 | def _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 app | 72 | 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.system | 97 | @pytest.mark.system |
| 57 | async def test_rest_stream_returns_sse_chunks(): | 98 | async 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 → 409 | 149 | 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.unit | 32 | @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 == 9999 | 42 | 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.unit | 49 | @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.unit | 64 | @pytest.mark.unit |
| 53 | def test_app_run_host_port_from_env(monkeypatch): | 65 | def test_app_run_host_port_from_env(monkeypatch): |
| 54 | import fakeredis.aioredis | 66 | import fakeredis.aioredis |
| @@ -2,7 +2,10 @@ | |||
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # 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 | + | ||
| 5 | import pytest | 7 | import pytest |
| 8 | +from pydantic import BaseModel | ||
| 6 | 9 | ||
| 7 | from openjiuwen_runtime.service.envelope import ( | 10 | from openjiuwen_runtime.service.envelope import ( |
| 8 | Envelope, | 11 | Envelope, |
| @@ -48,6 +51,34 @@ def test_metadata_extra_preserved(): | |||
| 48 | assert Metadata.from_dict(d) == md | 51 | 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.unit | 82 | @pytest.mark.unit |
| 52 | def test_response_envelope_to_dict_contract(): | 83 | def test_response_envelope_to_dict_contract(): |
| 53 | # 适配器据此序列化为 JSON 响应体 | 84 | # 适配器据此序列化为 JSON 响应体 |
| @@ -5,12 +5,16 @@ | |||
| 5 | import pytest | 5 | import pytest |
| 6 | 6 | ||
| 7 | from openjiuwen_runtime.service.errors import ( | 7 | from 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.IDEMPOTENT | 36 | assert IdempotentConflict("x").code == ErrorCode.IDEMPOTENT |
| 33 | assert LockNotAcquired("x").code == ErrorCode.LOCKED | 37 | assert LockNotAcquired("x").code == ErrorCode.LOCKED |
| 34 | assert LockLost("x").code == ErrorCode.LOCKED | 38 | 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) == 409 | 69 | assert http_status_for(ErrorCode.IDEMPOTENT) == 409 |
| 62 | assert http_status_for(ErrorCode.LOCKED) == 423 | 70 | assert http_status_for(ErrorCode.LOCKED) == 423 |
| 63 | assert http_status_for(ErrorCode.TIMEOUT) == 504 | 71 | 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) == 500 | 76 | assert http_status_for(ErrorCode.INTERNAL) == 500 |
| 65 | # 未知 code → 500(fail-safe) | 77 | # 未知 code → 500(fail-safe) |
| 66 | assert http_status_for("totally-unknown") == 500 | 78 | 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 False | 58 | 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 True | 82 | 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 reserved | 2 | # 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 pytest | 5 | +import asyncio |
| 6 | 6 | ||
| 7 | +import pytest | ||
| 8 | +from pydantic import BaseModel | ||
| 9 | + | ||
| 10 | +from openjiuwen_runtime.service.context.system_context import SystemContext | ||
| 7 | from openjiuwen_runtime.service.envelope import Envelope, Metadata, ResponseEnvelope | 11 | from openjiuwen_runtime.service.envelope import Envelope, Metadata, ResponseEnvelope |
| 8 | from openjiuwen_runtime.service.errors import ValidationError | 12 | from openjiuwen_runtime.service.errors import ValidationError |
| 9 | from openjiuwen_runtime.service.routing.router import MessageRouter | 13 | from 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 True | 153 | 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 | |||
| 7 | import fakeredis.aioredis | 7 | import fakeredis.aioredis |
| 8 | import pytest | 8 | import 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 | +) | ||
| 10 | from openjiuwen_runtime.service.context.system_context import SystemContext | 16 | from openjiuwen_runtime.service.context.system_context import SystemContext |
| 11 | -from openjiuwen_runtime.service.envelope import Metadata | 17 | +from openjiuwen_runtime.service.envelope import Envelope, Metadata |
| 12 | 18 | ||
| 13 | 19 | ||
| 14 | @pytest.mark.unit | 20 | @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.unit | 98 | @pytest.mark.unit |
| 44 | async def test_lock_owner_unique_per_request(): | 99 | async def test_lock_owner_unique_per_request(): |
| 45 | ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | 100 | ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) |