diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index ee9cb31..df59f54 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -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() diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index 0b5b2e4..b21d0ce 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -29,70 +29,149 @@ import ( // 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` > // 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到 // `.env` 档,而不是留在上一次的面板值上。 -// 2. **心跳要重排**:心跳正睡在「用**旧**周期算出来」的那一觉里(`nextDelay` 只在 -// 每一拍之前现算),最长可到旧周期。不重排的话,主人把周期从 24 小时改成 3 小时 -// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop + -// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是 -// 已经到点」,于是缩短周期往往**当场**补跑一轮)。 -// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次 -// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。 +// 2. **心跳要重排,但不许碰生命周期**:心跳正睡在「用**旧**周期算出来」的那一觉里 +// (`nextDelay` 只在每一拍之前现算),最长可到旧周期 —— 不重排的话,主人把周期从 +// 24 小时改成 3 小时时,新周期要等那一觉睡完才生效,等于「改了跟没改一样」。 +// 重排的做法是**唤醒**(`notifyReschedule`:一次性标记 + 取消当前那次等待), +// **不是** `Stop` + `Start`。这条是本切片修正后的形态,理由见下面「为什么不是 +// Stop/Start」。 +// 3. **失败不吞值**:重排不成立时值**照样**落到运行期(下一次调度判定就用它), +// 调用方据实回报 —— 绝不假装热生效,也绝不让保存白写。 // -// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound` -// 与手动轮 / 试算 `DryRun` 都持它),而它排除的是**轮次**;调度读(`nextDelay` / -// `dueNow` 读 `s.cfg`)不受 runMu 保护,因此写 `s.cfg` 必须发生在「没有任何 goroutine -// 读它」的窗口里 ⇒ 顺序铁律是 **先停、再写、后起**: +// 为什么不是 `Stop` + `Start`(早先那版就是它,被塔的 review 打回):`Stop` 会连同 +// `Start` 里起的**另一只后台件**(到期清扫 `expirySweepLoop`)一起取消,而清扫是 +// **启动即扫一轮**的形态 ⇒ 每次面板保存都会顺带触发一次真实删行 + 逐行审计,还会把 +// 清扫的 ticker 重置(`design/23 §19.1` 的「到期后 ≤ 一个清扫周期」因此被推后)。 +// 更要命的是「停止中重启」这件事本身:`Stop` 的超时分支里 `wg.Wait` 仍在飞,紧接着 +// `Start` 的 `wg.Add` 会踩上 `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律 +// (交叉调用 Start/Stop 在 `-race` 下 `panic: sync: WaitGroup is reused before previous +// Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。 // -// Stop(等到心跳与清扫两只后台件都退出)→ 写 cfg → Start(新循环读到的是新配置) -// -// 三条都是实测踩出来的,不是推演: -// - **不能「先拿 runMu 再 Stop」**:心跳若正卡在 runMu 上准备开跑一轮,Stop 等的就是 -// 一个被我挡在锁门口的 goroutine ⇒ 活锁到超时; -// - **不能「Stop 完先 Start 再写」**:新循环立刻就会读 `s.cfg` 重算下一拍,与写入并排 -// ⇒ `go test -race` 直接报竞争,而且那一拍还可能按**旧**配置算出来; -// - **Stop 不设超时**:超时分支里 Wait 仍在飞,紧接着 `Start` 的 `wg.Add` 会踩上 -// `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律(交叉调用 Start/Stop -// 在 `-race` 下 `panic: sync: WaitGroup is reused before previous Wait has returned`)。 -// 等它排空的最坏代价是「等正在跑的那一轮跑完」,而这条路径是人点一次保存触发的, -// 不持 runMu ⇒ 不会与「心跳要开跑一轮」互相锁死。 +// 并发口径:写 `s.cfg` 与调度读(`nextDelay` / `dueNow`)由 `s.mu` 互斥 +// (调度读走 `scheduleSnapshot`,与本次改动的同一把锁下取同一版本);写 `tidier.cfg` +// 与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、试算都持它)。因此这里只需要 +// 「持 runMu 写 → 发一次唤醒」,不与任何既有锁序冲突。 // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 // -// 返回 nil 表示**值已生效**(心跳在跑时也已被重排,下一拍按新周期算);返回错误时 -// 值**没有**落到运行期(落盘那一步早已完成,调用方据此如实回 `hot:false` + 原因)。 -// -// 顺序与三条由来见文件头:**先停 → 再写 → 后起**。 +// 返回 nil 表示**值已生效,且心跳已被告知重排**(下一拍按新周期算)。当前实现里 +// 它**不会失败**(错误返回保留给将来的校验),因此网关侧的 `hot` 语义就是「值已叠进 +// 运行期」。 func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error { next := tidyPanelTuning(base, panel, s.log) s.mu.Lock() - running := s.running unchanged := s.cfg == next s.mu.Unlock() if unchanged { - // 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写。 - // 刻意**不**走下面那条写路径:写一个「相同的值」也是写,照样会与调度读 - // 构成竞争,而它一点收益都没有。 + // 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写,也 + // 不必唤醒心跳。刻意**不**走下面那条写路径:写一个「相同的值」也是写, + // 照样会与调度读构成竞争,而它一点收益都没有。 return nil } - // 心跳在跑 ⇒ 先让两只后台件(心跳 + 到期清扫)停稳:`s.cfg` 的调度读不持锁, - // 写入必须落在一个「没有读者」的窗口里。**Stop 期间不持 runMu**(否则心跳若正 - // 卡在 runMu 上等开跑,会互相等死)。 - if running { - if err := s.Stop(context.Background()); err != nil { - return fmt.Errorf("memory/tidy: stop heartbeat before tuning: %w", err) - } - } + // 写运行期(两处持有者),再把「这一觉是按旧周期算的」这件事作废。 s.applyTidyConfig(next) - if running { - // 重新起后台件:`Stop` 已经把 running 置为 false,不重启就等于让定时整理 - // **与到期清扫一起**悄悄停摆。父 ctx 用 Background(本对象没有保存父 ctx 的 - // 字段,那在 tidy_service.go 里),关停链本来就由组合根显式 `Stop` 收尾。 - s.Start(context.Background()) - } + s.notifyReschedule() + s.log.Info("memory tidy panel tuning applied", + "interval_sec", next.IntervalSec, "quiet_interval_sec", next.QuietIntervalSec, + "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", next.QuietStartHour, next.QuietEndHour), + "auto_apply", !next.DisableAutoApply, "source", "admin panel") return nil } +// notifyReschedule 通知心跳「周期变了 ⇒ 把这一觉重排一次」。 +// +// 两件事,缺一不可: +// - **标记**(非阻塞发一个有缓冲的通道):保证这次通知**不会丢** —— 如果心跳此刻 +// 不在等(正在跑一轮、或还没 Start),标记会留在缓冲里,等它下一次「等」之前被 +// 清掉并重算。cap 1 + `default` 是刻意的:调用方在**面板保存的 HTTP 路径**上, +// 绝不能阻塞;而通道里那一个标记表达的就是「有一次待处理的重排」,重算是幂等的, +// 因此满了直接丢弃是**正确**语义(丢的只是重复通知,不是改动)。 +// - **中断**(取消当前那次等待):保证这次通知**立刻**生效 —— 只有标记的话,新的 +// 24h→3h 要等原来那一觉(可能是一天)睡完才落地,那正是「改了跟没改一样」。 +// +// 两件事都不碰生命周期(不 Start / 不 Stop / 不动 wg 与 running),因此到期清扫不会 +// 被顺带停掉或重启(见文件头「为什么不是 Stop + Start」)。 +func (s *TidyService) notifyReschedule() { + select { + case s.wake <- struct{}{}: + default: + } + s.wakeMu.Lock() + cancel := s.wakeCancel + s.wakeMu.Unlock() + if cancel != nil { + cancel() + } +} + +// armWake 登记「取消当前这一次等待」的手段;下一次调优保存靠它把这一觉当场作废。 +func (s *TidyService) armWake(cancel context.CancelFunc) { + s.wakeMu.Lock() + s.wakeCancel = cancel + s.wakeMu.Unlock() +} + +// disarmWake 撤掉登记(每次等待结束都要做,且必须在取消子 ctx **之前**)—— +// 否则下一次等待会被上一次的取消误伤。 +func (s *TidyService) disarmWake() { + s.wakeMu.Lock() + s.wakeCancel = nil + s.wakeMu.Unlock() +} + +// drainWake 清掉待处理的唤醒标记(非阻塞)。心跳在**重算下一拍之前**调它,否则 +// 标记会让「被唤醒 ⇒ 重算 ⇒ 又被唤醒」变成自旋。 +func (s *TidyService) drainWake() { + select { + case <-s.wake: + default: + } +} + +// tidyTickOutcome 是一次「等下一拍」的结局。 +type tidyTickOutcome int + +const ( + // tidyTickWaited:睡满了(或注入的假等待自报可以继续)⇒ 照常做 dueNow 判定。 + tidyTickWaited tidyTickOutcome = iota + // tidyTickWokenByTuning:被面板调优的重排打断 ⇒ 只重算下一拍,**不跑轮**。 + tidyTickWokenByTuning + // tidyTickStopped:父 ctx 被取消,或注入的假等待自报「停」⇒ 退出循环。 + tidyTickStopped +) + +// waitForTick 睡到下一拍,返回这次等待的结局(见 tidyTickOutcome)。 +// +// 三步的顺序不能变: +// 1. **先登记**取消手段(armWake)—— 否则调优那一下没法作废这一觉; +// 2. 调**注入的** `wait`(生产是 waitTidyTimer;测试可注入假等待),但给它的是一份 +// **可取消的子 ctx** ⇒ 唤醒 = 取消它。`wait` 的返回语义因此**一个字都没改**: +// `false` 仍表示「没睡成就结束了」(生产实现里它只在 ctx 结束时返回 false)。 +// 3. 无论结果如何都撤掉登记并回收子 ctx。 +// +// 判定顺序是刻意的:**先看父 ctx**,再看「子 ctx 是不是被取消」(= 调优唤醒)。反过来 +// 的话,Stop 的那一下会被读成「一次唤醒」,心跳就静默没了。 +func (s *TidyService) waitForTick(ctx context.Context) tidyTickOutcome { + wctx, wake := context.WithCancel(ctx) + s.armWake(wake) + waited := s.wait(wctx, s.nextDelay()) + s.disarmWake() + woken := wctx.Err() != nil + wake() + switch { + case ctx.Err() != nil: + return tidyTickStopped + case woken: + return tidyTickWokenByTuning + case !waited: + return tidyTickStopped + default: + return tidyTickWaited + } +} + // tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。 // // 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 → diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index d7d6827..293e892 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -1,6 +1,8 @@ package memory import ( + "context" + "strings" "sync" "testing" "time" @@ -76,16 +78,61 @@ func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) { } } -// TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳 -// 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。 -func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) { - t.Parallel() +// tuneWaitProbe 是一个「听得懂 ctx 取消」的假等待:记录每次被要求的时长,并在 +// ctx 结束时立刻返回 false —— 与生产的 `waitTidyTimer` 同语义。因此它既能被 +// **调优唤醒**打断(唤醒 = 取消这一次等待的子 ctx),也能被 Stop 停住。 +type tuneWaitProbe struct { + mu sync.Mutex + delays []time.Duration + calls chan time.Duration +} + +func newTuneWaitProbe() *tuneWaitProbe { + return &tuneWaitProbe{calls: make(chan time.Duration, 32)} +} + +func (p *tuneWaitProbe) wait(ctx context.Context, d time.Duration) bool { + p.mu.Lock() + p.delays = append(p.delays, d) + p.mu.Unlock() + select { + case p.calls <- d: + default: + } + select { + case <-ctx.Done(): + return false + case <-time.After(30 * time.Second): + // 兜底:用例失败时才可能走到这里(正常路径都由取消叫醒)。 + return false + } +} + +// awaitCall 等下一次「等待」被调用并返回它要求的时长(超时即 FAIL)。 +func (p *tuneWaitProbe) awaitCall(t *testing.T, step string) time.Duration { + t.Helper() + select { + case d := <-p.calls: + return d + case <-time.After(5 * time.Second): + t.Fatalf("等心跳走到「%s」超时(循环没走到预期阶段)", step) + return 0 + } +} + +// TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence 钉住**热生效的新机制** +// (唤醒,而不是 Stop/Start): +// +// ① 保存前那一觉是按**旧**周期算的(15:00 起、上次 14:30、周期 24h ⇒ 等 23h30m); +// ② 保存**当场**把那一觉作废并按新周期重排(下一觉 = 30m),不必等旧周期睡完; +// ③ 唤醒**不跑轮**(`lastRun` 不前移 —— 到没到点仍只由 dueNow 判); +// ④ 心跳**没有**被停过:`running` 仍为 true,等待由**同一个**循环继续(不是重启)。 +func TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence(t *testing.T) { at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local) base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil) - // 未启动:保存只改配置,不碰心跳状态(用一个不含 intervalSec 的补丁,免得 - // 影响下面「旧周期」的断言)。 + // 未启动:保存只改配置,不碰心跳状态(也**不**该把它启动起来)。 idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)} if err := svc.ApplyPanelTuning(base, idlePanel); err != nil { t.Fatalf("ApplyPanelTuning(idle): %v", err) @@ -97,29 +144,114 @@ func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) { t.Fatal("未 Start 时不该因为保存而启动心跳") } - // 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。 - // 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。 - svc.Start(nil) - t.Cleanup(func() { _ = svc.Stop(nil) }) + probe := newTuneWaitProbe() + svc.wait = probe.wait svc.mu.Lock() svc.lastRun = at.Add(-30 * time.Minute) svc.mu.Unlock() - if got := svc.nextDelay(); got != 23*time.Hour+30*time.Minute { - t.Fatalf("启动后 nextDelay = %s, want 23h30m(旧周期)", got) + svc.Start(nil) + t.Cleanup(func() { _ = svc.Stop(nil) }) + + if got := probe.awaitCall(t, "第一觉"); got != 23*time.Hour+30*time.Minute { + t.Fatalf("第一觉 = %s, want 23h30m(按旧周期)", got) } - // 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有 - // 30 分钟),而不是继续睡原来那 23.5 小时。 + + // 保存:周期 24h → 1h。这一觉必须**当场**作废并重排到 30 分钟。 if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil { t.Fatalf("ApplyPanelTuning(running): %v", err) } + if got := probe.awaitCall(t, "被唤醒后的第二觉"); got != 30*time.Minute { + t.Fatalf("第二觉 = %s, want 30m(仍按旧周期说明没被唤醒)", got) + } + svc.mu.Lock() - running = svc.running + last, runningAfter := svc.lastRun, svc.running + svc.mu.Unlock() + if !runningAfter { + t.Fatal("唤醒后心跳必须仍在跑(唤醒不是停表)") + } + if !last.Equal(at.Add(-30 * time.Minute)) { + t.Fatalf("唤醒跑了轮:lastRun = %v, want 14:30(唤醒只重排,不跑轮)", last) + } +} + +// TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows 钉住「重排不碰生命周期」: +// 面板保存**不再**顺带触发一次到期清扫,也**不再**重启清扫的 ticker。 +// +// 判据用行为而不是实现:保存前后各造一行已到期的记忆,保存之后它必须**还在**。 +// 若保存走了 Stop/Start(早先那版就是),`expirySweepLoop` 的「启动即扫一轮」会 +// 立刻把它删掉 —— 这正是塔要证伪的那条副作用。 +func TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows(t *testing.T) { + ctx := context.Background() + now := tidyTestTime(t) + store := newTestStore(t) + base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} + + var ( + mu sync.Mutex + audits []string + ) + svc := newTidyServiceForTest(t, store, base, nil, fixedTidyClock(now), + func(_, what, result string) { + mu.Lock() + defer mu.Unlock() + audits = append(audits, what+" "+result) + }) + // 假等待:把心跳停在 ctx 上(本轮只关心清扫那一路,心跳不搅局)。 + svc.wait = func(ctx context.Context, _ time.Duration) bool { + <-ctx.Done() + return false + } + expireAudits := func() int { + mu.Lock() + defer mu.Unlock() + n := 0 + for _, a := range audits { + if strings.HasPrefix(a, expireAuditVerb+" ") { + n++ + } + } + return n + } + + // ① 先证明清扫确实在跑:启动清扫会把这一行删掉。 + first := addItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: "启动清扫应当删掉它", ExpiresAt: now.Add(-time.Minute), + }) + svc.Start(ctx) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = svc.Stop(stopCtx) + }) + waitUntilRowGone(t, store, first.ID) + afterStart := expireAudits() + + // ② 再造一行已到期的,这次没有任何「启动清扫」来删它。 + pending := addItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: "面板保存不该把它删掉", ExpiresAt: now.Add(-time.Minute), + }) + + // ③ 面板保存(值真的变了,因此一定会走重排)。 + if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil { + t.Fatalf("ApplyPanelTuning: %v", err) + } + // 给「万一真被触发了」留出显形窗口:启动清扫只需一次 SQLite 往返(微秒级)。 + time.Sleep(200 * time.Millisecond) + + if !memoryRowExists(t, store, pending.ID) { + t.Fatal("面板保存触发了到期清扫(那行被删了)—— 重排不该碰生命周期") + } + if got := expireAudits(); got != afterStart { + t.Fatalf("面板保存后多出了清扫审计:%d ⇒ %d", afterStart, got) + } + svc.mu.Lock() + running := svc.running svc.mu.Unlock() if !running { - t.Fatal("重排后心跳必须仍在跑(否则定时整理会悄悄停摆)") - } - if got := svc.nextDelay(); got != 30*time.Minute { - t.Fatalf("重排后的 nextDelay = %s, want 30m(仍按旧周期说明没重排)", got) + t.Fatal("面板保存不该把心跳停掉") } }