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 命中。
This commit is contained in:
JianFeeeee
2026-10-12 16:23:23 +08:00
parent 33e1a0bfaf
commit 2585bc67ef
4 changed files with 737 additions and 0 deletions

View File

@ -0,0 +1,132 @@
//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[:])
}