Files
Cyrene_v2/cmd/cyrene/wire_document_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

100 lines
3.4 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 main
// 本文件把**文档回收(GC)**接进组合根(design/24 §3.2 的配额与清理,切片 S7):
// 按配置构造回收器、在启动期清扫一次、此后按间隔定时清扫,退出时干净停下。
//
// 三条取舍写在这里(实现细节见 `internal/runtime/documents/gc.go`):
//
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事(紧随子系统启动之后
// 开始,网关监听之前就绪),而不是在 newApp/run 里同步等它跑完 —— 数据目录在
// 慢盘/网络盘上时,一次清扫不该把「昔涟能不能起来」绑在磁盘上。
// 2. **失败不退出进程**:查询失败(库不可用、迁移没跑)只记一条 WARN 并等下一轮;
// 单件文件删除失败由 gc.go 记 WARN 后继续,整轮照常跑完。
// 3. **关闭 = 显式配 0**:`DOCUMENT_GC_INTERVAL=0` 或 `DOCUMENT_RETENTION=0`
// 都不构造回收器,NewGC 会把关闭原因记进日志(静默的「配了却不生效」不存在)。
import (
"context"
"log/slog"
"path/filepath"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// documentGC 是常驻的文档回收任务:一个回收器 + 一个可停的循环。
type documentGC struct {
gc *documents.GC
log *slog.Logger
cancel context.CancelFunc
done chan struct{}
}
// newDocumentGC 按当前配置构造回收器;GC 关闭时返回 nil(NewGC 已记下关闭原因)。
// 目录口径与网关的上传目录、摄取层的读取目录**逐字同源**
// (`<DataDir>/uploads/documents`,sidecar 在 `<DataDir>/extracted`)。
func (a *app) newDocumentGC() *documentGC {
docDataDir := resolveDataDir(a.cfg)
gc := documents.NewGC(documents.GCConfig{
DB: a.db,
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Interval: a.cfg.Server.DocumentGCInterval,
Retention: a.cfg.Server.DocumentRetention,
Log: a.log,
})
if !gc.Enabled() {
return nil
}
return &documentGC{gc: gc, log: logger.WithSource(a.log, "cmd/cyrene")}
}
// startDocumentGC 拉起常驻回收(无操作 = GC 关闭):立刻清扫一次,然后按间隔定时
// 清扫,直到 ctx 被取消或 stop 被调用。
func (a *app) startDocumentGC(ctx context.Context) {
runner := a.newDocumentGC()
if runner == nil {
return
}
runCtx, cancel := context.WithCancel(ctx)
runner.cancel = cancel
runner.done = make(chan struct{})
a.docGC = runner
go runner.loop(runCtx)
}
// loop 是回收循环:启动期清扫一次 → 定时清扫。
func (d *documentGC) loop(ctx context.Context) {
defer close(d.done)
d.sweep(ctx)
ticker := time.NewTicker(d.gc.Interval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.sweep(ctx)
}
}
}
// sweep 跑一轮回收。每轮的成功汇总由 gc.Sweep 自己记(删了几件/释放多少字节,
// **绝不记正文**);这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
func (d *documentGC) sweep(ctx context.Context) {
if _, err := d.gc.Sweep(ctx); err != nil {
d.log.Warn("document gc sweep failed", "err", err)
}
}
// stop 停掉回收循环并等它退出;未启动时是空操作。它必须在关库**之前**调用
// (清扫要用那条连接池)。
func (d *documentGC) stop() {
if d == nil || d.cancel == nil {
return
}
d.cancel()
<-d.done
}