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

75 lines
2.9 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
// 本文件归属**会话级删除**:`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
}