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