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