主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。
此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。
后端(读写端点,契约待塔登记 v0.92):
- GET /api/v1/admin/memories/tidy/tuning 读盘上 7 键值 + 取值规则 specs +
缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT 同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
(t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」
前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
「没有候选可比」显式区分)
验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
214 lines
9.7 KiB
Go
214 lines
9.7 KiB
Go
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 的转调没有落到审查服务上")
|
||
}
|
||
}
|