主人 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 通过。
525 lines
20 KiB
Go
525 lines
20 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
)
|
||
|
||
// 本文件锁定记忆整理「面板调优」读写端点(`GET|PUT /api/v1/admin/memories/tidy/tuning`)
|
||
// 的判据:
|
||
// ① 读侧如实回显**盘上值**(键缺位 ⇒ 该键不出现)+ 取值规则 specs + `.env` 兜底值,
|
||
// 且不回显同文件里不受管的键;
|
||
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动(含「不重写文件」);合法值 ⇒ 采用且保留
|
||
// 同文件其它键;非法值 ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
|
||
// ③ 盘上文件解不开 ⇒ 读/写都 `409 tuning_corrupt` 且绝不覆盖;
|
||
// ④ `null` 是清除哨兵(删键 ⇒ 回到下一档兜底);
|
||
// ⑤ RBAC 走 writeAuth(owner|admin)、写落审计 `memory.tidy.tuning`;
|
||
// ⑥ 热生效**如实**:接线了 ⇒ hot=true 且真把值交给运行期;没接线/失败 ⇒ hot=false +
|
||
// hotError,绝不把「落盘了」说成「生效了」。
|
||
|
||
// tuneEnvT 是一个注入了数据目录(= 面板配置文件落点所在)的网关测试环境。
|
||
type tuneEnvT struct {
|
||
srv *Server
|
||
ts *httptest.Server
|
||
dir string
|
||
jwt *auth.JWTService
|
||
}
|
||
|
||
// newTuneEnv 构造环境。`cfg.MemoryTidy` 被显式填上,用来验证读侧下发的
|
||
// 「缺位时 `.env` 那一档」确实取自进程生效配置(而不是网关自己编的数)。
|
||
func newTuneEnv(t *testing.T, deps Deps) *tuneEnvT {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
cfg := cfgDefault()
|
||
cfg.MemoryTidy = config.MemoryTidyConfig{IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||
deps.DataDir = dir
|
||
srv, ts, jwt := buildEnv(t, cfg, deps)
|
||
return &tuneEnvT{srv: srv, ts: ts, dir: dir, jwt: jwt}
|
||
}
|
||
|
||
func (e *tuneEnvT) url() string {
|
||
return e.ts.URL + "/api/v1/admin/memories/tidy/tuning"
|
||
}
|
||
|
||
func (e *tuneEnvT) path() string {
|
||
return filepath.Join(e.dir, filepath.FromSlash(runtimeconfig.MemoryTidyConfigRelPath))
|
||
}
|
||
|
||
func (e *tuneEnvT) token(t *testing.T, role auth.Role) string {
|
||
t.Helper()
|
||
return signAny(t, e.jwt, "acct-"+string(role), role)
|
||
}
|
||
|
||
// seed 写入一份面板配置并返回写后的字节(后续逐字节比对用)。
|
||
func (e *tuneEnvT) seed(t *testing.T, body string) []byte {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(e.path()), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(e.path(), []byte(body), 0o600); err != nil {
|
||
t.Fatalf("seed: %v", err)
|
||
}
|
||
return e.bytes(t)
|
||
}
|
||
|
||
// bytes 读回盘上的原始字节:「非法值不落盘」的证据就是它与之前相等。
|
||
func (e *tuneEnvT) bytes(t *testing.T) []byte {
|
||
t.Helper()
|
||
raw, err := os.ReadFile(e.path())
|
||
if err != nil {
|
||
t.Fatalf("read tuning file: %v", err)
|
||
}
|
||
return raw
|
||
}
|
||
|
||
func (e *tuneEnvT) put(t *testing.T, token, raw string) (*http.Response, []byte) {
|
||
t.Helper()
|
||
return doRaw(t, http.MethodPut, e.url(), raw, token)
|
||
}
|
||
|
||
func (e *tuneEnvT) get(t *testing.T, token string) (*http.Response, []byte) {
|
||
t.Helper()
|
||
return doReq(t, http.MethodGet, e.url(), nil, token)
|
||
}
|
||
|
||
// view 解一次成功响应的 data。
|
||
func (e *tuneEnvT) view(t *testing.T, body []byte) AdminMemoryTidyTuningView {
|
||
t.Helper()
|
||
var view AdminMemoryTidyTuningView
|
||
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
|
||
t.Fatalf("decode view: %v (body=%s)", err, body)
|
||
}
|
||
return view
|
||
}
|
||
|
||
// assertErrorCode 断言响应是错误信封且 code 匹配。
|
||
func assertErrorCode(t *testing.T, body []byte, want string) {
|
||
t.Helper()
|
||
var eobj struct {
|
||
Code string `json:"code"`
|
||
}
|
||
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
|
||
t.Fatalf("decode error: %v (body=%s)", err, body)
|
||
}
|
||
if eobj.Code != want {
|
||
t.Fatalf("error code = %q, want %q (body=%s)", eobj.Code, want, body)
|
||
}
|
||
}
|
||
|
||
// diskDoc 解出盘上的文档(键 → 原样 JSON 值)。
|
||
func (e *tuneEnvT) diskDoc(t *testing.T) map[string]json.RawMessage {
|
||
t.Helper()
|
||
doc := map[string]json.RawMessage{}
|
||
if err := json.Unmarshal(e.bytes(t), &doc); err != nil {
|
||
t.Fatalf("decode disk: %v", err)
|
||
}
|
||
return doc
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningRead_EchoesDiskValues 读侧:回显盘上 7 键(缺位不下发)、
|
||
// 下发取值规则与 `.env` 兜底值,且**不回显**同文件里不受管的键。
|
||
func TestAdminMemoryTidyTuningRead_EchoesDiskValues(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
env.seed(t, `{
|
||
"intervalSec": 3600,
|
||
"quietStartHour": 23,
|
||
"quietEndHour": 7,
|
||
"ingestGate": false,
|
||
"t1PerWindow": 120
|
||
}`)
|
||
resp, body := env.get(t, env.token(t, auth.RoleOwner))
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("read status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
view := env.view(t, body)
|
||
if view.Path != runtimeconfig.MemoryTidyConfigRelPath || !view.Present {
|
||
t.Fatalf("view path/present = %q/%v", view.Path, view.Present)
|
||
}
|
||
if got := view.Values["intervalSec"]; got != float64(3600) {
|
||
t.Fatalf("values.intervalSec = %#v, want 3600", got)
|
||
}
|
||
if got := view.Values["ingestGate"]; got != false {
|
||
t.Fatalf("values.ingestGate = %#v, want false", got)
|
||
}
|
||
// 键缺位 ⇒ 不出现(「没配」与「配成 0」必须分得开)。
|
||
for _, absent := range []string{"quietIntervalSec", "autoApply", "tempTtlSec"} {
|
||
if _, ok := view.Values[absent]; ok {
|
||
t.Fatalf("values 不该出现 %s(盘上没这个键):%#v", absent, view.Values)
|
||
}
|
||
}
|
||
// 不受管的键(t1PerWindow)不进 values:这张卡改不了它,混进来会误导。
|
||
if _, ok := view.Values["t1PerWindow"]; ok {
|
||
t.Fatalf("values 不该出现不受管的 t1PerWindow:%#v", view.Values)
|
||
}
|
||
if len(view.Specs) != len(memoryTidyTuningManagedKeys()) {
|
||
t.Fatalf("specs = %d 条, want %d", len(view.Specs), len(memoryTidyTuningManagedKeys()))
|
||
}
|
||
if view.EnvValues["intervalSec"] != 86400 || view.EnvValues["quietStartHour"] != 23 {
|
||
t.Fatalf("envValues = %#v, want intervalSec=86400 / quietStartHour=23", view.EnvValues)
|
||
}
|
||
if view.Hot {
|
||
t.Fatal("只读响应里 hot 必须为 false(读不改变任何东西)")
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError 文件还没播种时:正常回 200、
|
||
// present=false、values 为空 —— 「还没配过」不是错误(与 runtimeconfig 的读取口径一致)。
|
||
func TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
resp, body := env.get(t, env.token(t, auth.RoleOwner))
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
view := env.view(t, body)
|
||
if view.Present || len(view.Values) != 0 {
|
||
t.Fatalf("view = %+v, want present=false / 无值", view)
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningRead_ViewerForbidden RBAC:viewer 403、匿名 401。
|
||
func TestAdminMemoryTidyTuningRead_ViewerForbidden(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
env.seed(t, `{"intervalSec": 3600}`)
|
||
resp, body := env.get(t, env.token(t, auth.RoleViewer))
|
||
if resp.StatusCode != http.StatusForbidden {
|
||
t.Fatalf("viewer read status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
resp, _ = env.get(t, "")
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("anonymous read status = %d, want 401", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys 写侧「合法值 ⇒ 采用」:
|
||
// 7 键都能落盘,同文件里不受管的键**原样保留**(一次面板保存绝不抹掉别的旋钮)。
|
||
func TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
env.seed(t, `{"t1Judge": false, "t1PerWindow": 120}`)
|
||
// 用 admin 角色也走同一道门(writeAuth = owner|admin)。
|
||
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{
|
||
"intervalSec": 10800,
|
||
"quietIntervalSec": 3600,
|
||
"quietStartHour": 23,
|
||
"quietEndHour": 7,
|
||
"ingestGate": true,
|
||
"autoApply": true,
|
||
"tempTtlSec": 604800
|
||
}`)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("write status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
view := env.view(t, body)
|
||
for key, want := range map[string]any{
|
||
"intervalSec": float64(10800), "quietIntervalSec": float64(3600),
|
||
"quietStartHour": float64(23), "quietEndHour": float64(7),
|
||
"ingestGate": true, "autoApply": true, "tempTtlSec": float64(604800),
|
||
} {
|
||
if got := view.Values[key]; got != want {
|
||
t.Fatalf("回显 values[%s] = %#v, want %#v", key, got, want)
|
||
}
|
||
}
|
||
doc := env.diskDoc(t)
|
||
if string(doc["intervalSec"]) != "10800" || string(doc["t1PerWindow"]) != "120" {
|
||
t.Fatalf("disk = %s", string(env.bytes(t)))
|
||
}
|
||
if _, ok := doc["t1Judge"]; !ok {
|
||
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", string(env.bytes(t)))
|
||
}
|
||
// 未接运行期 ⇒ 如实说「已落盘、重启后生效」。
|
||
if view.Hot || view.HotError == "" {
|
||
t.Fatalf("未接热生效时 hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched 三态之一:键**缺位** ⇒
|
||
// 不改动。空补丁连文件都不重写(mtime 都不动);只提交一个键时另一个键逐字节不动。
|
||
func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
before := env.seed(t, `{"intervalSec": 86400, "quietStartHour": 23}`)
|
||
past := time.Now().Add(-time.Hour)
|
||
if err := os.Chtimes(env.path(), past, past); err != nil {
|
||
t.Fatalf("chtimes: %v", err)
|
||
}
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := env.put(t, tok, `{}`)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("empty patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if after := env.bytes(t); string(after) != string(before) {
|
||
t.Fatalf("空补丁改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
|
||
}
|
||
info, err := os.Stat(env.path())
|
||
if err != nil {
|
||
t.Fatalf("stat: %v", err)
|
||
}
|
||
if !info.ModTime().Equal(past) {
|
||
t.Fatalf("空补丁重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
|
||
}
|
||
if resp, body := env.put(t, tok, `{"quietStartHour": 22}`); resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("partial patch status = %d (body=%s)", resp.StatusCode, body)
|
||
}
|
||
doc := env.diskDoc(t)
|
||
if string(doc["intervalSec"]) != "86400" || string(doc["quietStartHour"]) != "22" {
|
||
t.Fatalf("partial patch 结果 = %s", string(env.bytes(t)))
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk 三态之三(本文件的核心
|
||
// 证据):任何非法输入都 `400 invalid_payload`,且**盘上一个字节都不动**。
|
||
func TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct{ name, body string }{
|
||
{"字符串冒充整数", `{"intervalSec": "3600"}`},
|
||
{"小数不是整数", `{"tempTtlSec": 1.5}`},
|
||
{"周期为 0(会让整理空转)", `{"intervalSec": 0}`},
|
||
{"周期为负", `{"quietIntervalSec": -60}`},
|
||
{"钟点越上界", `{"quietStartHour": 24}`},
|
||
{"钟点越下界", `{"quietEndHour": -1}`},
|
||
{"钟点不是数字", `{"quietEndHour": "07"}`},
|
||
{"开关不是布尔", `{"ingestGate": "yes"}`},
|
||
{"未知键(拼错的 quietEnd)", `{"quietEnd": 7}`},
|
||
{"请求体不是对象", `[1, 2]`},
|
||
{"请求体是 null", `null`},
|
||
{"请求体是空串", ``},
|
||
{"合法键与非法键同时提交", `{"intervalSec": 3600, "autoApply": "on"}`},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env := newTuneEnv(t, Deps{})
|
||
before := env.seed(t, `{
|
||
"intervalSec": 86400,
|
||
"quietStartHour": 23,
|
||
"quietEndHour": 7,
|
||
"t1Judge": false
|
||
}`)
|
||
resp, body := env.put(t, env.token(t, auth.RoleOwner), tc.body)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
assertErrorCode(t, body, "invalid_payload")
|
||
if after := env.bytes(t); string(after) != string(before) {
|
||
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
|
||
}
|
||
})
|
||
}
|
||
// 盘上**没有**文件时,非法输入也不得创建它(「先落盘再校验」同样是落盘)。
|
||
t.Run("文件不存在时也不创建", func(t *testing.T) {
|
||
env := newTuneEnv(t, Deps{})
|
||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 0}`)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if _, err := os.Stat(env.path()); !os.IsNotExist(err) {
|
||
t.Fatalf("非法输入创建了文件:err=%v", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_NullClearsKey 清除哨兵:`null` 删掉该键(回到下一档
|
||
// 兜底),同文件其它键不受影响。
|
||
func TestAdminMemoryTidyTuningWrite_NullClearsKey(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
env.seed(t, `{"intervalSec": 3600, "quietIntervalSec": 7200, "t1Judge": false}`)
|
||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": null}`)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("clear status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
view := env.view(t, body)
|
||
if _, ok := view.Values["intervalSec"]; ok {
|
||
t.Fatalf("清除后不该再回显 intervalSec:%#v", view.Values)
|
||
}
|
||
if got := view.Values["quietIntervalSec"]; got != float64(7200) {
|
||
t.Fatalf("清除一个键影响了别的键:%#v", view.Values)
|
||
}
|
||
doc := env.diskDoc(t)
|
||
if _, ok := doc["intervalSec"]; ok {
|
||
t.Fatalf("盘上仍有 intervalSec:%s", string(env.bytes(t)))
|
||
}
|
||
if _, ok := doc["t1Judge"]; !ok {
|
||
t.Fatalf("清除一个键抹掉了别的键:%s", string(env.bytes(t)))
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_RefusesCorruptFile 盘上那份运行期读不走时,**读与写
|
||
// 都**拒绝(`409 tuning_corrupt`)且绝不覆盖 —— 否则面板会说「已保存」而运行期仍在
|
||
// 整体回落 `.env` 并记 WARN。
|
||
func TestAdminMemoryTidyTuningWrite_RefusesCorruptFile(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range []struct{ name, body string }{
|
||
{"类型写错", `{"intervalSec": "3600"}`},
|
||
{"根本不是 JSON", `not json at all`},
|
||
{"顶层不是对象", `[1, 2, 3]`},
|
||
} {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env := newTuneEnv(t, Deps{})
|
||
before := env.seed(t, tc.body)
|
||
tok := env.token(t, auth.RoleOwner)
|
||
resp, body := env.put(t, tok, `{"intervalSec": 3600}`)
|
||
if resp.StatusCode != http.StatusConflict {
|
||
t.Fatalf("write status = %d, want 409 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
assertErrorCode(t, body, "tuning_corrupt")
|
||
if string(env.bytes(t)) != string(before) {
|
||
t.Fatalf("坏文件被覆盖了:%s", string(env.bytes(t)))
|
||
}
|
||
// 读侧同样如实报坏,而不是回一份空配置假装「没配过」。
|
||
rresp, rbody := env.get(t, tok)
|
||
if rresp.StatusCode != http.StatusConflict {
|
||
t.Fatalf("read status = %d, want 409 (body=%s)", rresp.StatusCode, rbody)
|
||
}
|
||
assertErrorCode(t, rbody, "tuning_corrupt")
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite 「保存同样的值」不该在盘上留
|
||
// 一次无意义的改写(内容逐字节相同 ⇒ 不落盘、mtime 不动)。
|
||
func TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite(t *testing.T) {
|
||
t.Parallel()
|
||
env := newTuneEnv(t, Deps{})
|
||
env.seed(t, `{"intervalSec": 3600}`)
|
||
past := time.Now().Add(-time.Hour)
|
||
if err := os.Chtimes(env.path(), past, past); err != nil {
|
||
t.Fatalf("chtimes: %v", err)
|
||
}
|
||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 3600}`)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
info, err := os.Stat(env.path())
|
||
if err != nil {
|
||
t.Fatalf("stat: %v", err)
|
||
}
|
||
if !info.ModTime().Equal(past) {
|
||
t.Fatalf("同值保存重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
|
||
}
|
||
if string(env.bytes(t)) != `{"intervalSec": 3600}` {
|
||
t.Fatalf("盘上内容变了:%s", string(env.bytes(t)))
|
||
}
|
||
}
|
||
|
||
// fakeTuningTidy 既是整理服务的只读出口,也是「可热生效」的实现(记录收到的值)。
|
||
type fakeTuningTidy struct {
|
||
fakeTidyDryRunner
|
||
gotBase *memory.TidyConfig
|
||
gotTuning *runtimeconfig.MemoryTidyTuning
|
||
err error
|
||
}
|
||
|
||
func (f *fakeTuningTidy) ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
|
||
f.gotBase = &base
|
||
f.gotTuning = &panel
|
||
return f.err
|
||
}
|
||
|
||
// fakeTuningMemories 同时实现 MemoryStore 与「入库侧可热生效」。
|
||
type fakeTuningMemories struct {
|
||
got *runtimeconfig.MemoryTidyTuning
|
||
err error
|
||
}
|
||
|
||
func (f *fakeTuningMemories) List(context.Context) ([]memory.MemoryItem, error) {
|
||
return []memory.MemoryItem{}, nil
|
||
}
|
||
|
||
func (f *fakeTuningMemories) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
|
||
f.got = &panel
|
||
return f.err
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits 接线了热生效实现时:保存后当场把
|
||
// **新值**交给运行期(hot=true),并落一条审计 `memory.tidy.tuning`/ok。
|
||
func TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits(t *testing.T) {
|
||
t.Parallel()
|
||
tidy := &fakeTuningTidy{}
|
||
mems := &fakeTuningMemories{}
|
||
env := newTuneEnv(t, Deps{MemoryTidy: tidy, Memories: mems})
|
||
env.seed(t, `{"intervalSec": 86400}`)
|
||
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{"intervalSec": 10800, "tempTtlSec": 259200}`)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
view := env.view(t, body)
|
||
if !view.Hot || view.HotError != "" {
|
||
t.Fatalf("hot/hotError = %v/%q, want true/空", view.Hot, view.HotError)
|
||
}
|
||
if tidy.gotTuning == nil || tidy.gotTuning.IntervalSec == nil || *tidy.gotTuning.IntervalSec != 10800 {
|
||
t.Fatalf("整理档没收到面板值:%#v", tidy.gotTuning)
|
||
}
|
||
if mems.got == nil || mems.got.TempTtlSec == nil || *mems.got.TempTtlSec != 259200 {
|
||
t.Fatalf("入库侧没收到面板值:%#v", mems.got)
|
||
}
|
||
// base 是 `.env` 层(进程生效配置投影),不是面板值本身。
|
||
if tidy.gotBase == nil || tidy.gotBase.IntervalSec != 86400 {
|
||
t.Fatalf("base 不是 .env 层:%#v", tidy.gotBase)
|
||
}
|
||
recs, err := env.srv.obs.audit.List(context.Background(), 50)
|
||
if err != nil {
|
||
t.Fatalf("list audit: %v", err)
|
||
}
|
||
found := false
|
||
for _, rec := range recs {
|
||
if rec.What == memoryTuningAuditAction && rec.Result == "ok" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("审计链里没有 %s/ok 的记录:%#v", memoryTuningAuditAction, recs)
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuningWrite_HotFailureIsHonest 热生效实现报错时:**值照旧留在盘上**
|
||
// (不丢),但响应如实 `hot:false` + 原因,绝不说成「已生效」。
|
||
func TestAdminMemoryTidyTuningWrite_HotFailureIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
tidy := &fakeTuningTidy{err: errors.New("boom")}
|
||
env := newTuneEnv(t, Deps{MemoryTidy: tidy})
|
||
env.seed(t, `{"intervalSec": 86400}`)
|
||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 10800}`)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
view := env.view(t, body)
|
||
if view.Hot || view.HotError == "" {
|
||
t.Fatalf("hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
|
||
}
|
||
if got := view.Values["intervalSec"]; got != float64(10800) {
|
||
t.Fatalf("热生效失败不该丢值:%#v", view.Values)
|
||
}
|
||
}
|
||
|
||
// TestAdminMemoryTidyTuning_UnavailableWithoutDataDir 数据目录未接线时如实报「没有落点」,
|
||
// 绝不猜一个目录去写(端点据此回 500 tuning_unavailable)。
|
||
func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
|
||
t.Parallel()
|
||
srv, err := New(cfgDefault(), Deps{
|
||
Auth: auth.New(config.AuthConfig{AdminUser: "a", AdminPass: "b", JWTSecret: testSecret}),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("new server: %v", err)
|
||
}
|
||
if _, ok := srv.memoryTidyTuningPath(); ok {
|
||
t.Fatal("没有数据目录时不该解析出落点")
|
||
}
|
||
}
|