Files
Cyrene_v2/internal/runtime/memory/tidy_service_tuning.go
T
AskaEth 4df8d6f53a fix(memory): 按字段扫清 s.cfg 的其余无锁读(P1)+ 回显文案按 rune 截断
review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁
访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**:

- TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与
  ?async=1 那条会起后台 goroutine 的版本(真机 90s 级);
- TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run;
- (原本也漏的)Enabled() 与 Start 的启动日志块。

按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器
内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处;
s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。

新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。
修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回
ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原
(diff 0 差异)。

顺手两条(塔指定):
- 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成
  唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。
- 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经
  JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的
  clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀
  错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。
2026-10-13 22:33:07 +08:00

210 lines
10 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 小时时,新周期要等那一觉睡完才生效,等于「改了跟没改一样」。
// 重排的做法是**唤醒**(`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` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被
// 运行期整体覆写,它的**每一个读点**都必须与写方同步,因此所有读点都收进了持锁访问器
// (`enabled()` / `cfgSnapshot()` / `scheduleSnapshot()`:调度读 `nextDelay` /
// `dueNow`、两个活端点的开关判定 `DryRun` / `ManualRun`、以及 `Enabled()` 与 `Start`
// 的启动日志)。写 `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`(排除并发的一轮:心跳轮与手动轮 / 试算都持它),因为调用方
// (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
}