Files
Hy-Memory/hy_memory/data/cache_mysql.py
T
fd12c8f1c0 feat(sdk): add MemHub authority migration contracts (#129)
* feat(sdk): expose memory lifecycle status filters

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(sdk): persist idempotent write outcomes

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(sdk): add explicit exact raw APIs

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(sdk): snapshot quiesced local data plane

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(sdk): restore quiesced local data plane

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sdk): report incomplete physical deletes

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(sdk): record authority migration contracts

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 16:43:25 +08:00

652 lines
25 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.
"""
Agent Memory V2 - MysqlCache
基于 aiomysql 的 MySQL 审计/观测落库后端。
适用于标准 MySQL 实例,支持多实例共享状态。
设计模式:
- aiomysql 原生 async(无需线程池包装)
- 连接池 aiomysql.create_pool
- autocommit=True
三张表(与 SQLite 版本一一对应):
- memory_operations: 知识库变动日志
- pipeline_logs: LLM 调用链中间结果
- system_metrics: 分钟粒度系统指标
"""
import json
import logging
import os
from datetime import datetime
from typing import Optional, List, Dict, Any
from .cache_base import CacheBase
from ..config import MemoryConfig
logger = logging.getLogger(__name__)
# DDL (MySQL syntax)
_CREATE_TABLES_SQL = [
"""
CREATE TABLE IF NOT EXISTS memory_operations_v2 (
id BIGINT AUTO_INCREMENT,
request_id VARCHAR(128) NOT NULL,
user_id VARCHAR(128) NOT NULL,
agent_id VARCHAR(128) NOT NULL DEFAULT '',
op VARCHAR(32) NOT NULL,
memory_id VARCHAR(128) NOT NULL,
old_memory_id VARCHAR(128) DEFAULT NULL,
content LONGTEXT NOT NULL,
layer VARCHAR(32) NOT NULL DEFAULT '',
reason TEXT NOT NULL,
supersedes TEXT NOT NULL,
created_at VARCHAR(64) NOT NULL,
created_date DATE NOT NULL,
PRIMARY KEY (id, created_date),
INDEX idx_memop_request (request_id),
INDEX idx_memop_memory (memory_id),
INDEX idx_memop_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (TO_DAYS(created_date)) (
PARTITION pmax VALUES LESS THAN MAXVALUE
)
""",
"""
CREATE TABLE IF NOT EXISTS pipeline_logs_v2 (
id BIGINT AUTO_INCREMENT,
request_id VARCHAR(128) NOT NULL,
namespace VARCHAR(128) NOT NULL DEFAULT '',
user_id VARCHAR(128) NOT NULL,
agent_id VARCHAR(128) NOT NULL DEFAULT '',
session_id VARCHAR(128) NOT NULL DEFAULT '',
step VARCHAR(64) NOT NULL,
prompt LONGTEXT NOT NULL,
response LONGTEXT NOT NULL,
parsed LONGTEXT NOT NULL,
memory_ids TEXT NOT NULL,
elapsed_ms DOUBLE NOT NULL DEFAULT 0,
prompt_tokens INT NOT NULL DEFAULT 0,
completion_tokens INT NOT NULL DEFAULT 0,
total_tokens INT NOT NULL DEFAULT 0,
event_at_ms BIGINT NOT NULL DEFAULT 0,
created_at VARCHAR(64) NOT NULL,
created_date DATE NOT NULL,
PRIMARY KEY (id, created_date),
INDEX idx_plog_request (request_id),
INDEX idx_plog_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (TO_DAYS(created_date)) (
PARTITION pmax VALUES LESS THAN MAXVALUE
)
""",
"""
CREATE TABLE IF NOT EXISTS write_outcomes (
request_id VARCHAR(256) NOT NULL,
user_id VARCHAR(255) NOT NULL,
agent_id VARCHAR(255) NOT NULL DEFAULT '',
session_id VARCHAR(255) NOT NULL DEFAULT '',
operation VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
result_json LONGTEXT NOT NULL,
created_at VARCHAR(64) NOT NULL,
updated_at VARCHAR(64) NOT NULL,
PRIMARY KEY (request_id),
KEY idx_write_outcomes_owner (user_id, agent_id, session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS system_metrics (
minute_ts VARCHAR(32) NOT NULL,
data LONGTEXT NOT NULL,
created_at VARCHAR(64) NOT NULL,
PRIMARY KEY (minute_ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# Episode(内部会话)状态:跨 write 批次把"一件事"归到同一个 episode_id。
# 缺了这张表,get_episode_state 永远返回 None → 每次写入都开新 episode,
# 划分等于没生效(线上实测 8798 次决策全是 new)。
"""
CREATE TABLE IF NOT EXISTS episode_state (
episode_key VARCHAR(255) NOT NULL,
user_id VARCHAR(255) NOT NULL DEFAULT '',
agent_id VARCHAR(255) NOT NULL DEFAULT '',
session_id VARCHAR(255) NOT NULL DEFAULT '',
episode_id VARCHAR(64) NOT NULL DEFAULT '',
last_write_ts DOUBLE NOT NULL DEFAULT 0,
memory_count INT NOT NULL DEFAULT 0,
updated_at VARCHAR(64) NOT NULL,
PRIMARY KEY (episode_key),
KEY idx_episode_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
class MysqlCache(CacheBase):
"""
MySQL 审计/观测落库后端(memory_operations / pipeline_logs / system_metrics)。
基于 aiomysql 的原生异步 MySQL 后端。
"""
def __init__(self, config: MemoryConfig):
self.config = config
self._pool = None # aiomysql.Pool
# 读取 MySQL 连接参数
cache_cfg = getattr(config, "cache", None)
self._host = getattr(cache_cfg, "mysql_host", None) or os.getenv("MEMORY_MYSQL_HOST", "localhost")
self._port = getattr(cache_cfg, "mysql_port", None) or int(os.getenv("MEMORY_MYSQL_PORT", "3306"))
self._user = getattr(cache_cfg, "mysql_user", None) or os.getenv("MEMORY_MYSQL_USER", "root")
self._password = getattr(cache_cfg, "mysql_password", None) or os.getenv("MEMORY_MYSQL_PASSWORD", "")
self._database = getattr(cache_cfg, "mysql_database", None) or os.getenv("MEMORY_MYSQL_DATABASE", "hy_memory")
self._pool_size = getattr(cache_cfg, "mysql_pool_size", None) or int(os.getenv("MEMORY_MYSQL_POOL_SIZE", "10"))
self._pool_recycle = getattr(cache_cfg, "mysql_pool_recycle", None) or int(os.getenv("MEMORY_MYSQL_POOL_RECYCLE", "3600"))
# ================================================================
# 生命周期
# ================================================================
async def initialize(self) -> None:
"""立即创建 pool(必须在正确的 event loop 上调用,即 _LoopThread 的 loop)"""
import aiomysql
self._pool = await aiomysql.create_pool(
host=self._host,
port=self._port,
user=self._user,
password=self._password,
db=self._database,
minsize=2,
maxsize=self._pool_size,
pool_recycle=self._pool_recycle,
autocommit=True,
charset='utf8mb4',
)
await self._ensure_tables()
logger.info(
f"MysqlCache initialized: {self._user}@{self._host}:{self._port}/{self._database} "
f"pool_size={self._pool_size}"
)
async def _ensure_tables(self) -> None:
"""确保所有表存在"""
for ddl in _CREATE_TABLES_SQL:
await self._execute(ddl)
await self._migrate_pipeline_logs_columns()
async def _migrate_pipeline_logs_columns(self) -> None:
"""给旧库的 pipeline_logs_v2 补 namespace/session/event time 列。
用 AFTER 指定列位置,与 CREATE TABLE 一致:
namespace 紧跟 request_id、session_id 紧跟 agent_id。
"""
migrations = (
("namespace", "ALTER TABLE pipeline_logs_v2 ADD COLUMN namespace "
"VARCHAR(128) NOT NULL DEFAULT '' AFTER request_id"),
("session_id", "ALTER TABLE pipeline_logs_v2 ADD COLUMN session_id "
"VARCHAR(128) NOT NULL DEFAULT '' AFTER agent_id"),
("event_at_ms", "ALTER TABLE pipeline_logs_v2 ADD COLUMN event_at_ms "
"BIGINT NOT NULL DEFAULT 0 AFTER total_tokens"),
)
for col, ddl in migrations:
try:
await self._execute(ddl)
except Exception as e:
# 已存在 (Duplicate column) 等 → 忽略
logger.debug(f"[mysql] add column {col} skipped: {e}")
async def close(self) -> None:
if self._pool is not None:
self._pool.close()
await self._pool.wait_closed()
self._pool = None
logger.info("MysqlCache closed")
# ================================================================
# 内部 DB helpers
# ================================================================
async def _execute(self, sql: str, args=None) -> int:
"""执行写操作,返回 affected rows"""
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(sql, args)
return cur.rowcount
async def _fetchone(self, sql: str, args=None) -> Optional[Dict[str, Any]]:
"""查询单行,返回 dict 或 None"""
import aiomysql
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(sql, args)
row = await cur.fetchone()
# 防御:极端情况下 DictCursor 可能返回 tuple
if row is not None and not isinstance(row, dict):
cols = [d[0] for d in cur.description] if cur.description else []
row = dict(zip(cols, row))
return row
async def _fetchall(self, sql: str, args=None) -> List[Dict[str, Any]]:
"""查询多行,返回 list of dict"""
import aiomysql
async with self._pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(sql, args)
rows = await cur.fetchall()
# 防御:确保返回 list of dict
if rows and not isinstance(rows[0], dict):
cols = [d[0] for d in cur.description] if cur.description else []
rows = [dict(zip(cols, r)) for r in rows]
return rows
# ================================================================
# 统计
# ================================================================
async def get_stats(self) -> Dict[str, Any]:
return {
"backend": "mysql",
"host": self._host,
"database": self._database,
}
# ================================================================
# Memory Operations Log
# ================================================================
async def store_memory_operation(
self,
request_id: str,
user_id: str,
agent_id: str,
op: str,
memory_id: str,
content: str,
layer: str = "",
old_memory_id: Optional[str] = None,
reason: str = "",
supersedes: Optional[List[str]] = None,
) -> bool:
from ..utils.pipeline_observability import is_memory_operations_enabled
if not is_memory_operations_enabled():
return True
try:
now = datetime.now()
created_at = now.isoformat()
created_date = now.strftime("%Y-%m-%d")
supersedes_str = json.dumps(supersedes or [], ensure_ascii=False)
await self._execute(
"""INSERT INTO memory_operations_v2
(request_id, user_id, agent_id, op, memory_id, old_memory_id,
content, layer, reason, supersedes, created_at, created_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(request_id, user_id, agent_id, op, memory_id,
old_memory_id, content, layer, reason, supersedes_str,
created_at, created_date),
)
return True
except Exception as e:
logger.warning(f"store_memory_operation failed: {e}")
return False
async def get_memory_operations(
self,
request_id: Optional[str] = None,
memory_id: Optional[str] = None,
user_id: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
try:
conditions = []
params = []
if request_id:
conditions.append("request_id = %s")
params.append(request_id)
if memory_id:
conditions.append("memory_id = %s")
params.append(memory_id)
if user_id:
conditions.append("user_id = %s")
params.append(user_id)
if not conditions:
return []
where = " AND ".join(conditions)
rows = await self._fetchall(
f"SELECT * FROM memory_operations_v2 WHERE {where} "
f"ORDER BY id DESC LIMIT %s",
params + [limit],
)
# 反序列化 supersedes
for row in rows:
raw_sup = row.get("supersedes", "")
try:
row["supersedes"] = json.loads(raw_sup) if raw_sup else []
except Exception:
row["supersedes"] = []
return rows
except Exception as e:
logger.warning(f"get_memory_operations failed: {e}")
return []
# ================================================================
# Pipeline Logs
# ================================================================
# ── Episode(内部会话)状态 ──
async def get_episode_state(self, episode_key: str) -> Optional[Dict[str, Any]]:
try:
row = await self._fetchone(
"SELECT episode_id, last_write_ts, memory_count "
"FROM episode_state WHERE episode_key = %s",
(episode_key,),
)
if not row:
return None
return {
"episode_id": row.get("episode_id") or "",
"last_write_ts": float(row.get("last_write_ts") or 0),
"memory_count": int(row.get("memory_count") or 0),
}
except Exception as e:
logger.warning(f"get_episode_state failed: {e}")
return None
async def upsert_episode_state(
self,
episode_key: str,
*,
user_id: str,
agent_id: str,
session_id: str,
episode_id: str,
last_write_ts: float,
memory_count: int,
) -> bool:
try:
from datetime import datetime as _dt
await self._execute(
"""
INSERT INTO episode_state (
episode_key, user_id, agent_id, session_id,
episode_id, last_write_ts, memory_count, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
episode_id = VALUES(episode_id),
last_write_ts = VALUES(last_write_ts),
memory_count = VALUES(memory_count),
updated_at = VALUES(updated_at)
""",
(episode_key, user_id, agent_id, session_id, episode_id,
float(last_write_ts), int(memory_count), _dt.now().isoformat()),
)
return True
except Exception as e:
logger.warning(f"upsert_episode_state failed: {e}")
return False
async def store_pipeline_log(
self,
request_id: str,
user_id: str,
agent_id: str,
step: str,
prompt: str,
response: str,
parsed: str = "",
memory_ids: Optional[List[str]] = None,
elapsed_ms: float = 0,
prompt_tokens: int = 0,
completion_tokens: int = 0,
total_tokens: int = 0,
session_id: str = "",
event_at_ms: int = 0,
) -> bool:
try:
from ..utils.pipeline_observability import split_namespace_user
now = datetime.now()
created_at = now.isoformat()
created_date = now.strftime("%Y-%m-%d")
event_at_ms = int(event_at_ms) if event_at_ms else int(now.timestamp() * 1000)
mem_ids_json = json.dumps(memory_ids or [])
namespace, uid = split_namespace_user(user_id)
await self._execute(
"""INSERT INTO pipeline_logs_v2
(request_id, namespace, user_id, agent_id, session_id,
step, prompt, response,
parsed, memory_ids, elapsed_ms,
prompt_tokens, completion_tokens, total_tokens,
event_at_ms, created_at, created_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(request_id, namespace, uid, agent_id, session_id or "",
step, prompt, response,
parsed, mem_ids_json, elapsed_ms,
prompt_tokens, completion_tokens, total_tokens,
event_at_ms, created_at, created_date),
)
return True
except Exception as e:
logger.warning(f"store_pipeline_log failed: {e}")
return False
async def get_pipeline_logs(
self,
request_id: Optional[str] = None,
user_id: Optional[str] = None,
step: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
try:
conditions = []
params = []
if request_id:
conditions.append("request_id = %s")
params.append(request_id)
if user_id:
conditions.append("user_id = %s")
params.append(user_id)
if step:
conditions.append("step = %s")
params.append(step)
if not conditions:
return []
where = " AND ".join(conditions)
rows = await self._fetchall(
f"SELECT * FROM pipeline_logs_v2 WHERE {where} "
f"ORDER BY id ASC LIMIT %s",
params + [limit],
)
# 派生可读的 event_at(ISO ms)供查询接口直接用;event_at_ms 为 0 时留空
for _r in rows:
_ms = _r.get("event_at_ms") or 0
_r["event_at"] = (
datetime.fromtimestamp(_ms / 1000.0).isoformat(timespec="milliseconds")
if _ms else ""
)
return rows
except Exception as e:
logger.warning(f"get_pipeline_logs failed: {e}")
return []
async def claim_write_outcome(
self,
request_id: str,
user_id: str,
agent_id: str,
session_id: str,
operation: str,
) -> Optional[Dict[str, Any]]:
now = datetime.now().isoformat()
inserted = await self._execute(
"""
INSERT IGNORE INTO write_outcomes (
request_id, user_id, agent_id, session_id, operation,
status, result_json, created_at, updated_at
) VALUES (%s, %s, %s, %s, %s, 'running', '{}', %s, %s)
""",
(request_id, user_id, agent_id, session_id, operation, now, now),
)
if inserted == 1:
return None
return await self.get_write_outcome(request_id)
async def finish_write_outcome(
self,
request_id: str,
status: str,
result: Dict[str, Any],
) -> bool:
updated = await self._execute(
"""
UPDATE write_outcomes
SET status = %s, result_json = %s, updated_at = %s
WHERE request_id = %s
""",
(
status,
json.dumps(result, ensure_ascii=False, default=str),
datetime.now().isoformat(),
request_id,
),
)
return updated == 1
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
row = await self._fetchone(
"SELECT * FROM write_outcomes WHERE request_id = %s",
(request_id,),
)
if row is None:
return None
try:
row["result"] = json.loads(row.pop("result_json") or "{}")
except (TypeError, json.JSONDecodeError):
row["result"] = {}
return row
# ================================================================
# System Metrics(分钟粒度落盘)
# ================================================================
async def store_metrics_minute(self, minute_ts: str, data: dict) -> None:
import aiomysql
try:
now = datetime.now().isoformat()
# Read-merge-write must be one transaction: two instances flushing
# the same minute used to overwrite each other's counters, and a
# concurrent insert could lose the whole batch to a duplicate key.
for attempt in range(2):
try:
async with self._pool.acquire() as conn:
await conn.begin()
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT data FROM system_metrics "
"WHERE minute_ts = %s FOR UPDATE",
(minute_ts,),
)
row = await cur.fetchone()
if row is not None:
old_raw = (
row["data"]
if isinstance(row, dict)
else row[0]
)
merged = self._merge_metric_buckets(
json.loads(old_raw), data,
)
await cur.execute(
"UPDATE system_metrics SET data = %s, "
"created_at = %s WHERE minute_ts = %s",
(
json.dumps(merged, default=str),
now,
minute_ts,
),
)
else:
await cur.execute(
"INSERT INTO system_metrics "
"(minute_ts, data, created_at) "
"VALUES (%s, %s, %s)",
(
minute_ts,
json.dumps(data, default=str),
now,
),
)
await conn.commit()
return
except Exception:
await conn.rollback()
raise
except (aiomysql.IntegrityError, aiomysql.OperationalError) as error:
code = error.args[0] if error.args else None
deadlock = code in (1205, 1213)
if attempt or (isinstance(error, aiomysql.OperationalError) and not deadlock):
raise
# A competing writer inserted the row, or InnoDB chose us
# as the deadlock victim. Retry so this batch merges.
continue
except Exception as e:
logger.warning(f"store_metrics_minute failed: {e}")
async def load_metrics_range(self, start_ts: str, end_ts: str) -> list:
try:
rows = await self._fetchall(
"SELECT data FROM system_metrics WHERE minute_ts >= %s AND minute_ts <= %s ORDER BY minute_ts",
(start_ts, end_ts),
)
results = []
for row in rows:
try:
results.append(json.loads(row["data"]))
except (json.JSONDecodeError, TypeError):
pass
return results
except Exception as e:
logger.warning(f"load_metrics_range failed: {e}")
return []
async def cleanup_old_metrics(self, before_ts: str) -> None:
try:
await self._execute(
"DELETE FROM system_metrics WHERE minute_ts < %s", (before_ts,)
)
except Exception as e:
logger.warning(f"cleanup_old_metrics failed: {e}")
# ================================================================
# Summary Buffer
# ================================================================
@staticmethod
def _merge_metric_buckets(old: dict, new: dict) -> dict:
"""合并两个 metric bucket(同一分钟多次 flush)"""
merged = dict(old)
for key in ("sys1_started", "sys1_completed", "sys1_failed",
"sys2_started", "sys2_completed", "sys2_failed",
"vdb_ops", "graph_ops"):
merged[key] = merged.get(key, 0) + new.get(key, 0)
for key in ("vdb_ops_sum_ms", "graph_ops_sum_ms"):
merged[key] = merged.get(key, 0) + new.get(key, 0)
# timing sums
for bucket_key in ("sys1_timing_sums", "sys2_timing_sums"):
old_sums = merged.get(bucket_key, {})
new_sums = new.get(bucket_key, {})
for k, v in new_sums.items():
old_sums[k] = old_sums.get(k, 0) + v
merged[bucket_key] = old_sums
return merged