feat(memory): 调优重排改为唤醒通道,不再 Stop/Start(消灭与到期清扫的耦合)

按塔的裁定把「重排心跳」从生命周期操作改成唤醒通道,调优路径上一个 Start/Stop
都没有了。

- tidy_service.go:TidyService 新增 wake(cap 1 + 非阻塞发)与 wakeMu/wakeCancel;
  loop 的等待段抽成 waitForTick,三态判定(睡满 / 被调优唤醒 / 停);被唤醒只
  重算下一拍、**绝不**跑轮。调度读(nextDelay / dueNow)改走 scheduleSnapshot,
  在同一把锁下同取「cfg + lastRun」,于是运行期写 cfg 不必再把后台件停下来。
  Start / Stop / wg / running 与 Enabled 早退语义**一个字节没动**。
- tidy_service_tuning.go:ApplyPanelTuning 变成「持 runMu 写 cfg → 发一次唤醒」。
  唤醒 = 一次性标记(保证不丢:心跳不在等时标记留在缓冲里)**加上**取消当前那次
  等待(保证立刻生效,否则 24h→3h 要等原来那一觉睡完)。

两条冲突就此消失,各配一条可复现断言:
- **面板保存不再触发到期清扫、也不重置清扫 ticker**:新用例在保存前后各造一行已
  到期的记忆,保存之后那行必须**还在**、清扫审计计数必须不变;该用例自带活对照
  (启动清扫会删掉第一行),因此「行还在」不是量具失灵。若保存走 Stop/Start,清扫
  的「启动即扫一轮」会立刻把它删掉。
- **调优路径上不再存在任何 Start 调用**(结构上消灭交叉 Start/Stop):包内生产代码
  已 grep 不到任何 `.Start(` / `.Stop(` 调用,Start 与 Stop 各只剩组合根那一处调用点
  (`cmd/cyrene/main.go` / `cmd/cyrene/wire_services.go`),M3 注释里那条「Start/Stop
  各只调用一次」的不变式因此重新成立;交叉场景由带真心跳的调优用例在 `-race` 下跑过
  (不再 panic)。

热生效没退化:原来那条「23h30m → 30m」的断言按新机制重写 —— 用一个「听得懂 ctx
取消」的假等待看两觉的时长(保存前 23h30m、保存当场的第二觉 30m),并补上「唤醒不
跑轮(lastRun 不前移)」「唤醒后 running 仍为 true」。

变异自证(都跑过再还原,`diff` 逐字一致):
- 去掉 `notifyReschedule` 调用 ⇒「被唤醒后的第二觉」等不到 ⇒ 用例红;
- 只留标记、去掉「取消当前等待」那一半 ⇒ 同一条红(两半都必需)。

M3 的守卫 `expire_loop_test.go` **一行未动**:它的假 wait 只认 ctx,父 ctx 取消时
返回 false,在新机制下语义不变(无人唤醒时照样把心跳停住)。
This commit is contained in:
AskaEth committed 2026-10-13 22:17:22 +08:00
1 parent a87fbeb494
commit 97c0c1b94a
3 files changed
+328 -82

No files matched your search

+53 -18
View File
@@ -63,8 +63,23 @@ type TidyService struct {
now func() time.Time
// wait 是心跳的等待原语:返回 false 表示 ctx 已取消。生产是 time.Timer;
// 测试注入假等待(推进假时钟后立刻返回),因此补跑判据可以确定性验证。
//
// 🔴 它的返回值语义**没有变**(`false` 仍表示「这一觉没睡成就结束了」):
// 面板调优的重排**不是**靠它自报,而是靠下面那个可被取消的子 ctx —— 于是注入的
// 假等待(不认识 wake 的那些)在测试里语义逐字不变(见 loop 的等待段)。
wait func(ctx context.Context, d time.Duration) bool
// wake 是「面板调优改了配置 ⇒ 把这一觉重排一次」的一次性标记:**有缓冲(cap 1)
// + 非阻塞发**,见 tidy_service_tuning.go 的 notifyReschedule。写端跑在面板的
// HTTP 保存路径上,因此**绝不允许阻塞**;满了丢弃是**正确**语义(通道里那一个
// 标记表达的就是「有一次待处理的重排」,重算是幂等的)。
wake chan struct{}
// wakeMu 保护 wakeCancel(= 取消「当前这一次等待」的手段;两次等待之间是 nil)。
// 它是 wake 的**中断**那一半:只有标记没有中断的话,新周期要等这一觉(可能是一天)
// 睡完才生效 —— 那正是主人把 24h 改成 3h 时看到的「改了跟没改一样」。
wakeMu sync.Mutex
wakeCancel context.CancelFunc
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
// 若撞在一起,后者排队而不是并发改写候选表。
runMu sync.Mutex
@@ -86,6 +101,7 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
log: tidier.log,
now: tidier.now,
wait: waitTidyTimer,
wake: make(chan struct{}, 1),
runs: newTidyRunRegistry(defaultTidyRunTTL),
}, nil
}
@@ -280,11 +296,19 @@ func (s *TidyService) loop(ctx context.Context) {
defer s.wg.Done()
s.catchUp(ctx)
for {
if !s.wait(ctx, s.nextDelay()) {
return
}
if ctx.Err() != nil {
// 上一轮攒下的「调优唤醒」标记在这里清掉:它全部的含义就是「把下一拍重算
// 一遍」,因此必须在重算**之前**清空 —— 否则重算完发现标记还在,会自旋。
s.drainWake()
switch s.waitForTick(ctx) {
case tidyTickStopped:
// 父 ctx 被取消(Stop / 进程退出),或注入的假等待自报「停」:既有语义。
return
case tidyTickWokenByTuning:
// 被**面板调优**唤醒:这一觉是按**旧**周期算出来的,现在作废 ⇒ 只重算
// 下一拍,**绝不**因此跑一轮(唤醒不是「到点了」,判据仍只有 dueNow)。
s.log.Debug("memory tidy heartbeat rescheduled by panel tuning",
"now_hour", s.now().Hour())
continue
}
// 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
// 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
@@ -330,21 +354,19 @@ func (s *TidyService) catchUp(ctx context.Context) {
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
func (s *TidyService) dueNow() (bool, string) {
now := s.now()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
cfg, last := s.scheduleSnapshot()
if last.IsZero() {
if s.cfg.inQuietHours(now) {
if cfg.inQuietHours(now) {
return true, "no_history_in_quiet_window"
}
return false, ""
}
if now.Sub(last) >= s.cfg.intervalAt(now) {
if now.Sub(last) >= cfg.intervalAt(now) {
return true, "interval_elapsed"
}
if s.cfg.inQuietHours(now) {
if start := s.cfg.currentQuietStart(now); last.Before(start) {
if cfg.inQuietHours(now) {
if start := cfg.currentQuietStart(now); last.Before(start) {
return true, "quiet_window_not_run"
}
}
@@ -368,18 +390,18 @@ func (s *TidyService) dueNow() (bool, string) {
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
func (s *TidyService) nextDelay() time.Duration {
now := s.now()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
cfg, last := s.scheduleSnapshot()
// lastRun 与 cfg 必须在**同一把锁**下取到同一版本(见 scheduleSnapshot),
// 否则一次面板保存可能让这里拿到「新周期 + 旧历史」的混合体。
var next time.Time
if last.IsZero() {
next = s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.intervalAt(now))
next = cfg.nextQuietStart(now)
if cfg.quietWindowDisabled() {
next = now.Add(cfg.intervalAt(now))
}
} else {
next = last.Add(s.cfg.intervalAt(now))
next = last.Add(cfg.intervalAt(now))
}
delay := next.Sub(now)
if delay < minTidyTickDelay {
@@ -388,6 +410,19 @@ func (s *TidyService) nextDelay() time.Duration {
return delay
}
// scheduleSnapshot 在锁下同时取「调度配置」与「上次运行时刻」。
//
// 🔴 为什么要有它(切片 MEMPANEL / M4 立):`cfg` 在这条特性之前是启动期冻结的,
// 因此调度读(`nextDelay` / `dueNow`)直接读字段本来安全;面板调优开了「运行期改
// cfg」这条路之后,调度读**必须**与写方(tidy_service_tuning.go 的 ApplyPanelTuning)
// 同步,否则就是数据竞争(`-race` 可复现)。两样东西一起取,是为了让一次判定看到
// 的是**同一个版本**(分开取可能落在两次不同的写入之间)。
func (s *TidyService) scheduleSnapshot() (TidyConfig, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg, s.lastRun
}
// runRound 跑一轮并把结果与审计留痕。单写者:runMu 串行化心跳与手动触发。
func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, error) {
s.runMu.Lock()