已合并
feat(service): 实现通用分布式服务框架(Envelope + App + SystemContext + 五原语) #408
feat(service): 实现通用分布式服务框架(Envelope + App + SystemContext + 五原语) #408
已合并
王明琦创建于 7月31日
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+@app.handle("echo") # 自动同时暴露 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-81# coding: utf-8
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4-from .app import BaseApp, AgentApp, PluginApp, AppGroup4+"""通用 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+@dataclass(frozen=True)
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+ @classmethod
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+@dataclass
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+ @asynccontextmanager
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+ @classmethod
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+@dataclass
133+class RequestContext:
134+ """每条 Envelope 派生的请求级上下文;handler 经它访问所有能力。"""
135+ 
136+ sysctx: SystemContext
137+ request_id: str
138+ user_id: Optional[str] = None
139+ chat_id: Optional[str] = None
140+ session_id: Optional[str] = None
141+ trace_id: Optional[str] = None
142+ bot_id: Optional[str] = None
143+ channel: Optional[str] = None
144+ lock_owner: str = ""
145+ logger: logging.Logger = field(default_factory=lambda: _logger)
146+ _kv: Optional[KVStore] = field(default=None, repr=False, compare=False)
147+ _idem: Any = field(default=None, repr=False, compare=False)
148+ _queue: Any = field(default=None, repr=False, compare=False)
149+ _pubsub: Any = field(default=None, repr=False, compare=False)
150+ 
151+ @property
152+ def db(self) -> Any:
153+ return self.sysctx.db
154+ 
155+ @property
156+ def kv(self) -> KVStore:
157+ """分布式字典 / 会话存储(顶替进程内 dict)。"""
158+ if self._kv is None:
159+ self._kv = KVStore(self.sysctx.redis, prefix=self.sysctx._ns("kv"))
160+ return self._kv
161+ 
162+ @property
163+ def idempotency(self):
164+ """幂等:按 request_id 全局去重 / 结果回放。"""
165+ if self._idem is None:
166+ from .primitives.idempotency import Idempotency
167+ self._idem = Idempotency(self.sysctx.redis, prefix=self.sysctx._ns("idem"))
168+ return self._idem
169+ 
170+ @property
171+ def queue(self):
172+ """队列:跨副本有序、副本重启不丢(Redis Streams + 消费组)。"""
173+ if self._queue is None:
174+ from .primitives.stream_queue import StreamQueue
175+ self._queue = StreamQueue(self.sysctx.redis, prefix=self.sysctx._ns("queue"))
176+ return self._queue
177+ 
178+ @property
179+ def pubsub(self):
180+ """发布订阅:瞬时扇出(Redis Pub/Sub)。"""
181+ if self._pubsub is None:
182+ from .primitives.pubsub import PubSub
183+ self._pubsub = PubSub(self.sysctx.redis, prefix=self.sysctx._ns("pubsub"))
184+ return self._pubsub
185+ 
186+ def lock(self, key: str, *, ttl: float = 30, timeout: float = 0, renew_interval: float | None = None):
187+ """分布式锁:``async with ctx.lock(key, ttl=..., timeout=...)``。"""
188+ from .primitives.lock import DistributedLock
189+ return DistributedLock(
190+ self.sysctx.redis, key, owner=self.lock_owner, ttl=ttl, timeout=timeout,
191+ prefix=self.sysctx._ns("lock"), renew_interval=renew_interval)
192+ 
193+ def transaction(self):
194+ """多操作原子事务(委托 SystemContext)。"""
195+ return self.sysctx.transaction()
@@ -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+@dataclass
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+ @classmethod
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+@dataclass
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+ @classmethod
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+@dataclass
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+ @classmethod
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+@dataclass
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+ @classmethod
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+ @property
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+@runtime_checkable
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+@runtime_checkable
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+@runtime_checkable
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+@dataclass
18+class UnaryResult:
19+ """非流式派发结果。"""
20+ 
21+ response: ResponseEnvelope
22+ 
23+ 
24+@dataclass
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/eliftype → 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+@dataclass
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @asynccontextmanager
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+ @property
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+ @fastapi.post(path)
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+ @fastapi.websocket("/ws")
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"
10dependencies = [10dependencies = [
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"]
38include = ["openjiuwen_runtime*"]42include = ["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+ @app.handle("echo")
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+@pytest.mark.system
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+@pytest.mark.system
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+@pytest.mark.system
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+ @app.stream("count")
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+ @app.handle("boom")
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+ @app.handle("send")
49+ async def send(ctx, env: Envelope):
50+ return {"sent": True}
51+ 
52+ return app
53+ 
54+ 
55+# ---------- REST 流式(SSE)----------
56+@pytest.mark.system
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+@pytest.mark.system
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+@pytest.mark.system
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+@pytest.mark.system
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
42+def test_explicit_code_override():
43+ # 允许实例级覆盖 code
44+ err = FrameworkError("x", code=ErrorCode.CONFLICT)
45+ assert err.code == ErrorCode.CONFLICT
46+ 
47+ 
48+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+ @router.handle("ping")
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+@pytest.mark.unit
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+ @router.handle("ping")
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
18+async def test_unary_dispatch_wraps_dict_into_response_envelope():
19+ router = MessageRouter()
20+ 
21+ @router.handle("echo")
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+@pytest.mark.unit
34+async def test_unary_dispatch_passes_through_response_envelope():
35+ router = MessageRouter()
36+ 
37+ @router.handle("echo")
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+@pytest.mark.unit
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+@pytest.mark.unit
56+async def test_handler_exception_normalized_to_error_envelope():
57+ router = MessageRouter()
58+ 
59+ @router.handle("echo")
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+ @router.handle("boom")
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+@pytest.mark.unit
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+ @router.handle("echo")
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+@pytest.mark.unit
108+async def test_duplicate_type_raises():
109+ router = MessageRouter()
110+ 
111+ @router.handle("echo")
112+ async def echo(ctx, env):
113+ return {}
114+ 
115+ with pytest.raises(Exception):
116+ @router.handle("echo")
117+ async def echo2(ctx, env):
118+ return {}
119+ 
120+ 
121+@pytest.mark.unit
122+async def test_stream_xor_unary_conflict():
123+ router = MessageRouter()
124+ 
125+ @router.handle("x")
126+ async def unary(ctx, env):
127+ return {}
128+ 
129+ with pytest.raises(Exception):
130+ @router.stream("x")
131+ async def stream(ctx, env):
132+ yield {}
133+ 
134+ 
135+@pytest.mark.unit
136+async def test_stream_dispatch_assigns_sequence_and_final():
137+ router = MessageRouter()
138+ 
139+ @router.stream("gen")
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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+@pytest.mark.unit
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 时负责关闭