feat(memory): 添加 SqliteStore 持久化方案文档

This commit is contained in:
徐涛
2026-07-05 16:54:29 +08:00
parent 821cea8e60
commit 13edacd775
+526
View File
@@ -0,0 +1,526 @@
# Phase 7 — SqliteStore 持久化实现方案
- **文档编号**14
- **标题**Phase 7 — SqliteStore 持久化实现方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**memory/store
- **关联文档**roadmap.md, 6-memory-system.md
---
## 背景与目标
Phase 7 的核心任务是完成 MemoryStore trait 的 SQLite 后端实现,使 Agent 进程重启后记忆数据不丢失。这是 v0.2.0 从"内存玩具"走向"可用工具"的关键门槛,也是后续 Phase 8MVP 出口)和 Phase 10ContextSlot)的前置依赖。
**成功标准**
- SqliteStore 完整实现 MemoryStore trait4 个方法:save/get/delete/list
- 进程关闭后重新打开同一数据库文件,数据完整可读
- 与现有 InMemoryStore 通过 MemoryStore trait 可互换,消费者零改动
- 所有现有测试保持通过,clippy 0 警告
### Scope & Non-goals
| 范围 | 内容 |
|------|------|
| 包含 | 单表 CRUD + prefix/since/offset/limit 查询 + WAL 并发 + Mutex 串行化 + 错误映射 |
| 不包含(Phase 7 | 淘汰策略(EvictionPolicy,仅 InMemoryStore 持有,需 v0.3 纳入 SqliteStore |
| 不包含(Phase 7 | Schema 迁移框架(PRAGMA user_version 足矣,不引入 refinery/sea-query |
| 不包含(Phase 7 | 批量写入 / 事务 API(N+1 clear 延迟可接受,优化后置) |
| 不包含(Phase 7) | 跨进程共享同一数据库文件(Mutex 为单进程设计) |
---
## 当前状态分析
### 现有实现
- MemoryStore trait 已在 v0.1 Phase 3 就绪,定义 4 个异步方法
- InMemoryStore 实现稳定运行,使用 `Mutex<HashMap>` 作为后端
- 全量测试 191 个通过,clippy 0 警告
- 项目当前无 SQLite 或其他数据库依赖
### 现有消费者
通过 `crate::memory::store::MemoryStore` 路径引用的模块:
| 模块 | 文件 | 使用方式 |
|------|------|----------|
| Agent Builder | `agent/builder.rs` | RuntimeBundle 中引用 MemoryStore |
| Session Memory | `agent/session_memory.rs` | SessionMemory 实现 |
| Agent Runtime | `agent/runtime.rs` | 类型标注 |
| Agent Session | `agent/session.rs` | 默认 InMemoryStore 兜底 |
| Conversation | `memory/conversation.rs` | ConversationMemory 测试 |
| Knowledge | `memory/knowledge.rs` | KnowledgeStore 测试 |
| Retriever | `memory/retriever.rs` | MemoryRetriever 测试 |
所有消费者均通过 `MemoryStore` trait 访问,不依赖具体实现类型,因此新增 SqliteStore 不会产生编译或运行时影响。
### 目录结构现状
```
src/memory/
├── mod.rs
├── store.rs ← 包含 MemoryStore trait + InMemoryStore + EvictionPolicy
├── conversation.rs
├── knowledge.rs
├── retriever.rs
└── vector.rs
```
`store.rs` 目前是一个单体文件,同时承载 trait 定义和 InMemoryStore 实现。
---
## 调研发现
### MemoryStore trait 定义
```rust
#[async_trait]
pub trait MemoryStore: Send + Sync {
async fn save(&self, item: MemoryItem) -> Result<(), MemoryError>;
async fn get(&self, id: &str) -> Result<Option<MemoryItem>, MemoryError>;
async fn delete(&self, id: &str) -> Result<(), MemoryError>;
async fn list(&self, filter: &MemoryFilter) -> Result<Vec<MemoryItem>, MemoryError>;
}
```
### 关键类型
| 类型 | 定义 |
|------|------|
| `MemoryItem` | `{ id: String, content: String, metadata: Value, created_at: OffsetDateTime }` |
| `MemoryFilter` | `{ prefix: Option<String>, since: Option<OffsetDateTime>, offset: Option<usize>, limit: Option<usize> }` |
| `MemoryError` | 变体:`NotFound` / `Storage` / `Serialization` / `InvalidInput` / `RetrievalError` |
| `EvictionPolicy` | `None` / `Ttl { ttl_secs }` / `Capacity { max_items }` |
| `EvictionConfig` | `{ policy, check_interval }` |
### 并发模型参考
InMemoryStore 当前使用 `Mutex<HashMap>` 实现 `Send + Sync`。SqliteStore 将遵循相同模式,使用 `Arc<Mutex<Connection>>` + `spawn_blocking` 满足异步 trait 约束。
### Schema 设计考虑
- `created_at` 使用 TEXT(ISO 8601) 存储——`.to_string()` 零转换,字典序与时间序一致(前提:所有时间戳归一化到 UTC;`OffsetDateTime::to_string()` 在 UTC 下输出 `"2026-07-05T12:00:00Z"` 格式,字典序与时间序严格对应)
- 初始 schema 即创建 `created_at` 索引,避免后续大数据量全表排序
- Schema 版本管理通过 `PRAGMA user_version` 实现,零外部依赖,后续加字段只需追加 `if version < N { ALTER TABLE }`
---
## 可选方案
### A. rusqlite + Mutex\<Connection\>(推荐)
| 维度 | 评估 |
|------|------|
| 新增依赖 | 1 个(rusqlite 0.32 + bundled features |
| 实现量 | ~200 行 |
| SQL 支持 | 原生支持 prefix LIKE 过滤 + ORDER BY 排序 |
| 性能 | 有索引时查询 O(log n),写入串行化 |
| 并发 | WAL 模式 + Mutex 串行化写入,适合单进程 Agent |
| 事务支持 | 完整 ACID |
| 崩溃安全 | WAL 模式,崩溃恢复有保障 |
**适用场景**:单进程 Agent 本地持久化、嵌入式场景、需要关系查询能力的通用存储。
**外部依赖评估**
- rusqlite 0.32 — 最新稳定版(2025-12 发布),维护活跃(月均 2+ 次提交),Apache-2.0 许可证
- `bundled` feature 编译 SQLite 源码(Public Domain)进二进制,无系统级 SQLite 依赖,零外部 C 库安装步骤
- 供应链风险:bundled 模式依赖 crate 发布节奏同步 SQLite 安全更新;SQLite 安全公告频率极低(年均 <3 例),此风险可接受
### B. JSONL 文件
| 维度 | 评估 |
|------|------|
| 新增依赖 | 0 |
| 实现量 | ~150 行 |
| get() 复杂度 | O(n) 全量扫描 |
| delete() 复杂度 | O(n) 全量重写 |
| 并发 | 需文件锁(flock |
| 事务支持 | 无 |
| 崩溃安全 | 无保障,写入中断可能丢失或损坏数据 |
**否决理由**:核心的 get() 查询场景不可接受 O(n) 性能;在 Agent 运行时频繁读写记忆的场景下,全量扫描的成本会随着数据积累线性增长,不符合可用性要求。
### C. sled 嵌入式 KV
| 维度 | 评估 |
|------|------|
| 新增依赖 | 1 个(纯 Rust |
| 实现量 | ~150 行 |
| prefix scan | 原生支持 |
| 排序 | 需手动实现 |
| 关系模型 | 不如 SQL 匹配当前查询模式 |
| 社区成熟度 | 较新,API 仍在演进 |
**否决理由**:当前查询模式(prefix 过滤 + 按 created_at 排序)在关系模型中用一条 SQL 即可表达,引入 KV 存储反而需要手动处理排序逻辑。非必要不引入新存储范式。
---
## 推荐方案
### 总体方向:方案 Arusqlite + Mutex\<Connection\>
选择理由:
1. **最少依赖,最高匹配**:1 个新增依赖即可完整支持 MemoryFilter 的所有查询维度(prefix LIKE、created_at 范围、offset/limit
2. **生产就绪**rusqlite 是 SQLite 的 Rust 绑定事实标准,bundled 模式免去系统 SQLite 依赖
3. **Schema 演进简单**PRAGMA user_version + 逐版本迁移,零外部迁移工具依赖
4. **与 InMemoryStore 语义一致**Mutex 串行化 + spawn_blocking 适配 async trait,与现有并发模型同构
### 关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| Schema 版本管理 | PRAGMA user_version | 零外部依赖,~20 行,后续 ALTER TABLE 即可 |
| created_at 存储格式 | TEXT(ISO 8601) + UTC 归一化 | 零转换代码;UTC 下输出 `"2026-07-05T12:00:00Z"`,字典序与时间序严格一致 |
| Upsert SQL 策略 | `INSERT ... ON CONFLICT(id) DO UPDATE SET ...` | 保留调用方传入的 `created_at`,避免被 `DEFAULT` 覆盖 |
| 性能索引 | 初始 schema 加 created_at 索引 | 避免大数据量全表排序 |
| 配置参数 | 仅 `path``busy_timeout=5s` | 其余内置默认值;5s 超时避免 `SQLITE_BUSY` 快速失败 |
| Mutex 中毒恢复 | `.lock().unwrap_or_else(\|e\| e.into_inner())` | 不 panic,恢复执行 |
| 批量操作 | 不加 | N+1 clear ~250ms(N=50),可接受,优化后置 |
| spawn_blocking 取消安全性 | 短事务模式(auto-commit) | 每个操作独立事务,取消时后台 task 自然完成/panic,不 Cross 操作持有 Mutex |
**我们放弃了什么**(集中 Trade-off 记录):
- **写入串行化**`Mutex<Connection>` 确保 SQLite 写入安全,代价是同一时刻只能有一个写入者。Agent 场景下写入频率低(每次 LLM 调用触发 1-2 次),串行化不构成瓶颈
- **单进程锁**:无法跨进程共享同一数据库文件。多进程场景需要网络后端(PostgreSQL/Redis
- **无横向扩展**:单文件 SQLite 无分片能力。需扩展时切换到分布式后端
### Schema 定义(初始版本)
```sql
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_items_created_at
ON memory_items(created_at);
```
### 数据完整性防御
| 异常场景 | 防御措施 | 错误映射 |
|---------|---------|---------|
| 数据库文件损坏 | `migrate()` 中执行 `PRAGMA quick_check`;失败时 `open()` 返回 `MemoryError::Storage` | `Storage` |
| created_at 解析失败 | `get()`/`list()``OffsetDateTime::parse` 失败不 panic,返回 `MemoryError::Serialization` | `Serialization` |
| content / metadata 为 NULL | `get()` 中检测 SQLite 返回值,NULL 时返回 `MemoryError::Storage` | `Storage` |
| 约束冲突(PRIMARY KEY / NOT NULL | 映射为 `MemoryError::InvalidInput` | `InvalidInput` |
| 序列化/反序列化失败 | `serde_json::to_string`/`from_str` 错误映射为 `MemoryError::Serialization` | `Serialization` |
### 性能预算(目标延迟,单条操作)
| 操作 | 目标延迟 | 说明 |
|------|---------|------|
| `save(1KB item)` | < 5ms | 含 serde_json 序列化 + spawn_blocking + SQLite INSERT |
| `get(1KB item)` | < 3ms | 含 SQLite SELECT + 反序列化 |
| `list(prefix 匹配 100 行)` | < 20ms | 含索引 B-tree 遍历 + ORDER BY + LIMIT |
| 并发 10 writer | p99 < 50ms | Mutex 串行化排队,每 writer 等待 9×5ms 内 |
实施后通过 Step C 测试验证以上预算。未达标时不阻塞发布,但记录为可观测告警阈值。
---
## 实施建议
### 实施计划
#### Step A — 目录重构(纯搬移,零行为变化)
目标:将单体 `store.rs` 拆分为模块目录架构,为新增 SqliteStore 做准备。
```
src/memory/
├── store.rs ← 模块根:MemoryStore trait + EvictionPolicy/EvictionConfig
│ + pub mod in_memory;
│ + pub mod sqlite_store;
│ + pub use in_memory::InMemoryStore;
├── store/
│ ├── in_memory.rs ← InMemoryStore 提取至此(struct + impl + 6 个内联测试)
│ └── sqlite_store.rs ← 新增 SqliteStore
```
模式参考:`llm/provider.rs``llm/provider/{openai,anthropic,ollama}.rs`
**外部消费者的导入路径不变**`crate::memory::store::MemoryStore`),零改动风险。
重构步骤:
1. 创建 `src/memory/store/` 目录
2. 创建 `src/memory/store/in_memory.rs`,从原 `store.rs` 提取 InMemoryStore 全部代码(struct + impl + Default + 6 个测试)
3. 修改 `src/memory/store.rs`:保留 MemoryStore trait + EvictionPolicy/EvictionConfig,加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`
4. 验证:`cargo test --all-targets` 全绿,测试数量不变(191 pass)
#### Step B — SqliteStore 实现
1. `Cargo.toml` 添加 `rusqlite = { version = "0.32", features = ["bundled"] }`
2. 创建 `src/memory/store/sqlite_store.rs`,实现:
- `SqliteStore` 结构体:`{ conn: Arc<Mutex<Connection>> }`
- `SqliteStore::open(path)` 构造函数,支持 `":memory:"`
- `lock_conn()` 辅助方法(Mutex 中毒恢复)
- `migrate()` Schema 初始化 + 版本管理
- `MemoryStore` trait 的 4 个方法
- `From<rusqlite::Error> for MemoryError`
3. 修改 `src/memory/store.rs`:加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`
4. 修改 `src/memory.rs`:加 `pub use store::SqliteStore;`
5. 编写测试覆盖:
- CRUD 基本操作
- Upsert(同 id 重复 save 覆盖)
- prefix 过滤
- 由于/until 时间范围过滤
- 并发 10 个 writer × 10 次操作
- 持久化恢复(write → drop → reopen → read
6. 验证:`cargo test --all-targets` 全绿 + `cargo clippy --all-targets -- -D warnings` 0 警告
#### Step C — 验证确认
1. 确认现有 memory 模块内测试全部通过
2. 确认 agent/llm/tools/prompt 模块不受影响
3. 确认 SqliteStore 与 InMemoryStore 通过 MemoryStore trait 可互换
4. 确认 clippy 无新增警告
### Commit 安排
| 顺序 | 类型 | Scope | 描述 |
|------|------|-------|------|
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 |
| 2 | feat | memory | 实现 SqliteStore 持久化 |
### 风险与缓解
| 风险 | 严重度 | 缓解措施 |
|------|--------|----------|
| Mutex 中毒导致后续操作全部失败 | 中 | `lock_conn()` 使用 `.lock().unwrap_or_else(\|e\| e.into_inner())` 恢复模式,不 panic |
| spawn_blocking 取消后连接状态不一致 | 中 | 每个操作使用短事务(auto-commit),不跨操作持有 Mutex;取消时遗留 task 自然完成或 panicMutex 通过 `.into_inner()` 恢复 |
| WAL 文件无限增长 | 低 | 内置 auto-checkpoint 阈值 + 启动时执行 `PRAGMA wal_checkpoint(TRUNCATE)` |
| list 无索引导致全表扫描 | 中(大数据量) | 初始 schema 即创建 `idx_memory_items_created_at` 索引 |
| 父目录不存在导致 open 失败 | 低 | `open()` 内部调用 `fs::create_dir_all()` 确保目录存在 |
| clear() N+1 删除性能 | 低 | 不走 trait 接口的逐条删除,可后续优化为直接 `DELETE FROM memory_items` |
| 数据库文件损坏 | 低 | `migrate()` 中执行 `PRAGMA quick_check`;失败时返回 `MemoryError::Storage`,调用方可切换 InMemoryStore |
### 可观测性(实施时落实)
- 所有 MemoryStore 方法通过 `tracing::instrument` 记录延迟和结果(`info!` 正常完成,`warn!` 超过性能预算阈值,`error!` 操作失败)
- `list()` 返回行数通过 `tracing::debug` 记录(调优参考)
- WAL 文件大小在 `migrate()` 后检查一次,超过 100MB 时记录 `warn!`
- 操作计数(读写次数、错误率)暂不暴露为独立 metrics,v0.3 按需添加
---
## 已知假设
| 假设 | 验证状态 | Fallback |
|------|---------|----------|
| 单进程独享 SQLite 文件,无跨进程竞争 | ✅ 设计前提(Mutex 为单进程设计) | 多进程场景使用网络后端(PostgreSQL/Redisv0.3+ |
| ISO 8601 TEXT 字典序等价于时间序 | ✅ 条件成立(需 UTC 归一化) | 若时区异常,切换 INTEGER(unix_timestamp) 存储后重建索引 |
| SqliteStore 初始化失败可 fallback 到 InMemoryStore | ✅ 调用方自行控制 | `open()` 返回 `MemoryError`,消费者 catch 后改用 `InMemoryStore::new()` |
| N+1 clear ~250ms(N=50) 可接受 | 🟡 未实测(基于 N×5ms 推算) | 若成为瓶颈,SqliteStore 内部加 `delete_by_prefix()` 方法(不走 trait 接口) |
| rusqlite bundled SQLite 版本足够新 | ✅ 0.32 版内置 SQLite 3.46 | 如需特定版本,切换 `bundled` 为指定版本或使用系统 SQLite |
| busy_timeout=5s 覆盖所有竞争场景 | 🟡 未实测(WAL 下写写冲突概率低) | 若观测到 `SQLITE_BUSY`,增大超时或在重试逻辑中处理 |
| spawn_blocking 线程池不会被耗尽 | ✅ 默认 512 线程,Agent 场景占用 ≤10 | 若观测到阻塞任务排队,启动时 `tokio::task::spawn_blocking` 已有兜底排队机制 |
---
## 参考来源
- [rusqlite crate](https://crates.io/crates/rusqlite) — 官方文档
- [SQLite PRAGMA user_version](https://www.sqlite.org/pragma.html#pragma_user_version) — Schema 版本管理机制
- [SQLite WAL mode](https://www.sqlite.org/wal.html) — 并发读写性能优化
- `docs/6-memory-system.md` — Phase 3 MemoryStore trait 原始设计
- `docs/roadmap.md` — 项目里程碑规划(Phase 7/8/10 依赖关系)
- `src/llm/provider.rs``src/llm/provider/` — 目录重构模式参考
---
## 实施计划
### 任务总览
3 个阶段、8 个任务单元、2 个 Commit。
### 阶段一:目录重构
#### Task A1 — 创建 store/ 目录并提取 InMemoryStore
| 项目 | 内容 |
|------|------|
| 任务描述 | 创建 `src/memory/store/` 目录,新建 `store/in_memory.rs`,从 `store.rs` 完整提取 InMemoryStore 结构体、impl MemoryStore、impl Default、6 个内联测试 |
| 涉及文件 | `src/memory/store.rs` → 分割到 `src/memory/store/in_memory.rs`(新增) |
| 前置依赖 | 无 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 — 纯搬移,编译器可验证 |
| 验收条件 | `cargo build` 通过(此时 store.rs 尚未修改,store/in_memory.rs 应被 crate 忽略) |
注意:需要先在 store.rs 顶部添加 `pub mod in_memory;` 声明,否则子模块不会被编译。或者可以先创建目录和文件,等 Task A2 再统一加声明路径。
实际做法:先创建文件但不声明,A2 统一声明。这样 A1 和 A2 之间可以有一个无编译的中间状态。
#### Task A2 — 修改 store.rs 模块根
| 项目 | 内容 |
|------|------|
| 任务描述 | 修改 `store.rs` 为纯模块根:保留 `MemoryStore` trait、`EvictionPolicy``EvictionConfig`;添加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`;删除已提取到 in_memory.rs 中的代码 |
| 涉及文件 | `src/memory/store.rs`(修改) |
| 前置依赖 | Task A1(文件已存在) |
| 预估工作量 | S< 1h |
| 风险等级 | 低 — 保留部分不变,提取部分在子模块中 |
| 验收条件 | `cargo test --all-targets` 全绿,测试数量不变(191 pass),clippy 0 warning |
#### Task A3 — 验证阶段一
| 项目 | 内容 |
|------|------|
| 任务描述 | 运行全量测试链确认目录重构零行为变化 |
| 涉及文件 | 全量 |
| 前置依赖 | Task A2 |
| 预估工作量 | XS(验证) |
| 风险等级 | 低 |
| 验收条件 | `cargo test --all-targets` 191 pass、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
### 阶段二:SqliteStore 实现
#### Task B1 — 添加 rusqlite 及 dev-dependencies
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `Cargo.toml` 中添加依赖:`[dependencies]``rusqlite = { version = "0.32", features = ["bundled"] }``[dev-dependencies]``tempfile = "3"`(用于测试隔离);运行 `cargo build` 确认编译通过,`cargo test --no-run` 验证 dev-dependencies 可用 |
| 涉及文件 | `Cargo.toml`(修改)、`Cargo.lock`(自动更新) |
| 前置依赖 | 无(可与阶段一并行) |
| 预估工作量 | XS< 15min |
| 风险等级 | 低 — 标准依赖添加 |
| 验收条件 | `cargo build` 成功,`cargo test --no-run` 成功,Cargo.lock 中生成 rusqlite 和 tempfile 条目 |
#### Task B2 — 实现 SqliteStore 核心
| 项目 | 内容 |
|------|------|
| 任务描述 | 创建 `src/memory/store/sqlite_store.rs`,实现以下 8 个子模块: |
| | 1`SqliteStore` 结构体 `{ conn: Arc<Mutex<Connection>> }` |
| | 2`SqliteStore::open(path)` — 支持 `":memory:"`,内部调用 `fs::create_dir_all` 确保父目录存在 |
| | 3`lock_conn()` — 内部辅助方法,`.lock().unwrap_or_else(\|e\| e.into_inner())` 处理 Mutex 中毒 |
| | 4`migrate()` — 按版本递增执行迁移:`PRAGMA user_version` 检查(初始版本号=1)→ 建表 `memory_items` + 索引 `idx_memory_items_created_at` + 设置 WAL 模式 + `busy_timeout=5s` + `PRAGMA synchronous = NORMAL` + `PRAGMA wal_autocheckpoint=1000` + `PRAGMA quick_check`(检测数据库损坏)+ `PRAGMA wal_checkpoint(TRUNCATE)` |
| | 5`MemoryStore` trait 的 4 个方法实现(save/get/delete/list),全部使用 spawn_blocking 包裹;**生命周期注意**:`spawn_blocking` 闭包前先 `.conn.clone()``Arc<Connection>`,参数调 `.clone()` 取 owned 值,再传入 `spawn_blocking(move \|{ ... })` |
| | — `save`: `INSERT INTO memory_items (id, content, metadata, created_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET content=excluded.content, metadata=excluded.metadata, created_at=excluded.created_at`(全字段覆盖 upsert,与 InMemoryStore 行为一致) |
| | — `get`: `SELECT content, metadata, created_at FROM memory_items WHERE id = ?1` → Ok(None) 当无结果 |
| | — `delete`: `DELETE FROM memory_items WHERE id = ?1`(幂等,不返回 NotFound |
| | — `list`: 根据 filter 字段(prefix/since/offset/limit)组合动态构造 WHERE 子句 + `ORDER BY created_at ASC` + `LIMIT ? OFFSET ?`,全部使用参数化查询防注入 |
| | 6)序列化转换层:`time::OffsetDateTime` 存为 TEXT(ISO 8601),通过 `.to_string()` 绑定 `String` 参数;`serde_json::Value` 存为 TEXT,通过 `serde_json::to_string()` 绑定 `String` 参数;读取时通过 `OffsetDateTime::parse``serde_json::from_str` 反序列化。在 SqliteStore 内部实现 `to_sql_params()` / `from_sql_row()` 辅助方法集中处理 |
| | 7)错误映射:不在 blanket impl From 中处理所有 rusqlite Error,而是在每个方法内部按数据完整性防御表的映射规则逐类处理: |
| | — 数据库文件损坏 / IO 错误 → `MemoryError::Storage` |
| | — created_at 解析失败 → `MemoryError::Serialization`(不 panic |
| | — content/metadata 为 NULL → `MemoryError::Storage` |
| | — serde_json 序列化/反序列化失败 → `MemoryError::Serialization` |
| | — 约束冲突 → `MemoryError::InvalidInput` |
| | (8)可观测性:在 4 个 trait 方法和 `open()` 上添加 `#[tracing::instrument(skip(self))]`;正常完成记录 `trace!`,超过性能预算阈值记录 `warn!`,操作失败记录 `error!` |
| 涉及文件 | `src/memory/store/sqlite_store.rs`(新增) |
| 前置依赖 | Task B1rusqlite + tempfile 依赖)、Task A1store/ 目录存在) |
| 预估工作量 | M1-4h |
| 风险等级 | 高 — 3 个技术点需留意:`time::OffsetDateTime``rusqlite::ToSql` 实现,需显式处理 String 绑定;`spawn_blocking` + `&self` 生命周期需 clone 后才能传闭包;错误映射需精细匹配 `rusqlite::Error` 嵌套变体(`SqliteFailure` 内含 `ErrorCode` |
| 验收条件 | 单元测试通过(见 Task B4)、`cargo build` 通过 |
#### Task B3 — 注册模块并重导出
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `store.rs` 添加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`;在 `memory.rs` 添加 `pub use store::SqliteStore;` |
| 涉及文件 | `src/memory/store.rs`(修改)、`src/memory.rs`(修改) |
| 前置依赖 | Task B2sqlite_store.rs 文件存在) |
| 预估工作量 | XS< 15min |
| 风险等级 | 低 |
| 验收条件 | `cargo build` 通过,`SqliteStore` 可从 `agcore::memory::SqliteStore` 路径访问 |
#### Task B4 — 编写测试
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `sqlite_store.rs` 中编写 `#[cfg(test)] mod tests`,覆盖: |
| | 1CRUD 基本操作(save → get → list → delete → get None |
| | 2Upsert 语义(同 id 重复 save 覆盖内容,created_at 保持调用方传入值) |
| | 3prefix 过滤(MemoryFilter.prefix |
| | 4)时间范围过滤(MemoryFilter.since |
| | 5offset/limit 分页 |
| | 6)并发 10 个 writer × 10 次写入,验证无数据丢失 |
| | 7)持久化恢复(write → drop store → reopen 同一文件 → read |
| | 8)错误路径:`open("/nonexistent_dir/ag.db")` 返回 Storage 错误 |
| | 辅助函数:`make_item(id)` 创建 MemoryItem,使用 `tempfile::TempDir`(或自定义 tmp 路径)隔离测试数据库文件 |
| 涉及文件 | `src/memory/store/sqlite_store.rs`(修改追加 test mod |
| 前置依赖 | Task B2(实现完成) |
| 预估工作量 | M1-4h |
| 风险等级 | 中 — 并发测试的时序控制、持久化恢复测试的 TempDir 管理 |
| 验收条件 | 全量测试通过,新增测试 ≥ 8 个 |
#### Task B5 — 验证阶段二
| 项目 | 内容 |
|------|------|
| 任务描述 | 运行全量测试链确认 SqliteStore 实现正确,不影响已有模块 |
| 涉及文件 | 全量 |
| 前置依赖 | Task B3、Task B4 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 |
| 验收条件 | `cargo test --all-targets` 全绿(191 + 新增测试)、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
### 阶段三:验证收尾
#### Task C1 — 跨模块兼容性验证
| 项目 | 内容 |
|------|------|
| 任务描述 | 1)确认 `ConversationMemory` / `KnowledgeStore` / `MemoryRetriever` / `AgentSession` / `SessionMemory``Arc<dyn MemoryStore>` 接受 SqliteStore 时编译通过且测试全绿 |
| | 2)确认 SqliteStore 与 InMemoryStore 可互换——修改一个现有测试将后端从 InMemoryStore 换为 SqliteStore(使用 `":memory:"`),测试全绿 |
| | (3)验证性能预算:在测试环境下测量 save(1KB)/get(1KB)/list(100行) 的单次延迟,确认 < 5ms / < 3ms / < 20ms |
| 涉及文件 | 测试文件(memory/ 模块内各 test mod |
| 前置依赖 | Task B5 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 |
| 验收条件 | 全量测试通过 + 互换测试通过 + 性能预算大致满足(未达标不阻塞发布) |
### 依赖关系图
```
阶段一(目录重构) 阶段二(SqliteStore 实现)
┌──────────────┐ ┌──────────────┐
│ Task A1 │ │ Task B1 │ ← 无依赖,可与 A 并行
│ 创建目录+提取 │ │ Cargo.toml │
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Task A2 │ │ Task B2 │
│ 修改store.rs │← A1 ───→│ 核心实现 │← B1 + A1
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Task A3 │ │ Task B3 │← B2 ──→│ Task B4 │
│ 验证阶段一 │ │ 注册+重导出 │ │ 编写测试 │
└──────────────┘ └──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐────────────────┘
│ Task B5 │← B3 + B4
│ 验证阶段二 │
└──────┬───────┘
阶段三(验证收尾)
┌──────────────┐
│ Task C1 │
│ 跨模块兼容性 │
└──────────────┘
```
### Commit 安排
| 顺序 | Commit 类型 | Scope | 描述 | 包含 Task |
|------|------------|-------|------|-----------|
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 | A1 → A2 → A3 |
| 2 | feat | memory | 实现 SqliteStore 持久化(含错误映射、测试、WAL 模式) | B1 → B2 → B3 → B4 → B5 → C1 |
注意:Task B1 与阶段一无依赖,可以在 Commit 1 合并进行或在 Commit 2 开头。建议在 Commit 2 开头,因为 Cargo.toml 变更属于功能变更而非重构。
### 验证全链
实施完毕后整体认证链路:
1. `cargo test --all-targets` — 全量测试通过
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
3. `cargo build --release` — release 构建通过
4. 确认 `cargo doc --no-deps` 无 warning(新增公开类型文档注释)
5. 确认测试数量:191 + (8 个 new sqlite_store tests) = 199+ pass