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 不阻塞消息写入 ⇒ 计数可能偏低(只会让文件**晚**回收,绝不早删)。
This commit is contained in:
1 parent
79b1ec36aa
commit
3ba31b58ff
18 files changed
+1951
-29
No files matched your search
@@ -143,6 +143,9 @@ type HistoryStore interface {
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
// refs 是文档引用计数的写入面(切片 S7,见 refs.go)。未注入时为 nil,
|
||||
// 此时消息读写照旧、只是不维护 documents.ref_count。
|
||||
refs DocumentRefCounter
|
||||
}
|
||||
|
||||
// rowScanner 由 *sql.Row 与 *sql.Rows 同时满足,因此单个 scanMessage 辅助函数
|
||||
@@ -155,7 +158,10 @@ type rowScanner interface {
|
||||
// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,前滚
|
||||
// 见 document.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
|
||||
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
//
|
||||
// opts 是可选的接线面(见 refs.go):组合根用 WithDocumentRefs 注入引用计数写入
|
||||
// 面;不传时 Store 的行为与切片 S7 之前逐字相同。
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger, opts ...StoreOption) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
@@ -184,6 +190,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
}
|
||||
}
|
||||
s := &Store{db: db, log: log}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
if err := s.ensureMainSession(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -213,6 +222,10 @@ func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) {
|
||||
// 裁决谁写下了这一行;命中冲突时不写入,改读回既有那一行返回(因此调用方拿到
|
||||
// 的是权威消息,而不是一条并不存在的「刚插入的行」)。判重不在这里「先查再插」
|
||||
// —— 那是两个并发重发都能通过检查、然后双双插入的竞态。
|
||||
//
|
||||
// 该行携带文档引用时(msg.Document 非空),引用计数的 +1 与这一行**同一个事务**
|
||||
// 落定(切片 S7):命中幂等冲突时不计数(那一行不是本次写的),计数写失败则整条
|
||||
// 消息回滚 —— 绝不留下「消息在库里、计数没加」这种让 GC 误删仍被引用文件的窗口。
|
||||
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
if msg.ID == "" {
|
||||
id, err := newID()
|
||||
@@ -236,7 +249,12 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
if msg.ClientMsgID != "" {
|
||||
query += ` ON CONFLICT DO NOTHING`
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, query,
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
res, err := tx.ExecContext(ctx, query,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image,
|
||||
msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID,
|
||||
msg.PersonaID, msg.ClientMsgID, msg.CreatedAt)
|
||||
@@ -249,9 +267,18 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
// 冲突:本次没有写下任何行,因此也没有引用要记。先释放事务再读回
|
||||
// 权威行(连接池只有一条连接,读回必须等事务让出它)。
|
||||
_ = tx.Rollback()
|
||||
return s.FindByClientMsgID(ctx, msg.SessionID, msg.ClientMsgID)
|
||||
}
|
||||
}
|
||||
if err := s.retainDocuments(ctx, tx, msg.Document); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
@@ -296,30 +323,6 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
|
||||
// 返回 ErrImmutableMain(design/16 §6)。
|
||||
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() }()
|
||||
|
||||
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)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("history: commit delete %s: %w", sessionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSubSession 创建一个不绑定显式角色的子会话并返回其 id
|
||||
// (MainSessionID:uuid)。新会话因此继承全局默认角色(persona_id 为空);要在
|
||||
// 创建时绑定角色请用 NewSubSessionFor。ownerID 为接口完整性而接受(Web 主人
|
||||
|
||||
Reference in new issue
Block a user