- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
379 lines
14 KiB
Go
379 lines
14 KiB
Go
package documents
|
||
|
||
// 本文件归属**文档回收(GC)**(design/24 §3.2 的「配额与清理」、§9.6 登记的
|
||
// 「sidecar 永不回收」这笔债,切片 S7)。
|
||
//
|
||
// 判据(只此一条,别处不再各判一次):
|
||
//
|
||
// ref_count = 0 且 last_referenced_at(从未被引用时用 created_at)早于 now − 保留期
|
||
//
|
||
// 动作 = 删除**三样**:原件(`<UploadsDir>/<存储名>`)、抽取 sidecar
|
||
// (`<DataDir>/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 在 `<DataDir>/extracted`。
|
||
DataDir string
|
||
// UploadsDir 是文档落盘的完整目录;留空时按 `<DataDir>/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
|
||
}
|