feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。 - 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若 $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」 (十键:既有六键 + 频率四键),此后面板接管 - 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、 一个字不写盘(不「修好它」) - 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename) - 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘 - 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行 行为零变化,有强断言(归一化后配置逐字相等) - 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
This commit is contained in:
1 parent
d680390300
commit
23b6e3fe1e
8 files changed
+1156
-6
No files matched your search
@@ -0,0 +1,85 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
|
||||
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
|
||||
//
|
||||
// 它回应主人 2026-10-07 的两条口径:
|
||||
//
|
||||
// - 「**`.env` 只做兜底用** —— 后端**自动创建**独立的面板配置文件之后就走面板
|
||||
// 配置文件,这些键优先从面板配置上取」⇒ 播种的时机与「此后以面板为准」;
|
||||
// - 「后端**自动创建**独立的面板配置文件**要做**」⇒ 这份快照就是「自动创建」的
|
||||
// 内容,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
|
||||
//
|
||||
// 为什么放在 memory 而不是 runtimeconfig:runtimeconfig 只管**文件形状与读写**,
|
||||
// 「缺位/坏值怎么回落、默认值是多少」属于 TidyConfig / ReviewConfig 的归一化语义
|
||||
// (与 normalizeTidyConfig / normalizeReviewConfig 同一层)。
|
||||
//
|
||||
// 为什么快照取**归一化之后**的值:主人要的是「播种那一刻行为零变化」——把当时真正
|
||||
// 生效的值写成文件,读回来叠上去之后(面板这一档等于生效值本身)结果逐字相同。
|
||||
// 若快照取的是「未归一化的中间值」(比如四键还是 0),写下去的就是一份**比现状更空**
|
||||
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
|
||||
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
|
||||
//
|
||||
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
|
||||
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
|
||||
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
|
||||
// 会与运行期悄悄分叉。
|
||||
//
|
||||
// 十键与它们的生效来源:
|
||||
//
|
||||
// 键 生效来源(无面板文件时) 快照值
|
||||
// autoApply DisableAutoApply 的反向(代码默认开) true
|
||||
// autoApplyPerRound defaultTidyAutoApplyPerRound 50
|
||||
// ingestGate DisableIngestGate 的反向(默认开) true
|
||||
// t1Judge DisableT1 的反向(默认开) true
|
||||
// t1PerWindow defaultT1PerWindow 120
|
||||
// t1WindowSec defaultT1Window 3600
|
||||
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
|
||||
// quietIntervalSec 缺省 = intervalSec 归一化值
|
||||
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
|
||||
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
|
||||
|
||||
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
|
||||
//
|
||||
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
|
||||
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
|
||||
// 文件形状 —— 它不读盘、不读 env、不认识面板文件:
|
||||
//
|
||||
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
|
||||
// 与自动档两键(autoApply / autoApplyPerRound);
|
||||
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
|
||||
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
|
||||
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
|
||||
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
|
||||
//
|
||||
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
|
||||
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
|
||||
func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.MemoryTidyTuning {
|
||||
tidy = normalizeTidyConfig(tidy)
|
||||
review = normalizeReviewConfig(review)
|
||||
|
||||
autoApply := !tidy.DisableAutoApply
|
||||
ingestGate := !review.DisableIngestGate
|
||||
t1Judge := !review.DisableT1
|
||||
intervalSec := tidy.IntervalSec
|
||||
quietIntervalSec := tidy.QuietIntervalSec
|
||||
quietStartHour := tidy.QuietStartHour
|
||||
quietEndHour := tidy.QuietEndHour
|
||||
|
||||
return runtimeconfig.MemoryTidyTuning{
|
||||
AutoApply: &autoApply,
|
||||
AutoApplyPerRound: tidy.AutoApplyPerRound,
|
||||
IngestGate: &ingestGate,
|
||||
T1Judge: &t1Judge,
|
||||
T1PerWindow: review.T1PerWindow,
|
||||
T1WindowSec: int(review.T1Window.Seconds()),
|
||||
IntervalSec: &intervalSec,
|
||||
QuietIntervalSec: &quietIntervalSec,
|
||||
QuietStartHour: &quietStartHour,
|
||||
QuietEndHour: &quietEndHour,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件锁定**启动播种的值来源**(tidy_snapshot.go;主人 2026-10-07:「`.env` 只做
|
||||
// 兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
|
||||
//
|
||||
// ① 快照十键齐全、值可用(否则 runtimeconfig.SeedMemoryTidy 会拒绝落盘);
|
||||
// ② 值取「**当时解析出来的生效值**」—— `.env` 给了就用 `.env`,没给就用代码默认
|
||||
// (默认值来自 normalizeTidyConfig / normalizeReviewConfig,不另写一份);
|
||||
// ③ 最关键的一条:把快照当成面板值叠回去,**归一化结果与「没有面板文件」逐字相同**
|
||||
// —— 这就是「播种那一刻行为零变化,之后改文件才生效」。
|
||||
|
||||
// snapBoolPtr / snapIntPtr 造「快照里显式写了这个键」的指针。
|
||||
// (不复用 tidy_tuning_test.go 的 intPtr:那是给**面板**用例造的,两者语义不同,
|
||||
// 分开命名便于读。)
|
||||
func snapBoolPtr(v bool) *bool { return &v }
|
||||
|
||||
func snapIntPtr(v int) *int { return &v }
|
||||
|
||||
// TestTidyTuningSnapshot_CodeDefaults 钉住「什么都没配」时的十键 = 归一化的代码默认值。
|
||||
// 期望值直接取自默认值常量(本包唯一那处定义),因此这条用例证明的是**接线**:
|
||||
// 快照确实走了 normalizeTidyConfig / normalizeReviewConfig,而不是另写一份默认值。
|
||||
func TestTidyTuningSnapshot_CodeDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
|
||||
|
||||
if snap.AutoApply == nil || !*snap.AutoApply {
|
||||
t.Fatalf("autoApply = %v, want 显式的 true(代码默认开)", snap.AutoApply)
|
||||
}
|
||||
if snap.IngestGate == nil || !*snap.IngestGate {
|
||||
t.Fatalf("ingestGate = %v, want 显式的 true(代码默认开)", snap.IngestGate)
|
||||
}
|
||||
if snap.T1Judge == nil || !*snap.T1Judge {
|
||||
t.Fatalf("t1Judge = %v, want 显式的 true(代码默认开)", snap.T1Judge)
|
||||
}
|
||||
if snap.AutoApplyPerRound != defaultTidyAutoApplyPerRound {
|
||||
t.Fatalf("autoApplyPerRound = %d, want %d", snap.AutoApplyPerRound, defaultTidyAutoApplyPerRound)
|
||||
}
|
||||
if snap.T1PerWindow != defaultT1PerWindow || snap.T1WindowSec != int(defaultT1Window.Seconds()) {
|
||||
t.Fatalf("T1 限速 = %d/%d, want %d/%d", snap.T1PerWindow, snap.T1WindowSec,
|
||||
defaultT1PerWindow, int(defaultT1Window.Seconds()))
|
||||
}
|
||||
if snap.IntervalSec == nil || *snap.IntervalSec != defaultTidyIntervalSec {
|
||||
t.Fatalf("intervalSec = %v, want %d", snap.IntervalSec, defaultTidyIntervalSec)
|
||||
}
|
||||
// quietIntervalSec 缺省 = intervalSec(normalizeTidyConfig 的既有口径:不因时段换档)。
|
||||
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != defaultTidyIntervalSec {
|
||||
t.Fatalf("quietIntervalSec = %v, want = intervalSec (%d)", snap.QuietIntervalSec, defaultTidyIntervalSec)
|
||||
}
|
||||
if snap.QuietStartHour == nil || *snap.QuietStartHour != defaultTidyQuietStartHour ||
|
||||
snap.QuietEndHour == nil || *snap.QuietEndHour != defaultTidyQuietEndHour {
|
||||
t.Fatalf("安静时段 = %v-%v, want %d-%d", snap.QuietStartHour, snap.QuietEndHour,
|
||||
defaultTidyQuietStartHour, defaultTidyQuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_TakesEnvLayer 钉住「`.env` 给的就用 `.env`」:四键的 env 档
|
||||
// (MEMORY_TIDY_INTERVAL_SEC / MEMORY_TIDY_QUIET_*_HOUR)必须原样进快照,否则播种写
|
||||
// 下去的就是代码默认值,等于把主人配的 `.env` 静默抹平。
|
||||
func TestTidyTuningSnapshot_TakesEnvLayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 22, QuietEndHour: 6}
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
|
||||
|
||||
if snap.IntervalSec == nil || *snap.IntervalSec != 7200 {
|
||||
t.Fatalf("intervalSec = %v, want 7200(取 `.env`)", snap.IntervalSec)
|
||||
}
|
||||
// env 只给了 intervalSec,quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec。
|
||||
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != 7200 {
|
||||
t.Fatalf("quietIntervalSec = %v, want 7200(缺省 = intervalSec)", snap.QuietIntervalSec)
|
||||
}
|
||||
if snap.QuietStartHour == nil || *snap.QuietStartHour != 22 ||
|
||||
snap.QuietEndHour == nil || *snap.QuietEndHour != 6 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", snap.QuietStartHour, snap.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_KeepsExplicitZeroHour 钉住钟点的零值口径:**0 是合法钟点**
|
||||
// (0-7 的凌晨窗口)。若快照把「0 起」当成「没配」而写成 23,播种就篡改了主人的窗口。
|
||||
//
|
||||
// 注意「两端都是 0」是**另一回事**:那是 normalize 认的「都没配」(回落到 23-07),
|
||||
// 这与显式写 0-5 的凌晨窗口必须可区分。
|
||||
func TestTidyTuningSnapshot_KeepsExplicitZeroHour(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{QuietStartHour: 0, QuietEndHour: 5}
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
|
||||
if snap.QuietStartHour == nil || *snap.QuietStartHour != 0 || *snap.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 0-5(显式 0 是合法钟点)", snap.QuietStartHour, snap.QuietEndHour)
|
||||
}
|
||||
|
||||
// 两端都是 0 = 「都没配」⇒ 归一成代码默认 23-07(既有口径,快照如实转述)。
|
||||
both := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
|
||||
if *both.QuietStartHour != defaultTidyQuietStartHour || *both.QuietEndHour != defaultTidyQuietEndHour {
|
||||
t.Fatalf("两端 0 = 都没配 ⇒ 应归一成 %d-%d,得到 %v-%v",
|
||||
defaultTidyQuietStartHour, defaultTidyQuietEndHour, *both.QuietStartHour, *both.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig 是**播种的全部意义**所在:
|
||||
// 把快照当成面板值叠回去之后,整理与审查两侧归一化出来的配置必须与「面板文件不存在」
|
||||
// 逐字相同 —— 于是播种那一刻行为零变化,之后改文件才生效。
|
||||
//
|
||||
// 两侧各按**运行期真实的叠加路径**走:
|
||||
// - 整理侧 = 组合根那三步(TidyConfigFromConfig → ApplyTidyScheduleTuning →
|
||||
// normalizeTidyConfig,即 resolveTidySchedule);
|
||||
// - 审查侧 = wireMemoryReview 的写法(开关取 IngestGateOff/T1JudgeOff,数值取
|
||||
// t1PerWindow/t1WindowSec,再交给 normalizeReviewConfig)。
|
||||
func TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
envs := []struct {
|
||||
name string
|
||||
env config.MemoryTidyConfig
|
||||
}{
|
||||
{"`.env` 什么都没配", config.MemoryTidyConfig{}},
|
||||
{"`.env` 配了频率与窗口", config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}},
|
||||
{"`.env` 的窗口从 0 点起(凌晨档)", config.MemoryTidyConfig{IntervalSec: 10800, QuietStartHour: 0, QuietEndHour: 6}},
|
||||
{"`.env` 把窗口关掉(两端相等且非 0)", config.MemoryTidyConfig{IntervalSec: 3600, QuietStartHour: 5, QuietEndHour: 5}},
|
||||
{"`.env` 只配了周期", config.MemoryTidyConfig{IntervalSec: 1800}},
|
||||
}
|
||||
for _, tc := range envs {
|
||||
log, _ := captureTidyLog(t)
|
||||
|
||||
// ① 没播种前:面板缺失 ⇒ 零值 tuning(= 当前生产里 data/config 下没这个文件的形态)。
|
||||
before := resolveTidySchedule(tc.env, runtimeconfig.MemoryTidyTuning{}, log)
|
||||
// ② 快照(这就是要写进面板文件的内容)。
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(tc.env), ReviewConfig{})
|
||||
// ③ 播种之后:面板文件就是那份快照,逐键叠回来。
|
||||
after := resolveTidySchedule(tc.env, snap, log)
|
||||
if before != after {
|
||||
t.Fatalf("%s: 播种改变了生效配置:\n before=%+v\n after =%+v", tc.name, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 审查侧同理(闸/T1 四键在 `.env` 里没有出处,只由面板与代码默认决定)。
|
||||
reviewBefore := normalizeReviewConfig(ReviewConfig{})
|
||||
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
|
||||
reviewAfter := normalizeReviewConfig(ReviewConfig{
|
||||
DisableIngestGate: snap.IngestGateOff(),
|
||||
DisableT1: snap.T1JudgeOff(),
|
||||
T1PerWindow: snap.T1PerWindow,
|
||||
T1Window: time.Duration(snap.T1WindowSec) * time.Second,
|
||||
})
|
||||
if reviewBefore != reviewAfter {
|
||||
t.Fatalf("播种改变了审查的生效配置:\n before=%+v\n after =%+v", reviewBefore, reviewAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_IsCompleteAndPositive 钉住快照契约:十键非 nil、数值键为正、
|
||||
// 钟点在 0..23。runtimeconfig.SeedMemoryTidy 会按这条契约拒收残缺种子,因此这里先
|
||||
// 在**产生方**把它钉住,免得「播种写不出去」要到启动日志里才发现。
|
||||
func TestTidyTuningSnapshot_IsCompleteAndPositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
|
||||
pointers := map[string]*int{
|
||||
"intervalSec": snap.IntervalSec,
|
||||
"quietIntervalSec": snap.QuietIntervalSec,
|
||||
"quietStartHour": snap.QuietStartHour,
|
||||
"quietEndHour": snap.QuietEndHour,
|
||||
}
|
||||
for key, p := range pointers {
|
||||
if p == nil {
|
||||
t.Fatalf("%s 是 nil(缺键的种子会被拒收)", key)
|
||||
}
|
||||
}
|
||||
if snap.AutoApply == nil || snap.IngestGate == nil || snap.T1Judge == nil {
|
||||
t.Fatalf("开关指针不得为 nil:%+v", snap)
|
||||
}
|
||||
for key, v := range map[string]int{
|
||||
"autoApplyPerRound": snap.AutoApplyPerRound,
|
||||
"t1PerWindow": snap.T1PerWindow,
|
||||
"t1WindowSec": snap.T1WindowSec,
|
||||
"intervalSec": *snap.IntervalSec,
|
||||
"quietIntervalSec": *snap.QuietIntervalSec,
|
||||
} {
|
||||
if v <= 0 {
|
||||
t.Fatalf("%s = %d, want > 0(非正周期会被 runtimeconfig 拒收)", key, v)
|
||||
}
|
||||
}
|
||||
for key, v := range map[string]int{"quietStartHour": *snap.QuietStartHour, "quietEndHour": *snap.QuietEndHour} {
|
||||
if v < 0 || v > 23 {
|
||||
t.Fatalf("%s = %d, want 0..23", key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user