一、审查档(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)。
107 lines
6.0 KiB
Go
107 lines
6.0 KiB
Go
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`(代码
|
||
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
|
||
//
|
||
// 🔴 并发口径(本方法引入的运行期写入面,已连同读侧一起收口):`cfg` 在这条特性
|
||
// 之前是**构造期冻结**的,读侧无锁本来安全;这里开了「运行期改两个字段」这条路,
|
||
// 于是读这两个字段的地方也必须同步。写侧持 `r.mu` 的**写锁**;读侧两处
|
||
// (`gateEnabled` / `deferDeadline`)走 `r.tuningSnapshot()` 取读锁快照。
|
||
// 其余读点读的是构造期冻结的字段(`Enabled` / `QueueSize` / `Timeout` / `Threshold` /
|
||
// T1 四件套…),与写方不交手,因此**刻意没有**一并加锁(不给入库热路径加无谓的锁);
|
||
// 将来面板再接管别的字段,那些读点必须与它一起收。
|
||
//
|
||
// 锁序:本方法持锁期间**只做字段赋值**,不去取任何别的锁(`normalizeReviewConfig` /
|
||
// `ApplyReviewTuning` 都在取锁**之前**跑完)⇒ 不引入新的锁序,也就不可能与既有路径
|
||
// 形成环。
|
||
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
|
||
}
|