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 }