Files
Cyrene_v2/internal/runtime/documents/gc.go
T
AskaEth 3ba31b58ff
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): 引用计数 + 文档/侧车 GC(主人 2.2 裁决;S7)
- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
2026-10-04 21:01:30 +08:00

379 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}