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
@@ -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)),
|
||||
|
||||
@@ -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);以接口形式持有,
|
||||
// 组合根才能用假出口做单元测试。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user