Files
Cyrene_v2/internal/runtime/memory/tidy_service_tuning.go
T
AskaEth a87fbeb494 fix(memory): 面板调优引入的两处运行期写入面收口(-race 修前红 → 修后绿)
一、审查档(preinsert.go / preinsert_gate.go):面板调优会在**运行期**改 cfg 的
两个字段(DisableIngestGate / TempTTL),而读这两个字段的两处不持锁
(gateEnabled 在同步入库闸上、deferDeadline 在第五态 defer 上)。
- 读侧改走新的 tuningSnapshot()(r.mu 由 Mutex 换成 RWMutex,读锁下取整份快照);
- **只**收这两个字段的读点:其余读点读的是构造期冻结的字段,不与写方交手,
  故刻意不给入库热路径加无谓的锁;注释写明「将来面板再接管别的字段,那些读点
  必须与它一起收」;
- 锁序:写方持锁期间只做字段赋值、不取任何别的锁(normalize/回落都在取锁之前
  跑完)⇒ 不引入新锁序。

二、整理档(tidy_service_tuning.go):修掉我自己的那条真竞争 —— 原顺序是
「Stop → Start → 写 cfg」,而新起的循环**立刻**读 s.cfg 重算下一拍,与写入并排
(那一拍还可能按旧配置算出来)。改成「Stop → 写 → Start」。
顺带去掉 Stop 的超时分支:超时意味着 wg 的 Wait 仍在飞,紧接着 Start 的 Add 会
踩上 M3 在 tidy_service.go 明文写下的 WaitGroup 复用纪律(-race 下 panic);`Stop`
期间也不持 runMu,避免与「心跳正要开跑一轮」互相等死。

证据(全部逐条跑过再还原,还原用 diff 逐字校验为 0 差异):
- 审查档:撤回读侧修复 ⇒ 2 处 DATA RACE(写 preinsert_tuning.go 的两个字段 × 读
  gateEnabled / deferDeadline);恢复 ⇒ 绿。
- 整理档:把顺序改回「先起后写」⇒ DATA RACE(写 applyTidyConfig × 读 nextDelay);
  恢复 ⇒ 绿。
- 新增两条只在 -race 下有意义的回归哨兵(tidy_service_tuning_test.go):审查档真
  并发读写;整理档用**真心跳**当读者(不伪造生产不存在的调用方,那个入口在包里
  只有心跳 goroutine 会调)。

热路径成本:新增的同步是「一次无竞争 RLock + 整份小结构体快照」,隔离实测约
4ns/次(5e6 次共 22.9ms);入库闸那条测量用例的批均值在 2.62ms → 2.71~2.81ms
之间波动,落在那条用例自身的运行间噪声里(同一份代码连跑两次,纯写入基线就从
39µs 飘到 60µs)。
2026-10-13 22:11:41 +08:00

129 lines
7.0 KiB
Go
Raw 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 小时
// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop +
// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是
// 已经到点」,于是缩短周期往往**当场**补跑一轮)。
// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次
// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。
//
// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound`
// 与手动轮 / 试算 `DryRun` 都持它),而它排除的是**轮次**;调度读(`nextDelay` /
// `dueNow` 读 `s.cfg`)不受 runMu 保护,因此写 `s.cfg` 必须发生在「没有任何 goroutine
// 读它」的窗口里 ⇒ 顺序铁律是 **先停、再写、后起**:
//
// 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 ⇒ 不会与「心跳要开跑一轮」互相锁死。
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
//
// 返回 nil 表示**值已生效**(心跳在跑时也已被重排,下一拍按新周期算);返回错误时
// 值**没有**落到运行期(落盘那一步早已完成,调用方据此如实回 `hot:false` + 原因)。
//
// 顺序与三条由来见文件头:**先停 → 再写 → 后起**。
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())
}
return nil
}
// 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`(排除并发的一轮:心跳轮与手动轮 / 试算都持它),因为调用方
// (ApplyPanelTuning)在 `Stop` 期间**不能**持它。前提仍是「心跳已停」:调用方负责。
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
}