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

+178
View File
@@ -0,0 +1,178 @@
package history
// 本文件归属**文档引用计数**在消息写路径上的接线(design/24 §3.2 的「配额与清理」,
// 切片 S7):引用来源 = chat_messages.document 列(≤5 个引用对象的 JSON 数组,
// 见 document.go),它在消息落库/编辑/删除时增减 documents.ref_count,并在被引用
// 时刷新 last_referenced_at。
//
// 三条纪律(它们就是「别四处散写」的落地方式):
//
// 1. **增减点只有消息写路径**:insert(Append/AppendFull)、
// UpdateMessageDocument(抽取结论回写)、DeleteMessage、DeleteSession;
// 别处一律不碰 documents 表。
// 2. **计数与消息行同生共死**:这些方法把引用计数的 SQL 放进**同一个事务**
// (SQLExecer 由 *sql.Tx 满足),任何一边失败就整体回滚 —— 否则会出现
// 「消息在库里、计数没加」的窗口,而 S7 的 GC 恰恰是按计数判死的,
// 那等于让一份仍被引用的文件被回收。
// 3. **只按名字集合的差增减**:抽取结论回写常常是「同一批名字、只补
// status/chars」(见 session_document_status.go),那一趟的增减必须为空;
// 否则同一份文档被重复计数,最终永远回收不掉。
//
// documents 表的具体 SQL 属 `internal/runtime/documents`(那张表的唯一写入面),
// 本包只定义**消费侧接口**并调用,因此依赖方向仍是 documents → history,不成环。
import (
"context"
"database/sql"
"fmt"
)
// SQLExecer 是 *sql.DB 与 *sql.Tx 共同满足的最小 SQL 执行面。它让引用计数的
// 写入能落在调用方的**同一个事务**里(见本文件的三条纪律之 2),而不是另开连接
// 各自为政。
type SQLExecer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// DocumentRefCounter 是文档引用计数的写入面,由 `internal/runtime/documents`
// 的 RefCounter 实现。names 是**次数敏感**的:同一个名字出现两次就代表两条引用,
// 实现方据此各增减一次。
//
// 它刻意不自己开事务:调用方(本包的消息写路径)把 ex 传进来,增减因此与消息行
// 原子落定。
type DocumentRefCounter interface {
// Retain 为每个出现的名字记一次引用(+1,并刷新 last_referenced_at)。
Retain(ctx context.Context, ex SQLExecer, names []string) error
// Release 为每个出现的名字撤销一次引用(≤0 时钳到 0,绝不变成负数)。
Release(ctx context.Context, ex SQLExecer, names []string) error
}
// StoreOption 配置 Store 的可选接线面(没有它时 Store 的行为与接线前逐字相同)。
type StoreOption func(*Store)
// WithDocumentRefs 注入文档引用计数写入面。未注入时引用计数不被维护(未接库的
// 构建/测试、以及只关心消息读写的调用方),本包对每次增减记一条 DEBUG 痕迹而
// 不是静默 —— 「没接线」与「接了但没记」必须能分辨。
func WithDocumentRefs(rc DocumentRefCounter) StoreOption {
return func(s *Store) { s.refs = rc }
}
// documentNames 解出 document 列涉及的全部文档名。它**保留重复**(同一条消息
// 里同一份文档出现两次就是两次引用,insert 与 delete 两侧因此对称),解码失败时
// 返回包装了 ErrBadDocuments 的错误。
func documentNames(raw string) ([]string, error) {
refs, err := DecodeDocuments(raw)
if err != nil {
return nil, err
}
names := make([]string, 0, len(refs))
for _, ref := range refs {
if ref.Name == "" {
// EncodeDocuments 拒绝无名引用,因此这只可能来自外部改坏的行:
// 名字是 URL 与展示身份的唯一来源,没有名字就没有可维护的引用。
continue
}
names = append(names, ref.Name)
}
return names, nil
}
// retainDocuments 给 raw 里出现的每个文档名记一次引用。未注入计数器或列里没有
// 引用时是空操作。
func (s *Store) retainDocuments(ctx context.Context, ex SQLExecer, raw string) error {
if s.refs == nil {
s.log.Debug("document refs not wired: retain skipped", "documents", raw != "")
return nil
}
names, err := documentNames(raw)
if err != nil {
// 名字解不出来就无从增减。这里**不**让消息写入失败:消息行才是用户的
// 事实,而坏列的成因是外部改库/历史坏数据,不是一个可以在此修复的问题。
// 代价是那份文档的计数可能偏低(保守方向:只会让它晚回收,绝不早删)。
s.log.Warn("document refs retained skipped: malformed column", "err", err)
return nil
}
if len(names) == 0 {
return nil
}
if err := s.refs.Retain(ctx, ex, names); err != nil {
return fmt.Errorf("history: retain document refs: %w", err)
}
return nil
}
// releaseDocuments 撤销 raw 里出现的每个文档名的一次引用。
func (s *Store) releaseDocuments(ctx context.Context, ex SQLExecer, raw string) error {
if s.refs == nil {
s.log.Debug("document refs not wired: release skipped", "documents", raw != "")
return nil
}
names, err := documentNames(raw)
if err != nil {
s.log.Warn("document refs released skipped: malformed column", "err", err)
return nil
}
if len(names) == 0 {
return nil
}
if err := s.refs.Release(ctx, ex, names); err != nil {
return fmt.Errorf("history: release document refs: %w", err)
}
return nil
}
// syncDocumentRefs 把一行消息的 document 列从 oldRaw 换成 newRaw 时按**差**增减
// 引用计数:新增的名字 retain、去掉的名字 release、两边都有的名字一动不动。两次
// 内容相同时(含都是空)它直接返回,因此「只补抽取结论」的回写不会重复计数。
func (s *Store) syncDocumentRefs(ctx context.Context, ex SQLExecer, oldRaw, newRaw string) error {
if s.refs == nil || oldRaw == newRaw {
return nil
}
oldNames, err := documentNames(oldRaw)
if err != nil {
s.log.Warn("document refs sync skipped: malformed old column", "err", err)
return nil
}
newNames, err := documentNames(newRaw)
if err != nil {
s.log.Warn("document refs sync skipped: malformed new column", "err", err)
return nil
}
add, remove := diffDocumentNames(oldNames, newNames)
if len(add) > 0 {
if err := s.refs.Retain(ctx, ex, add); err != nil {
return fmt.Errorf("history: retain document refs: %w", err)
}
}
if len(remove) > 0 {
if err := s.refs.Release(ctx, ex, remove); err != nil {
return fmt.Errorf("history: release document refs: %w", err)
}
}
return nil
}
// diffDocumentNames 返回从 oldNames 到 newNames 的净增与净减(按出现次数计)。
// 输出的顺序是确定的(按各自的输入顺序),使同一次写入的 SQL 次序可复现、日志与
// 测试也稳定;两个列表里的同一个名字最多出现「次数差的绝对值」次。
func diffDocumentNames(oldNames, newNames []string) (add, remove []string) {
remaining := make(map[string]int, len(oldNames))
for _, name := range oldNames {
remaining[name]++
}
for _, name := range newNames {
if remaining[name] > 0 {
remaining[name]--
continue
}
add = append(add, name)
}
for _, name := range oldNames {
if remaining[name] <= 0 {
continue
}
remaining[name]--
remove = append(remove, name)
}
return add, remove
}