- 引用计数真实维护(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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
75 lines
2.9 KiB
Go
75 lines
2.9 KiB
Go
package history
|
||
|
||
// 本文件归属**会话级删除**:`DeleteSession` 及其「删行前先把文档引用读出来」的辅助
|
||
// 查询。它从 store.go 拆出来有两条理由:store.go 贴着 500 行硬上限(AGENTS §10),
|
||
// 而「删一个会话」与「读写消息」本来就是两件事(与 session_persona.go /
|
||
// session_preview.go / list_after.go 拆出去的理由同一)。
|
||
//
|
||
// 切片 S7 在此加了一条不变量:被删消息携带的文档引用要在**同一个事务**里撤销。
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
)
|
||
|
||
// DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
|
||
// 返回 ErrImmutableMain(design/16 §6)。
|
||
//
|
||
// 被删消息携带的文档引用在**同一个事务**里撤销(切片 S7):会话删掉却留着计数,
|
||
// 那些文档就永远回收不掉。撤销前先把各行的 document 列读出来(删完就无从再问),
|
||
// 读列失败或计数写失败都整体回滚 —— 「消息删了、计数没减」同样是 GC 误删的窗口。
|
||
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
|
||
if sessionID == MainSessionID {
|
||
return ErrImmutableMain
|
||
}
|
||
tx, err := s.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("history: begin delete %s: %w", sessionID, err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
documents, err := sessionDocumentColumns(ctx, tx, sessionID)
|
||
if err != nil {
|
||
return fmt.Errorf("history: read documents %s: %w", sessionID, err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_messages WHERE session_id = ?`, sessionID); err != nil {
|
||
return fmt.Errorf("history: delete messages %s: %w", sessionID, err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM chat_sessions WHERE id = ? AND type = ?`, sessionID, SessionSub); err != nil {
|
||
return fmt.Errorf("history: delete session %s: %w", sessionID, err)
|
||
}
|
||
for _, raw := range documents {
|
||
if err := s.releaseDocuments(ctx, tx, raw); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return fmt.Errorf("history: commit delete %s: %w", sessionID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sessionDocumentColumns 读出 sessionID 下全部非空 document 列(原始 JSON 文本)。
|
||
// 它必须在删行**之前**调用:行一旦删掉,那些引用就再也无从撤销。
|
||
func sessionDocumentColumns(ctx context.Context, tx *sql.Tx, sessionID string) ([]string, error) {
|
||
rows, err := tx.QueryContext(ctx,
|
||
`SELECT document FROM chat_messages WHERE session_id = ? AND document != ''`, sessionID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query: %w", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
columns := make([]string, 0)
|
||
for rows.Next() {
|
||
var raw string
|
||
if err := rows.Scan(&raw); err != nil {
|
||
return nil, fmt.Errorf("scan: %w", err)
|
||
}
|
||
columns = append(columns, raw)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("iterate: %w", err)
|
||
}
|
||
return columns, nil
|
||
}
|