package memory import ( "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // 本文件是**入库前审查调优面**的解析点:把 `$DATA_DIR/config/memory-tidy.json` // 的面板旋钮叠到 ReviewConfig 上 —— 与 tidy_tuning.go 对 TidyConfig 做的事同源、 // 同一条口径(主人 2026-10-07:「**`.env` 只做兜底用** —— 后端自动创建独立的面板 // 配置文件之后就走面板配置文件,这些键优先从面板配置上取」)。 // // 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与 // 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 ReviewConfig // 的归一化语义 —— 与 normalizeReviewConfig 同一层,因此两者可以一起被单测覆盖。 // // 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**全部键一起** // 回落,这是本文件家族既有的粒度(见 runtimeconfig.LoadMemoryTidyTuning 与它的 // 用例③);本文件处理的是**类型正确但值不可用**(非正数)那一档。 // ApplyReviewTuning 把面板调优旋钮叠到 ReviewConfig 上,返回叠加后的配置 // (**未归一化**:调用方随后交给 NewPreInsertReviewer / normalizeReviewConfig // 填代码默认值,与组合根对 TidyConfig 的既有顺序一致)。 // // 目前只有一键,逐键独立,三档优先级是: // // 键 面板(memory-tidy.json) 下一档(.env) 代码默认 // tempTtlSec tempTtlSec **无此键**(结构性为空) 604800(7 天) // // 🔴 **中档(`.env`)在结构上是空的**,这是**刻意**的、不是漏配:`.env` 已冻结 // (design/13 §2.1「不加、不改、不删」),而「多久算临时」是本次新增的能力, // 它在 `.env` 里**从来就没有键**。因此实际生效链是「面板 > 代码默认」——报回报时 // 不许把它写成「面板 > .env > 代码默认」而让读者以为 `.env` 那档还有东西可调。 // // 坏值(非正秒数)**绝不写进 cfg**:回落代码默认 + 记一条 WARN。一个 ≤0 的 TTL // 会让每条 defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)——那是 // 坏配置而不是「不过期」,但也不许因此让启动失败(design/03 §9.6)。 func ApplyReviewTuning(cfg ReviewConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) ReviewConfig { log = logger.WithSource(baseTidyLog(log), "memory/review") if sec := panelPositiveTTLSec(panel.TempTtlSec, "tempTtlSec", log); sec > 0 { cfg.TempTTL = time.Duration(sec) * time.Second } return cfg } // panelPositiveTTLSec 解析**临时记忆 TTL** 旋钮,返回 0 表示「不覆盖」(调用方原样 // 留着上一档的值,最终由 normalizeReviewConfig 落到代码默认 7 天)。 // // 三种情形分得很清,因为它们该有不同的观测形状(与 tidy_tuning.go 的 // panelPositiveSec 逐字同构): // // - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「代码默认兜底」那一档; // - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会让每条临时记忆落库即过期 // (与「不过期」恰好相反),属于坏配置,必须留下痕迹; // - 正数 ⇒ 原值。 func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int { if v == nil { return 0 } if *v <= 0 { log.Warn("memory-tidy.json 的临时记忆 TTL 不可用,回落代码默认(非正 TTL 会让临时记忆落库即过期)", "key", key, "value", *v, "fallback_sec", tempTtlSec) return 0 } return *v } // ApplyPanelTuning 把面板值**当场**叠到已经在跑的审查服务上(切片 MEMPANEL / M4)。 // // 它只碰面板管得到、且**在 `.env` 里结构性没有出处**的两个键(`ingestGate` / // `tempTtlSec`)—— 因此重算起点就是这两个字段的**零值**(中档为空),不需要一份 // 「`.env` 快照」,也不会因为重算而误伤别的键:`Enabled` / `QueueSize` / `ScanLimit` / // `Timeout` / `Threshold` / T1 四件套一律**原样不动**(它们来自部署配置,不是面板旋钮; // 面板端点也拒收它们)。 // // 口径与装配期逐字一致(`wireMemoryReview` → `NewPreInsertReviewer`):面板写了就用, // 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码 // 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。 // // 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` / // `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个 // 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长 // 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成 // 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。 func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error { next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log)) r.mu.Lock() r.cfg.DisableIngestGate = panel.IngestGateOff() r.cfg.TempTTL = next.TempTTL gateOn := !r.cfg.DisableIngestGate ttl := r.cfg.TempTTL r.mu.Unlock() r.log.Info("memory review panel tuning applied", "ingest_gate", gateOn, "temp_ttl", ttl.String(), "source", "admin panel") return nil }