Files
AskaEth 3ba31b58ff
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
2026-10-04 21:01:30 +08:00

179 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}