feat(memory): 临时记忆到期物理删除(有界清扫 + 同步清向量 + 删前审计)

design/23 §19 的第 ② / ③ 步:读侧时效闸(M1)只负责「看不见」,本切片负责
「真删掉」。三条护栏照 §19.3:删除是本包内唯一 DELETE 出口、有界(500 行/批 ×
20 批)、删前留审计。

- internal/runtime/memory/expire.go:扫描集合写死为「expires_at ≠ '' ∧
  expires_at ≤ now ∧ status='active' ∧ 来源非受限」。空 expires_at(长期/日常
  记忆,含 M2 登记的「INSERT 与补写之间中断」窗口)结构性进不了集合;受限来源
  复用 nonOwnerSource(与整理/审查同一处判据)。键集游标 (expires_at, rowid)
  保证被跳过的行只读一次、扫描位置一定前移。审计串 = id + persona_id + scope +
  expires_at + 按 rune 截断(48)的摘要 —— 本仓唯一一处允许携带内容片段的审计。
- internal/runtime/memory/expire_loop.go:清扫循环挂在 TidyService 的生命周期上
  (AttachExpirySweep 由组合根调用),随心跳 ctx 跑、随 Stop 停;一轮只写一行
  汇总日志,逐条的那一行进审计。
- internal/runtime/memory/vector/delete.go:新增可选删除能力 vector.Deleter +
  两个后端实现,删行时同步清向量(M1 reviewer 的 finding:过期向量占 top-K 预算)。
- cmd/cyrene/wire_memory_tidy.go:挂上清扫;顺带把已过时的「这十键」改成「这十一
  键」(M2 加了第十一键 tempTtlSec)。
This commit is contained in:
AskaEth committed 2026-10-13 21:43:27 +08:00
1 parent 80096c8bd1
commit a017ab6cf8
7 files changed
+1262 -6

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
package memory
import (
"context"
"sync"
"time"
)
// 本文件是到期清扫的**后台件形态**:一个独立循环(与 `preinsert_gc.go` 的
// `sweepLoop` 同形),启动期先扫一次、此后每 DefaultExpireSweepInterval 扫一次。
//
// 设计口径(`design/23 §19.3-2`):删除只由内存模块的后台件串行触发,与整理 /
// 审查同一单写者纪律,**不新开并发写口**、不新开监听端口、不加任何 `.env` 键
// (`.env` 已冻结,`design/13 §2.1`),也不碰会话 / 历史读数。
//
// 🔴 **挂在哪个 ctx 上**:清扫与整理**共用同一条生命周期** —— 它的 ctx 就是整理
// 心跳的 ctx,它的 goroutine 登记进 TidyService 的 `wg`。因此
// `cmd/cyrene/wire_services.go` 关闭链里的 `a.memoryTidy.Stop(ctx)` 会一并取消它
// 并等它退出(§19.3-3 的「可停」),不必再为清扫加一条关闭路径、也不必改
// `tidy_service.go`(那个文件归别的切片)。
//
// 挂点用的是 TidyService 已有的注入缝 `wait`(心跳的等待原语,见
// tidy_service.go:64-66):它由心跳 goroutine 以**心跳的 cctx** 调用,因此「第一次
// wait 被调用」既是「Start 已经跑起来、ctx 已经建好」的确定信号,又恰好把清扫要的
// 那个可取消 ctx 递到手上。清扫随后跑在**自己的** goroutine 与**自己的**小时级
// ticker 上,不借用整理的调度节奏(整理的周期是 24h 档,不能拿来当清扫周期)。
// AttachExpirySweep 把到期清扫挂到整理服务的生命周期上。它由组合根在装配期调用
// (`cmd/cyrene/wire_memory_tidy.go`),每个服务实例调用一次。
//
// 它在**第一次心跳等待**时启动清扫循环:此时 `Start` 已经建好可取消的 ctx,
// 而那个 ctx 就是心跳 goroutine 手里这一个。ctx 每次变化(Start → Stop → Start)
// 都会重新挂一个循环,因此「重启一次就永久失去清扫」不会发生。
//
// 它不启动任何 goroutine 直到那一次等待发生:整理被配置关掉(`Start` 直接返回)
// 时,清扫也不会跑 —— 这与 `preinsert_gc.go` 的清扫随审查服务启停是同一条口径
// (后台件随它的宿主启停,而不是凭空常驻)。
func (s *TidyService) AttachExpirySweep() {
if s == nil {
return
}
inner := s.wait
if inner == nil {
inner = waitTidyTimer
}
var (
mu sync.Mutex
attached context.Context
)
s.wait = func(ctx context.Context, d time.Duration) bool {
mu.Lock()
if attached != ctx {
attached = ctx
s.wg.Add(1)
go s.expirySweepLoop(ctx)
}
mu.Unlock()
return inner(ctx, d)
}
}
// expirySweepLoop 是清扫的常驻循环:启动期先扫一次(清掉上次退出后到期的行),
// 此后按 DefaultExpireSweepInterval 扫一次。它随 ctx 取消退出(Stop 会 join 它)。
//
// 它**不**读任何记忆正文之外的输入:清扫只按 expires_at / status / 溯源列删行。
func (s *TidyService) expirySweepLoop(ctx context.Context) {
defer s.wg.Done()
s.expirySweepOnce(ctx)
ticker := time.NewTicker(DefaultExpireSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.expirySweepOnce(ctx)
}
}
}
// expirySweepOnce 跑一轮到期清扫。失败只记 WARN:清扫是旁路维护,绝不让整理服务
// 退出、也绝不影响任何一条**未到期**的记忆(与 `preinsert_gc.go` 的 fail-safe
// 同口径:已删的保持删除、未删的留给下一轮)。
//
// 日志口径(`design/23 §19.4`):**一轮只写一行汇总**,逐条的那一行进审计出口
// (见 auditExpiredMemory)。这里绝不逐条把正文刷进日志。
func (s *TidyService) expirySweepOnce(ctx context.Context) {
store := s.tidier.deps.Store
if store == nil {
return
}
stats, err := store.SweepExpiredMemories(ctx, s.now(), ExpirySweepPolicy{}, s.auditExpiredMemory)
if err != nil {
// ctx 取消(关闭中)不是故障:安静退出,不制造一条误导性的 WARN。
if ctx.Err() != nil {
return
}
s.log.Warn("memory expiry sweep failed, expired rows left for the next round",
"error", err, "deleted", stats.Deleted, "restricted_skipped", stats.Restricted)
return
}
if stats.Deleted == 0 && stats.Restricted == 0 {
s.log.Debug("memory expiry sweep: nothing expired")
return
}
s.log.Info("memory expiry sweep",
"deleted", stats.Deleted, "restricted_skipped", stats.Restricted, "batches", stats.Batches,
"interval", DefaultExpireSweepInterval.String())
}
// auditExpiredMemory 是每行删除前的审计出口(`design/23 §19.4`):
// `who = system/memorytidy`、`what = memory.tidy.expire`,result 见 expireAuditResult。
//
// 没有审计出口时**只记 id,不记摘要**:摘要仍是内容片段,把它逐条刷进日志正是
// 本设计要避免的那件事(「一轮汇总只进日志」)。
func (s *TidyService) auditExpiredMemory(row ExpiredMemory) {
hook := s.tidier.deps.Audit
if hook == nil {
s.log.Debug("memory expiry audit dropped: no sink configured",
"what", expireAuditVerb, "id", row.ID)
return
}
hook(tidyAuditWho, expireAuditVerb, expireAuditResult(row))
}