Files
AskaEth f6c47b0ae1 fix(memory): 清掉两处与实现相反的旧说法 + 并发哨兵改成窗口重叠(变异 8/8 红)
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 个汉字的**第三个字节**
(前两个字节被留下),而不是先前写的「第三个字节上」。
2026-10-13 22:50:38 +08:00

212 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}