feat(documents): 引用计数 + 文档/侧车 GC(主人 2.2 裁决;S7)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
This commit is contained in:
AskaEth committed 2026-10-04 21:01:30 +08:00
1 parent 79b1ec36aa
commit 3ba31b58ff
18 files changed
+1951 -29

No files matched your search

+378
View File
@@ -0,0 +1,378 @@
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
}