落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Redis 连接与前缀客户端。
|
||||
|
||||
本文件职责:从 REDIS_URL 建连;统一 key();禁止 FLUSHALL / 全局单例锁 API。
|
||||
db 号:写在 URL 路径(如 …/1),与现网 …/0 隔离;业务名看 REDIS_KEY_PREFIX。
|
||||
线程:连接对象可被多线程共用(redis-py 连接池);业务认领仍按条,不靠本客户端当进程锁。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from agent.config import Settings
|
||||
from agent.redis_coord.keys import join_key, normalize_prefix
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisLike(Protocol):
|
||||
"""骨架可替换的最小 Redis 协议(真客户端或内存假实现)。"""
|
||||
|
||||
def ping(self) -> Any: ...
|
||||
def get(self, name: str) -> Any: ...
|
||||
def set(self, name: str, value: Any, ex: Optional[int] = None, nx: bool = False) -> Any: ...
|
||||
def incr(self, name: str) -> int: ...
|
||||
def expire(self, name: str, time: int) -> Any: ...
|
||||
def delete(self, *names: str) -> Any: ...
|
||||
def xadd(self, name: str, fields: dict, id: str = "*", maxlen: Optional[int] = None) -> Any: ...
|
||||
def xgroup_create(
|
||||
self, name: str, groupname: str, id: str = "0", mkstream: bool = False
|
||||
) -> Any: ...
|
||||
def xreadgroup(
|
||||
self,
|
||||
groupname: str,
|
||||
consumername: str,
|
||||
streams: dict,
|
||||
count: Optional[int] = None,
|
||||
block: Optional[int] = None,
|
||||
) -> Any: ...
|
||||
def xack(self, name: str, groupname: str, *ids: str) -> Any: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryRedis:
|
||||
"""
|
||||
本机骨架假 Redis:仅覆盖自检所需命令,不持久化。
|
||||
|
||||
正式/并存禁止当生产;无 TTL 扫描后台,expire 只在 get 时懒过期。
|
||||
"""
|
||||
|
||||
_kv: dict[str, tuple[Any, Optional[float]]] = field(default_factory=dict)
|
||||
_streams: dict[str, list[tuple[str, dict]]] = field(default_factory=dict)
|
||||
_groups: dict[str, set[str]] = field(default_factory=dict)
|
||||
_seq: int = 0
|
||||
|
||||
def _now(self) -> float:
|
||||
import time
|
||||
|
||||
return time.time()
|
||||
|
||||
def _alive(self, key: str) -> bool:
|
||||
item = self._kv.get(key)
|
||||
if item is None:
|
||||
return False
|
||||
_val, exp = item
|
||||
if exp is not None and exp <= self._now():
|
||||
del self._kv[key]
|
||||
return False
|
||||
return True
|
||||
|
||||
def ping(self) -> bool:
|
||||
return True
|
||||
|
||||
def get(self, name: str) -> Any:
|
||||
if not self._alive(name):
|
||||
return None
|
||||
return self._kv[name][0]
|
||||
|
||||
def set(self, name: str, value: Any, ex: Optional[int] = None, nx: bool = False) -> Any:
|
||||
if nx and self._alive(name):
|
||||
return False
|
||||
exp = self._now() + ex if ex else None
|
||||
self._kv[name] = (value if isinstance(value, (bytes, str)) else str(value), exp)
|
||||
return True
|
||||
|
||||
def incr(self, name: str) -> int:
|
||||
cur = self.get(name)
|
||||
n = int(cur or 0) + 1
|
||||
exp = self._kv[name][1] if self._alive(name) else None
|
||||
self._kv[name] = (str(n), exp)
|
||||
return n
|
||||
|
||||
def expire(self, name: str, time: int) -> bool:
|
||||
if not self._alive(name):
|
||||
return False
|
||||
val, _ = self._kv[name]
|
||||
self._kv[name] = (val, self._now() + time)
|
||||
return True
|
||||
|
||||
def delete(self, *names: str) -> int:
|
||||
n = 0
|
||||
for name in names:
|
||||
if name in self._kv:
|
||||
del self._kv[name]
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def xadd(
|
||||
self, name: str, fields: dict, id: str = "*", maxlen: Optional[int] = None
|
||||
) -> str:
|
||||
self._seq += 1
|
||||
entry_id = id if id != "*" else f"1-{self._seq}"
|
||||
buf = self._streams.setdefault(name, [])
|
||||
# 字段值统一成 str,贴近 Redis
|
||||
norm = {str(k): (v if isinstance(v, str) else str(v)) for k, v in fields.items()}
|
||||
buf.append((entry_id, norm))
|
||||
if maxlen and len(buf) > maxlen:
|
||||
del buf[0 : len(buf) - maxlen]
|
||||
return entry_id
|
||||
|
||||
def xgroup_create(
|
||||
self, name: str, groupname: str, id: str = "0", mkstream: bool = False
|
||||
) -> bool:
|
||||
if mkstream and name not in self._streams:
|
||||
self._streams[name] = []
|
||||
key = f"{name}|{groupname}"
|
||||
if key in self._groups:
|
||||
raise Exception("BUSYGROUP Consumer Group name already exists")
|
||||
self._groups[key] = set()
|
||||
return True
|
||||
|
||||
def xreadgroup(
|
||||
self,
|
||||
groupname: str,
|
||||
consumername: str,
|
||||
streams: dict,
|
||||
count: Optional[int] = None,
|
||||
block: Optional[int] = None,
|
||||
) -> list:
|
||||
# 骨架:简单返回尚未标记的条目(忽略 PEL 细节)
|
||||
out: list = []
|
||||
for stream_name, _id in streams.items():
|
||||
gkey = f"{stream_name}|{groupname}"
|
||||
seen = self._groups.setdefault(gkey, set())
|
||||
entries = []
|
||||
for eid, fields in self._streams.get(stream_name, []):
|
||||
if eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
entries.append((eid, fields))
|
||||
if count and len(entries) >= count:
|
||||
break
|
||||
if entries:
|
||||
out.append((stream_name, entries))
|
||||
return out
|
||||
|
||||
def xack(self, name: str, groupname: str, *ids: str) -> int:
|
||||
return len(ids)
|
||||
|
||||
def close(self) -> None:
|
||||
self._kv.clear()
|
||||
self._streams.clear()
|
||||
self._groups.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisClient:
|
||||
"""
|
||||
带前缀的 Redis 门面。
|
||||
|
||||
backend:redis(真连接)或 memory(自检)。
|
||||
禁止提供 acquire_worker_singleton 之类方法。
|
||||
"""
|
||||
|
||||
raw: RedisLike
|
||||
key_prefix: str
|
||||
backend: str
|
||||
url_safe: str = ""
|
||||
|
||||
def key(self, *parts: str) -> str:
|
||||
"""拼带前缀的完整 key。"""
|
||||
return join_key(self.key_prefix, *parts)
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""连通性探测;失败抛异常。"""
|
||||
return bool(self.raw.ping())
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层连接。"""
|
||||
closer = getattr(self.raw, "close", None)
|
||||
if callable(closer):
|
||||
closer()
|
||||
|
||||
|
||||
def _mask_url(url: str) -> str:
|
||||
"""日志用:隐藏密码。"""
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
parts = urlsplit(url)
|
||||
if parts.password is None:
|
||||
return url
|
||||
netloc = parts.hostname or ""
|
||||
if parts.port:
|
||||
netloc = f"{netloc}:{parts.port}"
|
||||
if parts.username:
|
||||
netloc = f"{parts.username}:***@{netloc}"
|
||||
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
except Exception: # noqa: BLE001
|
||||
return "<redis-url>"
|
||||
|
||||
|
||||
def create_redis_client(settings: Settings) -> RedisClient:
|
||||
"""
|
||||
按配置创建客户端。
|
||||
|
||||
- REDIS_BACKEND=memory 或未配 REDIS_URL 且非 prod:MemoryRedis
|
||||
- 否则 redis.from_url(REDIS_URL),decode_responses=True
|
||||
副作用:可能对 Redis PING;不写业务键。
|
||||
"""
|
||||
backend = (getattr(settings, "redis_backend", None) or "redis").strip().lower()
|
||||
prefix = normalize_prefix(settings.redis_key_prefix)
|
||||
if prefix.startswith("ytd:prod:"):
|
||||
raise RuntimeError("禁止使用现网 Redis 前缀 ytd:prod:(prompt/13、14)")
|
||||
|
||||
url = (settings.redis_url or "").strip()
|
||||
force_memory = backend == "memory" or (not url and (settings.ytd_env or "").lower() != "prod")
|
||||
|
||||
if force_memory:
|
||||
if (settings.ytd_env or "").lower() == "prod" and backend == "memory":
|
||||
raise RuntimeError("正式环境禁止 REDIS_BACKEND=memory")
|
||||
logger.warning(
|
||||
"Redis 使用 Memory 假实现(仅骨架);正式须 REDIS_URL 指向 db1 + 前缀 inquiry_robot:"
|
||||
)
|
||||
return RedisClient(raw=MemoryRedis(), key_prefix=prefix, backend="memory")
|
||||
|
||||
if not url:
|
||||
raise RuntimeError("正式/已声明 redis 后端时必须配置 REDIS_URL(建议 …/1)")
|
||||
|
||||
import redis
|
||||
|
||||
raw = redis.Redis.from_url(url, decode_responses=True, socket_connect_timeout=3)
|
||||
client = RedisClient(raw=raw, key_prefix=prefix, backend="redis", url_safe=_mask_url(url))
|
||||
client.ping()
|
||||
logger.info("Redis 已连接 url=%s prefix=%s", client.url_safe, prefix)
|
||||
return client
|
||||
Reference in New Issue
Block a user