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

+3
View File
@@ -90,6 +90,9 @@ func run() error {
return fmt.Errorf("start automations: %w", err)
}
a.backthink.Start(ctx)
// 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。
// 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。
a.startDocumentGC(ctx)
a.log.Info("cyrene subsystems started",
"personas", a.personas.CountPersonas(),
"tools", len(a.caps.List(capreg.KindTool)),
+3
View File
@@ -82,6 +82,9 @@ type app struct {
gw *gateway.Server
channelBridge *channelbridge.Bridge
channels gateway.ChannelConfigStore
// docGC 是文档回收的常驻任务(切片 S7):未被引用且超过保留期的文档连同
// sidecar 与元数据行一起回收。GC 被配置关闭时为 nil(见 wire_document_gc.go)。
docGC *documentGC
// capacitySink 把 capacity_awaken 状态迁移广播给 Web UI(design/14 §5)。
// 由 *gateway.Server 满足(BroadcastCapacityAwaken);以接口形式持有,
// 组合根才能用假出口做单元测试。
+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
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestWireDocumentGC_IsWired 是一道「接线存在性」防回归检查(切片 S7;与
// TestWireChannels_WiresInboundDocumentStore 同一手法)。
//
// 组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在运行),因此
// 「引用计数确实被接上」「启动期/定时清扫确实被拉起」「关闭时确实被停掉」只能在
// 源码层钉住:少任何一处,GC 都会**静默不工作**(消息照写、文件照留、日志里一句
// 都没有),而这不会让任何既有用例变红。
//
// 最后一段是 `.env.example` 的同步检查(AGENTS §10 铁律):新键必须与代码里的
// env 标签**逐字一致**,否则照模板部署的新实例会漏配且静默失效。
func TestWireDocumentGC_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_services.go": {
// 引用计数写入面注入 history(消息落库/编辑/删除都在那里增减)。
"history.WithDocumentRefs(documents.NewRefCounter(a.log))",
// 回收循环必须在关库之前停下(清扫用那条连接池)。
"a.docGC.stop()",
},
"wire_document_gc.go": {
"documents.NewGC(documents.GCConfig{",
// 目录口径与网关上传、摄取层读取逐字同源(`<DataDir>/uploads/documents`)。
`UploadsDir: filepath.Join(docDataDir, "uploads", "documents")`,
"Interval: a.cfg.Server.DocumentGCInterval",
"Retention: a.cfg.Server.DocumentRetention",
},
"main.go": {
// 启动期清扫 + 定时:没有这一行,GC 只存在于代码里而从不运行。
"a.startDocumentGC(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 文档引用计数/回收没有被接上组合根", file, want)
}
}
}
// .env.example 的两个新键必须与代码里的 env 标签逐字一致。
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{"DOCUMENT_GC_INTERVAL=", "DOCUMENT_RETENTION="} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key)
}
}
}
+5 -1
View File
@@ -12,6 +12,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
@@ -45,7 +46,8 @@ func (a *app) wireAuxiliary() error {
}
a.sessions = sess
hist, herr := history.NewStore(context.Background(), a.db, a.log)
hist, herr := history.NewStore(context.Background(), a.db, a.log,
history.WithDocumentRefs(documents.NewRefCounter(a.log)))
if herr != nil {
return fmt.Errorf("history store: %w", herr)
}
@@ -100,6 +102,8 @@ func (a *app) pushOutletConfig() backthink.PushOutletConfig {
// close 按依赖顺序关闭每个子系统并释放资源。
func (a *app) close(ctx context.Context) {
// 文档回收循环要先用连接池(每轮一次查询),因此排在关库之前。
a.docGC.stop()
if a.backthink != nil {
_ = a.backthink.Stop(ctx)
}