Commit Graph

2 Commits

Author SHA1 Message Date
4ef3371701 test(memory): 媒体存储的压力、冒烟与长稳测试
在 a822674 的 CAS 层之上补齐三类验证。

## 压力测试(stress_test.go,9 例)

核心不是吞吐数字,而是并发下的不变量。为此写了 checkRefIntegrity:
用 SQL 对比每个 digest 的 ref_count 与 media_refs 实际行数。这条对不上
就意味着 GC 的判断依据是错的——计数偏低会误删有引用的内容,虚高会让
孤儿永远清不掉。所有并发用例收尾都验它。

  - 32 goroutine 并发 Put 同一内容 → digest 一致、磁盘只 1 份
  - 400 个不同内容并发入库 → 无丢条目、逐条回读无内容串位
  - 24 worker × 40 轮引用增删风暴(含故意重复 AddRef 验并发下的幂等)
  - GC 与读写并发 1.5s → 实测 11508 次 Put / 604 轮 GC,受保护内容零失败
  - Describe 与 Search/Pending 并发 → 无 database is locked
  - 容量上限持续加压 → 上限 256KB 收尾 98KB,有引用项全存活
  - 重度 churn 后重开 → 磁盘文件数 == 元数据条数,无双向孤儿
  - 4MB 单文件往返(see_video 10 帧 × 2MB 是现实上限附近)
  - data URL 往返 ×50(SetToolBlocks 给出的实际形态)

-race -count=3 干净。

## 冒烟测试(smoke_test.go,6 场景)

走真实数据路径:真 PNG(自建 IHDR/IDAT/IEND + zlib)、真 data URL、
真 sha256、真 GC、真重启,而不是随机字节。

  - 同一张截图连问 5 轮 → 磁盘 1 份、5 个 context 引用
  - see_video 6 帧内容各异 → 各存一份、共享一个 owner
  - 描述落库后按关键词检索命中(方案 C 最关键的一环:blob 可被淘汰,
    描述会长期留在记忆里)
  - L0→L2 归档时引用从 context owner 转到 document owner,期间内容可读
  - 别的工具留下的一次性图被 GC 清掉,被记忆引用的一个不少
  - 全生命周期跨重启:描述、引用、内容、磁盘一致性全部完好

第一次跑挂在「6 帧只搜到 5 条」,看着像存储丢帧,实际是夹具的
palette[(variant+y*3/h)%5] 只有 5 色,variant=0 与 5 产出逐字节相同的
PNG,被 CAS 正确去重。已把 variant 写进像素保证帧间真不同,并把这段
经过记进注释——误报本身证明了去重在工作,也证明冒烟确实有能力发现
「帧数对不上」这类问题。

冒烟原先是 internal/memory/media/smoke/ 下带 //go:build smoke 的独立
main,得记着加 -tags smoke 才跑得到,那种早晚被忘掉。已搬成普通测试,
随 go test ./... 一起跑,冒烟的意义才真正成立。

## 长稳测试(soak_test.go,-short 下跳过)

60 秒五路混合负载。实测:put=281885 get=1644084 gc=9142
describe=53875 search=23268 refOps=187926,零失败。收尾 20 个受保护项
内容字节一致、ref_count 全为 1;8MB 上限下实际占用 139KB / 48 条,
说明 28 万次写入产生的孤儿被持续清理,无无界增长。

描述者从 Pending() 取项再 Describe(),GC 随时可能在这两步之间清掉它。
这是正常竞态,故忽略 unknown digest 并注明原因;5 万多次调用没把它
升级成计数错位,印证了 Describe 对已删项返回错误而非静默建条目的选择。

全仓 go build / go vet / go test 通过,SDK 接口冻结 diff 为 0。
2026-09-04 11:29:18 +08:00
a82267484a feat(memory): 内容寻址媒体存储(CAS)——图记忆支持二进制多媒体节点的底座
此前四层记忆全是纯文本载体,没有任何一层能存二进制:
  L0 ContextEvent  — Input/Response/ToolResults[].Output 全 string
  L1 text.Event    — 同上
  L2 document.Doc  — Summary/Content/Tags 全 string
  L3 图库           — sentences.text TEXT UNIQUE,节点身份就是那串文本

于是 multimodal 插件注入的图只在本轮对话内可见(走 message 数组,不经
记忆),下一轮起只剩 ToolResultItem.Output 里那句
"[已将图片注入后续对话] /tmp/x.png"——一条路径字符串。那个文件被删或
被覆盖之后连线索都断了。

## 为何内容寻址而不是存路径

- 路径会失效。/tmp 下的探针图、下载缓存、别的进程的临时产物,记忆里
  留个路径等于留个悬空指针。
- 同一内容常被反复注入(连问几轮同一张截图、see_video 相邻帧高度相似),
  按 sha256 寻址天然去重。
- 内容即身份,与 L3 图库 sentences.text UNIQUE 思路一致:文本节点用文本
  本身做身份,媒体节点用内容摘要做身份。

## 结构

元数据(SQLite media.db)与内容(磁盘 blobs/ 两级前缀分桶)分离,不把
blob 塞进库:单张图动辄几 MB,塞进去让每次 VACUUM/备份都拖着几百 MB 走,
WAL 也会迅速膨胀。

  media(digest PK, kind, mime, size, width, height, origin_path, tool,
        description, described_by, ref_count, first_seen, last_seen)
  media_refs(digest, owner_kind, owner_id, created_at, PK 三列)

digest 既是主键也是文件名,所以没有 Path 字段——路径由 digest 推导,
不落库(落了就又是个会失效的引用)。origin_path 仅供人类溯源,注释里
明确标注不可用于读取。owner_kind 预留 context/document/graph_sentence。

## 几处刻意的决定

- Get 强制校验 digest:CAS 的全部保证建立在「文件名 == 内容摘要」上,
  位翻转或外部误改必须被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
- 先写 .tmp 再 rename:中途崩溃不留半个 blob 被当成完整内容读走。
- AddRef 幂等:只有真插进 media_refs 才递增,否则计数虚高会让 GC 永远
  不敢清。DropRef 用 MAX(0,...) 兜底防负数。
- Put 的空描述不冲掉已有描述(先到的可能来自更强的模型),但尺寸/工具名
  这类前一次缺失的信息会被补写。Describe 是显式操作,允许覆盖。
- GC 两段 + minAge 保护:刚 Put 还没 AddRef 的项 refcount 也是 0,minAge
  防「落地后还没挂上就被清掉」。有引用的项永不删除,即使超容量——宁可
  超限也不断引用。

## 测试

21 例,覆盖去重 / MIME 归类 / 损坏检测 / 无残留临时文件 / AddRef 幂等 /
计数不为负 / DropOwner / GC 保留有引用项 / minAge 保护 / 容量淘汰 /
描述覆盖与补写策略 / Search 按描述与 kind 过滤 / Pending / data URL
往返 / Stats / 跨重启持久化。

本 commit 只加存储层,尚未接入 L0/L2/L3 与描述生成。
2026-09-04 11:03:36 +08:00