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

416 lines
17 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 端到端测试。
覆盖:
1. Solo 模式(不传 runtime)行为完全不变 — backward compat 回归
2. Shared 模式两个 client 可以共存
3. Shared 模式两个 client 共享同一个 cache + 同一个 loop
4. Shared 模式 client.close() 不关 shared cache、不停 shared loop
5. Shared 模式 runtime.close() 关掉 cache 和 loop
6. MetricsCollector 单例在 shared 模式下只 bind 一次(关键 cross-loop bug 防护)
7. 多个 client 真的能并行 add/search 不挂
测试依赖只用 Chroma + SQLite cache(零外部依赖),避免 MySQL/网络。
"""
import asyncio
import uuid
import pytest
from hy_memory import HyMemoryClient, SharedRuntime, MemoryConfig
from hy_memory.metrics import MetricsCollector
def run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _make_config(tmp_path, *, dims=32, collection_suffix=""):
"""构造一个不依赖外部服务的 config(chroma + sqlite cache)"""
suffix = collection_suffix or uuid.uuid4().hex[:8]
return MemoryConfig.from_dict({
"vector_store": {
"provider": "chroma",
"collection_name": f"test_runtime_{suffix}",
"persist_directory": str(tmp_path / f"chroma_{suffix}"),
"embedding_dims": dims,
},
"cache": {
"backend": "sqlite",
"sqlite_path": str(tmp_path / "cache.db"),
},
"history": {"enable": False},
})
@pytest.fixture(autouse=True)
def _reset_metrics_singleton():
"""每个 test reset MetricsCollector 单例,避免 cross-test 污染"""
MetricsCollector.reset()
yield
MetricsCollector.reset()
@pytest.fixture(autouse=True)
def _safe_env(monkeypatch, tmp_path):
"""避免 test 误连真实 redis/mysql"""
monkeypatch.setenv("MEMORY_CACHE_BACKEND", "sqlite")
monkeypatch.delenv("REDIS_HOST", raising=False)
monkeypatch.delenv("MEMORY_VECTOR_HOST", raising=False)
monkeypatch.setenv("MEMORY_DATA_DIR", str(tmp_path / ".hy_memory"))
monkeypatch.setenv("MEMORY_LOG_DIR", str(tmp_path / "logs"))
yield
# ================================================================
# Phase 1: Solo 模式向后兼容
# ================================================================
class TestSoloModeUnchanged:
"""Solo 模式(不传 runtime)行为必须完全跟改造前一致"""
def test_solo_client_init_close(self, tmp_path):
cfg = _make_config(tmp_path)
client = HyMemoryClient(cfg, mode="lite")
try:
# solo 模式 owns_runtime=True
assert client._owns_runtime is True
# 自己有 cache + loop_thread
assert client._cache is not None
assert client._loop_thread is not None
finally:
client.close()
def test_solo_client_metrics_bound(self, tmp_path):
cfg = _make_config(tmp_path)
client = HyMemoryClient(cfg, mode="lite")
try:
mc = MetricsCollector.get()
assert mc._cache is client._cache
assert mc._background_started is True
finally:
client.close()
# ================================================================
# Phase 2: Shared 模式 — 单 client
# ================================================================
class TestSharedRuntimeSingleClient:
def test_runtime_create_and_close(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
assert runtime.cache is not None
assert runtime.loop_thread is not None
assert runtime.metrics is MetricsCollector.get()
# cache 已经 bind 到 metrics
assert runtime.metrics._cache is runtime.cache
assert runtime.metrics._background_started is True
finally:
runtime.close()
def test_client_with_runtime_shares_resources(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
client_cfg = _make_config(tmp_path, collection_suffix="cli1")
client = HyMemoryClient(client_cfg, mode="lite", runtime=runtime)
try:
assert client._owns_runtime is False
assert client._loop_thread is runtime.loop_thread
assert client._cache is runtime.cache
finally:
client.close()
finally:
runtime.close()
def test_client_close_does_not_close_shared_cache(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
client_cfg = _make_config(tmp_path, collection_suffix="cli1")
client = HyMemoryClient(client_cfg, mode="lite", runtime=runtime)
shared_cache = runtime.cache
client.close()
# client.close 后 runtime.cache 仍可用
assert runtime.cache is shared_cache
# runtime loop 仍在跑
assert runtime.loop_thread is not None
finally:
runtime.close()
# ================================================================
# Phase 3: Shared 模式 — 多 client(核心 bug 防护场景)
# ================================================================
class TestSharedRuntimeMultiClient:
def test_two_clients_share_one_cache(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
cfg_a = _make_config(tmp_path, collection_suffix="a")
cfg_b = _make_config(tmp_path, collection_suffix="b")
client_a = HyMemoryClient(cfg_a, mode="lite", runtime=runtime)
client_b = HyMemoryClient(cfg_b, mode="lite", runtime=runtime)
try:
assert client_a._cache is client_b._cache
assert client_a._loop_thread is client_b._loop_thread
assert client_a._cache is runtime.cache
finally:
client_a.close()
client_b.close()
finally:
runtime.close()
def test_metrics_singleton_only_bound_once(self, tmp_path):
"""关键测试:原 bug 是 bind_cache 被第二个 client 覆盖;
shared 模式下应该只 runtime 调一次 bind_cache,client 都不调"""
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
bound_cache_after_runtime = runtime.metrics._cache
bg_started_after_runtime = runtime.metrics._background_started
try:
cfg_a = _make_config(tmp_path, collection_suffix="a")
cfg_b = _make_config(tmp_path, collection_suffix="b")
client_a = HyMemoryClient(cfg_a, mode="lite", runtime=runtime)
client_b = HyMemoryClient(cfg_b, mode="lite", runtime=runtime)
try:
# 创建俩 client 之后,metrics._cache 仍然是 runtime.cache,
# 没被 client 的 bind_cache 覆盖
assert MetricsCollector.get()._cache is bound_cache_after_runtime
assert MetricsCollector.get()._cache is runtime.cache
# background 状态保持
assert MetricsCollector.get()._background_started is bg_started_after_runtime
finally:
client_a.close()
client_b.close()
finally:
runtime.close()
def test_clients_use_same_loop_thread(self, tmp_path):
"""关键 cross-loop 防护:所有 client 用同一个 loop"""
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
cfg_a = _make_config(tmp_path, collection_suffix="a")
cfg_b = _make_config(tmp_path, collection_suffix="b")
client_a = HyMemoryClient(cfg_a, mode="lite", runtime=runtime)
client_b = HyMemoryClient(cfg_b, mode="lite", runtime=runtime)
try:
# 同一个 _LoopThread 实例
assert client_a._loop_thread is client_b._loop_thread
# 同一个底层 event loop
assert client_a._loop_thread._loop is client_b._loop_thread._loop
finally:
client_a.close()
client_b.close()
finally:
runtime.close()
# ================================================================
# Phase 4: 真正跑 add / 验证 functionally works
# ================================================================
class TestSharedRuntimeFunctional:
"""真的发请求过 add/search 看流程能跑通,不挂在 cross-loop"""
def test_two_clients_add_concurrently_no_crossloop(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
cfg_a = _make_config(tmp_path, collection_suffix="a")
cfg_b = _make_config(tmp_path, collection_suffix="b")
client_a = HyMemoryClient(cfg_a, mode="lite", runtime=runtime)
client_b = HyMemoryClient(cfg_b, mode="lite", runtime=runtime)
try:
# 直接走 runtime 的 cache 上的 store/get_pipeline_log(base 协议层落库)
# 关键验证:两个 client 不在自己的 _LoopThread 上读写 cache
# 都委托给 runtime 的 loop(避免跨 loop pool 错误)
shared = runtime.cache
# 通过 client 的 loop_thread.run 写 cache(应该能跑通)
async def _w(rid):
from hy_memory.utils.pipeline_observability import (
drain_pipeline_log_tasks,
)
await shared.store_pipeline_log(
request_id=rid, user_id="u", agent_id="a",
step="TEST", prompt="p", response="r",
)
await drain_pipeline_log_tasks()
async def _r(rid):
return await shared.get_pipeline_logs(request_id=rid)
client_a._loop_thread.run(_w("rid_a"))
client_b._loop_thread.run(_w("rid_b"))
# 跨 client 读对方写的,证明同一 pool
assert len(client_a._loop_thread.run(_r("rid_b"))) == 1
assert len(client_b._loop_thread.run(_r("rid_a"))) == 1
finally:
client_a.close()
client_b.close()
finally:
runtime.close()
# ================================================================
# Phase 5: aclose / close 语义
# ================================================================
class TestSharedRuntimeClose:
def test_runtime_close_idempotent(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
runtime.close()
# 再关一次不挂
runtime.close()
def test_aclose_async(self, tmp_path):
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
# 用 runtime.loop_thread.run 调 aclose(因为 cache 在那个 loop 上)
runtime.close()
# ================================================================
# Phase 6: search 专用 loop 与 write loop 隔离
# search 使用独立 loop,避免被 write 队列阻塞。
# ================================================================
class TestSearchLoopIsolation:
"""search 走独立 _LoopThread,不被 write 队列挤占调度。"""
def test_solo_search_loop_is_distinct_thread(self, tmp_path):
"""solo 模式:search loop 与 write loop 是两个独立的 event loop/线程。"""
cfg = _make_config(tmp_path)
client = HyMemoryClient(cfg, mode="lite")
try:
assert client._search_loop_thread is not None
assert client._search_loop_thread is not client._loop_thread
assert client._search_loop_thread._loop is not client._loop_thread._loop
assert client._search_loop_thread._thread is not client._loop_thread._thread
assert client._search_loop_thread._thread.is_alive()
finally:
client.close()
def test_shared_search_loop_from_runtime(self, tmp_path):
"""shared 模式:client 的 search loop 来自 runtime.search_loop_thread,
且与 write loop 隔离;多个 client 共用同一个 search loop。"""
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
assert runtime.search_loop_thread is not None
assert runtime.search_loop_thread is not runtime.loop_thread
assert runtime.search_loop_thread._loop is not runtime.loop_thread._loop
cfg_a = _make_config(tmp_path, collection_suffix="a")
cfg_b = _make_config(tmp_path, collection_suffix="b")
client_a = HyMemoryClient(cfg_a, mode="lite", runtime=runtime)
client_b = HyMemoryClient(cfg_b, mode="lite", runtime=runtime)
try:
# search loop 来自 runtime,与 write loop 隔离
assert client_a._search_loop_thread is runtime.search_loop_thread
assert client_a._search_loop_thread is not client_a._loop_thread
# 多 client 共用同一个 search loop
assert client_a._search_loop_thread is client_b._search_loop_thread
finally:
client_a.close()
client_b.close()
finally:
runtime.close()
def test_backward_compat_old_runtime_without_search_loop(self, tmp_path):
"""兼容旧 runtime(无 search_loop_thread 属性):回落到 write loop,不报错。"""
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
# 模拟旧 runtime:抹掉 search 专用 loop
old_search = runtime._search_loop_thread
runtime._search_loop_thread = None
client_cfg = _make_config(tmp_path, collection_suffix="compat")
client = HyMemoryClient(client_cfg, mode="lite", runtime=runtime)
try:
# 回落到 write loop(行为等价旧路径,不隔离但不崩)
assert client._search_loop_thread is runtime.loop_thread
finally:
client.close()
runtime._search_loop_thread = old_search
finally:
runtime.close()
def test_search_loop_schedules_while_write_loop_blocked(self, tmp_path):
"""核心回归:write loop 被一个长时间同步阻塞的协程占满时,search loop
仍能及时调度协程——证明二者互不挤占。
直接在 loop 调度层验证(不拉入 chroma/embed),精确对应本次修复:
改动前 search 和 write 共用一个 loop,提交到该 loop 的 search 协程会被
卡到 write 的阻塞结束;改动后 search 有独立 loop,调度不受影响。
"""
import time
import asyncio as _aio
cfg = _make_config(tmp_path)
runtime = run(SharedRuntime.create(cfg))
try:
write_loop = runtime.loop_thread._loop
search_loop = runtime.search_loop_thread._loop
assert write_loop is not search_loop # 前提:确实是两个 loop
BLOCK_SEC = 2.0
async def _hog():
# 同步 sleep 占满 write loop(协作式调度下彻底卡住该 loop),
# 模拟 EXTRACT/RECONCILE 中夹杂的非 await 同步代码段。
time.sleep(BLOCK_SEC)
async def _probe():
# 极轻量探针:代表一次 search 协程被提交后能否被及时调度执行。
return "scheduled"
# 1. 占满 write loop
hog_fut = _aio.run_coroutine_threadsafe(_hog(), write_loop)
time.sleep(0.1) # 确保 hog 真正开始执行、占住 write loop
# 2. 此刻 write loop 卡死 → 提交到 search loop 的探针应立刻被调度
t0 = time.perf_counter()
probe_fut = _aio.run_coroutine_threadsafe(_probe(), search_loop)
assert probe_fut.result(timeout=1.0) == "scheduled"
search_latency = time.perf_counter() - t0
# search loop 调度延迟应是「毫秒级」——独立 loop 完全不受 write 阻塞影响。
# 阈值取远小于 BLOCK_SEC 的绝对值:改动前 search/write 共用一个 loop 时,
# 探针会被 hog 卡到接近 BLOCK_SEC 才调度,必然超过此阈值 → 回归被捕获。
_SCHED_BUDGET = 0.3
assert search_latency < _SCHED_BUDGET, (
f"search loop 调度耗时 {search_latency:.3f}s(应 < {_SCHED_BUDGET}s),"
f"说明未与 write loop(阻塞 {BLOCK_SEC}s)隔离"
)
# 3. 对照:同一探针提交到 write loop 会被 hog 卡住,直到阻塞结束
t1 = time.perf_counter()
write_probe_fut = _aio.run_coroutine_threadsafe(_probe(), write_loop)
assert write_probe_fut.result(timeout=BLOCK_SEC + 1.0) == "scheduled"
write_latency = time.perf_counter() - t1
# write loop 上的探针确实被 hog 挤压(证明 hog 生效、对照有意义)
assert write_latency > search_latency, (
"write loop 探针应比 search loop 探针慢(被 hog 阻塞),"
"否则说明 hog 没有真正占用 write loop,对照无效"
)
hog_fut.result(timeout=BLOCK_SEC + 1) # 收尾,避免 teardown 残留
finally:
runtime.close()