Files
HomeAgent/internal/memory/e2e_backfill_test.go
JianFeeeee 2585bc67ef feat(memory)!: 存量媒体字节回填(块 union 化第三阶段:数据迁移)
块 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 命中。
2026-10-12 16:23:23 +08:00

133 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build e2e
// e2e-media-backfill:在**真实生产数据副本**上验证块 union 化的读路径。
//
// 与单元测试的区别:单元测试用注入的假 resolver,这里读真的 graph.db +
// 真的 CAS 字节,走 RecallBlocks(向量召回)与 BlockData(负载按需取回)
// 两条真实读路径。
//
// 用法:
//
// E2E_DB=/tmp/e2e-backfill/graph.db go test -tags 'onnxruntime e2e' -run TestE2EMediaBackfill -v ./internal/memory/
package memory
import (
"crypto/sha256"
"encoding/hex"
"os"
"testing"
)
// 真实数据 e2e:回填后的 112 个媒体块必须
// 1. BlockData(id) 能取回非空字节(负载真的在块里)
// 2. 字节 sha256 与 payload_digest 一致(身份没变)
// 3. 用自己的向量召回自己 —— 命中且 Score≈1(读路径端到端可用)
// 4. 媒体块的 modality / mime 没被回填改写(只改 data 列)
func TestE2EMediaBackfill(t *testing.T) {
dbPath := os.Getenv("E2E_DB")
if dbPath == "" {
t.Skip("E2E_DB 未设置;跳过真实数据 e2e")
}
g, err := NewGraphDB(dbPath)
if err != nil {
t.Fatalf("open %s: %v", dbPath, err)
}
t.Cleanup(func() { g.Close() })
// 找出所有带 payload_digest 的媒体块
rows, err := g.db.Query(`SELECT id, payload_digest, modality, mime
FROM memory_blocks WHERE payload_digest != '' AND modality != 'text' ORDER BY id`)
if err != nil {
t.Fatalf("query: %v", err)
}
type row struct{ id, digest, modality, mime string }
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.digest, &r.modality, &r.mime); err != nil {
rows.Close()
t.Fatalf("scan: %v", err)
}
all = append(all, r)
}
rows.Close()
if len(all) == 0 {
t.Skip("库里没有带 payload_digest 的媒体块;跳过")
}
t.Logf("媒体块总数:%d", len(all))
// 判据 1 + 2:BlockData 取回非空,且 sha256 与 payload_digest 一致
empty, mismatch := 0, 0
for _, r := range all {
data, err := g.BlockData(r.id)
if err != nil {
t.Errorf("BlockData(%s): %v", r.id, err)
continue
}
if len(data) == 0 {
empty++
continue
}
if got := sha256Hex(data); got != r.digest {
mismatch++
if mismatch <= 3 {
t.Errorf("块 %s 字节 sha256=%s ≠ payload_digest=%s", r.id, got[:16], r.digest[:16])
}
}
}
if empty > 0 || mismatch > 0 {
t.Fatalf("判据 1/2 失败:空 data=%d,sha256 不吻合=%d", empty, mismatch)
}
t.Logf("判据 1/2 通过:%d 块全部可取回且字节身份吻合", len(all))
// 判据 3:用媒体块自己的向量召回自己(读路径端到端)
sampled, recalled := 0, 0
for _, r := range all {
b, ok, err := g.BlockByIDPrefix(r.id)
if err != nil || !ok || len(b.Vector) == 0 {
continue // 无向量的块跳过(回填不负责算向量)
}
sampled++
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: b.Vector,
Fingerprint: b.Fingerprint,
TopK: 5,
})
if err != nil {
t.Fatalf("RecallBlocks(%s): %v", r.id, err)
}
for _, h := range hits {
if h.Block.ID == r.id {
recalled++
if sampled <= 3 {
t.Logf(" 召回命中:%s score=%.4f", r.id, h.Score)
}
break
}
}
if sampled >= 20 {
break // 抽 20 个够证明读路径
}
}
if sampled > 0 && recalled < sampled {
t.Errorf("判据 3 失败:抽样 %d 个块,只有 %d 个能被自己的向量召回", sampled, recalled)
}
t.Logf("判据 3 通过:抽样 %d 个有向量的块,%d 个自召回命中", sampled, recalled)
// 判据 4:modality/mime 未被回填改写
for _, r := range all {
if r.modality == "" {
t.Errorf("块 %s modality 被清空", r.id)
}
if r.mime == "" {
t.Errorf("块 %s mime 被清空", r.id)
}
}
t.Logf("判据 4 通过:modality/mime 原样保留")
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}