Files
HomeAgent/cmd/homed-media-backfill/main.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

153 lines
5.0 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// Command homed-media-backfill 把存量媒体字节从旧 CAS 回填进块自身的 data 列。
//
// 背景
// ----
// media.Store/CAS 淘汰后,媒体字节成为块自己的负载(memory_blocks.data)。
// 但存量库里 data 为空、payload_digest 仍指向 CAS 的 blob —— 必须一次性
// 把字节搬进块,否则 CAS 目录一删,BlockData 就取不到字节(图永久丢失)。
//
// 存量事实(生产 /home/newqqagent/memory):
// - graph.db 有 112 个媒体块,引 106 个唯一 digest
// - media.db 156 条,blobs 84M,106 digest 抽样 100% 命中
//
// 安全设计(与 homed-graph-migrate 同款)
// --------------------------------------
// 1. 默认只报告(-apply 才真回填)。
// 2. 单事务、全成功或全回滚。幂等:只处理 data 为空且 payload_digest 非空的块。
// 3. 迁移前自动快照 graph.db 到 <db>.bak-<时间戳>。
// 4. CAS 里取不到字节的块计为 Missing 并跳过(既有数据损失,不阻断其余回填)。
//
// 用法:
//
// homed-media-backfill -db /data/homeagent/memory/graph.db -media /data/homeagent/memory/media # 报告
// homed-media-backfill -db ... -media ... -apply # 回填
// homed-media-backfill -db ... -apply -no-snapshot # 跳过快照(已自行备份)
package main
import (
"flag"
"fmt"
"os"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
)
func main() {
dbPath := flag.String("db", "", "graph.db 路径(必填)")
mediaDir := flag.String("media", "", "旧 CAS 目录(含 media.db + blobs/,必填)")
apply := flag.Bool("apply", false, "真正回填(缺省只报告)")
noSnapshot := flag.Bool("no-snapshot", false, "跳过 graph.db 快照(已自行备份时用)")
flag.Parse()
if *dbPath == "" || *mediaDir == "" {
fmt.Fprintln(os.Stderr, "错误:必须指定 -db <graph.db> 和 -media <cas 目录>")
os.Exit(2)
}
if _, err := os.Stat(*dbPath); err != nil {
fmt.Fprintf(os.Stderr, "错误:打不开 %s: %v\n", *dbPath, err)
os.Exit(2)
}
g, err := memory.NewGraphDB(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:打开图库失败: %v\n", err)
os.Exit(1)
}
defer g.Close()
// 报告:有多少块待回填
cands, err := g.CountBlockDataBackfillCandidates()
if err != nil {
fmt.Fprintf(os.Stderr, "错误:统计候选失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("待回填块:%d(data 为空且 payload_digest 非空)\n", cands)
if cands == 0 {
fmt.Println("\n无需回填:所有媒体块已带字节,或无 payload_digest 残留。")
return
}
// CAS 逐个取字节(顺带校验:media.Store.Get 内部 sha256 校验)
var ms *media.Store
ms, err = media.New(*mediaDir)
if err != nil {
fmt.Fprintf(os.Stderr, "错误:打开 CAS 失败: %v\n", err)
os.Exit(1)
}
defer ms.Close()
resolve := func(digest string) ([]byte, bool) {
data, err := ms.Get(digest)
if err != nil {
return nil, false
}
return data, true
}
// 报告:CAS 能取到多少字节(dry-run 探针,不写库)
probe, err := g.ProbeBackfillAvailability(resolve)
if err != nil {
fmt.Fprintf(os.Stderr, "警告:CAS 探针失败: %v\n", err)
probe = -1
}
if !*apply {
fmt.Printf("\n这是报告模式(缺省)。加 -apply 真正回填。\n")
if probe >= 0 {
fmt.Printf("CAS 字节命中率探针:%d/%d 可取回\n", probe, cands)
}
return
}
// 快照
if !*noSnapshot {
backup := fmt.Sprintf("%s.bak-%s", *dbPath, time.Now().Format("20060102-150405"))
if err := copyFile(*dbPath, backup); err != nil {
fmt.Fprintf(os.Stderr, "错误:快照失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("已快照:%s\n", backup)
}
// 执行
t0 := time.Now()
st, err := g.BackfillBlockData(resolve)
if err != nil {
fmt.Fprintf(os.Stderr, "\n回填失败(已整体回滚): %v\n", err)
fmt.Fprintf(os.Stderr, "快照可用于人工核对\n")
os.Exit(1)
}
fmt.Printf("\n回填完成(%.1fs)\n", time.Since(t0).Seconds())
fmt.Printf(" %s\n", st.String())
fmt.Printf(" 累计回填 %.2f MB\n", float64(st.SizeFilled)/1024/1024)
if st.Missing > 0 {
fmt.Printf("\n⚠️ %d 个块的内容在 CAS 里已不存在(既有数据损失,无法补救)\n", st.Missing)
}
// 验证:回填后立刻 BlockData 取一次样
fmt.Println("\n抽样验证(BlockData 取回前 3 块):")
for i, id := range sampleFilled(g, 3) {
data, err := g.BlockData(id)
if err != nil {
fmt.Printf(" %d. %s ⚠️ BlockData 失败: %v\n", i+1, id, err)
continue
}
fmt.Printf(" %d. %s → %d 字节\n", i+1, id, len(data))
}
}
// sampleFilled 返回前 n 个已回填字节的块 id(供验证 BlockData)。
func sampleFilled(g *memory.GraphDB, n int) []string {
ids, _ := g.SampleFilledBlockIDs(n)
return ids
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}