塔裁定:清扫不许挂在注入缝上 —— 包一层 s.wait 会让「清扫是否存活」依赖于
「loop 每一轮都调 s.wait」这个**没有任何契约固定**的假设;将来谁换了等待原语、
或把循环改成 select + 自己的 timer,清扫会**静默消失**(不报错、不红测试,
只是临时期限再也删不掉)。后台件起在 Start 里,读者一眼看到它们同生共死。
- tidy_service.go:Start 里紧跟 go s.loop(cctx) 起 go s.expirySweepLoop(cctx),
wg 计数与既有那一笔同处(同一把锁内加,避免 Add 与 Stop 的 wg.Wait 相撞)。
`if !s.cfg.Enabled { … return }` 仍在其前 ⇒ 语义与改动前逐字相同:整理被关掉
⇒ 清扫不跑(与 preinsert_gc 随审查服务启停同口径)。
- expire_loop.go:删掉 AttachExpirySweep 与对 s.wait 的包裹;文件头写明
「挂点必须是 Start 里那处显式的 go」,防止后人改回注入缝。
- expire_loop_test.go:那条运行时对照改写成 TestTidyServiceStart_清扫随服务起停
—— 真 Start、真 Stop(Stop 不超时即证明清扫随 ctx 退出且登记在同一个 wg)。
- wire_memory_tidy.go:去掉组合根的挂载调用,注释改成「起在 Start 里,组合根零接线」。
- wire_memory_tidy_test.go:把 go s.expirySweepLoop(cctx) 钉进 TestMemoryTidyIsWired
的 seams(摘掉那一行 ⇒ 用例红,已实测)。
90 lines
4.0 KiB
Go
90 lines
4.0 KiB
Go
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))
|
||
}
|