- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
431 lines
16 KiB
Go
431 lines
16 KiB
Go
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)
|
||
}
|
||
}
|