review P2 两条。 ① 旧说法没删净(同类第 N 次:改了 A,同一主张还留在 B / C)。这次按「**这句主张的所有 落点**」重扫了一遍我这一片里含 Stop / Start / 重启 / 心跳已停 的注释,改掉两处与实现 正面打架的: - applyTidyConfig 的注释还写着「调用方(ApplyPanelTuning)在 Stop 期间不能持 runMu」 「前提仍是心跳已停」—— 而同文件的文件头刚写过「调优路径上一个 Start/Stop 都没有」, 同一文件里两段话自相矛盾; - 整理档那条并发哨兵的循环注释还写着「Stop 把 running 置为 false,忘了重启就会静默 停摆」,而该循环里根本没有 Stop/Start(改成「唤醒不是停表」,并把 Stop/Start 那版 明确标成旧版)。 顺带把文件头「所有读点都收进访问器」的说法改准:此外还有一处**同一把锁下**的相等比较 (ApplyPanelTuning 里的 s.cfg == next),它本身不需要访问器。 ② 新哨兵是概率性守卫:固定 40 轮时撤回 DryRun 同步只红 4/8(写方那几十次覆写在微秒级 就跑完,读方还在慢端点里打转)⇒ 文档规定的 -count=1 会漏掉约一半回归。改成**两侧各自 跑满同一个 300ms 墙钟窗口**(写窗口与读窗口必然重叠,不靠快慢概率),读方两个活端点各 起两路;写方每轮换一个值(否则 ApplyPanelTuning 会被「配置没变」的早退挡掉,根本不写)。 实测:撤回 DryRun 同步红 **8/8**、撤回 ManualRun 同步红 **8/8**(各 8 次 -race -count=1); 干净 tip `-race -count=5` 全绿、三条哨兵一起 `-count=3` 全绿;该哨兵耗时 0.35s(原 0.18s)。 顺带把 clipJSON 用例注释里那句改准:40 字节处被切掉的是第 13 个汉字的**第三个字节** (前两个字节被留下),而不是先前写的「第三个字节上」。
212 lines
11 KiB
Go
212 lines
11 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// 本文件是**整理自动档的运行期调优面**(切片 MEMPANEL / M4):把
|
||
// `$DATA_DIR/config/memory-tidy.json` 的面板旋钮**当场**叠到已经在跑的 TidyService 上,
|
||
// 使主人点一次「保存」就生效,而不必重启后端。
|
||
//
|
||
// 为什么需要它(主人 2026-10-07 两条口径,逐字):
|
||
//
|
||
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
|
||
// 面板配置文件。这些键优先从面板配置上取」
|
||
// 「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到
|
||
// 了静默期稍微长一点」
|
||
//
|
||
// 在这之前,面板文件只在**装配期**被读一次(`wireMemoryTidy` → `NewTidyService`),
|
||
// 四个调度旋钮与 autoApply 被**拷进**服务的 `cfg` 字段后就不再变 —— 面板改完文件,
|
||
// 跑着的进程一个字节都不会变。
|
||
//
|
||
// 三条不可走样的口径:
|
||
//
|
||
// 1. **重算而不是叠加**:`base` 是 `.env` 层(由组合根用 `TidyConfigFromConfig`
|
||
// 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` >
|
||
// 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到
|
||
// `.env` 档,而不是留在上一次的面板值上。
|
||
// 2. **心跳要重排,但不许碰生命周期**:心跳正睡在「用**旧**周期算出来」的那一觉里
|
||
// (`nextDelay` 只在每一拍之前现算),最长可到旧周期 —— 不重排的话,主人把周期从
|
||
// 24 小时改成 3 小时时,新周期要等那一觉睡完才生效,等于「改了跟没改一样」。
|
||
// 重排的做法是**唤醒**(`notifyReschedule`:一次性标记 + 取消当前那次等待),
|
||
// **不是** `Stop` + `Start`。这条是本切片修正后的形态,理由见下面「为什么不是
|
||
// Stop/Start」。
|
||
// 3. **失败不吞值**:重排不成立时值**照样**落到运行期(下一次调度判定就用它),
|
||
// 调用方据实回报 —— 绝不假装热生效,也绝不让保存白写。
|
||
//
|
||
// 为什么不是 `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 都没有**。
|
||
//
|
||
// 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被
|
||
// 运行期整体覆写,它的**每一个**读点都必须与写方同步。做法是读写都在 `s.mu` 下:读点
|
||
// 收进三个访问器 `enabled()`(开关)/ `cfgSnapshot()`(整份)/ `scheduleSnapshot()`
|
||
// (整份 + lastRun),覆盖调度读 `nextDelay` / `dueNow`、两个活端点的开关判定
|
||
// `DryRun` / `ManualRun`、`Enabled()` 与 `Start` 的启动日志;此外 `ApplyPanelTuning`
|
||
// 里还有一处**同一把锁下**的相等比较(`s.cfg == next`),它本身不需要访问器。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、
|
||
// 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。
|
||
|
||
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
|
||
//
|
||
// 返回 nil 表示**值已生效,且心跳已被告知重排**(下一拍按新周期算)。当前实现里
|
||
// 它**不会失败**(错误返回保留给将来的校验),因此网关侧的 `hot` 语义就是「值已叠进
|
||
// 运行期」。
|
||
func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
|
||
next := tidyPanelTuning(base, panel, s.log)
|
||
|
||
s.mu.Lock()
|
||
unchanged := s.cfg == next
|
||
s.mu.Unlock()
|
||
if unchanged {
|
||
// 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写,也
|
||
// 不必唤醒心跳。刻意**不**走下面那条写路径:写一个「相同的值」也是写,
|
||
// 照样会与调度读构成竞争,而它一点收益都没有。
|
||
return nil
|
||
}
|
||
|
||
// 写运行期(两处持有者),再把「这一觉是按旧周期算的」这件事作废。
|
||
s.applyTidyConfig(next)
|
||
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 两个键 →
|
||
// ApplyTidyScheduleTuning → normalizeTidyConfig),只是可以随时再跑一次。刻意做成
|
||
// 一个函数而不是把两处各写一遍逻辑,是为了让「优先级链」只有一份可读的写法:
|
||
// 将来把组合根那一段改成调用它,两处就彻底合一(组合根那个文件不在本切片 scope 内,
|
||
// 因此这里先留一个等价的实现,并已在回报里登记)。
|
||
func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
|
||
cfg := base
|
||
cfg.DisableAutoApply = panel.AutoApplyOff()
|
||
if panel.AutoApplyPerRound > 0 {
|
||
cfg.AutoApplyPerRound = panel.AutoApplyPerRound
|
||
}
|
||
cfg = ApplyTidyScheduleTuning(cfg, panel, log)
|
||
return normalizeTidyConfig(cfg)
|
||
}
|
||
|
||
// applyTidyConfig 把新配置落到**两个**持有者上:
|
||
// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源(`s.mu` 下写);
|
||
// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。
|
||
//
|
||
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
|
||
//
|
||
// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它)—— 调优路径**不等
|
||
// 任何后台件停**:`s.cfg` 的读点全部持 `s.mu`(见文件头),`tidier.cfg` 的读点全部持
|
||
// `runMu`,因此这把锁足以让写入与每一轮的读不交手,不需要(也不再)停心跳。
|
||
func (s *TidyService) applyTidyConfig(cfg TidyConfig) {
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
s.mu.Lock()
|
||
s.cfg = cfg
|
||
s.mu.Unlock()
|
||
s.tidier.cfg = cfg
|
||
}
|