- 引用计数真实维护(documents 表唯一写入面):增减点只有四处 —— history 的 insert / UpdateMessageDocument / DeleteMessage / DeleteSession,且**全在同一事务**内(计数写失败 ⇒ 消息写入整体回滚,有钉住用例);UpdateMessageDocument 按新旧名字**集合差**增减(三阶段回写是常态,重算会重复计数 ⇒ 文件永远回收不掉)。 - GC:候选 = ref_count = 0;判据 = last_referenced_at(NULL 回落 created_at)早于 now − 保留期;动作 = 删 sidecar + 原件 + 元数据行(删行带 AND ref_count = 0 原子再确认);路径双检;汇总日志(含实占字节);单件失败 WARN 并继续;删除顺序 = 先文件后行(失败则行留着下轮重试,不留孤儿)。 - 组合根:启动期清扫一次(不阻塞启动)+ ticker 常驻(可停,close 顺序在关库之前);env DOCUMENT_GC_INTERVAL 默认 1h / DOCUMENT_RETENTION 默认 168h,**任一 ≤0 = 关闭 GC**(RETENTION=0 不解释成立即回收,关闭时记 WARN);.env.example 已同步(AGENTS §10)。 - 主会话独立验收:隔离 worktree(S7 的 18 文件,排除 F2 在途的 mcp/vision)build/vet 绿、全树 59 包 0 FAIL、GC/引用用例 20 PASS、双平台交叉编译绿;**变异亲自复现**(候选查询去掉 ref_count = 0 ⇒ TestGCDoesNotDeleteReferencedDocument 红:candidates = 1, want 0)。 - 未做(任务书允许的「只做后端」):面板手动「清理未引用附件」入口 —— 后端能力已就绪(documents.NewGC + GC.Sweep),接成端点属小切片(含 admin 路由 + 审计 document.gc + 契约登记)。 - 已知窄窗口(已在 gc.go 文件头登记):一轮 GC 与一次落库几乎同时发生时可能删掉刚被引用的文件(行不删、记 WARN);坏 document 列只记 WARN 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
133 lines
5.1 KiB
Go
133 lines
5.1 KiB
Go
package documents
|
||
|
||
// 本文件是**引用计数 × 回收**的端到端验收(必测 4:引用计数正确性):一条真实
|
||
// 链路 —— `documents.NewRefCounter` 注入 `history.Store` 之后,「两条消息引用 ⇒
|
||
// ref_count = 2;删一条 ⇒ 1(仍不删);删最后一条 ⇒ 0,超期后才可删」。
|
||
//
|
||
// 为什么必须有它:RefCounter 与 history 是两份代码,各自单测都绿仍可能凑不出一
|
||
// 条正确的计数(例如抽取结论回写把同一份文档又 +1 一次,文件就**永远**回收不掉)。
|
||
// 这里跑的是完整链路 + 真实 SQLite,因此这条不变量是机器证明的。
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
)
|
||
|
||
// refCountOf 读一份文档当前的引用计数。
|
||
func refCountOf(t *testing.T, f *gcFixture, name string) int64 {
|
||
t.Helper()
|
||
var refs int64
|
||
if err := f.db.QueryRowContext(context.Background(),
|
||
`SELECT ref_count FROM documents WHERE name = ?`, name).Scan(&refs); err != nil {
|
||
t.Fatalf("读 ref_count(%q): %v", name, err)
|
||
}
|
||
return refs
|
||
}
|
||
|
||
// TestRefCountEndToEndWithMessagesAndGC 覆盖必测 4 的完整链路。
|
||
func TestRefCountEndToEndWithMessagesAndGC(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
now := time.Now().UTC().Truncate(time.Second)
|
||
f := newGCFixture(t, now)
|
||
// 时间往后拨 30 天:保留期(7 天)早已过去,于是「还留着」只可能因为仍被引用。
|
||
later := func() time.Time { return now.Add(30 * 24 * time.Hour) }
|
||
gc := NewGC(GCConfig{
|
||
DB: f.db, DataDir: f.dataDir, UploadsDir: f.uploads,
|
||
Interval: DefaultGCInterval, Retention: DefaultRetention, Now: later,
|
||
})
|
||
|
||
// 真实上传:原件落盘 + 元数据行入表。
|
||
store := NewStore(StoreConfig{
|
||
UploadsDir: f.uploads,
|
||
Meta: MetadataWriterFor(filepath.Join(f.dataDir, "gc.db"), nil),
|
||
})
|
||
ref, err := store.Save(ctx, "端到端.txt", []byte("端到端引用计数的正文"))
|
||
if err != nil {
|
||
t.Fatalf("Save: %v", err)
|
||
}
|
||
sidecar := filepath.Join(f.extracted, ref.Name+sidecarSuffix)
|
||
if err := os.MkdirAll(f.extracted, sidecarDirMode); err != nil {
|
||
t.Fatalf("建 sidecar 目录: %v", err)
|
||
}
|
||
if err := os.WriteFile(sidecar, []byte("抽取正文"), sidecarFileMode); err != nil {
|
||
t.Fatalf("写 sidecar: %v", err)
|
||
}
|
||
|
||
hist, err := history.NewStore(ctx, f.db, nil, history.WithDocumentRefs(NewRefCounter(nil)))
|
||
if err != nil {
|
||
t.Fatalf("history.NewStore: %v", err)
|
||
}
|
||
raw, err := history.EncodeDocuments([]history.DocumentRef{{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}})
|
||
if err != nil {
|
||
t.Fatalf("EncodeDocuments: %v", err)
|
||
}
|
||
first, err := hist.AppendFull(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "读这份", Document: raw})
|
||
if err != nil {
|
||
t.Fatalf("append 1: %v", err)
|
||
}
|
||
second, err := hist.AppendFull(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "再读一次", Document: raw})
|
||
if err != nil {
|
||
t.Fatalf("append 2: %v", err)
|
||
}
|
||
if got := refCountOf(t, f, ref.Name); got != 2 {
|
||
t.Fatalf("两条消息引用后 ref_count = %d, want 2", got)
|
||
}
|
||
|
||
// 抽取结论回写(同一批名字、只补 status)**不得**再计一次。
|
||
merged := `[{"name":"` + ref.Name + `","mime":"` + ref.MIME + `","size":` +
|
||
strconv.FormatInt(ref.Size, 10) + `,"chars":8,"status":"extracted"}]`
|
||
if err := hist.UpdateMessageDocument(ctx, first.ID, merged); err != nil {
|
||
t.Fatalf("UpdateMessageDocument: %v", err)
|
||
}
|
||
if got := refCountOf(t, f, ref.Name); got != 2 {
|
||
t.Fatalf("结论回写后 ref_count = %d, want 2(同一批名字不重复计数)", got)
|
||
}
|
||
|
||
// 仍被两条消息引用:即便时钟已过保留期 30 天,三样都不许消失。
|
||
if _, err := gc.Sweep(ctx); err != nil {
|
||
t.Fatalf("Sweep(引用中): %v", err)
|
||
}
|
||
if o, s, r := f.exists(t, ref.Name); !o || !s || !r {
|
||
t.Fatalf("被引用的文档被回收了:原件=%v sidecar=%v 行=%v", o, s, r)
|
||
}
|
||
|
||
// 删掉一条消息 ⇒ 1(仍不删)。
|
||
if err := hist.DeleteMessage(ctx, first.ID); err != nil {
|
||
t.Fatalf("delete 1: %v", err)
|
||
}
|
||
if got := refCountOf(t, f, ref.Name); got != 1 {
|
||
t.Fatalf("删一条后 ref_count = %d, want 1", got)
|
||
}
|
||
if _, err := gc.Sweep(ctx); err != nil {
|
||
t.Fatalf("Sweep(还剩一条引用): %v", err)
|
||
}
|
||
if o, s, r := f.exists(t, ref.Name); !o || !s || !r {
|
||
t.Fatalf("还剩一条引用时被回收了:原件=%v sidecar=%v 行=%v", o, s, r)
|
||
}
|
||
|
||
// 删掉最后一条 ⇒ 0,且此时(时钟已越过保留期)才可回收:三样一起消失。
|
||
if err := hist.DeleteMessage(ctx, second.ID); err != nil {
|
||
t.Fatalf("delete 2: %v", err)
|
||
}
|
||
if got := refCountOf(t, f, ref.Name); got != 0 {
|
||
t.Fatalf("删净后 ref_count = %d, want 0", got)
|
||
}
|
||
stats, err := gc.Sweep(ctx)
|
||
if err != nil {
|
||
t.Fatalf("Sweep(无引用 + 超期): %v", err)
|
||
}
|
||
if stats.Deleted != 1 {
|
||
t.Fatalf("stats = %+v, want 删除 1", stats)
|
||
}
|
||
if o, s, r := f.exists(t, ref.Name); o || s || r {
|
||
t.Fatalf("无引用 + 超期后仍留着:原件=%v sidecar=%v 行=%v,want 三样都消失", o, s, r)
|
||
}
|
||
}
|