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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
This commit is contained in:
1 parent
79b1ec36aa
commit
3ba31b58ff
18 files changed
+1951
-29
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user