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

+99
View File
@@ -0,0 +1,99 @@
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
}