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
}
+430
View File
@@ -0,0 +1,430 @@
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)
}
}
+119
View File
@@ -0,0 +1,119 @@
package documents
// 本文件归属 `documents` 表的**引用计数写入面**(design/24 §3.2 的「配额与清理」,
// 切片 S7):它就是 history 侧 `DocumentRefCounter` 的实现,把消息的 document 列
// 的引用变化落到 `documents.ref_count` / `last_referenced_at` 上。
//
// 为什么在这里而不是在 history 里写 SQL:`documents` 表只有一个写入面(本包,
// 见 store_meta.go 的同一理由),history 只定义消费侧接口(refs.go);SQL 与
// 时间戳口径因此只有一份,参考计数与 GC 的判据不可能漂移。
//
// 两条纪律:
// - **不自己开事务**:执行器由调用方(history 的消息写路径)给出,引用计数因此
// 与消息行原子落定;
// - **只动计数列**:这里绝不删文件、绝不动 name/sha256/mime/size/uploader/
// created_at —— 回收由 gc.go 按 ref_count 判定。
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
const (
// retainRefSQL 记一次引用:计数 +1 并把「最后一次被引用」刷新为现在。名字用
// 参数绑定(无拼接)。`documents` 表由迁移 0014 建出,name 上有唯一索引。
retainRefSQL = `UPDATE documents
SET ref_count = ref_count + 1, last_referenced_at = ?
WHERE name = ?`
// releaseRefSQL 撤销一次引用:计数 -1,并**钳在 0**(`AND ref_count > 0`)。
// 计数绝不为负 —— 负数会让「未被引用」的判据永远不成立,一份文件从此再也
// 回收不掉,而那是本切片的全部意义。
releaseRefSQL = `UPDATE documents
SET ref_count = ref_count - 1
WHERE name = ? AND ref_count > 0`
// sqliteTimeLayout 是 `documents` 表时间戳列的格式,与迁移 0014 的默认值
// `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 逐字同形状(UTC、毫秒、字面量 Z)。
// 写入与解析共用它,使「我们写进去的」与「SQLite 默认生成的」可以直接比较。
sqliteTimeLayout = "2006-01-02T15:04:05.000"
)
// RefCounter 把引用计数的增减写进 `documents` 表。它实现
// `history.DocumentRefCounter`,由组合根注入 history.Store(WithDocumentRefs)。
// 它可安全并发使用(无可变状态)。
type RefCounter struct {
log *slog.Logger
now func() time.Time
}
// NewRefCounter 构造引用计数写入面。log 为 nil 时替换为标记来源 runtime/documents
// 的丢弃日志器(与摄取层同一口径)。
func NewRefCounter(log *slog.Logger) *RefCounter {
return &RefCounter{log: sourcedLog(log), now: time.Now}
}
// Retain 为 names 里出现的每个名字记一次引用(同一名字出现两次就是两次)。名字
// 命中不到任何元数据行时记一条 WARN 并继续:那意味着盘上有文件、表里没有行
// (元数据写入失败,design/24 §9.2-5 已登记的债),此时没有任何计数可加,但
// 消息写入绝不该因此失败。
func (r *RefCounter) Retain(ctx context.Context, ex history.SQLExecer, names []string) error {
if len(names) == 0 {
return nil
}
stamp := sqliteTimeStamp(r.now())
for _, name := range names {
res, err := ex.ExecContext(ctx, retainRefSQL, stamp, name)
if err != nil {
return fmt.Errorf("documents: 记引用 %q: %w", name, err)
}
if n, rerr := res.RowsAffected(); rerr == nil && n == 0 {
r.log.Warn("document ref retained without metadata row", "name", name)
}
}
return nil
}
// Release 撤销 names 里每个名字的一次引用。计数已在 0 时该次撤销是空操作(SQL 里
// 的 `AND ref_count > 0`),因此重复删除、并发删除都不会把计数打成负数。
func (r *RefCounter) Release(ctx context.Context, ex history.SQLExecer, names []string) error {
if len(names) == 0 {
return nil
}
for _, name := range names {
if _, err := ex.ExecContext(ctx, releaseRefSQL, name); err != nil {
return fmt.Errorf("documents: 撤销引用 %q: %w", name, err)
}
}
return nil
}
// sqliteTimeStamp 把时刻格式化成 `documents` 表的时间戳口径(UTC 毫秒 + 字面量 Z)。
func sqliteTimeStamp(t time.Time) string {
return t.UTC().Format(sqliteTimeLayout) + "Z"
}
// parseSQLiteTime 解析 `documents` 表的时间戳列。它先试本包写出的口径,再退回
// RFC3339(覆盖带偏移或非毫秒精度的历史值);两者都不成立时返回 false,调用方
// 据此**保守跳过**(时间读不出来的行绝不当成「已超期」)。
func parseSQLiteTime(raw string) (time.Time, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return time.Time{}, false
}
if t, err := time.Parse(sqliteTimeLayout+"Z", trimmed); err == nil {
return t.UTC(), true
}
if t, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return t.UTC(), true
}
if t, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
return t.UTC(), true
}
return time.Time{}, false
}
+132
View File
@@ -0,0 +1,132 @@
package documents
// 本文件是**引用计数 × 回收**的端到端验收(必测 4:引用计数正确性):一条真实
// 链路 —— `documents.NewRefCounter` 注入 `history.Store` 之后,「两条消息引用 ⇒
// ref_count = 2;删一条 ⇒ 1(仍不删);删最后一条 ⇒ 0,超期后才可删」。
//
// 为什么必须有它:RefCounter 与 history 是两份代码,各自单测都绿仍可能凑不出一
// 条正确的计数(例如抽取结论回写把同一份文档又 +1 一次,文件就**永远**回收不掉)。
// 这里跑的是完整链路 + 真实 SQLite,因此这条不变量是机器证明的。
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// refCountOf 读一份文档当前的引用计数。
func refCountOf(t *testing.T, f *gcFixture, name string) int64 {
t.Helper()
var refs int64
if err := f.db.QueryRowContext(context.Background(),
`SELECT ref_count FROM documents WHERE name = ?`, name).Scan(&refs); err != nil {
t.Fatalf("读 ref_count(%q): %v", name, err)
}
return refs
}
// TestRefCountEndToEndWithMessagesAndGC 覆盖必测 4 的完整链路。
func TestRefCountEndToEndWithMessagesAndGC(t *testing.T) {
t.Parallel()
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
f := newGCFixture(t, now)
// 时间往后拨 30 天:保留期(7 天)早已过去,于是「还留着」只可能因为仍被引用。
later := func() time.Time { return now.Add(30 * 24 * time.Hour) }
gc := NewGC(GCConfig{
DB: f.db, DataDir: f.dataDir, UploadsDir: f.uploads,
Interval: DefaultGCInterval, Retention: DefaultRetention, Now: later,
})
// 真实上传:原件落盘 + 元数据行入表。
store := NewStore(StoreConfig{
UploadsDir: f.uploads,
Meta: MetadataWriterFor(filepath.Join(f.dataDir, "gc.db"), nil),
})
ref, err := store.Save(ctx, "端到端.txt", []byte("端到端引用计数的正文"))
if err != nil {
t.Fatalf("Save: %v", err)
}
sidecar := filepath.Join(f.extracted, ref.Name+sidecarSuffix)
if err := os.MkdirAll(f.extracted, sidecarDirMode); err != nil {
t.Fatalf("建 sidecar 目录: %v", err)
}
if err := os.WriteFile(sidecar, []byte("抽取正文"), sidecarFileMode); err != nil {
t.Fatalf("写 sidecar: %v", err)
}
hist, err := history.NewStore(ctx, f.db, nil, history.WithDocumentRefs(NewRefCounter(nil)))
if err != nil {
t.Fatalf("history.NewStore: %v", err)
}
raw, err := history.EncodeDocuments([]history.DocumentRef{{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}})
if err != nil {
t.Fatalf("EncodeDocuments: %v", err)
}
first, err := hist.AppendFull(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "读这份", Document: raw})
if err != nil {
t.Fatalf("append 1: %v", err)
}
second, err := hist.AppendFull(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "再读一次", Document: raw})
if err != nil {
t.Fatalf("append 2: %v", err)
}
if got := refCountOf(t, f, ref.Name); got != 2 {
t.Fatalf("两条消息引用后 ref_count = %d, want 2", got)
}
// 抽取结论回写(同一批名字、只补 status)**不得**再计一次。
merged := `[{"name":"` + ref.Name + `","mime":"` + ref.MIME + `","size":` +
strconv.FormatInt(ref.Size, 10) + `,"chars":8,"status":"extracted"}]`
if err := hist.UpdateMessageDocument(ctx, first.ID, merged); err != nil {
t.Fatalf("UpdateMessageDocument: %v", err)
}
if got := refCountOf(t, f, ref.Name); got != 2 {
t.Fatalf("结论回写后 ref_count = %d, want 2(同一批名字不重复计数)", got)
}
// 仍被两条消息引用:即便时钟已过保留期 30 天,三样都不许消失。
if _, err := gc.Sweep(ctx); err != nil {
t.Fatalf("Sweep(引用中): %v", err)
}
if o, s, r := f.exists(t, ref.Name); !o || !s || !r {
t.Fatalf("被引用的文档被回收了:原件=%v sidecar=%v 行=%v", o, s, r)
}
// 删掉一条消息 ⇒ 1(仍不删)。
if err := hist.DeleteMessage(ctx, first.ID); err != nil {
t.Fatalf("delete 1: %v", err)
}
if got := refCountOf(t, f, ref.Name); got != 1 {
t.Fatalf("删一条后 ref_count = %d, want 1", got)
}
if _, err := gc.Sweep(ctx); err != nil {
t.Fatalf("Sweep(还剩一条引用): %v", err)
}
if o, s, r := f.exists(t, ref.Name); !o || !s || !r {
t.Fatalf("还剩一条引用时被回收了:原件=%v sidecar=%v 行=%v", o, s, r)
}
// 删掉最后一条 ⇒ 0,且此时(时钟已越过保留期)才可回收:三样一起消失。
if err := hist.DeleteMessage(ctx, second.ID); err != nil {
t.Fatalf("delete 2: %v", err)
}
if got := refCountOf(t, f, ref.Name); got != 0 {
t.Fatalf("删净后 ref_count = %d, want 0", got)
}
stats, err := gc.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep(无引用 + 超期): %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("stats = %+v, want 删除 1", stats)
}
if o, s, r := f.exists(t, ref.Name); o || s || r {
t.Fatalf("无引用 + 超期后仍留着:原件=%v sidecar=%v 行=%v,want 三样都消失", o, s, r)
}
}