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

+29 -28
View File
@@ -12,9 +12,11 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件锁定期清扫的**接线与后台形态**(design/23 §19):挂点(AttachExpirySweep)、
// 随 Stop 可停、一轮只写一行汇总日志,以及「删行时同步清掉向量」这条 finding 的
// 集成断言。清扫自身的边界(哪些行删、哪些行留、审计长什么样)在 expire_test.go。
// 本文件锁定期清扫的**接线与后台形态**(design/23 §19):挂点(TidyService.Start
// 里的 `go s.expirySweepLoop(cctx)`)、随 Stop 可停、一轮只写一行汇总日志,以及
// 「删行时同步清掉向量」这条 finding 的集成断言。清扫自身的边界(哪些行删、
// 哪些行留、审计长什么样)在 expire_test.go。
//
// TestExpirySweepOnce_一轮只写一行汇总:逐条的那一行进审计,日志里一轮只有一行
// (§19.4「一轮汇总只进日志,绝不逐条把正文刷进日志」)。
func TestExpirySweepOnce_一轮只写一行汇总(t *testing.T) {
@@ -60,18 +62,24 @@ func TestExpirySweepOnce_一轮只写一行汇总(t *testing.T) {
}
}
// TestAttachExpirySweep_挂上即扫_随Stop可停 钉住**接线**:清扫挂到整理服务的
// 心跳 ctx 上(AttachExpirySweep),第一次心跳等待就启动、启动即扫一轮;ctx 取消
// 后它的 goroutine 必须退出 —— `Stop` 走的就是「cancel + wg.Wait」,这里用同一个
// 手法验证「可停」(§19.3-3)。
func TestAttachExpirySweep_挂上即扫_随Stop可停(t *testing.T) {
// TestTidyServiceStart_清扫随服务起停 钉住**接线**(design/23 §19.3-3 的「可停」):
// 清扫起在 TidyService.Start 里,与心跳共用同一个 cctx 与 wg,因此
//
// ① Start 一跑它就立刻扫一轮(启动补扫,清掉上次退出后到期的行);
// ② Stop 会取消它并等它退出 —— Stop 没超时即证明它真的随 ctx 走了。
//
// 它同时是 `cmd/cyrene/wire_memory_tidy_test.go` 那条 seam 断言的运行时对照:把
// Start 里那行 `go s.expirySweepLoop(cctx)` 摘掉,这条用例会红(行永远不会被删,
// 且 Stop 会因为 wg 计数对不上而超时)。
func TestTidyServiceStart_清扫随服务起停(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
// 10:00Z:不在安静窗(23–7)内 ⇒ Start 里的补跑判定为「不该跑」,整理不搅局。
now := tidyTestTime(t)
row := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "在心跳上被扫掉的到期行", ExpiresAt: now.Add(-time.Minute),
Content: "随服务启动被扫掉的到期行", ExpiresAt: now.Add(-time.Minute),
})
var (
@@ -79,33 +87,26 @@ func TestAttachExpirySweep_挂上即扫_随Stop可停(t *testing.T) {
audits []string
)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(now),
func(who, what, result string) {
func(_, what, result string) {
mu.Lock()
defer mu.Unlock()
audits = append(audits, what+" "+result)
})
// 假等待:不真等(真等会让用例挂 24h)。它只用来模拟「心跳调用了一次 wait」。
svc.wait = func(context.Context, time.Duration) bool { return true }
svc.AttachExpirySweep()
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
if !svc.wait(runCtx, time.Hour) {
t.Fatal("包装后的 wait 必须是透传的(假等待返回 true,包装必须照原样返回)")
// 假等待:把整理心跳停在 ctx 上(真定时器要等 24h 档)。它只影响心跳 ——
// 清扫是**另一个** goroutine + 自己的小时级 ticker。
svc.wait = func(ctx context.Context, _ time.Duration) bool {
<-ctx.Done()
return false
}
svc.Start(ctx)
waitUntilRowGone(t, store, row.ID)
cancel()
done := make(chan struct{})
go func() {
svc.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("清扫 goroutine 没有随 ctx 取消退出 —— 「可停」不成立(Stop 会卡在 wg.Wait)")
stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := svc.Stop(stopCtx); err != nil {
t.Fatalf("Stop 未能在预算内合上 —— 清扫没有随 ctx 退出 / 没登记进同一个 wg: %v", err)
}
mu.Lock()