Files
Cyrene_v2/internal/runtime/documents/gc_test.go
T
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

431 lines
16 KiB
Go
Raw 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
// 本文件是**文档回收(GC)**的验收(design/24 §3.2 的配额与清理,切片 S7)。
// 六类必测逐条对应任务书 §3:
//
// 1. 引用中不删(即便超保留期,原件与 sidecar 都在);
// 2. 无引用 + 超期 ⇒ 原件 + sidecar + 元数据行**三样都消失**;
// 3. 保留期未到不删;保留期/间隔为 0 或 GC 关闭时**不动任何文件**;
// 4. 引用计数正确性(两条消息 ⇒ 2,删一条 ⇒ 1 且仍不删,删最后一条 ⇒ 超期后可删,
// 由 refs_e2e_test.go 的端到端用例覆盖);
// 5. 越界防护:元数据里塞 `../` 形态的名字 ⇒ 不碰盘(拒绝 + 日志);
// 6. 失败可继续:单件删除失败 ⇒ WARN + 继续处理其余。
//
// 断言的是**盘上的事实**(文件在不在、行在不在、汇总数字),不按日志文本断言。
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// gcFixture 是一套 GC 的临时环境:一个临时数据目录(uploads/documents 与
// extracted)加一张跑过迁移的临时库。
type gcFixture struct {
db *sql.DB
dataDir string
uploads string
extracted string
now time.Time
}
// newGCFixture 建好临时环境并把时钟钉在 now 上。
func newGCFixture(t *testing.T, now time.Time) *gcFixture {
t.Helper()
dataDir := t.TempDir()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(dataDir, "gc.db"), nil)
if err != nil {
t.Fatalf("打开临时库: %v", err)
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("临时库跑迁移: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return &gcFixture{
db: conn,
dataDir: dataDir,
uploads: filepath.Join(dataDir, uploadsSubDir, documentSubDir),
extracted: filepath.Join(dataDir, sidecarDirName),
now: now,
}
}
// gc 返回一个以 f.now 为时钟的回收器。
func (f *gcFixture) gc(interval, retention time.Duration) *GC {
return NewGC(GCConfig{
DB: f.db,
DataDir: f.dataDir,
UploadsDir: f.uploads,
Interval: interval,
Retention: retention,
Now: func() time.Time { return f.now },
})
}
// document 描述一件登记进库的文档(用例按需给出引用数与时间)。
type document struct {
name string
// body 是原件正文;sidecar 存在时其内容为 body + " 的抽取正文"。
body string
// refCount 是 documents.ref_count 的初值。
refCount int
// referencedAt 是 last_referenced_at;nil = 从未被引用(NULL,看 created_at)。
referencedAt *time.Time
// createdAt 是 created_at。
createdAt time.Time
// noSidecar 为真时不写 sidecar 文件(模拟「还没抽取过」或「sidecar 已丢」)。
noSidecar bool
}
// add 落一件文档:原件 + sidecar + 元数据行。
func (f *gcFixture) add(t *testing.T, doc document) string {
t.Helper()
if err := os.MkdirAll(f.uploads, dirMode); err != nil {
t.Fatalf("建上传目录: %v", err)
}
orig := filepath.Join(f.uploads, doc.name)
if err := os.WriteFile(orig, []byte(doc.body), 0o600); err != nil {
t.Fatalf("写原件: %v", err)
}
if !doc.noSidecar {
if err := os.MkdirAll(f.extracted, sidecarDirMode); err != nil {
t.Fatalf("建 sidecar 目录: %v", err)
}
sidecar := filepath.Join(f.extracted, doc.name+sidecarSuffix)
if err := os.WriteFile(sidecar, []byte(doc.body+" 的抽取正文"), sidecarFileMode); err != nil {
t.Fatalf("写 sidecar: %v", err)
}
}
sum := sha256.Sum256([]byte(doc.body))
var referenced any
if doc.referencedAt != nil {
referenced = sqliteTimeStamp(*doc.referencedAt)
}
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO documents (name, sha256, mime, size, uploader, created_at, last_referenced_at, ref_count)
VALUES (?, ?, ?, ?, 'test', ?, ?, ?)`,
doc.name, hex.EncodeToString(sum[:]), SniffOKMIME, len(doc.body),
sqliteTimeStamp(doc.createdAt), referenced, doc.refCount); err != nil {
t.Fatalf("插入元数据行: %v", err)
}
return orig
}
// exists 报告一件文档的三样(原件 + sidecar + 元数据行)各自是否还在。
func (f *gcFixture) exists(t *testing.T, name string) (orig, sidecar, row bool) {
t.Helper()
if _, err := os.Stat(filepath.Join(f.uploads, name)); err == nil {
orig = true
}
if _, err := os.Stat(filepath.Join(f.extracted, name+sidecarSuffix)); err == nil {
sidecar = true
}
var count int
if err := f.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM documents WHERE name = ?`, name).Scan(&count); err != nil {
t.Fatalf("查元数据行: %v", err)
}
return orig, sidecar, count > 0
}
// testName 造一个合法的存储名(32 位小写十六进制主名 + 扩展名)。
func testName(fill byte, ext string) string {
stem := make([]byte, HashHexLen)
for i := range stem {
stem[i] = fill
}
return string(stem) + ext
}
// TestGCDefaultsArePinned 把两个默认值单独钉在**取舍值**上(值被悄悄改宽/改窄时
// 有用例能发现):每小时一轮清扫、未引用满 7 天才回收。
func TestGCDefaultsArePinned(t *testing.T) {
t.Parallel()
if DefaultGCInterval != time.Hour {
t.Fatalf("DefaultGCInterval = %v, want 1h", DefaultGCInterval)
}
if DefaultRetention != 168*time.Hour {
t.Fatalf("DefaultRetention = %v, want 168h(7 天)", DefaultRetention)
}
}
// TestGCDerivesDirsFromDataDir 覆盖目录推导:只给 DataDir 时,GC 必须去
// `<DataDir>/uploads/documents` 找原件、去 `<DataDir>/extracted` 找 sidecar ——
// 与 Store/Resolver 的布局同源。推开错目录的症状是「GC 一直在跑、却一件也没删
// 过」(盘上文件还在,元数据行也还在),没有任何用例会因此变红。
func TestGCDerivesDirsFromDataDir(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
name := testName('4', ".toml")
stale := now.Add(-900 * time.Hour)
f.add(t, document{name: name, body: "只给 DataDir", refCount: 0, referencedAt: &stale, createdAt: stale})
gc := NewGC(GCConfig{
DB: f.db, DataDir: f.dataDir,
Interval: DefaultGCInterval, Retention: DefaultRetention,
Now: func() time.Time { return f.now },
})
stats, err := gc.Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("stats = %+v, want 删除 1(目录推导没命中 uploads/extracted)", stats)
}
if o, s, r := f.exists(t, name); o || s || r {
t.Fatalf("目录推导后三样没有一起消失:原件=%v sidecar=%v 行=%v", o, s, r)
}
}
// TestGCDoesNotDeleteReferencedDocument 覆盖必测 1:仍被消息引用的文档,即便
// 早已超过保留期也**不删** —— 原件与 sidecar 都在。
func TestGCDoesNotDeleteReferencedDocument(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
name := testName('a', ".txt")
old := now.Add(-30 * 24 * time.Hour)
f.add(t, document{name: name, body: "还被引用着", refCount: 1, referencedAt: &old, createdAt: old})
stats, err := f.gc(DefaultGCInterval, DefaultRetention).Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
orig, sidecar, row := f.exists(t, name)
if !orig || !sidecar || !row {
t.Fatalf("引用中的文档被动了:原件=%v sidecar=%v 行=%v,want 全在", orig, sidecar, row)
}
if stats.Deleted != 0 || stats.Bytes != 0 {
t.Fatalf("stats = %+v, want 一件都没删", stats)
}
// 也绝不该出现「被检查到但跳过」以外的动作:ref_count != 0 的行根本进不了候选面。
if stats.Candidates != 0 {
t.Fatalf("candidates = %d, want 0(引用中的行不进候选面)", stats.Candidates)
}
}
// TestGCDeletesUnreferencedExpiredDocument 覆盖必测 2:无引用 + 超期 ⇒ **原件 +
// sidecar + 元数据行三样都消失**,并如实汇总释放的字节数。
func TestGCDeletesUnreferencedExpiredDocument(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
name := testName('b', ".md")
body := "八天前上传、再没人引用过"
old := now.Add(-8 * 24 * time.Hour)
origPath := f.add(t, document{name: name, body: body, refCount: 0, referencedAt: &old, createdAt: old})
stats, err := f.gc(DefaultGCInterval, DefaultRetention).Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
orig, sidecar, row := f.exists(t, name)
if orig || sidecar || row {
t.Fatalf("三样没有一起消失:原件=%v sidecar=%v 行=%v,want 全无", orig, sidecar, row)
}
if stats.Deleted != 1 || stats.Candidates != 1 {
t.Fatalf("stats = %+v, want 候选 1 / 删除 1", stats)
}
want := int64(len(body)) + int64(len(body+" 的抽取正文"))
if stats.Bytes != want {
t.Fatalf("bytes = %d, want %d(原件 + sidecar 的实占字节)", stats.Bytes, want)
}
if _, err := os.Stat(origPath); !os.IsNotExist(err) {
t.Fatalf("原件仍在盘上:err=%v", err)
}
}
// TestNeverReferencedUsesCreatedAt 覆盖必测 2 的时间口径:从未被引用(列为 NULL)
// 时按 created_at 判超期。
func TestNeverReferencedUsesCreatedAt(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
expired := testName('c', ".csv")
fresh := testName('d', ".json")
f.add(t, document{name: expired, body: "很旧且从未被引用", refCount: 0, createdAt: now.Add(-400 * time.Hour)})
f.add(t, document{name: fresh, body: "很旧但刚建", refCount: 0, createdAt: now.Add(-time.Hour)})
stats, err := f.gc(DefaultGCInterval, DefaultRetention).Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if o, s, r := f.exists(t, expired); o || s || r {
t.Fatalf("超期的未引用文档仍在:原件=%v sidecar=%v 行=%v", o, s, r)
}
if o, s, r := f.exists(t, fresh); !o || !s || !r {
t.Fatalf("未超期的文档被删了:原件=%v sidecar=%v 行=%v,want 全在", o, s, r)
}
if stats.Deleted != 1 || stats.Kept != 1 {
t.Fatalf("stats = %+v, want 删除 1 / 保留 1", stats)
}
}
// TestGCKeepsWithinRetentionAndWhenDisabled 覆盖必测 3:保留期未到不删;保留期或
// 间隔为 0(= 关闭 GC)时**不动任何文件**。
func TestGCKeepsWithinRetentionAndWhenDisabled(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
ctx := context.Background()
// 保留期未到:刚被引用 1 小时,默认保留 7 天。
fresh := newGCFixture(t, now)
name := testName('e', ".yaml")
recent := now.Add(-time.Hour)
fresh.add(t, document{name: name, body: "刚用过", refCount: 0, referencedAt: &recent, createdAt: now.Add(-30 * 24 * time.Hour)})
if _, err := fresh.gc(DefaultGCInterval, DefaultRetention).Sweep(ctx); err != nil {
t.Fatalf("Sweep: %v", err)
}
if o, s, r := fresh.exists(t, name); !o || !s || !r {
t.Fatalf("保留期未到就删了:原件=%v sidecar=%v 行=%v,want 全在", o, s, r)
}
// 关闭侧:interval=0 / retention=0 都不动盘(哪怕这份文档早已超期)。
for _, tc := range []struct {
label string
interval time.Duration
retention time.Duration
}{
{"interval=0", 0, DefaultRetention},
{"retention=0", DefaultGCInterval, 0},
} {
label, interval, retention := tc.label, tc.interval, tc.retention
closed := newGCFixture(t, now)
stale := now.Add(-900 * time.Hour)
closed.add(t, document{name: testName('f', ".log"), body: "早该回收", refCount: 0, referencedAt: &stale, createdAt: stale})
gc := closed.gc(interval, retention)
if gc.Enabled() {
t.Fatalf("%s: Enabled() = true, want false", label)
}
stats, err := gc.Sweep(ctx)
if err != nil {
t.Fatalf("%s: Sweep: %v", label, err)
}
if stats != (GCStats{}) {
t.Fatalf("%s: stats = %+v, want 零值(关闭时不做任何事)", label, stats)
}
if o, s, r := closed.exists(t, testName('f', ".log")); !o || !s || !r {
t.Fatalf("%s: 关闭时仍动了文件:原件=%v sidecar=%v 行=%v", label, o, s, r)
}
}
}
// TestGCRejectsEscapingName 覆盖必测 5:元数据里被塞进 `../` 形态的名字时,
// GC **一根手指都不碰盘**(拒绝 + 日志 + 计数),目录外的同名文件毫发无损。
func TestGCRejectsEscapingName(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
outside := filepath.Join(f.dataDir, "escape.txt")
if err := os.WriteFile(outside, []byte("用户自己放在数据目录里的文件"), 0o600); err != nil {
t.Fatalf("写目录外文件: %v", err)
}
stale := now.Add(-900 * time.Hour)
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO documents (name, sha256, mime, size, uploader, created_at, ref_count)
VALUES ('../escape.txt', 'deadbeef', 'text/plain', 3, 'test', ?, 0)`,
sqliteTimeStamp(stale)); err != nil {
t.Fatalf("插入越界名字的行: %v", err)
}
stats, err := f.gc(DefaultGCInterval, DefaultRetention).Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if _, err := os.Stat(outside); err != nil {
t.Fatalf("目录外的文件被动了: %v", err)
}
if stats.Rejected != 1 || stats.Deleted != 0 {
t.Fatalf("stats = %+v, want 拒绝 1 / 删除 0", stats)
}
// 越界行本身也不该被删:本表登记不等于「可越过形态校验执行删除」。
var count int
if err := f.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM documents WHERE name = '../escape.txt'`).Scan(&count); err != nil {
t.Fatalf("查越界行: %v", err)
}
if count != 1 {
t.Fatalf("越界名字的元数据行被删了(count=%d),GC 不该动它", count)
}
}
// TestGCContinuesAfterSingleFailure 覆盖必测 6:单件删除失败(这里用一个非空
// **目录**占住原件路径,使 os.Remove 必然失败且与平台无关)⇒ 记 WARN、计入
// Failed、**继续**处理其余件;失败那件留到下一轮(行还在)。
func TestGCContinuesAfterSingleFailure(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
stale := now.Add(-900 * time.Hour)
stuck := testName('1', ".txt")
if err := os.MkdirAll(filepath.Join(f.uploads, stuck, "block"), dirMode); err != nil {
t.Fatalf("占住原件路径: %v", err)
}
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO documents (name, sha256, mime, size, uploader, created_at, ref_count)
VALUES (?, 'beef', 'text/plain', 4, 'test', ?, 0)`,
stuck, sqliteTimeStamp(stale)); err != nil {
t.Fatalf("插入失败件: %v", err)
}
ok := testName('2', ".md")
f.add(t, document{name: ok, body: "正常一件", refCount: 0, referencedAt: &stale, createdAt: stale})
stats, err := f.gc(DefaultGCInterval, DefaultRetention).Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Failed != 1 || stats.Deleted != 1 {
t.Fatalf("stats = %+v, want 失败 1 / 删除 1(单件失败不中断整轮)", stats)
}
var stuckRows int
if err := f.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM documents WHERE name = ?`, stuck).Scan(&stuckRows); err != nil {
t.Fatalf("查失败件: %v", err)
}
if stuckRows != 1 {
t.Fatal("失败件的元数据行不见了(留到下一轮重试才是正确行为)")
}
if o, s, r := f.exists(t, ok); o || s || r {
t.Fatalf("正常件没被回收:原件=%v sidecar=%v 行=%v", o, s, r)
}
}
// TestGCIdempotentWhenAlreadyGone 覆盖可重入:原件/sidecar 已被手工删掉时,GC 照
// 常把元数据行收掉(「本来就不在」不是失败),释放字节数为 0。
func TestGCIdempotentWhenAlreadyGone(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
f := newGCFixture(t, now)
name := testName('3', ".ini")
stale := now.Add(-900 * time.Hour)
origPath := f.add(t, document{name: name, body: "文件已被手工删除", refCount: 0, referencedAt: &stale, createdAt: stale})
if err := os.Remove(origPath); err != nil {
t.Fatalf("手工删原件: %v", err)
}
if err := os.Remove(filepath.Join(f.extracted, name+sidecarSuffix)); err != nil {
t.Fatalf("手工删 sidecar: %v", err)
}
stats, err := f.gc(DefaultGCInterval, DefaultRetention).Sweep(context.Background())
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 || stats.Failed != 0 || stats.Bytes != 0 {
t.Fatalf("stats = %+v, want 删除 1 / 失败 0 / 字节 0", stats)
}
if o, s, r := f.exists(t, name); o || s || r {
t.Fatalf("元数据行没被收掉:行=%v", r)
}
}