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

138 lines
5.5 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
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 本文件负责 Android 协同批次 ③a 背后的单条消息操作(D2 搜索、D3 编辑/删除),
// 以及 D 删除广播所需的按 id 读取。它们都严格限定在会话作用域或 id 作用域:
// 消息只能经由它所属的会话(Search)或它自己不透明的 id(GetMessage/
// UpdateMessage/DeleteMessage)到达,因此隔离的群聊/陌生人会话绝不浮现在
// 另一个会话的结果中(项目红线)。
// Search 按最新优先返回 sessionID 中内容包含 query 的至多 limit 条消息。匹配
// 是 ASCII 大小写不敏感的 LIKE(SQLite 对 LIKE 的默认行为),并把 query 中的
// %/_/\ 转义使它们按字面匹配;before 与 List 是同一个毫秒游标(严格早于
// before),limit 钳制到 [1, maxPageLimit]。rowid 打破 created_at 的并列,使
// 同一毫秒的消息保持稳定顺序。空 query 返回空(非 nil)切片 —— 调用方改用
// List 做普通翻页。Search 绝不跨越会话边界。
func (s *Store) Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
if query == "" {
return make([]Message, 0), nil
}
sqlText := `SELECT ` + messageColumns + `
FROM chat_messages WHERE session_id = ? AND content LIKE ? ESCAPE '\'`
args := []any{sessionID, "%" + escapeLike(query) + "%"}
if before > 0 {
sqlText += ` AND created_at < ?`
args = append(args, before)
}
sqlText += ` ORDER BY created_at DESC, rowid DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, fmt.Errorf("history: search %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan search %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate search %s: %w", sessionID, err)
}
return msgs, nil
}
// UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull
// 一样,使调用方能无需二次读取就返回/广播它)。只有 content 变化;role、
// platform、时间戳及其余字段保持已存值。未知 id 返回 ErrMessageNotFound。
func (s *Store) UpdateMessage(ctx context.Context, id, content string) (Message, error) {
res, err := s.db.ExecContext(ctx, `UPDATE chat_messages SET content = ? WHERE id = ?`, content, id)
if err != nil {
return Message{}, fmt.Errorf("history: update message %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return Message{}, fmt.Errorf("history: update message %s rows: %w", id, err)
}
if n == 0 {
return Message{}, fmt.Errorf("history: update message %s: %w", id, ErrMessageNotFound)
}
return s.messageByID(ctx, id)
}
// DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。
//
// 该行携带的文档引用在**同一个事务**里撤销(切片 S7):删掉最后一条引用它的消息
// 之后,那份文档才可能(且只能在超过保留期之后)被 GC 回收。列要先读后删 ——
// 行一旦删掉,引用就再也无从撤销。
func (s *Store) DeleteMessage(ctx context.Context, id string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("history: delete message %s: %w", id, err)
}
defer func() { _ = tx.Rollback() }()
var document string
err = tx.QueryRowContext(ctx, `SELECT document FROM chat_messages WHERE id = ?`, id).Scan(&document)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("history: read message %s document: %w", id, err)
}
res, err := tx.ExecContext(ctx, `DELETE FROM chat_messages WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("history: delete message %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: delete message %s rows: %w", id, err)
}
if n == 0 {
return fmt.Errorf("history: delete message %s: %w", id, ErrMessageNotFound)
}
if err := s.releaseDocuments(ctx, tx, document); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("history: delete message %s: %w", id, err)
}
return nil
}
// GetMessage 读取 id 所指消息,没有该行时返回 ErrMessageNotFound。它是传输层
// 在移除消息之前使用的按 id 读取:DeleteMessage 之后已无从再问,而那条告知
// 其他客户端丢弃该行的广播是按会话划分的。
func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) {
return s.messageByID(ctx, id)
}
// messageByID 按 id 读取一条消息,行不存在时返回 ErrMessageNotFound。
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+messageColumns+`
FROM chat_messages WHERE id = ?`, id)
m, err := scanMessage(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Message{}, fmt.Errorf("history: read message %s: %w", id, ErrMessageNotFound)
}
return Message{}, fmt.Errorf("history: read message %s: %w", id, err)
}
return m, nil
}
// escapeLike 转义用户查询中的 LIKE 通配符,使 %、_ 与 \ 按字面匹配(调用方
// 配合 ESCAPE '\' 使用)。它在 %...% 包裹之前应用。
func escapeLike(query string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
}