package memory import ( "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效, // 不要求重启后端)的两条判据: // ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)—— // 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳 // 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。 // ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒ // 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。 // tuningInt 返回面板调优用的 *int。 func tuningInt(v int) *int { return &v } // tuningBool 返回面板调优用的 *bool。 func tuningBool(v bool) *bool { return &v } // TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认, // 且**重算而不是叠加**:清掉一个键就回到 base 那一档。 func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) { t.Parallel() base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil) // 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。 panel := runtimeconfig.MemoryTidyTuning{ IntervalSec: tuningInt(10800), QuietIntervalSec: tuningInt(36000), AutoApply: tuningBool(false), } if err := svc.ApplyPanelTuning(base, panel); err != nil { t.Fatalf("ApplyPanelTuning: %v", err) } if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour { t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval) } if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour { t.Fatalf("quiet interval = %d / %s, want 36000 / 10h", svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval) } if !svc.cfg.DisableAutoApply { t.Fatal("autoApply:false 应当落到 DisableAutoApply") } if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 { t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg) } // 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。 if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply { t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg) } // 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400, // 而不是留在上一次的 10800。 cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)} if err := svc.ApplyPanelTuning(base, cleared); err != nil { t.Fatalf("ApplyPanelTuning(cleared): %v", err) } if svc.cfg.IntervalSec != 86400 { t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec) } if svc.cfg.QuietIntervalSec != 86400 { t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec) } if svc.cfg.DisableAutoApply { t.Fatal("清掉 autoApply 后应回到代码默认(开)") } if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 { t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg) } } // TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳 // 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。 func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) { t.Parallel() at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local) base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil) // 未启动:保存只改配置,不碰心跳状态(用一个不含 intervalSec 的补丁,免得 // 影响下面「旧周期」的断言)。 idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)} if err := svc.ApplyPanelTuning(base, idlePanel); err != nil { t.Fatalf("ApplyPanelTuning(idle): %v", err) } svc.mu.Lock() running := svc.running svc.mu.Unlock() if running { t.Fatal("未 Start 时不该因为保存而启动心跳") } // 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。 // 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。 svc.Start(nil) t.Cleanup(func() { _ = svc.Stop(nil) }) svc.mu.Lock() svc.lastRun = at.Add(-30 * time.Minute) svc.mu.Unlock() if got := svc.nextDelay(); got != 23*time.Hour+30*time.Minute { t.Fatalf("启动后 nextDelay = %s, want 23h30m(旧周期)", got) } // 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有 // 30 分钟),而不是继续睡原来那 23.5 小时。 if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil { t.Fatalf("ApplyPanelTuning(running): %v", err) } svc.mu.Lock() running = svc.running svc.mu.Unlock() if !running { t.Fatal("重排后心跳必须仍在跑(否则定时整理会悄悄停摆)") } if got := svc.nextDelay(); got != 30*time.Minute { t.Fatalf("重排后的 nextDelay = %s, want 30m(仍按旧周期说明没重排)", got) } } // TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变) // 时不打扰心跳:那一次保存不该白白停一次心跳。 func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) { t.Parallel() at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local) base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil) svc.Start(nil) t.Cleanup(func() { _ = svc.Stop(nil) }) same := normalizeTidyConfig(base) if svc.cfg != same { t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same) } if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil { t.Fatalf("ApplyPanelTuning(noop): %v", err) } if got := svc.nextDelay(); got != 8*time.Hour { t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got) } } // TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样 // **重算**:清掉即回代码默认(中档结构性为空)。 func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) { t.Parallel() store := newTestStore(t) reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()}) if err != nil { t.Fatalf("NewPreInsertReviewer: %v", err) } // 面板关闸 + 1 小时 TTL。 panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)} if err := reviewer.ApplyPanelTuning(panel); err != nil { t.Fatalf("ApplyPanelTuning: %v", err) } if !reviewer.cfg.DisableIngestGate { t.Fatal("ingestGate:false 应当当场关掉同步入库闸") } if reviewer.cfg.TempTTL != time.Hour { t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL) } // 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。 if reviewer.gateEnabled() { t.Fatal("关闸后 gateEnabled 仍为 true") } // 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。 if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil { t.Fatalf("ApplyPanelTuning(cleared): %v", err) } if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() { t.Fatal("清掉 ingestGate 后应当回到默认(开)") } if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want { t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want) } // 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。 if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil { t.Fatalf("ApplyPanelTuning(bad ttl): %v", err) } if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want { t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL) } // 审查本身的其它旋钮**一个都不动**(它们不是面板键)。 if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 { t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg) } } // TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储 // 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效; // 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。 func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) { t.Parallel() store := newTestStore(t) if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil { t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err) } reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()}) if err != nil { t.Fatalf("NewPreInsertReviewer: %v", err) } store.AttachIngestReviewer(reviewer) if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil { t.Fatalf("Store.ApplyIngestPanelTuning: %v", err) } if !reviewer.cfg.DisableIngestGate { t.Fatal("经 Store 的转调没有落到审查服务上") } }