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:
1 parent
a87fbeb494
commit
97c0c1b94a
3 files changed
+328
-82
No files matched your search
@@ -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 两个键 →
|
||||
|
||||
Reference in new issue
Block a user