refactor(memory): 到期清扫挂点从 wait 注入缝改为 TidyService.Start

塔裁定:清扫不许挂在注入缝上 —— 包一层 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(摘掉那一行 ⇒ 用例红,已实测)。
This commit is contained in:
AskaEth committed 2026-10-13 21:43:27 +08:00
1 parent a017ab6cf8
commit 44c25eb03b
5 files changed
+73 -81

No files matched your search

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