Files
AskaEth 3ba31b58ff
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): 引用计数 + 文档/侧车 GC(主人 2.2 裁决;S7)
- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
2026-10-04 21:01:30 +08:00

133 lines
5.1 KiB
Go
Raw Permalink 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.
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)
}
}