mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
块 union 化后媒体字节归块所有(memory_blocks.data),但存量库里
data 为空、payload_digest 仍指向旧 CAS。不回填就删 CAS ⇒ BlockData
取不到字节,图永久丢失。本提交补上这一步。
生产数据实测(/home/newqqagent/memory):
- graph.db 112 个媒体块,引 106 个唯一 digest
- media.db 156 条 + blobs 87M,112/112 digest 命中
内核(internal/memory/migrate_blockdata.go)
- BackfillBlockData(resolver):单事务、全回滚,按 payload_digest
把字节写进块自身 data 列。保留原块 ID —— 边/场景/向量都挂在它上面。
- CountBlockDataBackfillCandidates:报告模式,不写库。
- ProbeBackfillAvailability:dry-run 看 CAS 命中率。
- ensurePayloadDigestColumn:★ 关键。block union 化后新库的 CREATE
已删该列,旧库还留着。迁移必须两者都能跑:旧库忽略 duplicate
column 错误,新库补列后得到 0 候选(安全空操作),而不是崩在
"no such column"。
- CAS 取不到字节的块计 Missing 并跳过(既有数据损失,不阻断其余)。
命令(cmd/homed-media-backfill)
- 默认只报告,-apply 才写;写前自动 sqlite3 .backup 快照。
- 回填后立刻 BlockData 抽样自检。
判据(4 条单元 + 1 条真实数据 e2e,全部做过变异测试)
- RoundTrip:BlockData 取回字节 == 来源逐字节一致
- MissingDigestSkipped:缺失内容计 Missing 且不阻断
- IdempotentThreeRuns:连跑三次只真回填一次
- FreshDBSafeNoOp:新库(无该列、无历史数据)上是安全空操作
- E2E(-tags e2e,读真 graph.db):112 块全部可取回、sha256 与
payload_digest 全等、抽样 20 块用自己的向量自召回 score=1.0
变异测试(判据不是装饰):
- 回填写空字节 → RoundTrip 挂
- Missing 分支改为也写入 → MissingDigestSkipped 挂
- 候选查询不排除已回填 → IdempotentThreeRuns 挂
- 移除 ensure 调用 → FreshDBSafeNoOp 挂(no such column)
生产数据 e2e 已在 /tmp/e2e-backfill 副本上完成(sqlite3 .backup 快照 +
CAS 拷贝):报告 112/112 命中 → apply 回填 112 块 55.03MB → 独立
交叉验证(python hashlib 全量 112/112 sha256 吻合 + sha256sum 对照
磁盘 blob 一致)→ 幂等复跑 0 候选 → 读路径召回 20/20 命中。
241 lines
7.8 KiB
Go
241 lines
7.8 KiB
Go
package memory
|
||
|
||
import (
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// BackfillBlockData 把旧 CAS(payload_digest)的字节搬进块自身的 data 列。
|
||
//
|
||
// ★ 测试不 import internal/memory/media:字节来源是注入的 resolver,
|
||
// 正是为了让 memory 包对 CAS 零依赖(step⑥ 要删 media 包)。
|
||
// 真实 CAS 读盘 + 生产数据 e2e 由 cmd/homed-media-backfill 承担。
|
||
//
|
||
// 判据是**不变量**而不是「函数被调过」:
|
||
// 1. 回填后 BlockData(id) 能取回字节,且与来源逐字节一致
|
||
// 2. 幂等:再跑一次是空操作(已回填的块不再处理)
|
||
// 3. 来源取不到字节的块计 Missing 并跳过,不阻断其余回填
|
||
// 4. 回填只改 data 列,不动块 ID / 不建块 / 不删块
|
||
func TestBackfillBlockData_RoundTrip(t *testing.T) {
|
||
dir := t.TempDir()
|
||
g, err := NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
if err != nil {
|
||
t.Fatalf("NewGraphDB: %v", err)
|
||
}
|
||
t.Cleanup(func() { g.Close() })
|
||
|
||
// 旧 CAS 的等价物:digest → 字节
|
||
cas := map[string][]byte{
|
||
"d1": []byte("img-one"),
|
||
"d2": []byte("img-two"),
|
||
"d3": []byte("img-three"),
|
||
}
|
||
|
||
// 造 3 个媒体块,带 payload_digest(旧形态:块 ID 不是内容派生)
|
||
// ★ 必须用原生 SQL 写 payload_digest:MemoryBlock 结构体已删该字段,
|
||
// PutMemoryBlocks 写不进去。这正是存量生产库的形态——
|
||
// 块 ID 是 blk_<ts>,data 为空,payload_digest 指向 CAS。
|
||
// ★ 新库的 CREATE 不含该列(block union 化后删掉了),而「旧库有这列」
|
||
// 正是迁移的前提,所以先补列来模拟旧库形态。
|
||
if err := g.ensurePayloadDigestColumn(); err != nil {
|
||
t.Fatalf("ensure payload_digest: %v", err)
|
||
}
|
||
for _, b := range []struct{ id, digest string }{
|
||
{"blk_old_1", "d1"},
|
||
{"blk_old_2", "d2"},
|
||
{"blk_old_3", "d3"},
|
||
} {
|
||
if _, err := g.db.Exec(`INSERT INTO memory_blocks
|
||
(id, modality, payload_digest, mime, created_at, updated_at)
|
||
VALUES (?, 'image', ?, 'image/png', datetime('now'), datetime('now'))`,
|
||
b.id, b.digest); err != nil {
|
||
t.Fatalf("insert %s: %v", b.id, err)
|
||
}
|
||
}
|
||
|
||
// 回填前:data 列为空(块形态对,但字节不在块里)
|
||
before, _ := g.CountBlockDataBackfillCandidates()
|
||
if before != 3 {
|
||
t.Fatalf("回填前候选应为 3,实际 %d", before)
|
||
}
|
||
for _, b := range []string{"blk_old_1", "blk_old_2", "blk_old_3"} {
|
||
if data, err := g.BlockData(b); err == nil && len(data) > 0 {
|
||
t.Fatalf("回填前块 %s 不该有字节", b)
|
||
}
|
||
}
|
||
|
||
resolve := func(digest string) ([]byte, bool) {
|
||
data, ok := cas[digest]
|
||
return data, ok
|
||
}
|
||
|
||
// 执行回填
|
||
st, err := g.BackfillBlockData(resolve)
|
||
if err != nil {
|
||
t.Fatalf("BackfillBlockData: %v", err)
|
||
}
|
||
if st.Candidates != 3 || st.Filled != 3 || st.Missing != 0 {
|
||
t.Fatalf("回填统计不对: %+v", st)
|
||
}
|
||
if st.SizeFilled != 7+7+9 {
|
||
t.Errorf("SizeFilled = %d,期望 23", st.SizeFilled)
|
||
}
|
||
|
||
// 判据 1:BlockData 取回的字节与来源逐字节一致
|
||
for _, b := range []struct{ id, digest string }{
|
||
{"blk_old_1", "d1"},
|
||
{"blk_old_2", "d2"},
|
||
{"blk_old_3", "d3"},
|
||
} {
|
||
got, err := g.BlockData(b.id)
|
||
if err != nil {
|
||
t.Fatalf("BlockData(%s): %v", b.id, err)
|
||
}
|
||
if string(got) != string(cas[b.digest]) {
|
||
t.Errorf("块 %s 字节 = %q,期望 %q", b.id, got, cas[b.digest])
|
||
}
|
||
}
|
||
|
||
// 判据 2:幂等 —— 再跑一次是空操作
|
||
st2, err := g.BackfillBlockData(resolve)
|
||
if err != nil {
|
||
t.Fatalf("幂等回填: %v", err)
|
||
}
|
||
if st2.Candidates != 0 || st2.Filled != 0 {
|
||
t.Fatalf("幂等应空操作,实际 %+v", st2)
|
||
}
|
||
|
||
// 判据 4:候选清零,且块 ID 原样保留(迁移不换身份)
|
||
after, _ := g.CountBlockDataBackfillCandidates()
|
||
if after != 0 {
|
||
t.Errorf("回填后候选应为 0,实际 %d", after)
|
||
}
|
||
ids, _ := g.SampleFilledBlockIDs(10)
|
||
if len(ids) != 3 {
|
||
t.Errorf("带字节块数应为 3,实际 %d", len(ids))
|
||
}
|
||
}
|
||
|
||
// 来源里取不到字节的块计 Missing 并跳过,不阻断其余回填。
|
||
func TestBackfillBlockData_MissingDigestSkipped(t *testing.T) {
|
||
dir := t.TempDir()
|
||
g, err := NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
if err != nil {
|
||
t.Fatalf("NewGraphDB: %v", err)
|
||
}
|
||
t.Cleanup(func() { g.Close() })
|
||
|
||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||
{ID: "blk_ok", Modality: BlockImage, MIME: "image/png"},
|
||
{ID: "blk_lost", Modality: BlockImage, MIME: "image/png"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// payload_digest 用原生 SQL 注入(同上原因;先确保列存在)
|
||
if err := g.ensurePayloadDigestColumn(); err != nil {
|
||
t.Fatalf("ensure payload_digest: %v", err)
|
||
}
|
||
if _, err := g.db.Exec(`UPDATE memory_blocks SET payload_digest = 'exists' WHERE id = 'blk_ok'`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := g.db.Exec(`UPDATE memory_blocks SET payload_digest = 'gone' WHERE id = 'blk_lost'`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// resolver 只认 "exists"
|
||
resolve := func(digest string) ([]byte, bool) {
|
||
if digest == "exists" {
|
||
return []byte("bytes"), true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
st, err := g.BackfillBlockData(resolve)
|
||
if err != nil {
|
||
t.Fatalf("BackfillBlockData: %v", err)
|
||
}
|
||
if st.Candidates != 2 || st.Filled != 1 || st.Missing != 1 {
|
||
t.Fatalf("统计不对: %+v", st)
|
||
}
|
||
|
||
// ok 块有字节,lost 块仍空
|
||
if data, _ := g.BlockData("blk_ok"); string(data) != "bytes" {
|
||
t.Errorf("blk_ok 字节不对: %q", data)
|
||
}
|
||
if data, _ := g.BlockData("blk_lost"); len(data) != 0 {
|
||
t.Errorf("blk_lost 不该有字节: %q", data)
|
||
}
|
||
}
|
||
|
||
// 迁移是幂等的:连跑三次,结果与跑一次相同(不重复计数、不覆盖已有字节)。
|
||
func TestBackfillBlockData_IdempotentThreeRuns(t *testing.T) {
|
||
dir := t.TempDir()
|
||
g, _ := NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
t.Cleanup(func() { g.Close() })
|
||
|
||
if err := g.ensurePayloadDigestColumn(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := g.db.Exec(`INSERT INTO memory_blocks (id, modality, payload_digest, created_at, updated_at)
|
||
VALUES ('b1', 'image', 'k', datetime('now'), datetime('now'))`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
resolve := func(string) ([]byte, bool) { return []byte("payload"), true }
|
||
|
||
total := 0
|
||
for i := 0; i < 3; i++ {
|
||
st, err := g.BackfillBlockData(resolve)
|
||
if err != nil {
|
||
t.Fatalf("run %d: %v", i, err)
|
||
}
|
||
total += st.Filled
|
||
}
|
||
if total != 1 {
|
||
t.Errorf("三次回填累计 Filled = %d,期望 1(只有第一次真回填)", total)
|
||
}
|
||
if data, _ := g.BlockData("b1"); string(data) != "payload" {
|
||
t.Errorf("字节丢失: %q", data)
|
||
}
|
||
}
|
||
|
||
// 全新库(CREATE 不含 payload_digest 列、无历史数据)上回填必须安全。
|
||
//
|
||
// 这是 ensurePayloadDigestColumn 存在的理由:block union 化后新建的库
|
||
// 根本没有该列,而迁移命令可能被误跑在新库上——此时应当补列后得到
|
||
// 「0 候选」,而不是报 "no such column" 崩掉。
|
||
func TestBackfillBlockData_FreshDBSafeNoOp(t *testing.T) {
|
||
dir := t.TempDir()
|
||
g, _ := NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
t.Cleanup(func() { g.Close() })
|
||
|
||
// 放几个正常写入的块(新形态:字节直接在 data,无 payload_digest)
|
||
if err := g.PutMemoryBlocks([]MemoryBlock{
|
||
{ID: "blk_new_1", Modality: BlockImage, MIME: "image/png", Data: []byte("already-inline")},
|
||
{ID: "blk_new_2", Modality: BlockText, Text: "hello"},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 候选查询不得因缺列而报错
|
||
n, err := g.CountBlockDataBackfillCandidates()
|
||
if err != nil {
|
||
t.Fatalf("CountBlockDataBackfillCandidates 在新库上报错: %v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Errorf("新库候选应为 0,实际 %d", n)
|
||
}
|
||
|
||
// 回填是空操作,不碰已有字节
|
||
st, err := g.BackfillBlockData(func(string) ([]byte, bool) { return nil, false })
|
||
if err != nil {
|
||
t.Fatalf("新库回填报错: %v", err)
|
||
}
|
||
if st.Candidates != 0 || st.Filled != 0 {
|
||
t.Errorf("新库回填应空操作,实际 %+v", st)
|
||
}
|
||
if data, _ := g.BlockData("blk_new_1"); string(data) != "already-inline" {
|
||
t.Errorf("新库已有字节被改: %q", data)
|
||
}
|
||
}
|