28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
527 lines
28 KiB
Markdown
527 lines
28 KiB
Markdown
# 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 8(MVP 出口)和 Phase 10(ContextSlot)的前置依赖。
|
||
|
||
**成功标准**:
|
||
- SqliteStore 完整实现 MemoryStore trait(4 个方法: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 存储反而需要手动处理排序逻辑。非必要不引入新存储范式。
|
||
|
||
---
|
||
|
||
## 推荐方案
|
||
|
||
### 总体方向:方案 A(rusqlite + 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 自然完成或 panic,Mutex 通过 `.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/Redis,v0.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 B1(rusqlite + tempfile 依赖)、Task A1(store/ 目录存在) |
|
||
| 预估工作量 | M(1-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 B2(sqlite_store.rs 文件存在) |
|
||
| 预估工作量 | XS(< 15min) |
|
||
| 风险等级 | 低 |
|
||
| 验收条件 | `cargo build` 通过,`SqliteStore` 可从 `agcore::memory::SqliteStore` 路径访问 |
|
||
|
||
#### Task B4 — 编写测试
|
||
|
||
| 项目 | 内容 |
|
||
|------|------|
|
||
| 任务描述 | 在 `sqlite_store.rs` 中编写 `#[cfg(test)] mod tests`,覆盖: |
|
||
| | (1)CRUD 基本操作(save → get → list → delete → get None) |
|
||
| | (2)Upsert 语义(同 id 重复 save 覆盖内容,created_at 保持调用方传入值) |
|
||
| | (3)prefix 过滤(MemoryFilter.prefix) |
|
||
| | (4)时间范围过滤(MemoryFilter.since) |
|
||
| | (5)offset/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(实现完成) |
|
||
| 预估工作量 | M(1-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
|