Files
Hy-Memory/hy_memory/runtime.py
T
2026-10-16 16:26:27 +08:00

277 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
SharedRuntime — 多 client 部署的共享基础设施。
## 背景
`HyMemoryClient` 默认每个实例自带:
- 一个 `_LoopThread`(持久 event loop)
- 一份 `MysqlCache` / `SqliteCache` pool
- 一份 pipeline observability hook 包装
而 `MetricsCollector` 是进程级 singleton。当应用因不同数据域的 embedder dims
不同而**起多个 client** 时:
```
Client-A._LoopThread (loop_A) → Pool-A bound to loop_A
Client-B._LoopThread (loop_B) → Pool-B bound to loop_B
MetricsCollector (singleton)
├── bind_cache(Pool-A) ← Client-A init 时
├── bind_cache(Pool-B) ← Client-B init 时(覆盖!)
└── flush coroutine ← ensure_future on loop_A,但访问 Pool-B
→ 跨 loop 用 aiomysql → 连接污染 → KeyError
```
## 设计
把"进程级资源"显式抽出来:
| 归属 SharedRuntime(一进程一份) | 归属每个 HyMemoryClient |
|---|---|
| `_LoopThread`(共享 loop) | `_embed_service`(不同 dims) |
| `MysqlCache` / `SqliteCache` pool | `_vector_store`(不同 collection) |
| `MetricsCollector` 单例 + 唯一 bind | `_graph_store` |
| pipeline observability hook | `_history_store` / `_coding_store` |
| | `_registry` + 所有 pipeline |
## 使用
```python
from hy_memory import HyMemoryClient, SharedRuntime, MemoryConfig
# App 启动时一次性创建
base_config = MemoryConfig.from_env() # 提供 cache 配置
runtime = await SharedRuntime.create(base_config)
# 每个配置域一个 lightweight client,共用 runtime
clients = {
"default": HyMemoryClient(default_config, runtime=runtime),
"research": HyMemoryClient(research_config, runtime=runtime),
}
# App 退出
for c in clients.values():
c.close() # 只关该 client 的资源
await runtime.aclose() # 关 shared 基础设施
```
## 兼容性
不传 `runtime=` 的旧用法**完全不变**,client 自己持有所有资源(solo 模式)。
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .config import MemoryConfig
logger = logging.getLogger(__name__)
class SharedRuntime:
"""
多 client 共享的基础设施。
用 `await SharedRuntime.create(config)` 构造(不要直接 `SharedRuntime(config)`,
需要 async init 才能建 cache pool 并 bind metrics)。
"""
def __init__(self, config: "MemoryConfig"):
# 配置只用来建 cache 和决定 loop 策略;vector_store / embedder 等
# 由各个 client 自己管。
self._config = config
self._loop_thread = None # type: Optional[object] # _LoopThread(write/evolve/后台)
# search 专用 loop:与 write loop 物理隔离,避免 write 队列挤占 search 调度。
self._search_loop_thread = None # type: Optional[object] # _LoopThread
self._cache = None # type: Optional[CacheBase]
self._metrics = None # type: Optional[object] # MetricsCollector
self._observability_installed = False
self._closed = False
# ------------------------------------------------------------
# 工厂 / 生命周期
# ------------------------------------------------------------
@classmethod
async def create(cls, config: "MemoryConfig") -> "SharedRuntime":
"""
创建并初始化一个 SharedRuntime。
会立即:
1. 起一个 _LoopThread(持久 event loop)
2. 在该 loop 上 init cache pool
3. 把 cache 绑给 MetricsCollector 单例
4. 在该 loop 上启动 metrics 后台 flush + cleanup
5. 把 pipeline observability hook 安装到 cache 上
步骤 2-5 都在同一个 loop(即 _LoopThread 的 loop)上执行,
保证后续 metrics flush 用的就是这个 loop 上的 pool,不会跨 loop。
"""
rt = cls(config)
await rt._initialize()
return rt
async def _initialize(self) -> None:
# 注意:本方法可能从外部 loop 调到(如 app 的 uvicorn loop),
# 但内部所有"绑定到 loop"的操作必须在 _LoopThread 的 loop 上完成。
# 因此把真正的 init 委托过去。
from .client import _LoopThread
self._loop_thread = _LoopThread()
# search 专用 loop(独立线程 + 独立 event loop),只承载 async_search 协程。
# cache pool 仍绑在 write loop(self._loop_thread),search loop 上产生的
# pipeline-log DB 落库会被 observability 层派发回 cache 所属 loop 执行。
self._search_loop_thread = _LoopThread()
# 在 _LoopThread 的 loop 上做所有需要 loop affinity 的 init
await self._loop_thread.run_async(self._init_on_loop())
async def _init_on_loop(self) -> None:
"""在 _LoopThread 的 loop 上执行的初始化。"""
# 1. cache pool(pool 会绑定到当前 loop)
from .data.cache import create_cache
self._cache = create_cache(self._config)
await self._cache.initialize()
logger.info(
f"[runtime] Cache ready (backend={self._config.cache.backend})"
)
# 2. MetricsCollector 单例:唯一一次 bind_cache + start_background_tasks
# flush 协程从此固定在本 loop 上跑,永远不会跨 loop。
from .metrics import MetricsCollector
self._metrics = MetricsCollector.get()
self._metrics.bind_cache(self._cache)
await self._metrics.start_background_tasks()
logger.info("[runtime] MetricsCollector bound to shared cache")
# 3. pipeline observability hook — 包一次就够,所有 client 共用
self._install_pipeline_observability()
logger.info("[runtime] SharedRuntime ready")
def _install_pipeline_observability(self) -> None:
"""与 HyMemoryClient._install_pipeline_observability_hooks 等价,
但只装一次。重复装会导致每个 store_pipeline_log 调用被多层包装。"""
if self._observability_installed:
return
self._observability_installed = True
from .utils.pipeline_observability import install_pipeline_observability
install_pipeline_observability(self._cache, log_label="runtime")
# ------------------------------------------------------------
# 暴露给 HyMemoryClient 的属性(read-only)
# ------------------------------------------------------------
@property
def loop_thread(self):
"""共享的 _LoopThread 实例(write / evolve / 后台任务)。"""
return self._loop_thread
@property
def search_loop_thread(self):
"""search 专用的 _LoopThread 实例,与 write loop 物理隔离。"""
return self._search_loop_thread
@property
def cache(self):
"""共享的 cache(已 initialize 完毕)。"""
return self._cache
@property
def metrics(self):
"""MetricsCollector 单例(已 bind_cache 到 self.cache)。"""
return self._metrics
@property
def config(self) -> "MemoryConfig":
"""构造 runtime 时用的 base config(client 不必跟它一致,仅 cache 配置须一致)。"""
return self._config
# ------------------------------------------------------------
# 关闭
# ------------------------------------------------------------
async def aclose(self) -> None:
"""异步关闭 shared 资源(cache + loop)。"""
if self._closed:
return
self._closed = True
if self._metrics is not None:
try:
await self._loop_thread.run_async(
self._metrics.stop_background_tasks()
)
except Exception as e:
logger.warning(f"[runtime] metrics close failed: {e}")
self._metrics = None
if self._cache is not None:
try:
# cache 在 _LoopThread 的 loop 上建的,关也得在它上面关
await self._loop_thread.run_async(self._cache.close())
except Exception as e:
logger.warning(f"[runtime] cache close failed: {e}")
self._cache = None
if self._loop_thread is not None:
try:
self._loop_thread.stop()
except Exception as e:
logger.warning(f"[runtime] loop_thread stop failed: {e}")
self._loop_thread = None
if self._search_loop_thread is not None:
try:
self._search_loop_thread.stop()
except Exception as e:
logger.warning(f"[runtime] search_loop_thread stop failed: {e}")
self._search_loop_thread = None
logger.info("[runtime] SharedRuntime closed")
def close(self) -> None:
"""同步关闭,方便从非 async 场景调用。"""
if self._closed:
return
if self._loop_thread is not None:
try:
# cache.close 必须在它绑定的 loop 上跑
self._loop_thread.run(self._aclose_inner())
except Exception as e:
logger.warning(f"[runtime] sync close inner failed: {e}")
try:
self._loop_thread.stop()
except Exception as e:
logger.warning(f"[runtime] loop_thread stop failed: {e}")
if self._search_loop_thread is not None:
try:
self._search_loop_thread.stop()
except Exception as e:
logger.warning(f"[runtime] search_loop_thread stop failed: {e}")
self._loop_thread = None
self._search_loop_thread = None
self._cache = None
self._closed = True
logger.info("[runtime] SharedRuntime closed")
async def _aclose_inner(self) -> None:
"""在 loop 内执行的关闭流程(仅 close cache,不停 loop —— stop 由外面来做)。"""
if self._metrics is not None:
try:
await self._metrics.stop_background_tasks()
except Exception as e:
logger.warning(f"[runtime] metrics close failed: {e}")
self._metrics = None
if self._cache is not None:
try:
await self._cache.close()
except Exception as e:
logger.warning(f"[runtime] cache close failed: {e}")