package memory import ( "context" "time" ) // 本文件是到期清扫的**后台件形态**:一个独立循环(与 `preinsert_gc.go` 的 // `sweepLoop` 同形),启动期先扫一次、此后每 DefaultExpireSweepInterval 扫一次。 // // 设计口径(`design/23 §19.3-2`):删除只由内存模块的后台件串行触发,与整理 / // 审查同一单写者纪律,**不新开并发写口**、不新开监听端口、不加任何 `.env` 键 // (`.env` 已冻结,`design/13 §2.1`),也不碰会话 / 历史读数。 // // 🔴 **挂点**:清扫与整理**同生共死** —— 它的 ctx 是 TidyService.Start 里建的那个 // cctx(就是 `go s.loop(cctx)` 用的同一个),它的 goroutine 登记进同一个 `s.wg`。 // 因此 `cmd/cyrene/wire_services.go` 关闭链里的 `a.memoryTidy.Stop(ctx)` 会一并 // 取消它并等它退出(§19.3-3 的「可停」),不必再为清扫加一条关闭路径。 // // 🔴 挂点**必须**是 `Start` 里那处显式的 `go s.expirySweepLoop(cctx)`,不许改挂到 // 某个注入缝上(例如包一层 `s.wait` 这个「心跳等待原语」)。理由是**可证性**: // 包注入缝意味着清扫的存活依赖于「`loop` 每一轮都调 `s.wait`」这个**没有任何契约 // 固定**的假设 —— 将来谁换了等待原语、或把循环改成 `select` + 自己的 timer,清扫 // 会**静默消失**(不报错、不红测试,只是临时期限再也删不掉)。起在 `Start` 里, // 读者一眼就能看到两个后台件同生共死。 // 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)) }