package memory import ( "context" "errors" "fmt" "log/slog" "time" "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` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu → // Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个 // 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后 // 已经全部结束,因此也不与写入交手。 // memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。 // // 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了 // 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期), // 绝不静默假装重排成功。 const memoryTidyRescheduleTimeout = 5 * time.Second // ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于 // 下一次调度判定,只是当前那次等待没有被作废)。 var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out") // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 // // 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是 // 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。 func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error { next := tidyPanelTuning(base, panel, s.log) // 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意 // 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop // 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。 s.runMu.Lock() defer s.runMu.Unlock() s.mu.Lock() unchanged := s.cfg == next s.mu.Unlock() if unchanged { // 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。 s.applyTidyConfigLocked(next) return nil } reschedErr := s.rescheduleTidyHeartbeat() s.applyTidyConfigLocked(next) return reschedErr } // 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) } // applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让 // 心跳停下): // - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源; // - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。 // // 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。 func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) { s.mu.Lock() s.cfg = cfg s.mu.Unlock() s.tidier.cfg = cfg } // rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。 // // 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经 // 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默 // 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。 // // 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在 // tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop` // 收尾(见 wire_services.go),因此不依赖父 ctx 取消。 func (s *TidyService) rescheduleTidyHeartbeat() error { s.mu.Lock() running := s.running s.mu.Unlock() if !running { return nil } stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout) defer cancel() if err := s.Stop(stopCtx); err != nil { s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out", "timeout", memoryTidyRescheduleTimeout.String(), "error", err) s.Start(context.Background()) return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err) } s.Start(context.Background()) return nil }