review r2(p2-1items)指出:r1 那笔注释里的行号是旧值 —— 我在文件更前面插了 12 行之后 `Stop` 整体下移,而注释里仍写着「`Stop` 在 `tidy_service.go:214` 就放锁, 到 `:219` 才 Wait」(214 是 `Stop` 的文档注释行、219 是 `s.mu.Lock()`)。 行号会漂、符号不会,因此改成符号 + 相对位置: - 「在同一个 `func (s *TidyService) Stop` 里,`s.mu.Unlock()` 在前、`s.wg.Wait()` (另一个 goroutine 里)在后,两者之间没有锁」; - 「`Start` 只在组合根启动期调一次(`cmd/cyrene/main.go` 的 `a.memoryTidy.Start(ctx)`)、`Stop` 只在关停链里调一次(`cmd/cyrene/wire_services.go` 的 `_ = a.memoryTidy.Stop(ctx)`)」。 同一类问题只此一处遗留(`expire.go` 里「照 `preinsert_gc.go:65-69` 的批删范式」 同样改成符号:`ingestReviewSweepBatchSize` / `maxIngestReviewSweepBatches` 那一对 常量)。已 grep 全部改动文件确认再无 `*.go:<行号>` 形式的引用。 (本提交信息刻意不复述任何行号。)
321 lines
15 KiB
Go
321 lines
15 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||
)
|
||
|
||
// 本文件是**临时期限到期处置的第 ② 步与第 ③ 步**:后台到期清扫(物理 DELETE)
|
||
// 与「删前留审计」(design/23 §19,主人 Q1 裁定「临时记忆到期删除」)。
|
||
//
|
||
// 三步是刻意分开的(§19.1):
|
||
//
|
||
// ① 隐藏 —— 读侧时效闸(timegate.go):每次读比一次列值,**零写入**、即时;
|
||
// ② 删除 —— 本文件:显式、有界、有审计、可停的后台路径;
|
||
// ③ 审计 —— 本文件:**每行删除前**先写一条 `memory.tidy.expire`。
|
||
//
|
||
// 把删除混进读路径是这条链上最容易犯的错:读路径会因此变成「有副作用且不可逆」,
|
||
// 一次带 bug 的查询就能删记忆。因此删除只有这里一个出口,只由后台件调用。
|
||
//
|
||
// 🔴 **与「写路径唯一」红线怎么共存**:`design/23 §15.1 D3-1` 那条约束的是
|
||
// **INSERT(入库)**,`Store.Add` 是唯一 INSERT 路径。到期删除是一条 **DELETE
|
||
// (清除)**,不是第二条入库路径;既有先例是 `Store.Delete` / `DeleteScoped` 与
|
||
// `preinsert_gc.go` 的硬删留痕,两者都只删行、不造行。本清扫与之同类:**自带审计**,
|
||
// 不碰 `Store.Add`,也不复用任何「想写记忆」的调用点。
|
||
//
|
||
// 🔴 **删除范围写死**(`design/23 §19.2`,逐条可测):
|
||
//
|
||
// expires_at <> '' ∧ expires_at <= now ∧ status = 'active' ∧ 来源非受限
|
||
//
|
||
// - **空 `expires_at` = 永不过期,绝不删**。长期 / 日常记忆的 `expires_at` 是
|
||
// 空串(`store_schema.go` 的 DDL 默认值),因此它们**结构性**进不了扫描集合 ——
|
||
// 这不是靠注释约定,而是靠 SQL 里那条 `expires_at <> ''` 谓词。
|
||
// - **受限来源(群聊 / 陌生人)不参与**:判据复用整理/审查同一处实现
|
||
// `nonOwnerSource`(`tidy_rules.go`),因此三处口径不可能各说各话。
|
||
// - **已软删 / 已归档的行不参与**(`status='active'` 谓词):它们不是「到期的
|
||
// 临时记忆」,是整理软动作留下的行;该不该清是另一件事。
|
||
|
||
const (
|
||
// DefaultExpireSweepInterval 是到期清扫的默认周期:每小时一次
|
||
// (`design/23 §19.1` 表格「到期后 ≤ 一个清扫周期(默认每小时扫一次)」)。
|
||
// 与 `DefaultIngestReviewGCInterval` 同值同由:清扫只是删几行,频率再高无收益。
|
||
DefaultExpireSweepInterval = time.Hour
|
||
|
||
// expireSweepBatchSize 是单批取行 / 删除的行数上限,maxExpireSweepBatches 是
|
||
// 一轮清扫的批数上限。两者一起把「一轮清扫」的时长、单次内存占用与**连接池
|
||
// 占用**(本项目的 SQLite 池是单连接)都变得有界 —— 照 `preinsert_gc.go` 里
|
||
// `ingestReviewSweepBatchSize` / `maxIngestReviewSweepBatches` 那一对常量的
|
||
// 批删范式(`design/23 §19.3-3`「一批删除有条数 / 批数上限」)。
|
||
// 上限之积 = 一轮最多删 1 万行;积压时剩下的留给下一轮,不一次 DELETE 全表。
|
||
expireSweepBatchSize = 500
|
||
maxExpireSweepBatches = 20
|
||
|
||
// expireAuditSummaryRunes 是审计摘要的**按 rune 固定上限**(`design/23 §19.4`)。
|
||
// 48 个 rune 足够一眼看出「删的是哪条」,同时把审计表增长压住 —— 到期审计是
|
||
// **逐条高基数**行(一行一个 id),不截断等于把正文搬进审计表。取 48 而不是
|
||
// 更宽的值:摘要只需可辨认,不需要可复原(正文早已不可逆地删掉了)。
|
||
expireAuditSummaryRunes = 48
|
||
|
||
// expireAuditVerb 是到期删除的审计动作名,与 `memory.tidy.run/.apply/.reject`
|
||
// 同族(`design/23 §19.4`)。**只此一处**允许审计携带内容片段,且
|
||
// 只在删除这一动作上(本包其余审计一律只带计数)。
|
||
expireAuditVerb = "memory.tidy.expire"
|
||
)
|
||
|
||
// ExpirySweepPolicy 是一轮清扫的口径。零值即默认(由 normalized 填批次上限),
|
||
// 因此调用方可以只写 `ExpirySweepPolicy{}`。
|
||
//
|
||
// 它**没有「关掉清扫」的开关** —— 这是刻意的:临时记忆的到期删除是「声明了生命
|
||
// 周期就兑现定义」,留一个能关掉它的开关等于让库里的临时层无界留存(读侧闸只会
|
||
// 让它们「看不见」,不会让它们消失)。真要不产生临时记忆,该关的是第五态
|
||
// `defer` 的判据,而不是清扫。
|
||
type ExpirySweepPolicy struct {
|
||
// BatchSize 是单批上限;<=0 取 expireSweepBatchSize。
|
||
BatchSize int
|
||
// MaxBatches 是一轮的批数上限;<=0 取 maxExpireSweepBatches。
|
||
MaxBatches int
|
||
}
|
||
|
||
// normalized 填默认值。负值与非正值一律取默认(配置异常不该让清扫停摆)。
|
||
func (p ExpirySweepPolicy) normalized() ExpirySweepPolicy {
|
||
if p.BatchSize <= 0 {
|
||
p.BatchSize = expireSweepBatchSize
|
||
}
|
||
if p.MaxBatches <= 0 {
|
||
p.MaxBatches = maxExpireSweepBatches
|
||
}
|
||
return p
|
||
}
|
||
|
||
// ExpirySweepStats 是一轮清扫的汇总(**只记计数,绝不记任何行内容**)。
|
||
// 它进调用方的一条汇总日志,不进审计 —— 审计是逐行的那一条(ExpireFunc)。
|
||
type ExpirySweepStats struct {
|
||
// Deleted 是本轮物理删掉的行数。
|
||
Deleted int64
|
||
// Restricted 是本轮**跳过**的受限来源行数(群聊 / 陌生人)。它非零只说明库里
|
||
// 存在这类历史遗留行,是运维信号,不是错误。
|
||
Restricted int
|
||
// Batches 是本轮实际执行的取行批数。
|
||
Batches int
|
||
}
|
||
|
||
// ExpiredMemory 是一条**即将被物理删除**的到期临时记忆,只带审计需要的字段。
|
||
// 它刻意不带 tags / confidence 之类的列:审计口径就是 `design/23 §19.4` 那一行
|
||
// `id + persona_id + scope + expires_at + 截断摘要`,多带一列就多一分泄漏面。
|
||
type ExpiredMemory struct {
|
||
ID string
|
||
PersonaID string
|
||
Scope string
|
||
ExpiresAt string
|
||
Content string
|
||
}
|
||
|
||
// ExpireFunc 在**每行被删除之前**调用一次,是本清扫的审计出口
|
||
// (`design/23 §19.3-3`:每行删除前先写审计)。它没有返回值:审计出口失败与否
|
||
// 都不该改变「这一行删不删」—— 审计是留痕,不是闸。为 nil 时不写审计
|
||
// (只有不关心留痕的单元测试会这么用;生产的后台件恒传一个)。
|
||
type ExpireFunc func(row ExpiredMemory)
|
||
|
||
// expireCursor 是清扫的**键集游标**(`(expires_at, rowid)`),让被跳过的受限行
|
||
// 只被读到一次:每批处理完就前移到该批最后一行,下一批从它之后取。
|
||
//
|
||
// 为什么不用 `OFFSET`:受限行会被跳过但不删,用 OFFSET 分页时它们会**反复占住
|
||
// 每一批的头部**,极端情况下(头部 500 行都是受限行)真该删的行永远轮不到。
|
||
// 键集游标让进度与「删了多少」解耦 —— 只要取到了行,扫描位置就一定前移。
|
||
type expireCursor struct {
|
||
expiresAt string
|
||
rowid int64
|
||
}
|
||
|
||
// expiredMemoryRow 是扫描回来的一行:审计字段 + 受限判定要用的溯源 + 游标。
|
||
type expiredMemoryRow struct {
|
||
ExpiredMemory
|
||
sourceConversation string
|
||
cursor expireCursor
|
||
}
|
||
|
||
// selectExpiredMemoriesSQL 按 `design/23 §19.2` 的扫描集合取一批到期行。
|
||
//
|
||
// 三条谓词各自对应一条边界:`expires_at <> ”` 排除「永不过期」的行(空串是
|
||
// 长期 / 日常记忆的默认值,见 store_schema.go 的 DDL),`expires_at <= ?` 取到期
|
||
// 的行,`status = ?` 只取在册行。时间戳用定宽 UTC 文本比较:字典序等价于时间序,
|
||
// 与写入侧 `formatTime`(timefmt.go)同源 —— 换成 SQLite 的 datetime('now') 会
|
||
// 静默判错(timegate.go 有这条反证的实证)。
|
||
//
|
||
// 游标谓词用 `(expires_at, rowid)` **元组比较**:expires_at 是文本列,同一批
|
||
// 落库的行可能同值,只比 expires_at 会让扫描卡在原地。
|
||
const selectExpiredMemoriesSQL = `SELECT id, persona_id, scope, expires_at, content,
|
||
source_conversation, rowid
|
||
FROM memory_items
|
||
WHERE status = ? AND expires_at <> '' AND expires_at <= ?
|
||
AND (expires_at > ? OR (expires_at = ? AND rowid > ?))
|
||
ORDER BY expires_at, rowid
|
||
LIMIT ?`
|
||
|
||
// deleteExpiredMemorySQL 按主键删一行。它**只**由上一条语句选出来的 id 驱动,
|
||
// 因此不存在「按用户输入删行」的面。
|
||
const deleteExpiredMemorySQL = `DELETE FROM memory_items WHERE id = ?`
|
||
|
||
// SweepExpiredMemories 跑一轮到期清扫,返回汇总。now 由调用方给出(便于测试注入
|
||
// 固定时钟;本包不在 Store 里藏墙钟,与 SweepIngestReviews 同口径)。
|
||
//
|
||
// 边界(`design/23 §19.3-3`):
|
||
//
|
||
// - **有界**:一轮最多 maxExpireSweepBatches × expireSweepBatchSize 行,
|
||
// 每批一次取行 + 逐行删除,连接池占用与时长都有上界;
|
||
// - **有审计**:每行删除前调 onExpire(§19.4 的 `memory.tidy.expire`);
|
||
// - **可停**:ctx 取消即刻返回;循环体在每批开跑前复核 ctx.Err()。
|
||
//
|
||
// 失败语义:取行 / 删行失败时**返回错误并带上已完成的统计**(调用方记 WARN 后等
|
||
// 下一轮)。已删的行保持删除、未删的行绝不误删 —— 一轮失败只是「这一轮没扫干净」。
|
||
func (s *Store) SweepExpiredMemories(
|
||
ctx context.Context, now time.Time, policy ExpirySweepPolicy, onExpire ExpireFunc,
|
||
) (ExpirySweepStats, error) {
|
||
var stats ExpirySweepStats
|
||
if s == nil {
|
||
return stats, ErrNilStore
|
||
}
|
||
p := policy.normalized()
|
||
// 与写入侧同源:列里存的是 formatTime 产出的定宽 UTC 文本,右边也必须由
|
||
// 同一个函数产出(见 timegate.go 的「同源」口径)。
|
||
cutoff := formatTime(now.UTC())
|
||
cursor := expireCursor{rowid: -1}
|
||
|
||
for batch := 0; batch < p.MaxBatches; batch++ {
|
||
if err := ctx.Err(); err != nil {
|
||
return stats, fmt.Errorf("memory: sweep expired memories: %w", err)
|
||
}
|
||
rows, err := s.listExpiredMemories(ctx, cutoff, cursor, p.BatchSize)
|
||
if err != nil {
|
||
return stats, err
|
||
}
|
||
if len(rows) == 0 {
|
||
break
|
||
}
|
||
stats.Batches++
|
||
// 游标前移与「删没删」无关:受限行被跳过时也必须往前走,否则下一批会
|
||
// 取回同一批行(见 expireCursor 的注释)。
|
||
cursor = rows[len(rows)-1].cursor
|
||
|
||
for _, row := range rows {
|
||
if nonOwnerSource(row.sourceConversation) {
|
||
stats.Restricted++
|
||
continue
|
||
}
|
||
if onExpire != nil {
|
||
onExpire(row.ExpiredMemory)
|
||
}
|
||
if err := s.purgeExpiredMemory(ctx, row.ID); err != nil {
|
||
return stats, err
|
||
}
|
||
stats.Deleted++
|
||
}
|
||
if len(rows) < p.BatchSize {
|
||
break
|
||
}
|
||
}
|
||
return stats, nil
|
||
}
|
||
|
||
// listExpiredMemories 取一批到期行(含游标与溯源列)。limit 非正时返回空切片,
|
||
// 因此调用方不必自己防零。
|
||
//
|
||
// 它把整批**读进内存后才返回**(游标在返回前关闭),所以调用方的删除不会与一个
|
||
// 还开着的 SELECT 游标打架 —— 本项目的 SQLite 池是单连接,同一连接上「边读边删」
|
||
// 是自寻死路(写要等读,读永远读不完)。
|
||
func (s *Store) listExpiredMemories(
|
||
ctx context.Context, cutoff string, cursor expireCursor, limit int,
|
||
) ([]expiredMemoryRow, error) {
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
rows, err := s.db.QueryContext(ctx, selectExpiredMemoriesSQL,
|
||
string(tidyStatusActive), cutoff, cursor.expiresAt, cursor.expiresAt, cursor.rowid, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("memory: list expired items: %w", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]expiredMemoryRow, 0, limit)
|
||
for rows.Next() {
|
||
var r expiredMemoryRow
|
||
if err := rows.Scan(&r.ID, &r.PersonaID, &r.Scope, &r.ExpiresAt, &r.Content,
|
||
&r.sourceConversation, &r.cursor.rowid); err != nil {
|
||
return nil, fmt.Errorf("memory: scan expired item: %w", err)
|
||
}
|
||
r.cursor.expiresAt = r.ExpiresAt
|
||
out = append(out, r)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("memory: iterate expired items: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// purgeExpiredMemory 删掉一行到期临时记忆,并把它的向量从持久索引里同步清掉。
|
||
//
|
||
// 两步的顺序不是随手写的:**先删行、再清向量**。反过来(先清向量)的话,删行
|
||
// 失败会留下一行「有行、无向量」的记忆 —— 那一行不会被返回(读侧闸挡着),
|
||
// 但检索的语义路径会有一个查不到向量的 id;而先删行时最坏情况只是留一个孤儿
|
||
// 向量,那是本 finding 描述的既有状态(可下一轮再清),不会更坏。
|
||
func (s *Store) purgeExpiredMemory(ctx context.Context, id string) error {
|
||
if _, err := s.db.ExecContext(ctx, deleteExpiredMemorySQL, id); err != nil {
|
||
return fmt.Errorf("memory: delete expired item %s: %w", id, err)
|
||
}
|
||
s.purgeExpiredVector(ctx, id)
|
||
return nil
|
||
}
|
||
|
||
// purgeExpiredVector 把刚被删除的行从持久语义索引里同步清掉(切片 M1 的 reviewer
|
||
// 交上来的 finding):过期行的向量若留在索引里,会占掉 `idx.Search` 的 top-K
|
||
// 预算,只降召回质量、不泄漏(该行已被读侧时效闸挡住,不可能被返回)。
|
||
//
|
||
// 未接索引、或后端不支持删除(只读的桩)时是空操作;失败只记 WARN ——
|
||
// **向量清不掉不该让「行已删」这件事失败**,也不该让下一行不删。
|
||
func (s *Store) purgeExpiredVector(ctx context.Context, id string) {
|
||
idx := s.vectorIndex()
|
||
if idx == nil {
|
||
return
|
||
}
|
||
deleter, ok := idx.(vector.Deleter)
|
||
if !ok {
|
||
s.log.Debug("memory: vector index does not support delete, expired vector left behind", "id", id)
|
||
return
|
||
}
|
||
if err := deleter.Delete(ctx, id); err != nil {
|
||
s.log.Warn("memory: expired vector purge failed", "id", id, "error", err)
|
||
}
|
||
}
|
||
|
||
// expireAuditResult 渲染 `design/23 §19.4` 要求的定长结构化串:
|
||
//
|
||
// id=<id> persona_id=<..> scope=<..> expires_at=<..> summary=<截断摘要>
|
||
//
|
||
// 摘要是**本仓唯一**允许进入审计的内容片段,边界限这一个动作 + 固定截断
|
||
// (主会话已裁定允许)。它先做空白归一(正文里的换行 / 制表符会把审计行拆成
|
||
// 多行,那既破坏「一行一条」的可解析性,也把事情变成「把正文刷进日志」),
|
||
// 再按 rune 截断。
|
||
func expireAuditResult(row ExpiredMemory) string {
|
||
return fmt.Sprintf("id=%s persona_id=%s scope=%s expires_at=%s summary=%s",
|
||
row.ID, row.PersonaID, row.Scope, row.ExpiresAt, clipExpireSummary(row.Content))
|
||
}
|
||
|
||
// clipExpireSummary 把正文压成一行至多 expireAuditSummaryRunes 个 rune 的摘要。
|
||
// 空白(含换行)一律归一成单个空格;截断时追加省略号,让「这条被截过」在审计
|
||
// 里可见 —— 省略号本身也占一个 rune,总长仍恒为 ≤ 上限(上限-1 + 省略号)。
|
||
//
|
||
// 空正文(理论上不该存在,正文是 NOT NULL 且入库时校验非空)返回空串,
|
||
// 让审计行仍可解析。
|
||
func clipExpireSummary(content string) string {
|
||
flat := strings.Join(strings.Fields(content), " ")
|
||
runes := []rune(flat)
|
||
if len(runes) <= expireAuditSummaryRunes {
|
||
return flat
|
||
}
|
||
return string(runes[:expireAuditSummaryRunes-1]) + "…"
|
||
}
|