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)) }