已合并
feat(service): 实现通用分布式服务框架(Envelope + App + SystemContext + 五原语) #408
王明琦创建于 7月31日
feat(service): 实现通用分布式服务框架(Envelope + App + SystemContext + 五原语) #408
已合并
共 36 个文件变更+2661-8
| @@ -0,0 +1,10 @@ | |||
| 1 | +# echo 样例部署配置(复制为 .env 后按需修改) | ||
| 2 | +# 监听地址 / 端口 | ||
| 3 | +OPENJIUWEN_SERVICE_HOST=0.0.0.0 | ||
| 4 | +OPENJIUWEN_SERVICE_PORT=8090 | ||
| 5 | +# 协调用 redis(多副本须共享同一实例) | ||
| 6 | +OPENJIUWEN_SERVICE_REDIS_URL=redis://localhost:6379/0 | ||
| 7 | +# redis 键命名空间前缀(多副本须一致才能共享状态) | ||
| 8 | +OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX=service | ||
| 9 | +# 服务标题 | ||
| 10 | +OPENJIUWEN_SERVICE_TITLE=echo | ||
| @@ -0,0 +1,46 @@ | |||
| 1 | +# echo —— 最小分布式服务样例 | ||
| 2 | + | ||
| 3 | +基于 `openjiuwen_runtime.service` 通用分布式服务框架的最小示例:返回 `{echo, idx}`,`idx` 由 | ||
| 4 | +Redis 原子计数,**跨副本全局递增**(验证「统一入口 + 无内存状态多副本 + 极简上手」)。 | ||
| 5 | + | ||
| 6 | +## 运行 | ||
| 7 | + | ||
| 8 | +```bash | ||
| 9 | +# 1) 安装框架(openjiuwen_runtime.service)为可编辑包 | ||
| 10 | +pip install -e ../../service | ||
| 11 | + | ||
| 12 | +# 2) 准备一个 redis(本地或远端),按需设置环境变量(见 .env.example) | ||
| 13 | +export OPENJIUWEN_SERVICE_REDIS_URL=redis://localhost:6379/0 | ||
| 14 | + | ||
| 15 | +# 3) 启动 | ||
| 16 | +python echo_server.py | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +启动后: | ||
| 20 | +- REST:`POST /api/echo`,body = 完整 Envelope: | ||
| 21 | + ```bash | ||
| 22 | + curl -XPOST localhost:8090/api/echo \ | ||
| 23 | + -H 'content-type: application/json' \ | ||
| 24 | + -d '{"type":"echo","metadata":{"request_id":"r1"},"rawdata":{"message":"hi"}}' | ||
| 25 | + # → {"type":"echo","metadata":{...},"rawdata":{"echo":"hi","idx":1},"ok":true,...} | ||
| 26 | + ``` | ||
| 27 | +- WebSocket:连接 `/ws`,每条文本帧发一个 Envelope JSON,回帧含 `idx`。 | ||
| 28 | + | ||
| 29 | +## 多副本(验证全局递增) | ||
| 30 | + | ||
| 31 | +不同端口起两个实例,共享同一 redis,交替调用 → `idx` 全局递增(不各自从 1 开始): | ||
| 32 | + | ||
| 33 | +```bash | ||
| 34 | +OPENJIUWEN_SERVICE_PORT=8091 python echo_server.py & | ||
| 35 | +OPENJIUWEN_SERVICE_PORT=8092 python echo_server.py & | ||
| 36 | +``` | ||
| 37 | + | ||
| 38 | +## 环境变量 | ||
| 39 | + | ||
| 40 | +| 变量 | 含义 | 默认 | | ||
| 41 | +|---|---|---| | ||
| 42 | +| `OPENJIUWEN_SERVICE_HOST` | 监听地址 | `0.0.0.0` | | ||
| 43 | +| `OPENJIUWEN_SERVICE_PORT` | 监听端口 | `8090` | | ||
| 44 | +| `OPENJIUWEN_SERVICE_REDIS_URL` | 协调用 redis 连接串 | `redis://localhost:6379/0` | | ||
| 45 | +| `OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX` | redis 键命名空间前缀 | `service` | | ||
| 46 | +| `OPENJIUWEN_SERVICE_TITLE` | 服务标题 | `service` | | ||
| @@ -0,0 +1,33 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""最小 echo server(附录 A.1):返回 {echo, idx},idx 由 Redis 原子计数,跨副本全局递增。 | ||
| 5 | + | ||
| 6 | +业务只写 ctx + env,框架统一入口 + 无内存状态多副本(idx 走 Redis,进程内无状态)。 | ||
| 7 | + | ||
| 8 | +部署配置全部来自环境变量(详见 README.md / .env.example): | ||
| 9 | + OPENJIUWEN_SERVICE_HOST / OPENJIUWEN_SERVICE_PORT | ||
| 10 | + OPENJIUWEN_SERVICE_REDIS_URL / OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX | ||
| 11 | + | ||
| 12 | +运行: | ||
| 13 | + pip install -e ../../service # 安装 openjiuwen_runtime.service(框架) | ||
| 14 | + python echo_server.py # 读环境变量;多副本:不同 OPENJIUWEN_SERVICE_PORT 起多实例 | ||
| 15 | +""" | ||
| 16 | +from openjiuwen_runtime.service import App, Envelope, SystemContext | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def make_ctx() -> SystemContext: | ||
| 20 | + return SystemContext.from_settings() # 生产:读 OPENJIUWEN_SERVICE_REDIS_URL 等 | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +app = App(make_ctx, prefix="/api") # make_ctx 作为构造参数;内部持有 router | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +# 自动同时暴露 POST /api/echo 与 WS type="echo" | ||
| 27 | +async def echo(ctx, env: Envelope): | ||
| 28 | + idx = await ctx.kv.incr("echo:idx") # Redis INCR:跨副本原子递增,进程内无状态 | ||
| 29 | + return {"echo": env.rawdata.get("message", ""), "idx": idx} | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +if __name__ == "__main__": | ||
| 33 | + app.run() # 部署(uvicorn):读 OPENJIUWEN_SERVICE_HOST/PORT | ||
| @@ -1,11 +1,48 @@ | |||
| 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 | -from .app import BaseApp, AgentApp, PluginApp, AppGroup | 4 | +"""通用 Python 分布式服务框架(openjiuwen_runtime.service)。 |
| 5 | -from .app import Middleware, MiddlewareContext, LoggingMiddleware | ||
| 6 | -from .models import QueryRequest, ResetConversationRequest | ||
| 7 | 5 | ||
| 8 | -__all__ = ["AgentApp", "BaseApp", "PluginApp", "AppGroup", "QueryRequest", "ResetConversationRequest", | 6 | +对外出口随子模块实现逐步充实(Envelope / App / SystemContext / 原语 / 错误类)。 |
| 9 | - "Middleware", "MiddlewareContext", "LoggingMiddleware"] | 7 | +""" |
| 8 | +from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk | ||
| 9 | +from .config import ServiceConfig | ||
| 10 | +from .errors import ( | ||
| 11 | + ErrorCode, | ||
| 12 | + FrameworkError, | ||
| 13 | + IdempotentConflict, | ||
| 14 | + LockLost, | ||
| 15 | + LockNotAcquired, | ||
| 16 | + NotFoundError, | ||
| 17 | + ValidationError, | ||
| 18 | +) | ||
| 19 | +from .context.system_context import RequestContext, SystemContext | ||
| 20 | +from .context.primitives.idempotency import idempotency_guard | ||
| 21 | +from .server.app import App | ||
| 10 | 22 | ||
| 11 | -__version__ = "0.2.0" | 23 | +__version__ = "0.1.0" |
| 24 | + | ||
| 25 | +__all__ = [ | ||
| 26 | + # envelope | ||
| 27 | + "Envelope", | ||
| 28 | + "Metadata", | ||
| 29 | + "ResponseEnvelope", | ||
| 30 | + "StreamChunk", | ||
| 31 | + # config | ||
| 32 | + "ServiceConfig", | ||
| 33 | + # errors | ||
| 34 | + "ErrorCode", | ||
| 35 | + "FrameworkError", | ||
| 36 | + "ValidationError", | ||
| 37 | + "NotFoundError", | ||
| 38 | + "IdempotentConflict", | ||
| 39 | + "LockNotAcquired", | ||
| 40 | + "LockLost", | ||
| 41 | + # context | ||
| 42 | + "SystemContext", | ||
| 43 | + "RequestContext", | ||
| 44 | + # middleware | ||
| 45 | + "idempotency_guard", | ||
| 46 | + # server | ||
| 47 | + "App", | ||
| 48 | +] | ||
| @@ -0,0 +1,48 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""部署相关配置(设计:环境变量驱动,命名详细清楚)。 | ||
| 5 | + | ||
| 6 | +所有部署相关项从环境变量读取,缺省给出可直接本地跑的安全值。环境变量统一前缀 | ||
| 7 | +``OPENJIUWEN_SERVICE_``,避免与 foundation 的通用 ``HOST``/``PORT`` 混淆。 | ||
| 8 | + | ||
| 9 | +| 环境变量 | 含义 | 默认 | | ||
| 10 | +|---|---|---| | ||
| 11 | +| ``OPENJIUWEN_SERVICE_HOST`` | 监听地址 | ``0.0.0.0`` | | ||
| 12 | +| ``OPENJIUWEN_SERVICE_PORT`` | 监听端口 | ``8090`` | | ||
| 13 | +| ``OPENJIUWEN_SERVICE_REDIS_URL`` | 协调用 redis 连接串 | ``redis://localhost:6379/0`` | | ||
| 14 | +| ``OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX`` | redis 键命名空间前缀 | ``service`` | | ||
| 15 | +| ``OPENJIUWEN_SERVICE_TITLE`` | 服务标题(OpenAPI/日志) | ``service`` | | ||
| 16 | +""" | ||
| 17 | +from __future__ import annotations | ||
| 18 | + | ||
| 19 | +import os | ||
| 20 | +from dataclasses import dataclass | ||
| 21 | + | ||
| 22 | +_DEFAULT_HOST = "0.0.0.0" | ||
| 23 | +_DEFAULT_PORT = 8090 | ||
| 24 | +_DEFAULT_REDIS_URL = "redis://localhost:6379/0" | ||
| 25 | +_DEFAULT_KEY_PREFIX = "service" | ||
| 26 | +_DEFAULT_TITLE = "service" | ||
| 27 | + | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +class ServiceConfig: | ||
| 31 | + """服务部署配置。用 :meth:`from_env` 从环境变量构造。""" | ||
| 32 | + | ||
| 33 | + host: str = _DEFAULT_HOST | ||
| 34 | + port: int = _DEFAULT_PORT | ||
| 35 | + redis_url: str = _DEFAULT_REDIS_URL | ||
| 36 | + key_prefix: str = _DEFAULT_KEY_PREFIX | ||
| 37 | + title: str = _DEFAULT_TITLE | ||
| 38 | + | ||
| 39 | + | ||
| 40 | + def from_env(cls) -> "ServiceConfig": | ||
| 41 | + """从环境变量读取;非法端口立即报错(fail-fast)。""" | ||
| 42 | + return cls( | ||
| 43 | + host=os.getenv("OPENJIUWEN_SERVICE_HOST", _DEFAULT_HOST), | ||
| 44 | + port=int(os.getenv("OPENJIUWEN_SERVICE_PORT", str(_DEFAULT_PORT))), | ||
| 45 | + redis_url=os.getenv("OPENJIUWEN_SERVICE_REDIS_URL", _DEFAULT_REDIS_URL), | ||
| 46 | + key_prefix=os.getenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", _DEFAULT_KEY_PREFIX), | ||
| 47 | + title=os.getenv("OPENJIUWEN_SERVICE_TITLE", _DEFAULT_TITLE), | ||
| 48 | + ) | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,88 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""幂等(设计 §9.2)。 | ||
| 5 | + | ||
| 6 | +- ``Idempotency.acquire(request_id, window)``:``SETNX`` 去重;返回 guard。 | ||
| 7 | +- ``idempotency_guard(window, mode)``:中间件。``reject``(默认,重复 → idempotent 错误)/ | ||
| 8 | + ``cache``(回放首次成功结果)。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import json | ||
| 13 | +from dataclasses import dataclass | ||
| 14 | +from typing import Any, Optional | ||
| 15 | + | ||
| 16 | +from ...envelope import Envelope, ResponseEnvelope | ||
| 17 | +from ...routing.result import UnaryResult | ||
| 18 | + | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class IdempotencyGuard: | ||
| 22 | + """``acquire`` 的结论。``acquired=False`` 时 ``cached_result`` 可能非空(cache 模式回放)。""" | ||
| 23 | + | ||
| 24 | + acquired: bool | ||
| 25 | + cached_result: Optional[ResponseEnvelope] = None | ||
| 26 | + _idem: Optional["Idempotency"] = None | ||
| 27 | + _request_id: Optional[str] = None | ||
| 28 | + _window: int = 60 | ||
| 29 | + | ||
| 30 | + async def succeed(self, result: ResponseEnvelope) -> None: | ||
| 31 | + """成功后缓存结果(供后续重复请求回放)。""" | ||
| 32 | + if self._idem is not None and self._request_id is not None: | ||
| 33 | + await self._idem._store_result(self._request_id, result, self._window) | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +class Idempotency: | ||
| 37 | + def __init__(self, redis: Any, prefix: str = "idem") -> None: | ||
| 38 | + self._redis = redis | ||
| 39 | + self._prefix = prefix | ||
| 40 | + | ||
| 41 | + def _owned_key(self, request_id: str) -> str: | ||
| 42 | + return f"{self._prefix}:req:{request_id}" | ||
| 43 | + | ||
| 44 | + def _result_key(self, request_id: str) -> str: | ||
| 45 | + return f"{self._prefix}:res:{request_id}" | ||
| 46 | + | ||
| 47 | + async def acquire(self, request_id: str, window: int = 60) -> IdempotencyGuard: | ||
| 48 | + owned = await self._redis.set(self._owned_key(request_id), "1", nx=True, ex=window) | ||
| 49 | + if owned: | ||
| 50 | + return IdempotencyGuard(acquired=True, cached_result=None, | ||
| 51 | + _idem=self, _request_id=request_id, _window=window) | ||
| 52 | + raw = await self._redis.get(self._result_key(request_id)) | ||
| 53 | + cached: ResponseEnvelope | None = None | ||
| 54 | + if raw is not None: | ||
| 55 | + text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw | ||
| 56 | + cached = ResponseEnvelope.from_dict(json.loads(text)) | ||
| 57 | + return IdempotencyGuard(acquired=False, cached_result=cached) | ||
| 58 | + | ||
| 59 | + async def _store_result(self, request_id: str, result: ResponseEnvelope, window: int) -> None: | ||
| 60 | + await self._redis.set( | ||
| 61 | + self._result_key(request_id), json.dumps(result.to_dict()), ex=window) | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def idempotency_guard(window: int = 60, mode: str = "reject"): | ||
| 65 | + """幂等中间件工厂。 | ||
| 66 | + | ||
| 67 | + - ``reject``(默认):重复 request_id → ``idempotent`` 错误信封。 | ||
| 68 | + - ``cache``:重复 request_id → 回放首次成功结果;handler 不再执行。 | ||
| 69 | + """ | ||
| 70 | + | ||
| 71 | + async def middleware(ctx: Any, env: Envelope, nxt) -> Any: | ||
| 72 | + guard = await ctx.idempotency.acquire(env.metadata.request_id, window=window) | ||
| 73 | + if not guard.acquired: | ||
| 74 | + if mode == "cache" and guard.cached_result is not None: | ||
| 75 | + return UnaryResult(response=guard.cached_result) | ||
| 76 | + return UnaryResult(response=_idempotent_error(env)) | ||
| 77 | + result = await nxt(ctx, env) | ||
| 78 | + if mode == "cache" and isinstance(result, UnaryResult) and result.response.ok: | ||
| 79 | + await guard.succeed(result.response) | ||
| 80 | + return result | ||
| 81 | + | ||
| 82 | + return middleware | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def _idempotent_error(env: Envelope) -> ResponseEnvelope: | ||
| 86 | + return ResponseEnvelope( | ||
| 87 | + type=env.type, metadata=env.metadata, rawdata={}, ok=False, | ||
| 88 | + error_code="idempotent", error_message=f"duplicate request_id {env.metadata.request_id!r}") | ||
| @@ -0,0 +1,67 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""分布式字典 / 会话存储(设计 §9.3)。 | ||
| 5 | + | ||
| 6 | +Redis kv + TTL,按前缀命名空间;之上 set_json/get_json 做结构化存取。 | ||
| 7 | +跨副本共享的"黑板"——顶替进程内 dict,满足无内存状态硬约束。 | ||
| 8 | +""" | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +import json | ||
| 12 | +from typing import Any | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +class KVStore: | ||
| 16 | + def __init__(self, redis: Any, prefix: str = "kv") -> None: | ||
| 17 | + self._redis = redis | ||
| 18 | + self._prefix = prefix | ||
| 19 | + | ||
| 20 | + def _key(self, key: str) -> str: | ||
| 21 | + return f"{self._prefix}:{key}" if self._prefix else key | ||
| 22 | + | ||
| 23 | + async def get(self, key: str) -> str | None: | ||
| 24 | + val = await self._redis.get(self._key(key)) | ||
| 25 | + if val is None: | ||
| 26 | + return None | ||
| 27 | + return val.decode() if isinstance(val, (bytes, bytearray)) else val | ||
| 28 | + | ||
| 29 | + async def set(self, key: str, value: str, ttl: int | None = None) -> None: | ||
| 30 | + k = self._key(key) | ||
| 31 | + if ttl is not None: | ||
| 32 | + await self._redis.set(k, value, ex=ttl) | ||
| 33 | + else: | ||
| 34 | + await self._redis.set(k, value) | ||
| 35 | + | ||
| 36 | + async def delete(self, key: str) -> bool: | ||
| 37 | + return bool(await self._redis.delete(self._key(key))) | ||
| 38 | + | ||
| 39 | + async def exists(self, key: str) -> bool: | ||
| 40 | + return bool(await self._redis.exists(self._key(key))) | ||
| 41 | + | ||
| 42 | + async def incr(self, key: str, amount: int = 1) -> int: | ||
| 43 | + """原子递增(Redis INCRBY);跨副本全局递增。""" | ||
| 44 | + return int(await self._redis.incrby(self._key(key), amount)) | ||
| 45 | + | ||
| 46 | + async def set_json(self, key: str, obj: Any, ttl: int | None = None) -> None: | ||
| 47 | + await self.set(key, json.dumps(obj), ttl=ttl) | ||
| 48 | + | ||
| 49 | + async def get_json(self, key: str, default: Any = None) -> Any: | ||
| 50 | + raw = await self.get(key) | ||
| 51 | + if raw is None: | ||
| 52 | + return default | ||
| 53 | + return json.loads(raw) | ||
| 54 | + | ||
| 55 | + async def scan(self, pattern: str) -> list[str]: | ||
| 56 | + """按用户空间 pattern 扫描键(剥离前缀后返回)。""" | ||
| 57 | + full = self._key(pattern) | ||
| 58 | + keys: list[str] = [] | ||
| 59 | + cursor = 0 | ||
| 60 | + while True: | ||
| 61 | + cursor, batch = await self._redis.scan(cursor=cursor, match=full, count=100) | ||
| 62 | + for k in batch: | ||
| 63 | + ks = k.decode() if isinstance(k, (bytes, bytearray)) else k | ||
| 64 | + keys.append(ks[len(self._prefix) + 1:] if self._prefix else ks) | ||
| 65 | + if cursor == 0: | ||
| 66 | + break | ||
| 67 | + return keys | ||
| @@ -0,0 +1,128 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""分布式锁(设计 §9.1)。 | ||
| 5 | + | ||
| 6 | +``SET key owner NX PX ttl`` 抢锁;释放与续期用 WATCH/MULTI 做 compare-and-set(仅 owner | ||
| 7 | +匹配才删/续),避免误删别人的锁。后台自动续期;续期失锁 → 退出时抛 ``LockLost``。 | ||
| 8 | +``timeout=0`` 非阻塞、抢不到抛 ``LockNotAcquired``。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import asyncio | ||
| 13 | +import logging | ||
| 14 | +from typing import Any | ||
| 15 | + | ||
| 16 | +from redis.exceptions import WatchError | ||
| 17 | + | ||
| 18 | +from ...errors import LockLost, LockNotAcquired | ||
| 19 | + | ||
| 20 | +logger = logging.getLogger(__name__) | ||
| 21 | + | ||
| 22 | +_ACQUIRE_RETRY = 0.05 # 阻塞抢锁时的轮询间隔(秒) | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class DistributedLock: | ||
| 26 | + """一次锁获取:``async with ctx.lock(key, ttl=..., timeout=...)``。""" | ||
| 27 | + | ||
| 28 | + def __init__( | ||
| 29 | + self, | ||
| 30 | + redis: Any, | ||
| 31 | + key: str, | ||
| 32 | + *, | ||
| 33 | + owner: str, | ||
| 34 | + ttl: float = 30, | ||
| 35 | + timeout: float = 0, | ||
| 36 | + prefix: str = "lock", | ||
| 37 | + renew_interval: float | None = None, | ||
| 38 | + ) -> None: | ||
| 39 | + self._redis = redis | ||
| 40 | + self.key = f"{prefix}:{key}" if prefix else key | ||
| 41 | + self._owner = owner | ||
| 42 | + self._ttl_ms = int(ttl * 1000) | ||
| 43 | + self._timeout = timeout | ||
| 44 | + 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._lost = False | ||
| 47 | + | ||
| 48 | + # -------------------------------------------------------------- 抢锁 | ||
| 49 | + async def _try_acquire(self) -> bool: | ||
| 50 | + ok = await self._redis.set(self.key, self._owner, nx=True, px=self._ttl_ms) | ||
| 51 | + return bool(ok) | ||
| 52 | + | ||
| 53 | + async def __aenter__(self) -> "DistributedLock": | ||
| 54 | + if self._timeout and self._timeout > 0: | ||
| 55 | + loop = asyncio.get_event_loop() | ||
| 56 | + deadline = loop.time() + self._timeout | ||
| 57 | + while True: | ||
| 58 | + if await self._try_acquire(): | ||
| 59 | + break | ||
| 60 | + if loop.time() >= deadline: | ||
| 61 | + raise LockNotAcquired( | ||
| 62 | + f"could not acquire lock {self.key!r} within {self._timeout}s") | ||
| 63 | + await asyncio.sleep(_ACQUIRE_RETRY) | ||
| 64 | + else: | ||
| 65 | + if not await self._try_acquire(): | ||
| 66 | + raise LockNotAcquired(f"could not acquire lock {self.key!r} (non-blocking)") | ||
| 67 | + self._renew_task = asyncio.create_task(self._renew_loop()) | ||
| 68 | + return self | ||
| 69 | + | ||
| 70 | + async def __aexit__(self, exc_type, exc, tb) -> bool: | ||
| 71 | + if self._renew_task is not None: | ||
| 72 | + self._renew_task.cancel() | ||
| 73 | + try: | ||
| 74 | + await self._renew_task | ||
| 75 | + except (asyncio.CancelledError, Exception): # noqa: BLE001 | ||
| 76 | + pass | ||
| 77 | + lost = self._lost | ||
| 78 | + await self._safe_release() | ||
| 79 | + # 仅在 body 无异常时抛 LockLost,避免掩盖原始异常 | ||
| 80 | + if lost and exc_type is None: | ||
| 81 | + raise LockLost(f"lock {self.key!r} lost during hold (renew failed)") | ||
| 82 | + return False | ||
| 83 | + | ||
| 84 | + # -------------------------------------------------------------- 续期 | ||
| 85 | + async def renew_once(self) -> bool: | ||
| 86 | + """续期一次:仍是 owner 则续 TTL 返回 True,失锁返回 False。""" | ||
| 87 | + return await self._cas(lambda pipe: pipe.pexpire(self.key, self._ttl_ms)) | ||
| 88 | + | ||
| 89 | + async def _renew_loop(self) -> None: | ||
| 90 | + try: | ||
| 91 | + while True: | ||
| 92 | + await asyncio.sleep(self._renew_interval) | ||
| 93 | + if not await self.renew_once(): | ||
| 94 | + self._lost = True | ||
| 95 | + return | ||
| 96 | + except asyncio.CancelledError: | ||
| 97 | + return | ||
| 98 | + | ||
| 99 | + # -------------------------------------------------------------- 释放 | ||
| 100 | + async def _safe_release(self) -> bool: | ||
| 101 | + return await self._cas(lambda pipe: pipe.delete(self.key)) | ||
| 102 | + | ||
| 103 | + async def _cas(self, mutate) -> bool: | ||
| 104 | + """WATCH/MULTI compare-and-set:仅当当前 value == owner 时执行 mutate。""" | ||
| 105 | + owner_b = self._owner.encode() if isinstance(self._owner, str) else self._owner | ||
| 106 | + pipe = self._redis.pipeline(transaction=True) | ||
| 107 | + try: | ||
| 108 | + await pipe.watch(self.key) | ||
| 109 | + cur = await pipe.get(self.key) | ||
| 110 | + if _as_bytes(cur) == owner_b: | ||
| 111 | + pipe.multi() | ||
| 112 | + mutate(pipe) | ||
| 113 | + await pipe.execute() | ||
| 114 | + return True | ||
| 115 | + await pipe.unwatch() | ||
| 116 | + return False | ||
| 117 | + except WatchError: | ||
| 118 | + try: | ||
| 119 | + await pipe.unwatch() | ||
| 120 | + except Exception: # noqa: BLE001 | ||
| 121 | + pass | ||
| 122 | + return False | ||
| 123 | + | ||
| 124 | + | ||
| 125 | +def _as_bytes(v: Any) -> bytes | None: | ||
| 126 | + if v is None: | ||
| 127 | + return None | ||
| 128 | + return v.encode() if isinstance(v, str) else bytes(v) | ||
| @@ -0,0 +1,54 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""发布订阅(Redis Pub/Sub,设计 §9.5)。 | ||
| 5 | + | ||
| 6 | +瞬时扇出:``await ctx.pubsub.publish(channel, dict)``;``async for msg in ctx.pubsub.subscribe(channel)``。 | ||
| 7 | +喊完即逝、不存储——勿与队列(持久交付)混用。用途:leader 交接通知、流式分片跨副本扇出。 | ||
| 8 | +""" | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +import json | ||
| 12 | +from typing import Any, AsyncIterator | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +class PubSub: | ||
| 16 | + def __init__(self, redis: Any, prefix: str = "pubsub") -> None: | ||
| 17 | + self._redis = redis | ||
| 18 | + self._prefix = prefix | ||
| 19 | + | ||
| 20 | + def _chan(self, channel: str) -> str: | ||
| 21 | + return f"{self._prefix}:{channel}" if self._prefix else channel | ||
| 22 | + | ||
| 23 | + async def publish(self, channel: str, data: dict) -> int: | ||
| 24 | + """发布事件;返回收到该事件的订阅者数。""" | ||
| 25 | + return int(await self._redis.publish(self._chan(channel), json.dumps(data))) | ||
| 26 | + | ||
| 27 | + def subscribe(self, channel: str) -> AsyncIterator[dict]: | ||
| 28 | + """订阅迭代器:每条消息解码为 dict。""" | ||
| 29 | + return self._subscribe(channel) | ||
| 30 | + | ||
| 31 | + async def _subscribe(self, channel: str) -> AsyncIterator[dict]: | ||
| 32 | + ch = self._chan(channel) | ||
| 33 | + pubsub = self._redis.pubsub() | ||
| 34 | + await pubsub.subscribe(ch) | ||
| 35 | + try: | ||
| 36 | + async for msg in pubsub.listen(): | ||
| 37 | + if msg.get("type") == "message": | ||
| 38 | + raw = msg.get("data") | ||
| 39 | + if isinstance(raw, (bytes, bytearray)): | ||
| 40 | + raw = raw.decode() | ||
| 41 | + yield json.loads(raw) if raw else {} | ||
| 42 | + finally: | ||
| 43 | + try: | ||
| 44 | + await pubsub.unsubscribe(ch) | ||
| 45 | + except Exception: # noqa: BLE001 - 订阅清理容错 | ||
| 46 | + pass | ||
| 47 | + close = getattr(pubsub, "aclose", None) or getattr(pubsub, "close", None) | ||
| 48 | + if close is not None: | ||
| 49 | + try: | ||
| 50 | + res = close() | ||
| 51 | + if hasattr(res, "__await__"): | ||
| 52 | + await res | ||
| 53 | + except Exception: # noqa: BLE001 | ||
| 54 | + pass | ||
| @@ -0,0 +1,98 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""队列(Redis Streams + 消费组,设计 §9.4)。 | ||
| 5 | + | ||
| 6 | +持久、跨副本有序、可回放:``XADD`` / ``XREADGROUP`` / ``XACK``。``ack`` 即成功,不 ack 重投 | ||
| 7 | +(at-least-once)。 | ||
| 8 | + | ||
| 9 | +- ``block=0``:非阻塞,排空即停(便于测试 / 有限消费)。 | ||
| 10 | +- ``block>0``:长轮询循环(lifespan worker)。 | ||
| 11 | +""" | ||
| 12 | +from __future__ import annotations | ||
| 13 | + | ||
| 14 | +import json | ||
| 15 | +from typing import Any, AsyncIterator | ||
| 16 | + | ||
| 17 | +from redis.exceptions import ResponseError | ||
| 18 | + | ||
| 19 | +_DATA_FIELD = "data" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class StreamItem: | ||
| 23 | + """一条队列消息:``item.data`` 为入队载荷,``await item.ack()`` 确认成功。""" | ||
| 24 | + | ||
| 25 | + def __init__(self, redis: Any, stream: str, group: str, msg_id: bytes | str, data: dict) -> None: | ||
| 26 | + self._redis = redis | ||
| 27 | + self._stream = stream | ||
| 28 | + self._group = group | ||
| 29 | + self.id = msg_id | ||
| 30 | + self.data = data | ||
| 31 | + | ||
| 32 | + async def ack(self) -> None: | ||
| 33 | + await self._redis.xack(self._stream, self._group, self.id) | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +class StreamQueue: | ||
| 37 | + def __init__(self, redis: Any, prefix: str = "queue") -> None: | ||
| 38 | + self._redis = redis | ||
| 39 | + self._prefix = prefix | ||
| 40 | + | ||
| 41 | + def _stream(self, name: str) -> str: | ||
| 42 | + return f"{self._prefix}:{name}" if self._prefix else name | ||
| 43 | + | ||
| 44 | + async def enqueue(self, stream: str, data: dict) -> str: | ||
| 45 | + """入队(持久)。返回消息 id。""" | ||
| 46 | + return await self._redis.xadd(self._stream(stream), {_DATA_FIELD: json.dumps(data)}) | ||
| 47 | + | ||
| 48 | + async def _ensure_group(self, stream: str, group: str) -> None: | ||
| 49 | + try: | ||
| 50 | + await self._redis.xgroup_create(self._stream(stream), group, id="0", mkstream=True) | ||
| 51 | + except ResponseError as exc: | ||
| 52 | + if "BUSYGROUP" not in str(exc): | ||
| 53 | + raise | ||
| 54 | + | ||
| 55 | + def consume( | ||
| 56 | + self, | ||
| 57 | + group: str, | ||
| 58 | + consumer: str, | ||
| 59 | + *, | ||
| 60 | + stream: str, | ||
| 61 | + block: int = 0, | ||
| 62 | + count: int = 10, | ||
| 63 | + ) -> AsyncIterator[StreamItem]: | ||
| 64 | + """消费迭代器。先排干本 consumer 的 pending(at-least-once),再读新消息。""" | ||
| 65 | + return self._consume(group, consumer, stream, block, count) | ||
| 66 | + | ||
| 67 | + async def _consume( | ||
| 68 | + self, group: str, consumer: str, stream: str, block: int, count: int | ||
| 69 | + ) -> AsyncIterator[StreamItem]: | ||
| 70 | + key = self._stream(stream) | ||
| 71 | + await self._ensure_group(stream, group) | ||
| 72 | + | ||
| 73 | + # 1) 排干本 consumer 的 pending(未 ack 重投) | ||
| 74 | + pending = await self._redis.xreadgroup(group, consumer, {key: "0"}, count=count) | ||
| 75 | + for _stream_name, messages in pending: | ||
| 76 | + for msg_id, fields in messages: | ||
| 77 | + yield self._to_item(key, group, msg_id, fields) | ||
| 78 | + | ||
| 79 | + # 2) 读新消息 | ||
| 80 | + if block == 0: | ||
| 81 | + resp = await self._redis.xreadgroup(group, consumer, {key: ">"}, count=count) | ||
| 82 | + for _stream_name, messages in resp: | ||
| 83 | + for msg_id, fields in messages: | ||
| 84 | + yield self._to_item(key, group, msg_id, fields) | ||
| 85 | + return # 排空即停 | ||
| 86 | + | ||
| 87 | + while True: | ||
| 88 | + resp = await self._redis.xreadgroup(group, consumer, {key: ">"}, count=count, block=block) | ||
| 89 | + for _stream_name, messages in resp: | ||
| 90 | + for msg_id, fields in messages: | ||
| 91 | + yield self._to_item(key, group, msg_id, fields) | ||
| 92 | + | ||
| 93 | + def _to_item(self, key: str, group: str, msg_id: Any, fields: dict) -> StreamItem: | ||
| 94 | + raw = fields.get(_DATA_FIELD) or fields.get(_DATA_FIELD.encode()) | ||
| 95 | + if isinstance(raw, (bytes, bytearray)): | ||
| 96 | + raw = raw.decode() | ||
| 97 | + data = json.loads(raw) if raw else {} | ||
| 98 | + return StreamItem(self._redis, key, group, msg_id, data) | ||
| @@ -0,0 +1,195 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""SystemContext / RequestContext(设计 §8)。 | ||
| 5 | + | ||
| 6 | +- 进程级 SystemContext(lifespan 创建/释放):db / redis / settings / logger / 原语工厂。 | ||
| 7 | +- 请求级 RequestContext(``for_request(metadata)`` 派生):request_id 等 + lock_owner + | ||
| 8 | + 绑定 request_id 的 logger + 对进程级组件的引用;handler 只通过它访问能力。 | ||
| 9 | +- 事务:``async with ctx.transaction() as s`` 取 SQLAlchemy session(多操作原子)。 | ||
| 10 | +- 硬约束:handler 禁止读写模块级可变状态——无内存状态的多副本。 | ||
| 11 | +""" | ||
| 12 | +from __future__ import annotations | ||
| 13 | + | ||
| 14 | +import logging | ||
| 15 | +import socket | ||
| 16 | +from contextlib import asynccontextmanager | ||
| 17 | +from dataclasses import dataclass, field | ||
| 18 | +from typing import Any, Optional | ||
| 19 | +from uuid import uuid4 | ||
| 20 | + | ||
| 21 | +import redis.asyncio | ||
| 22 | + | ||
| 23 | +from ..config import ServiceConfig | ||
| 24 | +from ..envelope import Metadata | ||
| 25 | +from ..errors import FrameworkError | ||
| 26 | +from .primitives.kv_store import KVStore | ||
| 27 | + | ||
| 28 | +_logger = logging.getLogger("openjiuwen_runtime.service") | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +class SystemContext: | ||
| 32 | + """进程级系统能力容器 + 请求级上下文工厂。""" | ||
| 33 | + | ||
| 34 | + def __init__( | ||
| 35 | + self, | ||
| 36 | + redis: Any = None, | ||
| 37 | + db: Any = None, | ||
| 38 | + settings: Any = None, | ||
| 39 | + *, | ||
| 40 | + key_prefix: str = "service", | ||
| 41 | + instance_id: str | None = None, | ||
| 42 | + _owns_redis: bool = False, | ||
| 43 | + ) -> None: | ||
| 44 | + self.redis = redis | ||
| 45 | + self.db = db | ||
| 46 | + self.settings = settings | ||
| 47 | + self.key_prefix = key_prefix or "" | ||
| 48 | + self.instance_id = instance_id or f"{socket.gethostname()}:{uuid4().hex[:8]}" | ||
| 49 | + self._owns_redis = _owns_redis | ||
| 50 | + self._started = False | ||
| 51 | + | ||
| 52 | + # -------------------------------------------------------------- 命名空间 | ||
| 53 | + def _ns(self, suffix: str) -> str: | ||
| 54 | + return f"{self.key_prefix}:{suffix}" if self.key_prefix else suffix | ||
| 55 | + | ||
| 56 | + # -------------------------------------------------------------- 生命周期 | ||
| 57 | + async def start(self) -> None: | ||
| 58 | + if self._started: | ||
| 59 | + return | ||
| 60 | + if self.db is not None and hasattr(self.db, "connect"): | ||
| 61 | + await self.db.connect() | ||
| 62 | + if self.redis is not None: | ||
| 63 | + await self.redis.ping() # fakeredis / 真 redis 均支持,失败即早上报 | ||
| 64 | + self._started = True | ||
| 65 | + | ||
| 66 | + async def stop(self) -> None: | ||
| 67 | + if self.redis is not None and self._owns_redis: | ||
| 68 | + await self.redis.aclose() | ||
| 69 | + if self.db is not None and hasattr(self.db, "disconnect"): | ||
| 70 | + await self.db.disconnect() | ||
| 71 | + self._started = False | ||
| 72 | + | ||
| 73 | + # -------------------------------------------------------------- 请求上下文 | ||
| 74 | + def for_request(self, metadata: Metadata) -> "RequestContext": | ||
| 75 | + return RequestContext( | ||
| 76 | + sysctx=self, | ||
| 77 | + request_id=metadata.request_id, | ||
| 78 | + user_id=metadata.user_id, | ||
| 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}", | ||
| 85 | + logger=_logger, | ||
| 86 | + ) | ||
| 87 | + | ||
| 88 | + # -------------------------------------------------------------- 事务 | ||
| 89 | + | ||
| 90 | + async def transaction(self): | ||
| 91 | + """多操作原子事务(独立 SQLAlchemy session,不改 foundation)。 | ||
| 92 | + | ||
| 93 | + ``db.session_factory`` 在 foundation ``SQLAlchemyHandler`` 上已暴露;未连接时为 None。 | ||
| 94 | + """ | ||
| 95 | + sf = getattr(self.db, "session_factory", None) if self.db is not None else None | ||
| 96 | + if sf is None: | ||
| 97 | + raise FrameworkError("db has no session_factory; transaction() unavailable") | ||
| 98 | + session = sf() | ||
| 99 | + try: | ||
| 100 | + yield session | ||
| 101 | + await session.commit() | ||
| 102 | + except Exception: | ||
| 103 | + await session.rollback() | ||
| 104 | + raise | ||
| 105 | + finally: | ||
| 106 | + await session.close() | ||
| 107 | + | ||
| 108 | + # -------------------------------------------------------------- 生产构造 | ||
| 109 | + | ||
| 110 | + def from_settings( | ||
| 111 | + cls, | ||
| 112 | + *, | ||
| 113 | + redis_url: str | None = None, | ||
| 114 | + settings: Any = None, | ||
| 115 | + db: Any = None, | ||
| 116 | + key_prefix: str | None = None, | ||
| 117 | + ) -> "SystemContext": | ||
| 118 | + """生产便捷构造:redis 连接串与键前缀默认取自 ``ServiceConfig``(环境变量)。 | ||
| 119 | + | ||
| 120 | + - ``OPENJIUWEN_SERVICE_REDIS_URL``(默认 redis://localhost:6379/0) | ||
| 121 | + - ``OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX``(默认 service) | ||
| 122 | + 连接在 ``start()`` 时才真正建立(``from_url`` 惰性)。 | ||
| 123 | + """ | ||
| 124 | + cfg = ServiceConfig.from_env() | ||
| 125 | + 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 | ||
| 127 | + client = redis.asyncio.from_url(url, decode_responses=False) | ||
| 128 | + return cls(redis=client, db=db, settings=settings, | ||
| 129 | + key_prefix=kp, _owns_redis=True) | ||
| 130 | + | ||
| 131 | + | ||
| 132 | + | ||
| 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 | + | ||
| 152 | + def db(self) -> Any: | ||
| 153 | + return self.sysctx.db | ||
| 154 | + | ||
| 155 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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() | ||
| @@ -0,0 +1,151 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""统一消息结构(Envelope)。 | ||
| 5 | + | ||
| 6 | +设计 §5。字段名对齐现有 Message / E2AEnvelope / IRequest,使业务层 normalize 极轻。 | ||
| 7 | +序列化为 JSON:每个结构提供 ``to_dict`` / ``from_dict``,``metadata.timestamp`` 为 | ||
| 8 | +float(直接 JSON 可序列化);``rawdata`` 为任意 JSON 可序列化 dict。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +from dataclasses import dataclass, field | ||
| 13 | +from typing import Any | ||
| 14 | + | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class Metadata: | ||
| 18 | + """请求元数据。``request_id`` 必填(幂等键 + 链路追踪键);``extra`` 可扩展不破坏 schema。""" | ||
| 19 | + | ||
| 20 | + request_id: str | ||
| 21 | + user_id: str | None = None | ||
| 22 | + chat_id: str | None = None | ||
| 23 | + session_id: str | None = None | ||
| 24 | + bot_id: str | None = None | ||
| 25 | + channel: str | None = None | ||
| 26 | + timestamp: float | None = None | ||
| 27 | + trace_id: str | None = None | ||
| 28 | + extra: dict = field(default_factory=dict) | ||
| 29 | + | ||
| 30 | + def to_dict(self) -> dict[str, Any]: | ||
| 31 | + return { | ||
| 32 | + "request_id": self.request_id, | ||
| 33 | + "user_id": self.user_id, | ||
| 34 | + "chat_id": self.chat_id, | ||
| 35 | + "session_id": self.session_id, | ||
| 36 | + "bot_id": self.bot_id, | ||
| 37 | + "channel": self.channel, | ||
| 38 | + "timestamp": self.timestamp, | ||
| 39 | + "trace_id": self.trace_id, | ||
| 40 | + "extra": dict(self.extra), | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + def from_dict(cls, d: dict[str, Any]) -> "Metadata": | ||
| 45 | + return cls( | ||
| 46 | + request_id=d["request_id"], | ||
| 47 | + user_id=d.get("user_id"), | ||
| 48 | + chat_id=d.get("chat_id"), | ||
| 49 | + session_id=d.get("session_id"), | ||
| 50 | + bot_id=d.get("bot_id"), | ||
| 51 | + channel=d.get("channel"), | ||
| 52 | + timestamp=d.get("timestamp"), | ||
| 53 | + trace_id=d.get("trace_id"), | ||
| 54 | + extra=dict(d.get("extra") or {}), | ||
| 55 | + ) | ||
| 56 | + | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +class Envelope: | ||
| 60 | + """框架唯一入口消息结构。``type`` 既是路由键又是 REST 路径段。""" | ||
| 61 | + | ||
| 62 | + type: str | ||
| 63 | + metadata: Metadata | ||
| 64 | + rawdata: dict | ||
| 65 | + version: str = "1" | ||
| 66 | + | ||
| 67 | + def to_dict(self) -> dict[str, Any]: | ||
| 68 | + return { | ||
| 69 | + "type": self.type, | ||
| 70 | + "metadata": self.metadata.to_dict(), | ||
| 71 | + "rawdata": self.rawdata, | ||
| 72 | + "version": self.version, | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + | ||
| 76 | + def from_dict(cls, d: dict[str, Any]) -> "Envelope": | ||
| 77 | + return cls( | ||
| 78 | + type=d["type"], | ||
| 79 | + metadata=Metadata.from_dict(d.get("metadata") or {}), | ||
| 80 | + rawdata=dict(d.get("rawdata") or {}), | ||
| 81 | + version=d.get("version", "1"), | ||
| 82 | + ) | ||
| 83 | + | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +class ResponseEnvelope: | ||
| 87 | + """统一响应信封(非流式)。失败一律 ``ok=False`` + error_code/error_message。""" | ||
| 88 | + | ||
| 89 | + type: str | ||
| 90 | + metadata: Metadata | ||
| 91 | + rawdata: dict | ||
| 92 | + ok: bool | ||
| 93 | + error_code: str | None = None | ||
| 94 | + error_message: str | None = None | ||
| 95 | + version: str = "1" | ||
| 96 | + | ||
| 97 | + def to_dict(self) -> dict[str, Any]: | ||
| 98 | + return { | ||
| 99 | + "type": self.type, | ||
| 100 | + "metadata": self.metadata.to_dict(), | ||
| 101 | + "rawdata": self.rawdata, | ||
| 102 | + "ok": self.ok, | ||
| 103 | + "error_code": self.error_code, | ||
| 104 | + "error_message": self.error_message, | ||
| 105 | + "version": self.version, | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + | ||
| 109 | + def from_dict(cls, d: dict[str, Any]) -> "ResponseEnvelope": | ||
| 110 | + return cls( | ||
| 111 | + type=d["type"], | ||
| 112 | + metadata=Metadata.from_dict(d.get("metadata") or {}), | ||
| 113 | + rawdata=dict(d.get("rawdata") or {}), | ||
| 114 | + ok=bool(d.get("ok", False)), | ||
| 115 | + error_code=d.get("error_code"), | ||
| 116 | + error_message=d.get("error_message"), | ||
| 117 | + version=d.get("version", "1"), | ||
| 118 | + ) | ||
| 119 | + | ||
| 120 | + | ||
| 121 | + | ||
| 122 | +class StreamChunk: | ||
| 123 | + """流式响应分片。``sequence`` 递增,末帧 ``is_final=True``。""" | ||
| 124 | + | ||
| 125 | + sequence: int | ||
| 126 | + is_final: bool | ||
| 127 | + metadata: Metadata | ||
| 128 | + rawdata: dict | ||
| 129 | + error_code: str | None = None | ||
| 130 | + error_message: str | None = None | ||
| 131 | + | ||
| 132 | + def to_dict(self) -> dict[str, Any]: | ||
| 133 | + return { | ||
| 134 | + "sequence": self.sequence, | ||
| 135 | + "is_final": self.is_final, | ||
| 136 | + "metadata": self.metadata.to_dict(), | ||
| 137 | + "rawdata": self.rawdata, | ||
| 138 | + "error_code": self.error_code, | ||
| 139 | + "error_message": self.error_message, | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + | ||
| 143 | + def from_dict(cls, d: dict[str, Any]) -> "StreamChunk": | ||
| 144 | + return cls( | ||
| 145 | + sequence=int(d["sequence"]), | ||
| 146 | + is_final=bool(d.get("is_final", False)), | ||
| 147 | + metadata=Metadata.from_dict(d.get("metadata") or {}), | ||
| 148 | + rawdata=dict(d.get("rawdata") or {}), | ||
| 149 | + error_code=d.get("error_code"), | ||
| 150 | + error_message=d.get("error_message"), | ||
| 151 | + ) | ||
| @@ -0,0 +1,102 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""错误模型(设计 §12)。 | ||
| 5 | + | ||
| 6 | +统一错误码 + FrameworkError 体系:中间件外层捕获后归一化为 | ||
| 7 | +``ResponseEnvelope(ok=False, error_code, error_message)``,绝不裸 500。 | ||
| 8 | +""" | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +from typing import Protocol, runtime_checkable | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class ErrorCode: | ||
| 15 | + """错误码常量(字符串,便于序列化到 ResponseEnvelope.error_code)。""" | ||
| 16 | + | ||
| 17 | + VALIDATION = "validation" | ||
| 18 | + NOT_FOUND = "not_found" | ||
| 19 | + CONFLICT = "conflict" | ||
| 20 | + IDEMPOTENT = "idempotent" | ||
| 21 | + TIMEOUT = "timeout" | ||
| 22 | + LOCKED = "locked" | ||
| 23 | + INTERNAL = "internal" | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +class FrameworkError(Exception): | ||
| 27 | + """框架统一异常基类。``code`` 为错误码,默认 internal。""" | ||
| 28 | + | ||
| 29 | + code: str = ErrorCode.INTERNAL | ||
| 30 | + | ||
| 31 | + def __init__(self, message: str = "", *, code: str | None = None) -> None: | ||
| 32 | + super().__init__(message) | ||
| 33 | + if code is not None: | ||
| 34 | + self.code = code | ||
| 35 | + | ||
| 36 | + | ||
| 37 | + def message(self) -> str: | ||
| 38 | + return self.args[0] if self.args else "" | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +class ValidationError(FrameworkError): | ||
| 42 | + """请求校验失败。""" | ||
| 43 | + | ||
| 44 | + code = ErrorCode.VALIDATION | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +class NotFoundError(FrameworkError): | ||
| 48 | + """资源/路由未找到。""" | ||
| 49 | + | ||
| 50 | + code = ErrorCode.NOT_FOUND | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +class IdempotentConflict(FrameworkError): | ||
| 54 | + """幂等冲突:重复 request_id 且 mode=reject。""" | ||
| 55 | + | ||
| 56 | + code = ErrorCode.IDEMPOTENT | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +class LockNotAcquired(FrameworkError): | ||
| 60 | + """非阻塞抢锁失败(timeout=0)或等待超时抢不到。""" | ||
| 61 | + | ||
| 62 | + code = ErrorCode.LOCKED | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +class LockLost(FrameworkError): | ||
| 66 | + """持锁期间续期失锁(被别人抢占或过期)。""" | ||
| 67 | + | ||
| 68 | + code = ErrorCode.LOCKED | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +class FrameworkTimeout(FrameworkError): | ||
| 72 | + """handler 超时。""" | ||
| 73 | + | ||
| 74 | + code = ErrorCode.TIMEOUT | ||
| 75 | + | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +class _HasCode(Protocol): | ||
| 79 | + code: str | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +def exception_code(exc: BaseException) -> str: | ||
| 83 | + """归一化任意异常为错误码:FrameworkError 取其 code,其余一律 internal。""" | ||
| 84 | + if isinstance(exc, FrameworkError): | ||
| 85 | + return exc.code | ||
| 86 | + return ErrorCode.INTERNAL | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +_HTTP_STATUS = { | ||
| 90 | + ErrorCode.VALIDATION: 400, | ||
| 91 | + ErrorCode.NOT_FOUND: 404, | ||
| 92 | + ErrorCode.CONFLICT: 409, | ||
| 93 | + ErrorCode.IDEMPOTENT: 409, | ||
| 94 | + ErrorCode.LOCKED: 423, | ||
| 95 | + ErrorCode.TIMEOUT: 504, | ||
| 96 | + ErrorCode.INTERNAL: 500, | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +def http_status_for(code: str) -> int: | ||
| 101 | + """错误码 → HTTP 状态码;未知码 fail-safe 返回 500。""" | ||
| 102 | + return _HTTP_STATUS.get(code, 500) | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,41 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Handler 与中间件协议 / 类型别名(设计 §6.2、§6.3)。 | ||
| 5 | + | ||
| 6 | +handler 与传输无关:签名只认 ``(ctx, env)``,没有 Request/WebSocket。 | ||
| 7 | +ctx 为 RequestContext(duck-typed,本模块仅作注解,运行期不强依赖)。 | ||
| 8 | +""" | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, Union, runtime_checkable | ||
| 12 | + | ||
| 13 | +from ..envelope import Envelope, ResponseEnvelope, StreamChunk | ||
| 14 | + | ||
| 15 | +if TYPE_CHECKING: # 仅注解用,避免运行期循环导入 | ||
| 16 | + from ..context.system_context import RequestContext | ||
| 17 | + | ||
| 18 | +# handler 返回:dict(框架包成 ResponseEnvelope)或现成的 ResponseEnvelope | ||
| 19 | +UnaryReturn = Union[ResponseEnvelope, dict] | ||
| 20 | +Handler = Callable[[Any, Envelope], Awaitable[UnaryReturn]] | ||
| 21 | +StreamHandler = Callable[[Any, Envelope], AsyncIterator[StreamChunk]] | ||
| 22 | + | ||
| 23 | +# 中间件:洋葱模型,nxt 为链中下一步 | ||
| 24 | +Middleware = Callable[[Any, Envelope, "Next"], Awaitable[Any]] | ||
| 25 | +Next = Callable[[Any, Envelope], Awaitable[Any]] | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +class MessageHandler(Protocol): | ||
| 30 | + """非流式 handler 协议:``async (ctx, env) -> dict | ResponseEnvelope``。""" | ||
| 31 | + | ||
| 32 | + async def __call__(self, ctx: "RequestContext", env: Envelope) -> UnaryReturn: # pragma: no cover - 协议 | ||
| 33 | + ... | ||
| 34 | + | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +class StreamMessageHandler(Protocol): | ||
| 38 | + """流式 handler 协议:``(ctx, env) -> AsyncIterator[StreamChunk]``。""" | ||
| 39 | + | ||
| 40 | + def __call__(self, ctx: "RequestContext", env: Envelope) -> AsyncIterator[StreamChunk]: # pragma: no cover - 协议 | ||
| 41 | + ... | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""派发结果(设计 §6.3)。 | ||
| 5 | + | ||
| 6 | +``dispatch`` 返回 ``DispatchResult``:非流式 → ``UnaryResult``(单个 ResponseEnvelope); | ||
| 7 | +流式 → ``StreamResult``(StreamChunk 异步迭代器)。适配器据此选择响应方式。 | ||
| 8 | +""" | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | +from typing import AsyncIterator, Union | ||
| 13 | + | ||
| 14 | +from ..envelope import ResponseEnvelope, StreamChunk | ||
| 15 | + | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class UnaryResult: | ||
| 19 | + """非流式派发结果。""" | ||
| 20 | + | ||
| 21 | + response: ResponseEnvelope | ||
| 22 | + | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class StreamResult: | ||
| 26 | + """流式派发结果:``chunks`` 为 StreamChunk 异步迭代器。""" | ||
| 27 | + | ||
| 28 | + chunks: AsyncIterator[StreamChunk] | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +DispatchResult = Union[UnaryResult, StreamResult] | ||
| @@ -0,0 +1,196 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""MessageRouter:type→handler 注册表 + 中间件链 + dispatch(设计 §6.3)。 | ||
| 5 | + | ||
| 6 | +- 注册表取代 if/elif:type → handler 的 O(1) 查表。 | ||
| 7 | +- 唯一性约束:同一 type 重复注册抛错;同一 type 不能同时流式与非流式。 | ||
| 8 | +- 中间件链:洋葱模型,先注册为外层;handler 是链尾。 | ||
| 9 | +- v1 仅精确匹配,不做通配/前缀路由。 | ||
| 10 | +""" | ||
| 11 | +from __future__ import annotations | ||
| 12 | + | ||
| 13 | +import logging | ||
| 14 | +from dataclasses import dataclass | ||
| 15 | +from typing import Any, AsyncIterator | ||
| 16 | + | ||
| 17 | +from ..envelope import Envelope, ResponseEnvelope, StreamChunk | ||
| 18 | +from ..errors import ErrorCode, FrameworkError, NotFoundError, ValidationError, exception_code | ||
| 19 | +from .handlers import Middleware | ||
| 20 | +from .result import DispatchResult, StreamResult, UnaryResult | ||
| 21 | + | ||
| 22 | +logger = logging.getLogger(__name__) | ||
| 23 | + | ||
| 24 | +_UNARY = "unary" | ||
| 25 | +_STREAM = "stream" | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +class _Endpoint: | ||
| 30 | + handler: Any | ||
| 31 | + kind: str # _UNARY | _STREAM | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +class MessageRouter: | ||
| 35 | + """App 内部实现,不对用户直接暴露。""" | ||
| 36 | + | ||
| 37 | + def __init__(self) -> None: | ||
| 38 | + self._endpoints: dict[str, _Endpoint] = {} | ||
| 39 | + self._middleware: list[Middleware] = [] | ||
| 40 | + | ||
| 41 | + # ------------------------------------------------------------------ 注册 | ||
| 42 | + def handle(self, msg_type: str): | ||
| 43 | + """非流式 handler 装饰器。""" | ||
| 44 | + | ||
| 45 | + def decorator(fn): | ||
| 46 | + self._register(msg_type, fn, _UNARY) | ||
| 47 | + return fn | ||
| 48 | + | ||
| 49 | + return decorator | ||
| 50 | + | ||
| 51 | + def stream(self, msg_type: str): | ||
| 52 | + """流式 handler 装饰器。""" | ||
| 53 | + | ||
| 54 | + def decorator(fn): | ||
| 55 | + self._register(msg_type, fn, _STREAM) | ||
| 56 | + return fn | ||
| 57 | + | ||
| 58 | + return decorator | ||
| 59 | + | ||
| 60 | + def use(self, middleware: Middleware) -> None: | ||
| 61 | + """注册中间件(先注册为外层)。""" | ||
| 62 | + self._middleware.append(middleware) | ||
| 63 | + | ||
| 64 | + def has(self, msg_type: str) -> bool: | ||
| 65 | + return msg_type in self._endpoints | ||
| 66 | + | ||
| 67 | + def kinds(self) -> dict[str, str]: | ||
| 68 | + """type → kind 视图(适配器据此区分流式/非流式)。""" | ||
| 69 | + return {t: ep.kind for t, ep in self._endpoints.items()} | ||
| 70 | + | ||
| 71 | + def _register(self, msg_type: str, handler: Any, kind: str) -> None: | ||
| 72 | + if not msg_type: | ||
| 73 | + raise ValidationError("msg_type must be a non-empty string") | ||
| 74 | + existing = self._endpoints.get(msg_type) | ||
| 75 | + if existing is not None: | ||
| 76 | + raise FrameworkError( | ||
| 77 | + f"type {msg_type!r} already registered as {existing.kind}", | ||
| 78 | + code=ErrorCode.CONFLICT, | ||
| 79 | + ) | ||
| 80 | + self._endpoints[msg_type] = _Endpoint(handler=handler, kind=kind) | ||
| 81 | + | ||
| 82 | + # ------------------------------------------------------------------ 派发 | ||
| 83 | + async def dispatch(self, env: Envelope, rctx: Any) -> DispatchResult: | ||
| 84 | + endpoint = self._endpoints.get(env.type) | ||
| 85 | + if endpoint is None: | ||
| 86 | + return UnaryResult(response=self._error_response( | ||
| 87 | + env, NotFoundError(f"no handler registered for type {env.type!r}"))) | ||
| 88 | + | ||
| 89 | + core = self._build_core(endpoint) | ||
| 90 | + chain = self._compose(self._middleware, core) | ||
| 91 | + try: | ||
| 92 | + return await chain(rctx, env) | ||
| 93 | + except FrameworkError as exc: | ||
| 94 | + return UnaryResult(response=self._error_response(env, exc)) | ||
| 95 | + except Exception as exc: # noqa: BLE001 - 归一化为 internal 错误信封 | ||
| 96 | + 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)))) | ||
| 98 | + | ||
| 99 | + # -------------------------------------------------------------- 核心组装 | ||
| 100 | + def _build_core(self, endpoint: _Endpoint): | ||
| 101 | + if endpoint.kind == _UNARY: | ||
| 102 | + return self._unary_core(endpoint.handler) | ||
| 103 | + return self._stream_core(endpoint.handler) | ||
| 104 | + | ||
| 105 | + def _unary_core(self, handler): | ||
| 106 | + async def core(ctx, env): | ||
| 107 | + result = await handler(ctx, env) | ||
| 108 | + return UnaryResult(response=self._normalize_unary(result, env)) | ||
| 109 | + | ||
| 110 | + return core | ||
| 111 | + | ||
| 112 | + def _stream_core(self, handler): | ||
| 113 | + async def core(ctx, env): | ||
| 114 | + ait = handler(ctx, env) # async generator → async iterator(同步调用) | ||
| 115 | + return StreamResult(chunks=self._wrap_stream(ait, env)) | ||
| 116 | + | ||
| 117 | + return core | ||
| 118 | + | ||
| 119 | + | ||
| 120 | + def _compose(middlewares: list[Middleware], core): | ||
| 121 | + fn = core | ||
| 122 | + for mw in reversed(middlewares): # 先注册为外层 | ||
| 123 | + fn = MessageRouter._wrap_one(mw, fn) | ||
| 124 | + return fn | ||
| 125 | + | ||
| 126 | + | ||
| 127 | + def _wrap_one(mw, nxt): | ||
| 128 | + async def wrapped(ctx, env): | ||
| 129 | + return await mw(ctx, env, nxt) | ||
| 130 | + | ||
| 131 | + return wrapped | ||
| 132 | + | ||
| 133 | + # -------------------------------------------------------------- 归一化 | ||
| 134 | + | ||
| 135 | + def _normalize_unary(result: Any, env: Envelope) -> ResponseEnvelope: | ||
| 136 | + if isinstance(result, ResponseEnvelope): | ||
| 137 | + return result | ||
| 138 | + if isinstance(result, dict): | ||
| 139 | + return ResponseEnvelope(type=env.type, metadata=env.metadata, | ||
| 140 | + rawdata=result, ok=True) | ||
| 141 | + raise FrameworkError( | ||
| 142 | + f"unary handler must return dict or ResponseEnvelope, got {type(result).__name__}") | ||
| 143 | + | ||
| 144 | + def _wrap_stream(self, ait: AsyncIterator, env: Envelope) -> AsyncIterator[StreamChunk]: | ||
| 145 | + """为流式 handler 的产物分配 sequence、置末帧 is_final;出错发末帧错误分片。""" | ||
| 146 | + | ||
| 147 | + async def gen(): | ||
| 148 | + seq = 0 | ||
| 149 | + it = ait.__aiter__() | ||
| 150 | + try: | ||
| 151 | + try: | ||
| 152 | + nxt = await it.__anext__() | ||
| 153 | + except StopAsyncIteration: | ||
| 154 | + return | ||
| 155 | + while True: | ||
| 156 | + cur = nxt | ||
| 157 | + try: | ||
| 158 | + nxt = await it.__anext__() | ||
| 159 | + except StopAsyncIteration: | ||
| 160 | + seq += 1 | ||
| 161 | + yield self._to_chunk(cur, seq, is_final=True, env=env) | ||
| 162 | + return | ||
| 163 | + seq += 1 | ||
| 164 | + yield self._to_chunk(cur, seq, is_final=False, env=env) | ||
| 165 | + except FrameworkError as exc: | ||
| 166 | + seq += 1 | ||
| 167 | + yield self._error_chunk(seq, env, exc) | ||
| 168 | + except Exception as exc: # noqa: BLE001 | ||
| 169 | + logger.exception("stream handler failed: type=%s", env.type) | ||
| 170 | + seq += 1 | ||
| 171 | + yield self._error_chunk(seq, env, FrameworkError(str(exc))) | ||
| 172 | + | ||
| 173 | + return gen() | ||
| 174 | + | ||
| 175 | + | ||
| 176 | + def _to_chunk(item: Any, sequence: int, is_final: bool, env: Envelope) -> StreamChunk: | ||
| 177 | + if isinstance(item, StreamChunk): | ||
| 178 | + return StreamChunk(sequence=sequence, is_final=is_final, metadata=env.metadata, | ||
| 179 | + rawdata=item.rawdata, error_code=item.error_code, | ||
| 180 | + error_message=item.error_message) | ||
| 181 | + if isinstance(item, dict): | ||
| 182 | + return StreamChunk(sequence=sequence, is_final=is_final, metadata=env.metadata, | ||
| 183 | + rawdata=item) | ||
| 184 | + raise FrameworkError( | ||
| 185 | + f"stream handler must yield dict or StreamChunk, got {type(item).__name__}") | ||
| 186 | + | ||
| 187 | + # -------------------------------------------------------------- 错误信封 | ||
| 188 | + def _error_response(self, env: Envelope, exc: FrameworkError) -> ResponseEnvelope: | ||
| 189 | + return ResponseEnvelope( | ||
| 190 | + type=env.type, metadata=env.metadata, rawdata={}, | ||
| 191 | + ok=False, error_code=exception_code(exc), error_message=exc.message) | ||
| 192 | + | ||
| 193 | + def _error_chunk(self, sequence: int, env: Envelope, exc: FrameworkError) -> StreamChunk: | ||
| 194 | + return StreamChunk( | ||
| 195 | + sequence=sequence, is_final=True, metadata=env.metadata, rawdata={}, | ||
| 196 | + error_code=exception_code(exc), error_message=exc.message) | ||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| @@ -0,0 +1,124 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""App —— 对外入口(设计 §6.1、§7)。 | ||
| 5 | + | ||
| 6 | +持有内部 ``MessageRouter``,构造时把 REST/WS 适配器挂到该 router;对外暴露 | ||
| 7 | +``@app.handle`` / ``@app.stream`` / ``app.use`` / ``app.dispatch`` / ``app.asgi`` / ``app.run``。 | ||
| 8 | +仅此层(及适配器)import fastapi/websockets,核心代码零 HTTP/WS 导入。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +from contextlib import asynccontextmanager | ||
| 13 | +from typing import Any, Callable | ||
| 14 | + | ||
| 15 | +from fastapi import FastAPI | ||
| 16 | + | ||
| 17 | +from ..context.system_context import SystemContext | ||
| 18 | +from ..envelope import Envelope | ||
| 19 | +from ..routing.router import MessageRouter | ||
| 20 | +from ..routing.result import DispatchResult | ||
| 21 | +from .rest_adapter import mount_rest | ||
| 22 | +from .ws_adapter import mount_ws | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +async def _ensure_sysctx_async( | ||
| 26 | + fastapi_app: FastAPI, ctx_factory: Callable[[], SystemContext] | ||
| 27 | +) -> SystemContext: | ||
| 28 | + """取得当前 sysctx:lifespan 已建则复用,否则惰性建(兼容不跑 lifespan 的 httpx ASGITransport)。""" | ||
| 29 | + sysctx = getattr(fastapi_app.state, "sysctx", None) | ||
| 30 | + if sysctx is None: | ||
| 31 | + sysctx = ctx_factory() | ||
| 32 | + await sysctx.start() | ||
| 33 | + fastapi_app.state.sysctx = sysctx | ||
| 34 | + return sysctx | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def _build_fastapi( | ||
| 38 | + router: MessageRouter, | ||
| 39 | + ctx_factory: Callable[[], SystemContext], | ||
| 40 | + prefix: str, | ||
| 41 | + enable_rest: bool, | ||
| 42 | + enable_ws: bool, | ||
| 43 | + title: str, | ||
| 44 | +) -> FastAPI: | ||
| 45 | + | ||
| 46 | + async def lifespan(fastapi_app: FastAPI): | ||
| 47 | + # 生产 / TestClient 走 lifespan:进程级 sysctx 在此创建与释放 | ||
| 48 | + sysctx = ctx_factory() | ||
| 49 | + await sysctx.start() | ||
| 50 | + fastapi_app.state.sysctx = sysctx | ||
| 51 | + try: | ||
| 52 | + yield | ||
| 53 | + finally: | ||
| 54 | + await sysctx.stop() | ||
| 55 | + | ||
| 56 | + fastapi = FastAPI(title=title, lifespan=lifespan) | ||
| 57 | + | ||
| 58 | + async def ensure(fastapi_app: FastAPI) -> SystemContext: | ||
| 59 | + return await _ensure_sysctx_async(fastapi_app, ctx_factory) | ||
| 60 | + | ||
| 61 | + if enable_rest: | ||
| 62 | + mount_rest(fastapi, router, prefix, ensure) | ||
| 63 | + if enable_ws: | ||
| 64 | + mount_ws(fastapi, router, ensure) | ||
| 65 | + return fastapi | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +class App: | ||
| 69 | + """对外入口类。""" | ||
| 70 | + | ||
| 71 | + def __init__( | ||
| 72 | + self, | ||
| 73 | + ctx_factory: Callable[[], SystemContext], | ||
| 74 | + *, | ||
| 75 | + prefix: str = "/api", | ||
| 76 | + enable_rest: bool = True, | ||
| 77 | + enable_ws: bool = True, | ||
| 78 | + title: str = "service", | ||
| 79 | + ) -> None: | ||
| 80 | + self._router = MessageRouter() | ||
| 81 | + self._ctx_factory = ctx_factory | ||
| 82 | + self._prefix = prefix.rstrip("/") if prefix else "" | ||
| 83 | + self._fastapi = _build_fastapi( | ||
| 84 | + self._router, ctx_factory, self._prefix, enable_rest, enable_ws, title | ||
| 85 | + ) | ||
| 86 | + | ||
| 87 | + # ------------------------------------------------------------ 注册委托 | ||
| 88 | + def handle(self, msg_type: str): | ||
| 89 | + """非流式 handler 装饰器(委托 router)。""" | ||
| 90 | + return self._router.handle(msg_type) | ||
| 91 | + | ||
| 92 | + def stream(self, msg_type: str): | ||
| 93 | + """流式 handler 装饰器(委托 router)。""" | ||
| 94 | + return self._router.stream(msg_type) | ||
| 95 | + | ||
| 96 | + def use(self, middleware) -> None: | ||
| 97 | + """中间件(委托 router)。""" | ||
| 98 | + self._router.use(middleware) | ||
| 99 | + | ||
| 100 | + # ------------------------------------------------------------ 派发 | ||
| 101 | + async def dispatch(self, env: Envelope, rctx: Any) -> DispatchResult: | ||
| 102 | + """核心派发(传输无关)。适配器与直调共用此路径。""" | ||
| 103 | + return await self._router.dispatch(env, rctx) | ||
| 104 | + | ||
| 105 | + # ------------------------------------------------------------ 传输 | ||
| 106 | + | ||
| 107 | + def asgi(self) -> FastAPI: | ||
| 108 | + """底层 ASGI,供 TestClient/httpx 测试与 uvicorn 部署。""" | ||
| 109 | + return self._fastapi | ||
| 110 | + | ||
| 111 | + def run(self, host: str | None = None, port: int | None = None, **kwargs: Any) -> None: | ||
| 112 | + """uvicorn 部署;多副本:不同端口起多实例,共享 redis。 | ||
| 113 | + | ||
| 114 | + host/port 缺省取自环境变量 ``OPENJIUWEN_SERVICE_HOST`` / ``OPENJIUWEN_SERVICE_PORT``; | ||
| 115 | + 显式传参则覆盖环境变量。 | ||
| 116 | + """ | ||
| 117 | + import uvicorn | ||
| 118 | + | ||
| 119 | + from ..config import ServiceConfig | ||
| 120 | + | ||
| 121 | + cfg = ServiceConfig.from_env() | ||
| 122 | + host = cfg.host if host is None else host | ||
| 123 | + port = cfg.port if port is None else port | ||
| 124 | + uvicorn.run(self._fastapi, host=host, port=port, **kwargs) | ||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""REST 适配器(设计 §7.1)。 | ||
| 5 | + | ||
| 6 | +单一参数化路由 ``POST /{prefix}/{msg_type}``,body = 完整 Envelope;非流式 → JSON | ||
| 7 | +ResponseEnvelope,流式 → SSE(每个 StreamChunk 一个 data: 事件)。与 body 冲突时以 | ||
| 8 | +body 中的 ``type`` 为准。仅此层 import fastapi。 | ||
| 9 | +""" | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import json | ||
| 13 | +from typing import Any, Awaitable, Callable | ||
| 14 | + | ||
| 15 | +from fastapi import FastAPI, Request | ||
| 16 | +from fastapi.responses import JSONResponse, StreamingResponse | ||
| 17 | + | ||
| 18 | +from ..envelope import Envelope | ||
| 19 | +from ..errors import ErrorCode, http_status_for | ||
| 20 | +from ..routing.result import UnaryResult | ||
| 21 | +from ..routing.router import MessageRouter | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def _error_json(msg_type: str, code: str, message: str, request_id: str = "") -> JSONResponse: | ||
| 25 | + body = { | ||
| 26 | + "type": msg_type, | ||
| 27 | + "metadata": {"request_id": request_id}, | ||
| 28 | + "rawdata": {}, | ||
| 29 | + "ok": False, | ||
| 30 | + "error_code": code, | ||
| 31 | + "error_message": message, | ||
| 32 | + "version": "1", | ||
| 33 | + } | ||
| 34 | + return JSONResponse(body, status_code=http_status_for(code)) | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +async def _sse(chunks): | ||
| 38 | + async for chunk in chunks: | ||
| 39 | + yield f"data: {json.dumps(chunk.to_dict(), ensure_ascii=False)}\n\n" | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def mount_rest( | ||
| 43 | + fastapi: FastAPI, | ||
| 44 | + router: MessageRouter, | ||
| 45 | + prefix: str, | ||
| 46 | + ensure_sysctx: Callable[[FastAPI], Awaitable[Any]], | ||
| 47 | +) -> None: | ||
| 48 | + path = f"{prefix}/{{msg_type}}" if prefix else "/{msg_type}" | ||
| 49 | + | ||
| 50 | + | ||
| 51 | + async def handler(msg_type: str, request: Request): | ||
| 52 | + try: | ||
| 53 | + body = await request.json() | ||
| 54 | + except Exception: | ||
| 55 | + return _error_json(msg_type, ErrorCode.VALIDATION, "invalid JSON body") | ||
| 56 | + | ||
| 57 | + try: | ||
| 58 | + env = Envelope.from_dict(body) | ||
| 59 | + except (KeyError, TypeError) as exc: | ||
| 60 | + return _error_json(msg_type, ErrorCode.VALIDATION, f"invalid envelope: {exc}") | ||
| 61 | + | ||
| 62 | + sysctx = await ensure_sysctx(fastapi) | ||
| 63 | + rctx = sysctx.for_request(env.metadata) | ||
| 64 | + result = await router.dispatch(env, rctx) | ||
| 65 | + | ||
| 66 | + if isinstance(result, UnaryResult): | ||
| 67 | + resp = result.response | ||
| 68 | + status = 200 if resp.ok else http_status_for(resp.error_code or ErrorCode.INTERNAL) | ||
| 69 | + return JSONResponse(resp.to_dict(), status_code=status) | ||
| 70 | + | ||
| 71 | + # 流式 → SSE | ||
| 72 | + return StreamingResponse(_sse(result.chunks), media_type="text/event-stream") | ||
| @@ -0,0 +1,63 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""WebSocket 适配器(设计 §7.2)。 | ||
| 5 | + | ||
| 6 | +单一 ``/ws``,每条入站文本帧 = 一个 Envelope JSON;非流式 → 回一帧,流式 → 回 N 帧 | ||
| 7 | +(末帧 ``is_final=True``)。每连接一把 ``send_lock`` 防并发写。 | ||
| 8 | +""" | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +import asyncio | ||
| 12 | +import json | ||
| 13 | +from typing import Any, Awaitable, Callable | ||
| 14 | + | ||
| 15 | +from fastapi import FastAPI, WebSocket, WebSocketDisconnect | ||
| 16 | + | ||
| 17 | +from ..envelope import Envelope | ||
| 18 | +from ..routing.result import UnaryResult | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def _error_frame(message: str, code: str = "validation") -> str: | ||
| 22 | + return json.dumps({ | ||
| 23 | + "type": "", | ||
| 24 | + "metadata": {"request_id": ""}, | ||
| 25 | + "rawdata": {}, | ||
| 26 | + "ok": False, | ||
| 27 | + "error_code": code, | ||
| 28 | + "error_message": message, | ||
| 29 | + "version": "1", | ||
| 30 | + }, ensure_ascii=False) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def mount_ws( | ||
| 34 | + fastapi: FastAPI, | ||
| 35 | + router: Any, | ||
| 36 | + ensure_sysctx: Callable[[FastAPI], Awaitable[Any]], | ||
| 37 | +) -> None: | ||
| 38 | + | ||
| 39 | + async def handler(ws: WebSocket): | ||
| 40 | + await ws.accept() | ||
| 41 | + send_lock = asyncio.Lock() | ||
| 42 | + sysctx = await ensure_sysctx(fastapi) | ||
| 43 | + try: | ||
| 44 | + while True: | ||
| 45 | + text = await ws.receive_text() | ||
| 46 | + try: | ||
| 47 | + env = Envelope.from_dict(json.loads(text)) | ||
| 48 | + except (KeyError, TypeError, ValueError): | ||
| 49 | + async with send_lock: | ||
| 50 | + await ws.send_text(_error_frame("invalid envelope frame")) | ||
| 51 | + continue | ||
| 52 | + | ||
| 53 | + rctx = sysctx.for_request(env.metadata) | ||
| 54 | + result = await router.dispatch(env, rctx) | ||
| 55 | + if isinstance(result, UnaryResult): | ||
| 56 | + async with send_lock: | ||
| 57 | + await ws.send_text(json.dumps(result.response.to_dict(), ensure_ascii=False)) | ||
| 58 | + else: | ||
| 59 | + async for chunk in result.chunks: | ||
| 60 | + async with send_lock: | ||
| 61 | + await ws.send_text(json.dumps(chunk.to_dict(), ensure_ascii=False)) | ||
| 62 | + except WebSocketDisconnect: | ||
| 63 | + return | ||
| @@ -10,6 +10,8 @@ requires-python = ">=3.11.4" | |||
| 10 | dependencies = [ | 10 | dependencies = [ |
| 11 | "fastapi==0.115.11", | 11 | "fastapi==0.115.11", |
| 12 | "uvicorn[standard]==0.42.0", | 12 | "uvicorn[standard]==0.42.0", |
| 13 | + "websockets>=14.0", | ||
| 14 | + "redis>=5.0", | ||
| 13 | "pydantic==2.11.7", | 15 | "pydantic==2.11.7", |
| 14 | "pydantic[email]==2.11.7", | 16 | "pydantic[email]==2.11.7", |
| 15 | "pydantic-settings==2.5.2", | 17 | "pydantic-settings==2.5.2", |
| @@ -27,6 +29,8 @@ dev = [ | |||
| 27 | "pytest-asyncio==1.2.0", | 29 | "pytest-asyncio==1.2.0", |
| 28 | "pytest-mock==3.14.0", | 30 | "pytest-mock==3.14.0", |
| 29 | "ruff==0.9.10", | 31 | "ruff==0.9.10", |
| 32 | + "httpx>=0.27", | ||
| 33 | + "fakeredis>=2.20", | ||
| 30 | ] | 34 | ] |
| 31 | 35 | ||
| 32 | [[tool.uv.index]] | 36 | [[tool.uv.index]] |
| @@ -38,5 +42,14 @@ exclude = ["*.egg-info", "tests"] | |||
| 38 | include = ["openjiuwen_runtime*"] | 42 | include = ["openjiuwen_runtime*"] |
| 39 | 43 | ||
| 40 | [tool.pytest.ini_options] | 44 | [tool.pytest.ini_options] |
| 41 | -testpaths = ["examples", "tests"] | 45 | +testpaths = ["tests"] |
| 42 | -pythonpath = ["."] | 46 | +pythonpath = ["."] |
| 47 | +asyncio_mode = "auto" | ||
| 48 | +asyncio_default_fixture_loop_scope = "function" | ||
| 49 | +markers = [ | ||
| 50 | + "unit: 单元测试(router/envelope/原语,fakeredis)", | ||
| 51 | + "integration: 集成测试", | ||
| 52 | + "system: 系统验收(REST/WS 适配器、多副本)", | ||
| 53 | + "slow: 慢测试", | ||
| 54 | + "async: 异步测试", | ||
| 55 | +] | ||
| @@ -0,0 +1,84 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""第一验收用例(system)。验收: | ||
| 5 | + 1) 路由层:两个 SystemContext(副本) 共享 redis,idx 全局递增(不各自从 1 开始)。 | ||
| 6 | + 2) REST:POST /api/echo,body=完整 Envelope,返回 ResponseEnvelope,idx 递增。 | ||
| 7 | + 3) WebSocket:/ws 帧=Envelope,回帧含 idx。 | ||
| 8 | +依赖:httpx、fakeredis。""" | ||
| 9 | +import json | ||
| 10 | + | ||
| 11 | +import fakeredis.aioredis | ||
| 12 | +import httpx | ||
| 13 | +import pytest | ||
| 14 | + | ||
| 15 | +from openjiuwen_runtime.service import App, Envelope, Metadata, SystemContext | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +def build_echo_app(ctx_factory) -> App: | ||
| 19 | + app = App(ctx_factory) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | + async def echo(ctx, env: Envelope): | ||
| 23 | + idx = await ctx.kv.incr("echo:idx") # 分布式原子计数 | ||
| 24 | + return {"echo": env.rawdata.get("message", ""), "idx": idx} | ||
| 25 | + | ||
| 26 | + return app | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def _envelope(request_id: str, message: str) -> dict: | ||
| 30 | + return {"type": "echo", "metadata": {"request_id": request_id}, | ||
| 31 | + "rawdata": {"message": message}} | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +# ---------- 1) 核心分布式验收:双副本共享 redis,idx 全局递增 ---------- | ||
| 35 | + | ||
| 36 | +async def test_global_idx_across_two_replicas(): | ||
| 37 | + shared = fakeredis.aioredis.FakeServer() # 模拟两副本共享的 redis | ||
| 38 | + | ||
| 39 | + ctx_a = SystemContext(redis=fakeredis.aioredis.FakeRedis(server=shared)) | ||
| 40 | + ctx_b = SystemContext(redis=fakeredis.aioredis.FakeRedis(server=shared)) | ||
| 41 | + await ctx_a.start() | ||
| 42 | + await ctx_b.start() | ||
| 43 | + | ||
| 44 | + app = build_echo_app(lambda: None) # 直调派发,不触发 lifespan | ||
| 45 | + try: | ||
| 46 | + for i, ctx in enumerate([ctx_a, ctx_b, ctx_a, ctx_b]): # 交替打两个副本 | ||
| 47 | + env = Envelope(type="echo", | ||
| 48 | + metadata=Metadata(request_id=f"r{i}"), | ||
| 49 | + rawdata={"message": "hi"}) | ||
| 50 | + rctx = ctx.for_request(env.metadata) # 派生请求上下文 | ||
| 51 | + res = await app.dispatch(env, rctx) # 非流式 → UnaryResult(ResponseEnvelope) | ||
| 52 | + assert res.response.rawdata == {"echo": "hi", "idx": i + 1} # 1,2,3,4 全局递增 | ||
| 53 | + finally: | ||
| 54 | + await ctx_a.stop() | ||
| 55 | + await ctx_b.stop() | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +# ---------- 2) REST 适配器 ---------- | ||
| 59 | + | ||
| 60 | +async def test_rest_echo_returns_incrementing_idx(): | ||
| 61 | + app = build_echo_app(lambda: SystemContext(redis=fakeredis.aioredis.FakeRedis())) | ||
| 62 | + | ||
| 63 | + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.asgi), | ||
| 64 | + base_url="http://test") as client: | ||
| 65 | + r1 = await client.post("/api/echo", json=_envelope("r1", "hi")) | ||
| 66 | + r2 = await client.post("/api/echo", json=_envelope("r2", "yo")) | ||
| 67 | + body1, body2 = r1.json(), r2.json() | ||
| 68 | + assert r1.status_code == 200 and body1["ok"] is True | ||
| 69 | + assert body1["rawdata"] == {"echo": "hi", "idx": 1} | ||
| 70 | + assert body2["rawdata"] == {"echo": "yo", "idx": 2} | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +# ---------- 3) WebSocket 适配器 ---------- | ||
| 74 | + | ||
| 75 | +def test_ws_echo_returns_idx(): | ||
| 76 | + from starlette.testclient import TestClient | ||
| 77 | + | ||
| 78 | + app = build_echo_app(lambda: SystemContext(redis=fakeredis.aioredis.FakeRedis())) | ||
| 79 | + client = TestClient(app.asgi) | ||
| 80 | + with client.websocket_connect("/ws") as ws: | ||
| 81 | + ws.send_text(json.dumps(_envelope("r1", "hi"))) | ||
| 82 | + data = json.loads(ws.receive_text()) | ||
| 83 | + assert data["ok"] is True | ||
| 84 | + assert data["rawdata"] == {"echo": "hi", "idx": 1} | ||
| @@ -0,0 +1,109 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""适配器集成测试:REST SSE 流式 / 错误状态、WS 流式、REST 幂等。覆盖 echo 验收之外的路径。""" | ||
| 5 | +import json | ||
| 6 | + | ||
| 7 | +import fakeredis.aioredis | ||
| 8 | +import httpx | ||
| 9 | +import pytest | ||
| 10 | +from starlette.testclient import TestClient | ||
| 11 | + | ||
| 12 | +from openjiuwen_runtime.service import App, Envelope, SystemContext, ValidationError, idempotency_guard | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +def _env(t: str, rid="r1", raw=None): | ||
| 16 | + return {"type": t, "metadata": {"request_id": rid}, "rawdata": raw or {}} | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def _ctx_factory(): | ||
| 20 | + return SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def _build_stream_app(): | ||
| 24 | + app = App(_ctx_factory) | ||
| 25 | + | ||
| 26 | + | ||
| 27 | + async def count(ctx, env: Envelope): | ||
| 28 | + for i in range(3): | ||
| 29 | + yield {"n": i} | ||
| 30 | + | ||
| 31 | + return app | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def _build_error_app(): | ||
| 35 | + app = App(_ctx_factory) | ||
| 36 | + | ||
| 37 | + | ||
| 38 | + async def boom(ctx, env: Envelope): | ||
| 39 | + raise ValidationError("nope") | ||
| 40 | + | ||
| 41 | + return app | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +def _build_idem_app(): | ||
| 45 | + app = App(_ctx_factory) | ||
| 46 | + app.use(idempotency_guard(window=60, mode="reject")) | ||
| 47 | + | ||
| 48 | + | ||
| 49 | + async def send(ctx, env: Envelope): | ||
| 50 | + return {"sent": True} | ||
| 51 | + | ||
| 52 | + return app | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +# ---------- REST 流式(SSE)---------- | ||
| 56 | + | ||
| 57 | +async def test_rest_stream_returns_sse_chunks(): | ||
| 58 | + app = _build_stream_app() | ||
| 59 | + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.asgi), | ||
| 60 | + base_url="http://test") as client: | ||
| 61 | + r = await client.post("/api/count", json=_env("count")) | ||
| 62 | + assert r.status_code == 200 | ||
| 63 | + data_lines = [ln[len("data: "):] for ln in r.text.splitlines() if ln.startswith("data: ")] | ||
| 64 | + chunks = [json.loads(line) for line in data_lines] | ||
| 65 | + assert [c["sequence"] for c in chunks] == [1, 2, 3] | ||
| 66 | + assert chunks[0]["rawdata"] == {"n": 0} | ||
| 67 | + assert chunks[-1]["is_final"] is True | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +# ---------- REST 错误状态 ---------- | ||
| 71 | + | ||
| 72 | +async def test_rest_error_returns_non_200_with_error_envelope(): | ||
| 73 | + app = _build_error_app() | ||
| 74 | + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.asgi), | ||
| 75 | + base_url="http://test") as client: | ||
| 76 | + r = await client.post("/api/boom", json=_env("boom")) | ||
| 77 | + body = r.json() | ||
| 78 | + assert r.status_code == 400 # validation → 400 | ||
| 79 | + assert body["ok"] is False | ||
| 80 | + assert body["error_code"] == "validation" | ||
| 81 | + assert "nope" in body["error_message"] | ||
| 82 | + | ||
| 83 | + | ||
| 84 | +# ---------- WebSocket 流式 ---------- | ||
| 85 | + | ||
| 86 | +def test_ws_stream_returns_chunked_frames(): | ||
| 87 | + app = _build_stream_app() | ||
| 88 | + client = TestClient(app.asgi) | ||
| 89 | + with client.websocket_connect("/ws") as ws: | ||
| 90 | + ws.send_text(json.dumps(_env("count"))) | ||
| 91 | + frames = [json.loads(ws.receive_text()) for _ in range(3)] | ||
| 92 | + assert [f["sequence"] for f in frames] == [1, 2, 3] | ||
| 93 | + assert frames[-1]["is_final"] is True | ||
| 94 | + assert frames[0]["rawdata"] == {"n": 0} | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +# ---------- REST 幂等(reject)---------- | ||
| 98 | + | ||
| 99 | +async def test_rest_idempotency_rejects_duplicate(): | ||
| 100 | + app = _build_idem_app() | ||
| 101 | + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app.asgi), | ||
| 102 | + base_url="http://test") as client: | ||
| 103 | + env = _env("send", rid="dup-1") | ||
| 104 | + r1 = await client.post("/api/send", json=env) | ||
| 105 | + r2 = await client.post("/api/send", json=env) | ||
| 106 | + assert r1.status_code == 200 and r1.json()["ok"] is True | ||
| 107 | + assert r1.json()["rawdata"] == {"sent": True} | ||
| 108 | + assert r2.status_code == 409 # idempotent → 409 | ||
| 109 | + assert r2.json()["error_code"] == "idempotent" | ||
| @@ -0,0 +1,92 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""ServiceConfig 单测:部署相关项从环境变量读取(命名详细清楚),缺省安全回落。""" | ||
| 5 | +import pytest | ||
| 6 | + | ||
| 7 | +from openjiuwen_runtime.service.config import ServiceConfig | ||
| 8 | + | ||
| 9 | +_ENVS = [ | ||
| 10 | + "OPENJIUWEN_SERVICE_HOST", | ||
| 11 | + "OPENJIUWEN_SERVICE_PORT", | ||
| 12 | + "OPENJIUWEN_SERVICE_REDIS_URL", | ||
| 13 | + "OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", | ||
| 14 | + "OPENJIUWEN_SERVICE_TITLE", | ||
| 15 | +] | ||
| 16 | + | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_defaults_when_env_unset(monkeypatch): | ||
| 20 | + for v in _ENVS: | ||
| 21 | + monkeypatch.delenv(v, raising=False) | ||
| 22 | + cfg = ServiceConfig.from_env() | ||
| 23 | + assert cfg.host == "0.0.0.0" | ||
| 24 | + assert cfg.port == 8090 | ||
| 25 | + assert cfg.redis_url == "redis://localhost:6379/0" | ||
| 26 | + assert cfg.key_prefix == "service" | ||
| 27 | + assert cfg.title == "service" | ||
| 28 | + | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def test_reads_all_env_vars(monkeypatch): | ||
| 32 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_HOST", "127.0.0.1") | ||
| 33 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_PORT", "9999") | ||
| 34 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_URL", "redis://cache.internal:6380/3") | ||
| 35 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", "prod-ns") | ||
| 36 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_TITLE", "echo-app") | ||
| 37 | + cfg = ServiceConfig.from_env() | ||
| 38 | + assert cfg.host == "127.0.0.1" | ||
| 39 | + assert cfg.port == 9999 | ||
| 40 | + assert cfg.redis_url == "redis://cache.internal:6380/3" | ||
| 41 | + assert cfg.key_prefix == "prod-ns" | ||
| 42 | + assert cfg.title == "echo-app" | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +def test_invalid_port_fails_fast(monkeypatch): | ||
| 47 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_PORT", "not-a-port") | ||
| 48 | + with pytest.raises(ValueError): | ||
| 49 | + ServiceConfig.from_env() | ||
| 50 | + | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def test_app_run_host_port_from_env(monkeypatch): | ||
| 54 | + import fakeredis.aioredis | ||
| 55 | + | ||
| 56 | + from openjiuwen_runtime.service import App, SystemContext | ||
| 57 | + | ||
| 58 | + captured: dict = {} | ||
| 59 | + | ||
| 60 | + def fake_run(app, host=None, port=None, **kw): | ||
| 61 | + captured["host"] = host | ||
| 62 | + captured["port"] = port | ||
| 63 | + | ||
| 64 | + monkeypatch.setattr("uvicorn.run", fake_run) | ||
| 65 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_HOST", "127.0.0.1") | ||
| 66 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_PORT", "7000") | ||
| 67 | + | ||
| 68 | + app = App(lambda: SystemContext(redis=fakeredis.aioredis.FakeRedis())) | ||
| 69 | + app.run() | ||
| 70 | + assert captured["host"] == "127.0.0.1" | ||
| 71 | + assert captured["port"] == 7000 | ||
| 72 | + | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def test_app_run_explicit_args_override_env(monkeypatch): | ||
| 76 | + import fakeredis.aioredis | ||
| 77 | + | ||
| 78 | + from openjiuwen_runtime.service import App, SystemContext | ||
| 79 | + | ||
| 80 | + captured: dict = {} | ||
| 81 | + | ||
| 82 | + def fake_run(app, host=None, port=None, **kw): | ||
| 83 | + captured["host"] = host | ||
| 84 | + captured["port"] = port | ||
| 85 | + | ||
| 86 | + monkeypatch.setattr("uvicorn.run", fake_run) | ||
| 87 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_PORT", "7000") | ||
| 88 | + | ||
| 89 | + app = App(lambda: SystemContext(redis=fakeredis.aioredis.FakeRedis())) | ||
| 90 | + app.run(host="0.0.0.0", port=1234) | ||
| 91 | + assert captured["host"] == "0.0.0.0" # 显式参覆盖环境变量 | ||
| 92 | + assert captured["port"] == 1234 | ||
| @@ -0,0 +1,83 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Envelope / Metadata / ResponseEnvelope / StreamChunk 序列化往返与默认值单测。""" | ||
| 5 | +import pytest | ||
| 6 | + | ||
| 7 | +from openjiuwen_runtime.service.envelope import ( | ||
| 8 | + Envelope, | ||
| 9 | + Metadata, | ||
| 10 | + ResponseEnvelope, | ||
| 11 | + StreamChunk, | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def test_envelope_defaults_and_roundtrip(): | ||
| 17 | + env = Envelope(type="echo", metadata=Metadata(request_id="r1"), | ||
| 18 | + rawdata={"message": "hi"}) | ||
| 19 | + assert env.version == "1" # 默认 schema 版本 | ||
| 20 | + assert env.metadata.user_id is None # Metadata 可选字段默认 None | ||
| 21 | + assert env.metadata.extra == {} # extra 默认空 dict | ||
| 22 | + | ||
| 23 | + d = env.to_dict() | ||
| 24 | + assert d["type"] == "echo" | ||
| 25 | + assert d["version"] == "1" | ||
| 26 | + assert d["metadata"]["request_id"] == "r1" | ||
| 27 | + assert d["rawdata"] == {"message": "hi"} | ||
| 28 | + | ||
| 29 | + assert Envelope.from_dict(d) == env # 往返无损 | ||
| 30 | + | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def test_envelope_from_rest_body_shape(): | ||
| 34 | + # REST/WS body 仅含 type/metadata/rawdata,无 version → 回落默认 "1" | ||
| 35 | + body = {"type": "echo", "metadata": {"request_id": "r1"}, "rawdata": {"message": "hi"}} | ||
| 36 | + env = Envelope.from_dict(body) | ||
| 37 | + assert env.type == "echo" | ||
| 38 | + assert env.metadata.request_id == "r1" | ||
| 39 | + assert env.rawdata == {"message": "hi"} | ||
| 40 | + assert env.version == "1" | ||
| 41 | + | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +def test_metadata_extra_preserved(): | ||
| 45 | + md = Metadata(request_id="r1", user_id="u1", extra={"channel_id": "c9"}) | ||
| 46 | + d = md.to_dict() | ||
| 47 | + assert d["extra"]["channel_id"] == "c9" | ||
| 48 | + assert Metadata.from_dict(d) == md | ||
| 49 | + | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +def test_response_envelope_to_dict_contract(): | ||
| 53 | + # 适配器据此序列化为 JSON 响应体 | ||
| 54 | + resp = ResponseEnvelope(type="echo", metadata=Metadata(request_id="r1"), | ||
| 55 | + rawdata={"echo": "hi", "idx": 1}, ok=True) | ||
| 56 | + d = resp.to_dict() | ||
| 57 | + assert d["ok"] is True | ||
| 58 | + assert d["type"] == "echo" | ||
| 59 | + assert d["rawdata"] == {"echo": "hi", "idx": 1} | ||
| 60 | + assert d["metadata"]["request_id"] == "r1" # 回填 request_id | ||
| 61 | + assert d["error_code"] is None and d["error_message"] is None | ||
| 62 | + assert ResponseEnvelope.from_dict(d) == resp | ||
| 63 | + | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def test_response_envelope_error_shape(): | ||
| 67 | + resp = ResponseEnvelope(type="echo", metadata=Metadata(request_id="r1"), | ||
| 68 | + rawdata={}, ok=False, error_code="internal", | ||
| 69 | + error_message="boom") | ||
| 70 | + d = resp.to_dict() | ||
| 71 | + assert d["ok"] is False | ||
| 72 | + assert d["error_code"] == "internal" | ||
| 73 | + assert d["error_message"] == "boom" | ||
| 74 | + | ||
| 75 | + | ||
| 76 | + | ||
| 77 | +def test_stream_chunk_to_dict_and_roundtrip(): | ||
| 78 | + chunk = StreamChunk(sequence=1, is_final=False, metadata=Metadata(request_id="r1"), | ||
| 79 | + rawdata={"delta": "x"}) | ||
| 80 | + d = chunk.to_dict() | ||
| 81 | + assert d["sequence"] == 1 and d["is_final"] is False | ||
| 82 | + assert d["rawdata"] == {"delta": "x"} | ||
| 83 | + assert StreamChunk.from_dict(d) == chunk | ||
| @@ -0,0 +1,66 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""错误模型:错误码、异常类、code 归一化与 HTTP 状态映射(设计 §12)。""" | ||
| 5 | +import pytest | ||
| 6 | + | ||
| 7 | +from openjiuwen_runtime.service.errors import ( | ||
| 8 | + ErrorCode, | ||
| 9 | + FrameworkError, | ||
| 10 | + IdempotentConflict, | ||
| 11 | + LockLost, | ||
| 12 | + LockNotAcquired, | ||
| 13 | + NotFoundError, | ||
| 14 | + ValidationError, | ||
| 15 | + exception_code, | ||
| 16 | + http_status_for, | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def test_framework_error_default_code(): | ||
| 22 | + err = FrameworkError("boom") | ||
| 23 | + assert err.code == ErrorCode.INTERNAL | ||
| 24 | + assert str(err) == "boom" | ||
| 25 | + assert isinstance(err, Exception) | ||
| 26 | + | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def test_subclass_codes(): | ||
| 30 | + assert ValidationError("x").code == ErrorCode.VALIDATION | ||
| 31 | + assert NotFoundError("x").code == ErrorCode.NOT_FOUND | ||
| 32 | + assert IdempotentConflict("x").code == ErrorCode.IDEMPOTENT | ||
| 33 | + assert LockNotAcquired("x").code == ErrorCode.LOCKED | ||
| 34 | + assert LockLost("x").code == ErrorCode.LOCKED | ||
| 35 | + # 都是 FrameworkError 子类 → 中间件可统一捕获 | ||
| 36 | + for exc in (ValidationError(""), NotFoundError(""), IdempotentConflict(""), | ||
| 37 | + LockNotAcquired(""), LockLost("")): | ||
| 38 | + assert isinstance(exc, FrameworkError) | ||
| 39 | + | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def test_explicit_code_override(): | ||
| 43 | + # 允许实例级覆盖 code | ||
| 44 | + err = FrameworkError("x", code=ErrorCode.CONFLICT) | ||
| 45 | + assert err.code == ErrorCode.CONFLICT | ||
| 46 | + | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def test_exception_code_normalizes_unknown(): | ||
| 50 | + assert exception_code(ValidationError("x")) == ErrorCode.VALIDATION | ||
| 51 | + # 非 FrameworkError 一律归一为 internal | ||
| 52 | + assert exception_code(ValueError("x")) == ErrorCode.INTERNAL | ||
| 53 | + assert exception_code(RuntimeError()) == ErrorCode.INTERNAL | ||
| 54 | + | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def test_http_status_mapping(): | ||
| 58 | + assert http_status_for(ErrorCode.VALIDATION) == 400 | ||
| 59 | + assert http_status_for(ErrorCode.NOT_FOUND) == 404 | ||
| 60 | + assert http_status_for(ErrorCode.CONFLICT) == 409 | ||
| 61 | + assert http_status_for(ErrorCode.IDEMPOTENT) == 409 | ||
| 62 | + assert http_status_for(ErrorCode.LOCKED) == 423 | ||
| 63 | + assert http_status_for(ErrorCode.TIMEOUT) == 504 | ||
| 64 | + assert http_status_for(ErrorCode.INTERNAL) == 500 | ||
| 65 | + # 未知 code → 500(fail-safe) | ||
| 66 | + assert http_status_for("totally-unknown") == 500 | ||
| @@ -0,0 +1,84 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""幂等原语 + idempotency_guard 中间件单测(设计 §9.2)。""" | ||
| 5 | +import fakeredis.aioredis | ||
| 6 | +import pytest | ||
| 7 | + | ||
| 8 | +from openjiuwen_runtime.service.context.primitives.idempotency import Idempotency, idempotency_guard | ||
| 9 | +from openjiuwen_runtime.service.envelope import Envelope, Metadata, ResponseEnvelope | ||
| 10 | +from openjiuwen_runtime.service.context.system_context import SystemContext | ||
| 11 | +from openjiuwen_runtime.service.routing.router import MessageRouter | ||
| 12 | + | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +async def test_acquire_first_then_duplicate(): | ||
| 16 | + idem = Idempotency(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 17 | + g1 = await idem.acquire("r1", window=60) | ||
| 18 | + assert g1.acquired is True | ||
| 19 | + assert g1.cached_result is None | ||
| 20 | + g2 = await idem.acquire("r1", window=60) | ||
| 21 | + assert g2.acquired is False # 同 request_id 重复 → 不再获得 | ||
| 22 | + | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +async def test_cache_mode_replays_first_result(): | ||
| 26 | + idem = Idempotency(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 27 | + g1 = await idem.acquire("r1", window=60) | ||
| 28 | + assert g1.acquired | ||
| 29 | + await g1.succeed(ResponseEnvelope(type="echo", metadata=Metadata(request_id="r1"), | ||
| 30 | + rawdata={"v": 9}, ok=True)) | ||
| 31 | + g2 = await idem.acquire("r1", window=60) | ||
| 32 | + assert g2.acquired is False | ||
| 33 | + assert g2.cached_result is not None | ||
| 34 | + assert g2.cached_result.rawdata == {"v": 9} | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def _env(rid): | ||
| 38 | + return Envelope(type="ping", metadata=Metadata(request_id=rid), rawdata={}) | ||
| 39 | + | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +async def test_guard_reject_mode_runs_handler_once(): | ||
| 43 | + ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 44 | + await ctx.start() | ||
| 45 | + rctx = ctx.for_request(Metadata(request_id="r1")) | ||
| 46 | + router = MessageRouter() | ||
| 47 | + router.use(idempotency_guard(window=60, mode="reject")) | ||
| 48 | + calls = [] | ||
| 49 | + | ||
| 50 | + | ||
| 51 | + async def ping(c, env): | ||
| 52 | + calls.append(1) | ||
| 53 | + return {"pong": await c.kv.incr("p")} | ||
| 54 | + | ||
| 55 | + res1 = await router.dispatch(_env("r1"), rctx) | ||
| 56 | + res2 = await router.dispatch(_env("r1"), rctx) | ||
| 57 | + assert res1.response.ok and res1.response.rawdata == {"pong": 1} | ||
| 58 | + assert res2.response.ok is False | ||
| 59 | + assert res2.response.error_code == "idempotent" # 重复 → 拒绝 | ||
| 60 | + assert len(calls) == 1 # handler 只跑一次 | ||
| 61 | + await ctx.stop() | ||
| 62 | + | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +async def test_guard_cache_mode_replays_result_without_rerunning(): | ||
| 66 | + ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 67 | + await ctx.start() | ||
| 68 | + rctx = ctx.for_request(Metadata(request_id="r1")) | ||
| 69 | + router = MessageRouter() | ||
| 70 | + router.use(idempotency_guard(window=60, mode="cache")) | ||
| 71 | + calls = [] | ||
| 72 | + | ||
| 73 | + | ||
| 74 | + async def ping(c, env): | ||
| 75 | + calls.append(1) | ||
| 76 | + return {"pong": await c.kv.incr("p")} | ||
| 77 | + | ||
| 78 | + res1 = await router.dispatch(_env("r1"), rctx) | ||
| 79 | + res2 = await router.dispatch(_env("r1"), rctx) | ||
| 80 | + assert res1.response.ok and res1.response.rawdata == {"pong": 1} | ||
| 81 | + assert res2.response.ok is True | ||
| 82 | + assert res2.response.rawdata == {"pong": 1} # 回放首次结果 | ||
| 83 | + assert len(calls) == 1 # handler 只跑一次 | ||
| 84 | + await ctx.stop() | ||
| @@ -0,0 +1,73 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""KVStore 原语单测(设计 §9.3):fakeredis。""" | ||
| 5 | +import pytest | ||
| 6 | +import fakeredis.aioredis | ||
| 7 | + | ||
| 8 | +from openjiuwen_runtime.service.context.primitives.kv_store import KVStore | ||
| 9 | + | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +async def test_set_get_roundtrip_and_missing(): | ||
| 13 | + kv = KVStore(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 14 | + await kv.set("mode", "agent.plan") | ||
| 15 | + assert await kv.get("mode") == "agent.plan" | ||
| 16 | + assert await kv.get("missing") is None | ||
| 17 | + | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +async def test_exists_and_delete(): | ||
| 21 | + kv = KVStore(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 22 | + await kv.set("k", "v") | ||
| 23 | + assert await kv.exists("k") is True | ||
| 24 | + assert await kv.delete("k") is True | ||
| 25 | + assert await kv.exists("k") is False | ||
| 26 | + assert await kv.delete("k") is False # 已删 → False | ||
| 27 | + | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +async def test_incr_atomic_sequential(): | ||
| 31 | + kv = KVStore(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 32 | + assert await kv.incr("counter") == 1 | ||
| 33 | + assert await kv.incr("counter") == 2 | ||
| 34 | + assert await kv.incr("counter") == 3 | ||
| 35 | + assert await kv.incr("counter", amount=5) == 8 | ||
| 36 | + | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +async def test_incr_shared_across_two_instances(): | ||
| 40 | + # 两个副本共享同一 redis(FakeServer)→ 计数全局递增(无内存状态硬约束) | ||
| 41 | + shared = fakeredis.aioredis.FakeServer() | ||
| 42 | + kv_a = KVStore(fakeredis.aioredis.FakeRedis(server=shared), prefix="svc") | ||
| 43 | + kv_b = KVStore(fakeredis.aioredis.FakeRedis(server=shared), prefix="svc") | ||
| 44 | + assert await kv_a.incr("echo:idx") == 1 | ||
| 45 | + assert await kv_b.incr("echo:idx") == 2 | ||
| 46 | + assert await kv_a.incr("echo:idx") == 3 | ||
| 47 | + | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +async def test_set_with_ttl_is_visible_and_expires(): | ||
| 51 | + kv = KVStore(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 52 | + await kv.set("tmp", "v", ttl=100) | ||
| 53 | + assert await kv.exists("tmp") is True | ||
| 54 | + # 底层 redis 已设置 TTL(白盒校验 EX 生效) | ||
| 55 | + assert await kv._redis.ttl(kv._key("tmp")) > 0 | ||
| 56 | + | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +async def test_json_get_set(): | ||
| 60 | + kv = KVStore(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 61 | + await kv.set_json("sess", {"mode": "plan", "n": 3}, ttl=100) | ||
| 62 | + assert await kv.get_json("sess") == {"mode": "plan", "n": 3} | ||
| 63 | + assert await kv.get_json("missing", default={}) == {} | ||
| 64 | + | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +async def test_scan_returns_user_space_keys(): | ||
| 68 | + kv = KVStore(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 69 | + await kv.set("echo:a", "1") | ||
| 70 | + await kv.set("echo:b", "2") | ||
| 71 | + await kv.set("other:c", "3") | ||
| 72 | + found = sorted(await kv.scan("echo:*")) | ||
| 73 | + assert found == ["echo:a", "echo:b"] | ||
| @@ -0,0 +1,80 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""DistributedLock 原语单测(设计 §9.1):SET NX EX + WATCH CAS 释放 + 续期/失锁。""" | ||
| 5 | +import asyncio | ||
| 6 | +import time | ||
| 7 | + | ||
| 8 | +import fakeredis.aioredis | ||
| 9 | +import pytest | ||
| 10 | + | ||
| 11 | +from openjiuwen_runtime.service.context.primitives.lock import DistributedLock | ||
| 12 | +from openjiuwen_runtime.service.errors import LockLost, LockNotAcquired | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +def _new(redis=None, **kw): | ||
| 16 | + kw.setdefault("owner", "A") | ||
| 17 | + kw.setdefault("ttl", 10) | ||
| 18 | + return DistributedLock(redis or fakeredis.aioredis.FakeRedis(), "res", **kw) | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +async def test_acquire_then_release_allows_reacquire(): | ||
| 23 | + redis = fakeredis.aioredis.FakeRedis() | ||
| 24 | + async with _new(redis): | ||
| 25 | + pass | ||
| 26 | + # 释放后另一个 owner 可立即获取 | ||
| 27 | + async with DistributedLock(redis, "res", owner="B", ttl=10): | ||
| 28 | + pass | ||
| 29 | + | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +async def test_nonblocking_conflict_raises_lock_not_acquired(): | ||
| 33 | + redis = fakeredis.aioredis.FakeRedis() | ||
| 34 | + async with _new(redis, owner="A"): | ||
| 35 | + with pytest.raises(LockNotAcquired): | ||
| 36 | + async with DistributedLock(redis, "res", owner="B", ttl=10, timeout=0): | ||
| 37 | + pass | ||
| 38 | + | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +async def test_blocking_timeout_eventually_raises(): | ||
| 42 | + redis = fakeredis.aioredis.FakeRedis() | ||
| 43 | + async with _new(redis, owner="A"): | ||
| 44 | + t0 = time.monotonic() | ||
| 45 | + with pytest.raises(LockNotAcquired): | ||
| 46 | + async with DistributedLock(redis, "res", owner="B", ttl=10, timeout=0.4): | ||
| 47 | + pass | ||
| 48 | + elapsed = time.monotonic() - t0 | ||
| 49 | + assert elapsed >= 0.3 # 至少等了一段(阻塞) | ||
| 50 | + | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +async def test_safe_release_does_not_delete_others_lock(): | ||
| 54 | + redis = fakeredis.aioredis.FakeRedis() | ||
| 55 | + lk = _new(redis, owner="A") | ||
| 56 | + async with lk: | ||
| 57 | + # 模拟:A 持有期间锁过期并被 B 抢走(覆写 value) | ||
| 58 | + await redis.set(lk.key, "B") | ||
| 59 | + # A 退出不应删除 B 的锁(CAS:value 不匹配) | ||
| 60 | + assert await redis.get(lk.key) == b"B" | ||
| 61 | + | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +async def test_renew_once_true_when_owner_false_when_lost(): | ||
| 65 | + redis = fakeredis.aioredis.FakeRedis() | ||
| 66 | + lk = _new(redis, owner="A") | ||
| 67 | + async with lk: | ||
| 68 | + assert await lk.renew_once() is True # 仍是 owner → 续期成功 | ||
| 69 | + await redis.delete(lk.key) # 模拟失锁 | ||
| 70 | + assert await lk.renew_once() is False # 失锁 → 续期失败 | ||
| 71 | + | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +async def test_lock_lost_surfaces_on_exit(): | ||
| 75 | + redis = fakeredis.aioredis.FakeRedis() | ||
| 76 | + lk = _new(redis, owner="A", ttl=10, renew_interval=0.05) | ||
| 77 | + with pytest.raises(LockLost): | ||
| 78 | + async with lk: | ||
| 79 | + await redis.delete(lk.key) # 续期循环将检测到失锁 | ||
| 80 | + await asyncio.sleep(0.2) # 等续期循环跑一轮 | ||
| @@ -0,0 +1,39 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""PubSub 原语单测(设计 §9.5):Redis Pub/Sub 事件总线。""" | ||
| 5 | +import asyncio | ||
| 6 | + | ||
| 7 | +import fakeredis.aioredis | ||
| 8 | +import pytest | ||
| 9 | + | ||
| 10 | +from openjiuwen_runtime.service.context.primitives.pubsub import PubSub | ||
| 11 | + | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +async def test_publish_subscribe_roundtrip(): | ||
| 15 | + shared = fakeredis.aioredis.FakeServer() | ||
| 16 | + pub = PubSub(fakeredis.aioredis.FakeRedis(server=shared), prefix="svc") | ||
| 17 | + sub = PubSub(fakeredis.aioredis.FakeRedis(server=shared), prefix="svc") | ||
| 18 | + | ||
| 19 | + received: list[dict] = [] | ||
| 20 | + | ||
| 21 | + async def reader(): | ||
| 22 | + async for msg in sub.subscribe("events"): | ||
| 23 | + received.append(msg) | ||
| 24 | + if len(received) >= 2: | ||
| 25 | + break | ||
| 26 | + | ||
| 27 | + task = asyncio.create_task(reader()) | ||
| 28 | + await asyncio.sleep(0.1) # 等订阅生效 | ||
| 29 | + n = await pub.publish("events", {"a": 1}) | ||
| 30 | + assert n == 1 # 1 个订阅者收到 | ||
| 31 | + await pub.publish("events", {"b": 2}) | ||
| 32 | + await asyncio.wait_for(task, timeout=2) | ||
| 33 | + assert received == [{"a": 1}, {"b": 2}] | ||
| 34 | + | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +async def test_publish_with_no_subscribers_returns_zero(): | ||
| 38 | + pub = PubSub(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 39 | + assert await pub.publish("lonely", {"x": 1}) == 0 | ||
| @@ -0,0 +1,151 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""MessageRouter 单测:注册/派发/中间件链/唯一性/流式(设计 §6.2、§6.3)。""" | ||
| 5 | +import pytest | ||
| 6 | + | ||
| 7 | +from openjiuwen_runtime.service.envelope import Envelope, Metadata, ResponseEnvelope | ||
| 8 | +from openjiuwen_runtime.service.errors import ValidationError | ||
| 9 | +from openjiuwen_runtime.service.routing.router import MessageRouter | ||
| 10 | +from openjiuwen_runtime.service.routing.result import UnaryResult, StreamResult | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def _env(t="echo", rid="r1", raw=None): | ||
| 14 | + return Envelope(type=t, metadata=Metadata(request_id=rid), rawdata=raw or {}) | ||
| 15 | + | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +async def test_unary_dispatch_wraps_dict_into_response_envelope(): | ||
| 19 | + router = MessageRouter() | ||
| 20 | + | ||
| 21 | + | ||
| 22 | + async def echo(ctx, env): | ||
| 23 | + return {"echo": env.rawdata["m"]} | ||
| 24 | + | ||
| 25 | + res = await router.dispatch(_env(raw={"m": "hi"}), object()) | ||
| 26 | + assert isinstance(res, UnaryResult) | ||
| 27 | + assert res.response.ok is True | ||
| 28 | + assert res.response.type == "echo" | ||
| 29 | + assert res.response.rawdata == {"echo": "hi"} | ||
| 30 | + assert res.response.metadata.request_id == "r1" # 回填 request_id | ||
| 31 | + | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +async def test_unary_dispatch_passes_through_response_envelope(): | ||
| 35 | + router = MessageRouter() | ||
| 36 | + | ||
| 37 | + | ||
| 38 | + async def echo(ctx, env): | ||
| 39 | + return ResponseEnvelope(type="echo", metadata=env.metadata, rawdata={"k": 1}, ok=True) | ||
| 40 | + | ||
| 41 | + res = await router.dispatch(_env(), object()) | ||
| 42 | + assert res.response.rawdata == {"k": 1} | ||
| 43 | + assert res.response.ok is True | ||
| 44 | + | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +async def test_unknown_type_returns_not_found_error_result(): | ||
| 48 | + router = MessageRouter() | ||
| 49 | + res = await router.dispatch(_env(t="missing"), object()) | ||
| 50 | + assert isinstance(res, UnaryResult) | ||
| 51 | + assert res.response.ok is False | ||
| 52 | + assert res.response.error_code == "not_found" | ||
| 53 | + | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +async def test_handler_exception_normalized_to_error_envelope(): | ||
| 57 | + router = MessageRouter() | ||
| 58 | + | ||
| 59 | + | ||
| 60 | + async def echo(ctx, env): | ||
| 61 | + raise ValidationError("bad input") | ||
| 62 | + | ||
| 63 | + res = await router.dispatch(_env(), object()) | ||
| 64 | + assert res.response.ok is False | ||
| 65 | + assert res.response.error_code == "validation" | ||
| 66 | + assert "bad input" in res.response.error_message | ||
| 67 | + | ||
| 68 | + | ||
| 69 | + async def boom(ctx, env): | ||
| 70 | + raise RuntimeError("unexpected") | ||
| 71 | + | ||
| 72 | + res2 = await router.dispatch(_env(t="boom"), object()) | ||
| 73 | + assert res2.response.ok is False | ||
| 74 | + assert res2.response.error_code == "internal" # 非 FrameworkError 归一 internal | ||
| 75 | + | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +async def test_middleware_onion_order(): | ||
| 79 | + router = MessageRouter() | ||
| 80 | + calls: list[str] = [] | ||
| 81 | + | ||
| 82 | + async def mw_a(ctx, env, nxt): | ||
| 83 | + calls.append("a-before") | ||
| 84 | + res = await nxt(ctx, env) | ||
| 85 | + calls.append("a-after") | ||
| 86 | + return res | ||
| 87 | + | ||
| 88 | + async def mw_b(ctx, env, nxt): | ||
| 89 | + calls.append("b-before") | ||
| 90 | + res = await nxt(ctx, env) | ||
| 91 | + calls.append("b-after") | ||
| 92 | + return res | ||
| 93 | + | ||
| 94 | + router.use(mw_a) | ||
| 95 | + router.use(mw_b) | ||
| 96 | + | ||
| 97 | + | ||
| 98 | + async def echo(ctx, env): | ||
| 99 | + calls.append("handler") | ||
| 100 | + return {"ok": 1} | ||
| 101 | + | ||
| 102 | + await router.dispatch(_env(), object()) | ||
| 103 | + # 先注册为外层:a-before → b-before → handler → b-after → a-after | ||
| 104 | + assert calls == ["a-before", "b-before", "handler", "b-after", "a-after"] | ||
| 105 | + | ||
| 106 | + | ||
| 107 | + | ||
| 108 | +async def test_duplicate_type_raises(): | ||
| 109 | + router = MessageRouter() | ||
| 110 | + | ||
| 111 | + | ||
| 112 | + async def echo(ctx, env): | ||
| 113 | + return {} | ||
| 114 | + | ||
| 115 | + with pytest.raises(Exception): | ||
| 116 | + | ||
| 117 | + async def echo2(ctx, env): | ||
| 118 | + return {} | ||
| 119 | + | ||
| 120 | + | ||
| 121 | + | ||
| 122 | +async def test_stream_xor_unary_conflict(): | ||
| 123 | + router = MessageRouter() | ||
| 124 | + | ||
| 125 | + | ||
| 126 | + async def unary(ctx, env): | ||
| 127 | + return {} | ||
| 128 | + | ||
| 129 | + with pytest.raises(Exception): | ||
| 130 | + | ||
| 131 | + async def stream(ctx, env): | ||
| 132 | + yield {} | ||
| 133 | + | ||
| 134 | + | ||
| 135 | + | ||
| 136 | +async def test_stream_dispatch_assigns_sequence_and_final(): | ||
| 137 | + router = MessageRouter() | ||
| 138 | + | ||
| 139 | + | ||
| 140 | + async def gen(ctx, env): | ||
| 141 | + yield {"n": 1} | ||
| 142 | + yield {"n": 2} | ||
| 143 | + | ||
| 144 | + res = await router.dispatch(_env(t="gen"), object()) | ||
| 145 | + assert isinstance(res, StreamResult) | ||
| 146 | + chunks = [c async for c in res.chunks] | ||
| 147 | + assert len(chunks) == 2 | ||
| 148 | + assert [c.sequence for c in chunks] == [1, 2] | ||
| 149 | + assert chunks[0].is_final is False and chunks[1].is_final is True | ||
| 150 | + assert chunks[0].rawdata == {"n": 1} | ||
| 151 | + assert chunks[1].metadata.request_id == "r1" | ||
| @@ -0,0 +1,65 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""StreamQueue 原语单测(设计 §9.4):XADD/XREADGROUP/XACK,at-least-once。""" | ||
| 5 | +import fakeredis.aioredis | ||
| 6 | +import pytest | ||
| 7 | + | ||
| 8 | +from openjiuwen_runtime.service.context.primitives.stream_queue import StreamQueue | ||
| 9 | + | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +async def test_enqueue_consume_ack(): | ||
| 13 | + sq = StreamQueue(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 14 | + await sq.enqueue("tasks", {"x": 1}) | ||
| 15 | + got = [] | ||
| 16 | + async for item in sq.consume("g1", "c1", stream="tasks", block=0): | ||
| 17 | + got.append(item.data) | ||
| 18 | + await item.ack() | ||
| 19 | + break | ||
| 20 | + assert got == [{"x": 1}] | ||
| 21 | + | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +async def test_unacked_message_stays_pending(): | ||
| 25 | + # at-least-once:不 ack → 消息留在消费组 PEL(不丢,可被重新处理/重投) | ||
| 26 | + # 注:fakeredis 的 XREADGROUP 不能二次重投 pending,故用 xpending 验证「未丢」契约。 | ||
| 27 | + sq = StreamQueue(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 28 | + await sq.enqueue("tasks", {"x": 1}) | ||
| 29 | + async for item in sq.consume("g1", "c1", stream="tasks", block=0): | ||
| 30 | + break # 不 ack | ||
| 31 | + pending = await sq._redis.xpending("svc:tasks", "g1") | ||
| 32 | + assert pending["pending"] == 1 # 仍挂账 → at-least-once | ||
| 33 | + | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +async def test_group_does_not_double_deliver_same_message(): | ||
| 37 | + # 同一消费组内:一条消息只被一个 consumer 取走(不重复投递给同组) | ||
| 38 | + sq = StreamQueue(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 39 | + await sq.enqueue("tasks", {"x": 1}) | ||
| 40 | + | ||
| 41 | + taken = [] | ||
| 42 | + async for item in sq.consume("g1", "c1", stream="tasks", block=0, count=1): | ||
| 43 | + taken.append(item.data) | ||
| 44 | + await item.ack() | ||
| 45 | + break | ||
| 46 | + | ||
| 47 | + # c1 已 ack;c2(同组)随后非阻塞读取应拿不到该消息(生成器空即停止) | ||
| 48 | + async for item in sq.consume("g1", "c2", stream="tasks", block=0, count=1): | ||
| 49 | + pytest.fail("同组已 ack 的消息不应再投给 c2") | ||
| 50 | + assert taken == [{"x": 1}] | ||
| 51 | + | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +async def test_ack_makes_message_not_redelivered(): | ||
| 55 | + sq = StreamQueue(fakeredis.aioredis.FakeRedis(), prefix="svc") | ||
| 56 | + await sq.enqueue("tasks", {"x": 1}) | ||
| 57 | + async for item in sq.consume("g1", "c1", stream="tasks", block=0): | ||
| 58 | + await item.ack() | ||
| 59 | + break | ||
| 60 | + # 已 ack → 同 consumer 再读 own-pending 为空,新读无消息(不重投) | ||
| 61 | + redelivered = [] | ||
| 62 | + async for item in sq.consume("g1", "c1", stream="tasks", block=0): | ||
| 63 | + redelivered.append(item.data) | ||
| 64 | + break | ||
| 65 | + assert redelivered == [] | ||
| @@ -0,0 +1,122 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""SystemContext / RequestContext 单测(设计 §8):进程级/请求级、start/stop、for_request、transaction。""" | ||
| 5 | +import logging | ||
| 6 | + | ||
| 7 | +import fakeredis.aioredis | ||
| 8 | +import pytest | ||
| 9 | + | ||
| 10 | +from openjiuwen_runtime.service.context.system_context import SystemContext | ||
| 11 | +from openjiuwen_runtime.service.envelope import Metadata | ||
| 12 | + | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +async def test_start_stop_and_kv_on_request_context(): | ||
| 16 | + ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 17 | + await ctx.start() | ||
| 18 | + try: | ||
| 19 | + rctx = ctx.for_request(Metadata(request_id="r1")) | ||
| 20 | + # 请求上下文上的 kv 绑定进程级 redis → 原子递增 | ||
| 21 | + assert await rctx.kv.incr("c") == 1 | ||
| 22 | + assert await rctx.kv.incr("c") == 2 | ||
| 23 | + finally: | ||
| 24 | + await ctx.stop() | ||
| 25 | + | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +async def test_for_request_propagates_metadata_and_lock_owner(): | ||
| 29 | + ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 30 | + await ctx.start() | ||
| 31 | + try: | ||
| 32 | + rctx = ctx.for_request(Metadata(request_id="r1", user_id="u", session_id="s", trace_id="t")) | ||
| 33 | + assert rctx.request_id == "r1" | ||
| 34 | + assert rctx.user_id == "u" | ||
| 35 | + assert rctx.session_id == "s" | ||
| 36 | + assert rctx.trace_id == "t" | ||
| 37 | + assert isinstance(rctx.logger, logging.Logger) | ||
| 38 | + assert ctx.instance_id in rctx.lock_owner # lock_owner 含 instance_id | ||
| 39 | + finally: | ||
| 40 | + await ctx.stop() | ||
| 41 | + | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +async def test_lock_owner_unique_per_request(): | ||
| 45 | + ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 46 | + a = ctx.for_request(Metadata(request_id="r1")) | ||
| 47 | + b = ctx.for_request(Metadata(request_id="r2")) | ||
| 48 | + assert a.lock_owner != b.lock_owner | ||
| 49 | + | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +async def test_start_and_stop_are_idempotent(): | ||
| 53 | + ctx = SystemContext(redis=fakeredis.aioredis.FakeRedis()) | ||
| 54 | + await ctx.start() | ||
| 55 | + await ctx.start() # 幂等,不报错 | ||
| 56 | + await ctx.stop() | ||
| 57 | + await ctx.stop() # 幂等,不报错 | ||
| 58 | + | ||
| 59 | + | ||
| 60 | +class _FakeSession: | ||
| 61 | + def __init__(self) -> None: | ||
| 62 | + self.committed = False | ||
| 63 | + self.rolled_back = False | ||
| 64 | + self.closed = False | ||
| 65 | + | ||
| 66 | + async def commit(self) -> None: | ||
| 67 | + self.committed = True | ||
| 68 | + | ||
| 69 | + async def rollback(self) -> None: | ||
| 70 | + self.rolled_back = True | ||
| 71 | + | ||
| 72 | + async def close(self) -> None: | ||
| 73 | + self.closed = True | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +class _FakeDb: | ||
| 77 | + """模拟 SQLAlchemyHandler:``session_factory()`` 返回一个 session。""" | ||
| 78 | + | ||
| 79 | + def __init__(self) -> None: | ||
| 80 | + self.created: list[_FakeSession] = [] | ||
| 81 | + | ||
| 82 | + def session_factory(self) -> _FakeSession: | ||
| 83 | + s = _FakeSession() | ||
| 84 | + self.created.append(s) | ||
| 85 | + return s | ||
| 86 | + | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +async def test_transaction_commits_on_success_and_closes(): | ||
| 90 | + ctx = SystemContext(db=_FakeDb()) | ||
| 91 | + async with ctx.transaction() as s: | ||
| 92 | + assert isinstance(s, _FakeSession) | ||
| 93 | + assert s.committed is True | ||
| 94 | + assert s.closed is True | ||
| 95 | + assert s.rolled_back is False | ||
| 96 | + | ||
| 97 | + | ||
| 98 | + | ||
| 99 | +async def test_transaction_rolls_back_on_exception(): | ||
| 100 | + ctx = SystemContext(db=_FakeDb()) | ||
| 101 | + with pytest.raises(ValueError): | ||
| 102 | + async with ctx.transaction() as s: | ||
| 103 | + raise ValueError("boom") | ||
| 104 | + assert s.rolled_back is True | ||
| 105 | + assert s.closed is True | ||
| 106 | + assert s.committed is False | ||
| 107 | + | ||
| 108 | + | ||
| 109 | + | ||
| 110 | +async def test_transaction_without_db_raises(): | ||
| 111 | + ctx = SystemContext() | ||
| 112 | + with pytest.raises(Exception): | ||
| 113 | + async with ctx.transaction() as _: | ||
| 114 | + pass | ||
| 115 | + | ||
| 116 | + | ||
| 117 | + | ||
| 118 | +def test_from_settings_builds_redis_from_env(monkeypatch): | ||
| 119 | + monkeypatch.setenv("OPENJIUWEN_SERVICE_REDIS_URL", "redis://localhost:6379/0") | ||
| 120 | + ctx = SystemContext.from_settings() | ||
| 121 | + assert ctx.redis is not None | ||
| 122 | + assert ctx._owns_redis is True # 自建 → stop 时负责关闭 | ||