- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
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
|
||
}
|