package documents // 本文件归属**文档回收(GC)**(design/24 §3.2 的「配额与清理」、§9.6 登记的 // 「sidecar 永不回收」这笔债,切片 S7)。 // // 判据(只此一条,别处不再各判一次): // // ref_count = 0 且 last_referenced_at(从未被引用时用 created_at)早于 now − 保留期 // // 动作 = 删除**三样**:原件(`/<存储名>`)、抽取 sidecar // (`/extracted/<存储名>.txt`,见 §9.6 的定稿口径)、`documents` 元数据行。 // 三样缺一不可 —— 只删文件会留下永远指向空文件的行,只删行会让正文与原件永远 // 躺在盘上(那正是本切片要还的债)。 // // 安全(每条都有用例钉住): // - **绝不删仍被引用的文件**:候选查询只要 ref_count = 0 的行,删前**再查一次** // 引用数,删行时还用 `AND ref_count = 0` 原子再确认一次; // - **绝不跨出 uploads/extracted 目录**:名字必须通过存储名形态校验,路径再经 // `targetPath` / `sidecarStore.pathFor` 的双检(拼出的目录必须恰是目标目录); // - **不删非本表登记的文件**:删除面只由 `documents` 表的名字驱动,绝不 walk // 目录(用户手动放进 uploads 的文件因此不受影响)。 // // 日志口径(design/24 §3.2):每轮只记**一条汇总**(候选/删除/释放字节/跳过/失败), // **绝不记正文**(这里也从不读正文);单件失败记 WARN 并继续,绝不中断整轮。 // // 已知取舍:删除顺序是「先文件、后元数据行」,因此单件文件删除失败时该行留到下一 // 轮重试(行还在,就不会变成没人再看的孤儿);而删行受 `ref_count = 0` 守护, // 与并发落库的竞态窗口因此收敛到「一轮 GC 与一次落库几乎同时发生」这一瞬。 import ( "context" "database/sql" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "time" ) const ( // DefaultGCInterval 是 `DOCUMENT_GC_INTERVAL` 的默认值:每小时清扫一次。 DefaultGCInterval = time.Hour // DefaultRetention 是 `DOCUMENT_RETENTION` 的默认值:未被引用满 7 天才能回收。 // 7 天是「她刚读过的东西不该第二天就没了」与「磁盘只涨不降」之间的取舍值。 DefaultRetention = 168 * time.Hour ) // ErrGCMisconfigured 报告 GC 想动盘却拿不到可用的落盘目录。它是防御性的哨兵: // 目录为空时**绝不**退化成「在当前工作目录里删同名文件」。 var ErrGCMisconfigured = errors.New("documents: GC 落盘目录未配置") // gcCandidate 是一条待检查的元数据行(只取判据与记账需要的列)。 type gcCandidate struct { name string size int64 // created / lastReferenced 是原始的 TEXT 时间戳(后者可能为空 = 从未被引用)。 created string lastReferenced string } // GCStats 是一轮回收的汇总,也是 GC 唯一的对外事实(成功件只计数,见文件头的 // 日志口径)。 type GCStats struct { // Candidates 是满足 ref_count = 0、进入本轮检查面的行数。 Candidates int // Deleted 是被完整回收(原件 + sidecar + 元数据行三样都清了)的件数。 Deleted int // Bytes 是释放的字节数(原件实占 + 已存在的 sidecar 实占)。 Bytes int64 // Kept 是本轮判定「仍然不能删」的件数(引用数非 0、时间未到、或删行被并发 // 引用挡住)。 Kept int // Rejected 是被形态/时间判据拒绝、**根本没碰盘**的件数(非法名字、目录未配、 // 时间戳读不出来)。 Rejected int // Failed 是试图删除但失败、留待下一轮的件数。 Failed int } // GCConfig 是回收器的接线参数。时间与目录口径由调用方(组合根)给出:本包不读 // 环境变量、不猜数据目录布局(与 StoreConfig / Config 同一理由)。 type GCConfig struct { // DB 是元数据库的活连接(组合根注入主连接池;测试注入临时库)。为 nil = // GC 关闭。 DB *sql.DB // DataDir 是数据目录:sidecar 在 `/extracted`。 DataDir string // UploadsDir 是文档落盘的完整目录;留空时按 `/uploads/documents` // 推导(与 NewStore 同一布局)。 UploadsDir string // Interval 是定时清扫间隔(`DOCUMENT_GC_INTERVAL`)。**≤0 = 关闭 GC** // (含启动期清扫),默认 DefaultGCInterval。 Interval time.Duration // Retention 是未引用文档的保留期(`DOCUMENT_RETENTION`)。**≤0 = 关闭 GC**, // 默认 DefaultRetention。0 刻意**不**解释成「立即回收」:那会让一次笔误 // 把整库未引用的附件清空。 Retention time.Duration // Log 是基础 logger;nil 时替换为标记来源 runtime/documents 的丢弃 logger。 Log *slog.Logger // Now 是可注入时钟(测试用);nil 时用 time.Now。 Now func() time.Time } // GC 是文档回收器。它可安全并发使用:单轮内部串行,跨轮的并发由「候选查询只取 // ref_count = 0」+「删行带 `AND ref_count = 0` 守护」保证不会误删。 type GC struct { db *sql.DB uploadsDir string sidecars *sidecarStore interval time.Duration retention time.Duration log *slog.Logger now func() time.Time enabled bool } // NewGC 构造回收器。关闭时(DB 为 nil、间隔 ≤0、保留期 ≤0)返回的对象仍然可用, // 只是 Enabled() 为 false、Sweep 是空操作 —— 关闭这件事必须**可见**(记 WARN), // 而不是静默变成「一直在跑但从没删过」。 func NewGC(cfg GCConfig) *GC { log := sourcedLog(cfg.Log) now := cfg.Now if now == nil { now = time.Now } uploads := strings.TrimSpace(cfg.UploadsDir) if uploads == "" { if root := strings.TrimSpace(cfg.DataDir); root != "" { uploads = filepath.Join(root, uploadsSubDir, documentSubDir) } } g := &GC{ db: cfg.DB, uploadsDir: uploads, sidecars: newSidecarStore(cfg.DataDir, log), interval: cfg.Interval, retention: cfg.Retention, log: log, now: now, } switch { case cfg.DB == nil: g.log.Warn("document gc disabled: no metadata database") case cfg.Interval <= 0: g.log.Warn("document gc disabled: interval <= 0", "interval", cfg.Interval.String()) case cfg.Retention <= 0: g.log.Warn("document gc disabled: retention <= 0", "retention", cfg.Retention.String()) default: g.enabled = true g.log.Info("document gc enabled", "interval", g.interval.String(), "retention", g.retention.String(), "uploads_dir", g.uploadsDir) if g.sidecars == nil { // sidecar 目录未知(DataDir 为空):原件照常回收,但抽取正文会留在盘上。 // 这不是致命错误(GC 仍能把元数据与原件对上),但必须说清。 g.log.Warn("document gc: data dir not configured, sidecar files are not reclaimed") } } return g } // Enabled 报告 GC 是否真的会动盘。 func (g *GC) Enabled() bool { return g != nil && g.enabled } // Interval 返回定时清扫间隔(关闭时为 0)。 func (g *GC) Interval() time.Duration { if g == nil { return 0 } return g.interval } // Retention 返回未引用文档的保留期(关闭时为 0)。 func (g *GC) Retention() time.Duration { if g == nil { return 0 } return g.retention } // Sweep 跑一轮回收并返回汇总。关闭时它是空操作(返回零值、无错误、不做任何盘上 // 动作);元数据查询失败返回错误(调用方记 WARN 并等下一轮,绝不因此退出进程)。 // 单件失败**不**让整轮失败:记 WARN、计入 Failed、继续下一件。 func (g *GC) Sweep(ctx context.Context) (GCStats, error) { if !g.Enabled() { return GCStats{}, nil } rows, err := g.candidates(ctx) if err != nil { return GCStats{}, err } cutoff := g.now().Add(-g.retention) var stats GCStats stats.Candidates = len(rows) for _, row := range rows { if err := ctx.Err(); err != nil { return stats, fmt.Errorf("documents: gc: %w", err) } if err := g.collect(ctx, row, cutoff, &stats); err != nil { return stats, err } } g.log.Info("document gc sweep", "candidates", stats.Candidates, "deleted", stats.Deleted, "bytes", stats.Bytes, "kept", stats.Kept, "rejected", stats.Rejected, "failed", stats.Failed) return stats, nil } // candidates 读出 ref_count = 0 的全部元数据行。它**先读全、再关结果集**才返回: // 连接池只有一条连接(db.Open 的写序列化取舍),留着未关的 rows 会让紧随其后的 // 每条语句死等那条连接。 func (g *GC) candidates(ctx context.Context) ([]gcCandidate, error) { rows, err := g.db.QueryContext(ctx, `SELECT name, size, created_at, COALESCE(last_referenced_at, '') FROM documents WHERE ref_count = 0`) if err != nil { return nil, fmt.Errorf("documents: gc 查候选行: %w", err) } defer func() { _ = rows.Close() }() out := make([]gcCandidate, 0) for rows.Next() { var row gcCandidate if err := rows.Scan(&row.name, &row.size, &row.created, &row.lastReferenced); err != nil { return nil, fmt.Errorf("documents: gc 读候选行: %w", err) } out = append(out, row) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("documents: gc 遍历候选行: %w", err) } return out, nil } // collect 处理一件候选项:判据 → 再确认引用数 → 删文件 → 删元数据行 → 记账。 // 它只在自己「没做成」之外返回错误:查询失败(连接/库层面的问题)向上传, // 让调用方决定整轮是否算失败。 func (g *GC) collect(ctx context.Context, row gcCandidate, cutoff time.Time, stats *GCStats) error { referencedAt, ok := g.referenceTime(row) if !ok { stats.Rejected++ return nil } if !referencedAt.Before(cutoff) { stats.Kept++ return nil } if g.uploadsDir == "" { g.log.Warn("document gc rejected: uploads dir not configured", "name", row.name) stats.Rejected++ return nil } refs, err := g.currentRefCount(ctx, row.name) if err != nil { return err } if refs != 0 { // 候选查询之后有人引用了它(并发落库):这一轮不动它。 stats.Kept++ return nil } freed, err := g.removeFiles(row.name) if err != nil { g.log.Warn("document gc remove files", "name", row.name, "err", err) stats.Failed++ return nil } removed, err := g.deleteRow(ctx, row.name) if err != nil { return err } if !removed { // 删行被 `AND ref_count = 0` 挡住(或那一行已被别人删掉):竞态里那一瞬有 // 人引用了它。行留着、文件已清,因此必须**可见**(这是本文件登记的那条 // 已知窗口)。 g.log.Warn("document gc claim lost: row gone or still referenced", "name", row.name) stats.Kept++ return nil } stats.Deleted++ stats.Bytes += freed return nil } // referenceTime 解析一件候选的「最后一次相关时刻」并校验它的存储名形态与时间戳: // 曾被引用就用 last_referenced_at,从未被引用才退回 created_at。名字形态非法 // (可能把路径带出目录)或时间戳读不出来都**不**放行(保守:读不懂的时间绝不当成 // 「已超期」),并各记一条 WARN。 func (g *GC) referenceTime(row gcCandidate) (time.Time, bool) { if !validStorageName(row.name) { g.log.Warn("document gc rejected: invalid name", "name", row.name) return time.Time{}, false } reference := row.lastReferenced if strings.TrimSpace(reference) == "" { reference = row.created } at, ok := parseSQLiteTime(reference) if !ok { g.log.Warn("document gc rejected: unparsable timestamp", "name", row.name, "raw", row.created) return time.Time{}, false } return at, true } // currentRefCount 再查一次引用数(design/24 §3.2 的「删前再确认一次」,用来收敛 // 与并发落库的竞态)。行已消失时返回 0(没什么可保护的)。 func (g *GC) currentRefCount(ctx context.Context, name string) (int64, error) { var refs int64 err := g.db.QueryRowContext(ctx, `SELECT ref_count FROM documents WHERE name = ?`, name).Scan(&refs) if errors.Is(err, sql.ErrNoRows) { return 0, nil } if err != nil { return 0, fmt.Errorf("documents: gc 复查引用数: %w", err) } return refs, nil } // deleteRow 删掉元数据行,并把 `ref_count = 0` 写进 WHERE —— 这是本切片最锋利的 // 一条守护:即便在「复查」与「删行」之间有人引用了它,这一句也不会删掉那一行。 // 第二个返回值报告到底删没删。 func (g *GC) deleteRow(ctx context.Context, name string) (bool, error) { res, err := g.db.ExecContext(ctx, `DELETE FROM documents WHERE name = ? AND ref_count = 0`, name) if err != nil { return false, fmt.Errorf("documents: gc 删元数据行: %w", err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("documents: gc 删元数据行 rows: %w", err) } return n > 0, nil } // removeFiles 删掉一件文档的原件与 sidecar,返回**实占字节数**(stat 到的:文件 // 本就不在或为空时是 0,因此汇总里的「释放了多少字节」是实测值而不是账面值)。 // 文件本就不在 = 成功(可重入:上一次删了一半也照样收敛)。任一条路径拼不出 // (名字形态非法、路径逃出目录)或删除失败即返回错误,由调用方记 WARN 并留待下一轮。 func (g *GC) removeFiles(name string) (int64, error) { original, err := targetPath(g.uploadsDir, name) if err != nil { return 0, err } var freed int64 // 先删 sidecar:它是抽取正文,本体通常更大;两者都在同一数据目录树内, // 单件失败不会让另一件留在无人看守的状态(元数据行还在,下一轮会再来)。 if sidecar, ok := g.sidecars.pathFor(name); ok { n, err := removeIfExists(sidecar) if err != nil { return 0, fmt.Errorf("documents: gc 删 sidecar %q: %w", name, err) } freed += n } n, err := removeIfExists(original) if err != nil { return freed, fmt.Errorf("documents: gc 删原件 %q: %w", name, err) } return freed + n, nil } // removeIfExists 删除一个文件并返回它实占的字节数;文件不存在返回 (0, nil) —— // 「本来就不在」不是失败,那只是上一轮已经删过。 func removeIfExists(path string) (int64, error) { info, err := os.Stat(path) if errors.Is(err, fs.ErrNotExist) { return 0, nil } if err != nil { return 0, err } size := info.Size() if err := os.Remove(path); err != nil { return 0, err } return size, nil }