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 必须去 // `/uploads/documents` 找原件、去 `/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) } }