* 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>
669 lines
26 KiB
Python
669 lines
26 KiB
Python
"""
|
||
Agent Memory V2 - SqliteCache
|
||
|
||
基于 Python 标准库 sqlite3 的零依赖审计/观测落库后端。
|
||
单机开箱即用。
|
||
|
||
设计模式跟随 history_store.py:
|
||
- 标准库 sqlite3 + _run_in_sqlite_pool()
|
||
- WAL 模式 + check_same_thread=False
|
||
- 每实例 threading.RLock 保护共享连接的全部操作与关闭
|
||
|
||
三张表:
|
||
- memory_operations / pipeline_logs / system_metrics: 审计与观测
|
||
"""
|
||
|
||
import concurrent.futures
|
||
import functools
|
||
import json
|
||
import logging
|
||
import os
|
||
import sqlite3
|
||
import threading
|
||
from datetime import datetime
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from .cache_base import CacheBase
|
||
from ..config import MemoryConfig
|
||
from ..utils.async_executor import run_in_executor_guarded
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# SQLite 独立线程池(不与 VDB/Graph 竞争)
|
||
_SQLITE_POOL_SIZE = 32
|
||
_sqlite_executor = concurrent.futures.ThreadPoolExecutor(
|
||
max_workers=_SQLITE_POOL_SIZE, thread_name_prefix="sqlite"
|
||
)
|
||
|
||
|
||
async def _run_in_sqlite_pool(func, *args, **kwargs):
|
||
"""在 SQLite 独立线程池中执行同步函数"""
|
||
call = functools.partial(func, *args, **kwargs) if args or kwargs else func
|
||
return await run_in_executor_guarded(_sqlite_executor, call)
|
||
|
||
|
||
# DDL
|
||
# 表名 / 列与 MySQL 分区版对齐(含 created_date),SQLite 不支持分区,仅普通建表。
|
||
_CREATE_TABLES_SQL = """
|
||
CREATE TABLE IF NOT EXISTS memory_operations_v2 (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
request_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
agent_id TEXT NOT NULL DEFAULT '',
|
||
op TEXT NOT NULL, -- ADD / UPDATE
|
||
memory_id TEXT NOT NULL, -- 操作后的节点 ID(ADD: 新节点 ID;UPDATE: 新节点 ID)
|
||
old_memory_id TEXT, -- UPDATE 时的旧节点 ID
|
||
content TEXT NOT NULL, -- 最终写入的内容
|
||
layer TEXT NOT NULL DEFAULT '',
|
||
reason TEXT NOT NULL DEFAULT '',
|
||
supersedes TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
created_date TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_memop_request ON memory_operations_v2(request_id);
|
||
CREATE INDEX IF NOT EXISTS idx_memop_memory ON memory_operations_v2(memory_id);
|
||
CREATE INDEX IF NOT EXISTS idx_memop_user ON memory_operations_v2(user_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS pipeline_logs_v2 (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
request_id TEXT NOT NULL,
|
||
namespace TEXT NOT NULL DEFAULT '',
|
||
user_id TEXT NOT NULL,
|
||
agent_id TEXT NOT NULL DEFAULT '',
|
||
session_id TEXT NOT NULL DEFAULT '',
|
||
step TEXT NOT NULL, -- EXTRACT / SEARCH_QUERY / RECONCILE / SUMMARY
|
||
prompt TEXT NOT NULL, -- LLM prompt 原文
|
||
response TEXT NOT NULL, -- LLM response 原文
|
||
parsed TEXT NOT NULL DEFAULT '', -- 解析后的结构化结果 (JSON)
|
||
memory_ids TEXT NOT NULL DEFAULT '', -- 关联的 memory_id 列表 (JSON array),reconcile 阶段有值
|
||
elapsed_ms REAL NOT NULL DEFAULT 0,
|
||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||
event_at_ms INTEGER NOT NULL DEFAULT 0, -- step 发生时刻(epoch 毫秒,与 JSONL log 同源,消费方自行转可读)
|
||
created_at TEXT NOT NULL,
|
||
created_date TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_plog_request ON pipeline_logs_v2(request_id);
|
||
CREATE INDEX IF NOT EXISTS idx_plog_user ON pipeline_logs_v2(user_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS write_outcomes (
|
||
request_id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
agent_id TEXT NOT NULL DEFAULT '',
|
||
session_id TEXT NOT NULL DEFAULT '',
|
||
operation TEXT NOT NULL,
|
||
status TEXT NOT NULL,
|
||
result_json TEXT NOT NULL DEFAULT '{}',
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_write_outcomes_owner
|
||
ON write_outcomes(user_id, agent_id, session_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS system_metrics (
|
||
minute_ts TEXT PRIMARY KEY,
|
||
data TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_metrics_ts ON system_metrics(minute_ts);
|
||
|
||
|
||
-- Episode(内部会话)状态:跨 write 批次把"一件事"归到同一个 episode_id。
|
||
-- 只存三个值;划分规则(时间间隔)在 writer 侧,此处不含业务逻辑。
|
||
CREATE TABLE IF NOT EXISTS episode_state (
|
||
episode_key TEXT PRIMARY KEY, -- user::agent::session
|
||
user_id TEXT NOT NULL DEFAULT '',
|
||
agent_id TEXT NOT NULL DEFAULT '',
|
||
session_id TEXT NOT NULL DEFAULT '',
|
||
episode_id TEXT NOT NULL DEFAULT '',
|
||
last_write_ts REAL NOT NULL DEFAULT 0,
|
||
memory_count INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_episode_user ON episode_state(user_id);
|
||
"""
|
||
|
||
|
||
class SqliteCache(CacheBase):
|
||
"""
|
||
SQLite 审计/观测落库后端(memory_operations / pipeline_logs / system_metrics)。
|
||
|
||
零依赖本地后端,适用于单机 / 开发环境。
|
||
使用标准库 sqlite3,通过 _run_in_sqlite_pool() 包装为异步。
|
||
"""
|
||
|
||
def __init__(self, config: MemoryConfig):
|
||
self.config = config
|
||
self._conn: Optional[sqlite3.Connection] = None
|
||
# One connection is shared by the executor workers. Use the same lock
|
||
# for reads, writes, initialization and close so close() cannot free
|
||
# the connection while another worker is inside sqlite3.
|
||
self._lock = threading.RLock()
|
||
|
||
# 读取 db_path
|
||
cache_cfg = getattr(config, "cache", None)
|
||
if cache_cfg and getattr(cache_cfg, "db_path", None):
|
||
self._db_path = cache_cfg.db_path
|
||
else:
|
||
from ..config import _default_data_dir
|
||
self._db_path = os.getenv(
|
||
"MEMORY_CACHE_DB_PATH",
|
||
os.path.join(_default_data_dir(), "data", "cache.db"),
|
||
)
|
||
|
||
# ================================================================
|
||
# 生命周期
|
||
# ================================================================
|
||
|
||
async def initialize(self) -> None:
|
||
await _run_in_sqlite_pool(self._init_sync)
|
||
logger.debug(f"SqliteCache initialized: {self._db_path}")
|
||
|
||
def _init_sync(self) -> None:
|
||
with self._lock:
|
||
db_dir = os.path.dirname(self._db_path)
|
||
if db_dir:
|
||
os.makedirs(db_dir, exist_ok=True)
|
||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||
self._conn.row_factory = sqlite3.Row
|
||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||
self._conn.executescript(_CREATE_TABLES_SQL)
|
||
self._migrate_pipeline_logs_columns()
|
||
self._conn.commit()
|
||
|
||
def _migrate_pipeline_logs_columns(self) -> None:
|
||
"""给旧库的 pipeline_logs_v2 补 namespace/session/event time 列。"""
|
||
with self._lock:
|
||
try:
|
||
cols = {
|
||
row["name"]
|
||
for row in self._conn.execute("PRAGMA table_info(pipeline_logs_v2)")
|
||
}
|
||
_text_cols = ("namespace", "session_id")
|
||
for col in _text_cols:
|
||
if col not in cols:
|
||
self._conn.execute(
|
||
f"ALTER TABLE pipeline_logs_v2 ADD COLUMN {col} TEXT NOT NULL DEFAULT ''"
|
||
)
|
||
if "event_at_ms" not in cols:
|
||
self._conn.execute(
|
||
"ALTER TABLE pipeline_logs_v2 ADD COLUMN event_at_ms INTEGER NOT NULL DEFAULT 0"
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"[sqlite] pipeline_logs_v2 column migration skipped: {e}")
|
||
|
||
async def close(self) -> None:
|
||
if self._conn is not None:
|
||
await _run_in_sqlite_pool(self._close_sync)
|
||
logger.info("SqliteCache closed")
|
||
|
||
def _close_sync(self) -> None:
|
||
with self._lock:
|
||
if self._conn:
|
||
self._conn.close()
|
||
self._conn = None
|
||
|
||
# ================================================================
|
||
# 统计
|
||
# ================================================================
|
||
|
||
async def get_stats(self) -> Dict[str, Any]:
|
||
return {
|
||
"backend": "sqlite",
|
||
"db_path": self._db_path,
|
||
}
|
||
|
||
# ================================================================
|
||
# 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:
|
||
"""记录一条知识库变动操作(ADD / EVOLVE)"""
|
||
from ..utils.pipeline_observability import is_memory_operations_enabled
|
||
if not is_memory_operations_enabled():
|
||
return True
|
||
try:
|
||
import json as _json
|
||
from datetime import datetime as dt
|
||
now = dt.now()
|
||
created_at = now.isoformat()
|
||
created_date = now.strftime("%Y-%m-%d")
|
||
supersedes_str = _json.dumps(supersedes or [], ensure_ascii=False)
|
||
|
||
def _insert():
|
||
with self._lock:
|
||
self._conn.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(request_id, user_id, agent_id, op, memory_id,
|
||
old_memory_id, content, layer, reason, supersedes_str,
|
||
created_at, created_date),
|
||
)
|
||
self._conn.commit()
|
||
|
||
await _run_in_sqlite_pool(_insert)
|
||
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]]:
|
||
"""
|
||
查询知识库变动记录,支持按 request_id / memory_id / user_id 过滤。
|
||
至少指定一个过滤条件。
|
||
"""
|
||
try:
|
||
def _query():
|
||
with self._lock:
|
||
conditions = []
|
||
params = []
|
||
if request_id:
|
||
conditions.append("request_id = ?")
|
||
params.append(request_id)
|
||
if memory_id:
|
||
conditions.append("memory_id = ?")
|
||
params.append(memory_id)
|
||
if user_id:
|
||
conditions.append("user_id = ?")
|
||
params.append(user_id)
|
||
|
||
if not conditions:
|
||
return []
|
||
|
||
where = " AND ".join(conditions)
|
||
rows = self._conn.execute(
|
||
f"SELECT * FROM memory_operations_v2 WHERE {where} "
|
||
f"ORDER BY id DESC LIMIT ?",
|
||
params + [limit],
|
||
).fetchall()
|
||
result = []
|
||
for r in rows:
|
||
row = dict(r)
|
||
# 反序列化 supersedes JSON 字符串 → list
|
||
import json as _json
|
||
raw_sup = row.get("supersedes", "")
|
||
try:
|
||
row["supersedes"] = _json.loads(raw_sup) if raw_sup else []
|
||
except Exception:
|
||
row["supersedes"] = []
|
||
result.append(row)
|
||
return result
|
||
|
||
return await _run_in_sqlite_pool(_query)
|
||
except Exception as e:
|
||
logger.warning(f"get_memory_operations failed: {e}")
|
||
return []
|
||
|
||
# ================================================================
|
||
# Pipeline Logs (LLM 调用链中间结果)
|
||
# ================================================================
|
||
|
||
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:
|
||
"""
|
||
记录一条 pipeline 中间结果(EXTRACT / SEARCH_QUERY / RECONCILE / SUMMARY)。
|
||
|
||
user_id 按 "__" 拆出 namespace 单独落库;session_id 与 user/agent 同为隔离维度。
|
||
event_at_ms: step 发生时刻(epoch 毫秒,与 JSONL log timestamp 同源);缺省回落入库时刻。
|
||
"""
|
||
try:
|
||
from datetime import datetime as dt
|
||
from ..utils.pipeline_observability import split_namespace_user
|
||
now = dt.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)
|
||
|
||
def _insert():
|
||
with self._lock:
|
||
self._conn.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(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),
|
||
)
|
||
self._conn.commit()
|
||
|
||
await _run_in_sqlite_pool(_insert)
|
||
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]]:
|
||
"""
|
||
查询 pipeline 中间结果日志。
|
||
|
||
支持按 request_id / user_id / step 过滤,至少指定一个。
|
||
"""
|
||
try:
|
||
def _query():
|
||
with self._lock:
|
||
conditions = []
|
||
params = []
|
||
if request_id:
|
||
conditions.append("request_id = ?")
|
||
params.append(request_id)
|
||
if user_id:
|
||
conditions.append("user_id = ?")
|
||
params.append(user_id)
|
||
if step:
|
||
conditions.append("step = ?")
|
||
params.append(step)
|
||
|
||
if not conditions:
|
||
return []
|
||
|
||
where = " AND ".join(conditions)
|
||
rows = self._conn.execute(
|
||
f"SELECT * FROM pipeline_logs_v2 WHERE {where} "
|
||
f"ORDER BY id ASC LIMIT ?",
|
||
params + [limit],
|
||
).fetchall()
|
||
out = [dict(r) for r in rows]
|
||
# 派生可读的 event_at(ISO ms)供查询接口直接用;event_at_ms 为 0 时留空
|
||
from datetime import datetime as _dt
|
||
for _r in out:
|
||
_ms = _r.get("event_at_ms") or 0
|
||
_r["event_at"] = (
|
||
_dt.fromtimestamp(_ms / 1000.0).isoformat(timespec="milliseconds")
|
||
if _ms else ""
|
||
)
|
||
return out
|
||
|
||
return await _run_in_sqlite_pool(_query)
|
||
except Exception as e:
|
||
logger.warning(f"get_pipeline_logs failed: {e}")
|
||
return []
|
||
|
||
# ================================================================
|
||
# Durable write outcomes
|
||
# ================================================================
|
||
|
||
async def claim_write_outcome(
|
||
self,
|
||
request_id: str,
|
||
user_id: str,
|
||
agent_id: str,
|
||
session_id: str,
|
||
operation: str,
|
||
) -> Optional[Dict[str, Any]]:
|
||
def _claim():
|
||
with self._lock:
|
||
now = datetime.now().isoformat()
|
||
cursor = self._conn.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO write_outcomes (
|
||
request_id, user_id, agent_id, session_id, operation,
|
||
status, result_json, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, 'running', '{}', ?, ?)
|
||
""",
|
||
(request_id, user_id, agent_id, session_id, operation, now, now),
|
||
)
|
||
self._conn.commit()
|
||
if cursor.rowcount == 1:
|
||
return None
|
||
row = self._conn.execute(
|
||
"SELECT * FROM write_outcomes WHERE request_id = ?",
|
||
(request_id,),
|
||
).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
existing = await _run_in_sqlite_pool(_claim)
|
||
if existing is None:
|
||
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:
|
||
def _finish():
|
||
with self._lock:
|
||
cursor = self._conn.execute(
|
||
"""
|
||
UPDATE write_outcomes
|
||
SET status = ?, result_json = ?, updated_at = ?
|
||
WHERE request_id = ?
|
||
""",
|
||
(
|
||
status,
|
||
json.dumps(result, ensure_ascii=False, default=str),
|
||
datetime.now().isoformat(),
|
||
request_id,
|
||
),
|
||
)
|
||
self._conn.commit()
|
||
return cursor.rowcount == 1
|
||
|
||
return await _run_in_sqlite_pool(_finish)
|
||
|
||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||
def _get():
|
||
with self._lock:
|
||
row = self._conn.execute(
|
||
"SELECT * FROM write_outcomes WHERE request_id = ?",
|
||
(request_id,),
|
||
).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
row = await _run_in_sqlite_pool(_get)
|
||
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:
|
||
"""存储一个分钟桶的增量指标数据(UPSERT: 同一分钟多次写入会合并)"""
|
||
try:
|
||
def _store():
|
||
with self._lock:
|
||
now = datetime.now().isoformat()
|
||
# UPSERT: 如果同一分钟已存在,合并数据
|
||
existing = self._conn.execute(
|
||
"SELECT data FROM system_metrics WHERE minute_ts = ?",
|
||
(minute_ts,),
|
||
).fetchone()
|
||
if existing:
|
||
import json as _json
|
||
old_data = _json.loads(existing["data"])
|
||
merged = self._merge_metric_buckets(old_data, data)
|
||
self._conn.execute(
|
||
"UPDATE system_metrics SET data = ?, created_at = ? WHERE minute_ts = ?",
|
||
(_json.dumps(merged, default=str), now, minute_ts),
|
||
)
|
||
else:
|
||
self._conn.execute(
|
||
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (?, ?, ?)",
|
||
(minute_ts, json.dumps(data, default=str), now),
|
||
)
|
||
self._conn.commit()
|
||
await _run_in_sqlite_pool(_store)
|
||
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:
|
||
def _load():
|
||
with self._lock:
|
||
rows = self._conn.execute(
|
||
"SELECT data FROM system_metrics WHERE minute_ts >= ? AND minute_ts <= ? ORDER BY minute_ts",
|
||
(start_ts, end_ts),
|
||
).fetchall()
|
||
results = []
|
||
for row in rows:
|
||
try:
|
||
results.append(json.loads(row["data"]))
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
return results
|
||
return await _run_in_sqlite_pool(_load)
|
||
except Exception as e:
|
||
logger.warning(f"load_metrics_range failed: {e}")
|
||
return []
|
||
|
||
async def cleanup_old_metrics(self, before_ts: str) -> None:
|
||
"""删除指定时间之前的 metrics 数据"""
|
||
try:
|
||
def _cleanup():
|
||
with self._lock:
|
||
self._conn.execute(
|
||
"DELETE FROM system_metrics WHERE minute_ts < ?",
|
||
(before_ts,),
|
||
)
|
||
self._conn.commit()
|
||
await _run_in_sqlite_pool(_cleanup)
|
||
except Exception as e:
|
||
logger.warning(f"cleanup_old_metrics failed: {e}")
|
||
|
||
# ================================================================
|
||
# Summary Buffer
|
||
# ================================================================
|
||
|
||
|
||
|
||
|
||
async def get_episode_state(self, episode_key: str) -> Optional[Dict[str, Any]]:
|
||
try:
|
||
def _query():
|
||
with self._lock:
|
||
row = self._conn.execute(
|
||
"SELECT episode_id, last_write_ts, memory_count "
|
||
"FROM episode_state WHERE episode_key = ?",
|
||
(episode_key,),
|
||
).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
row = await _run_in_sqlite_pool(_query)
|
||
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
|
||
|
||
def _write():
|
||
with self._lock:
|
||
self._conn.execute(
|
||
"""
|
||
INSERT INTO episode_state (
|
||
episode_key, user_id, agent_id, session_id,
|
||
episode_id, last_write_ts, memory_count, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(episode_key) DO UPDATE SET
|
||
episode_id = excluded.episode_id,
|
||
last_write_ts = excluded.last_write_ts,
|
||
memory_count = excluded.memory_count,
|
||
updated_at = excluded.updated_at
|
||
""",
|
||
(episode_key, user_id, agent_id, session_id, episode_id,
|
||
float(last_write_ts), int(memory_count),
|
||
_dt.now().isoformat()),
|
||
)
|
||
self._conn.commit()
|
||
return True
|
||
|
||
return await _run_in_sqlite_pool(_write)
|
||
except Exception as e:
|
||
logger.warning(f"upsert_episode_state failed: {e}")
|
||
return False
|
||
|
||
@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
|