Files
Cyrene_v2/internal/runtime/memory/expire_loop.go
T
AskaEth a017ab6cf8 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)。
2026-10-13 21:43:27 +08:00

125 lines
5.2 KiB
Go
Raw 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 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))
}