feat(documents): 引用计数 + 文档/侧车 GC(主人 2.2 裁决;S7)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
This commit is contained in:
AskaEth committed 2026-10-04 21:01:30 +08:00
1 parent 79b1ec36aa
commit 3ba31b58ff
18 files changed
+1951 -29

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
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)
}
}