From eddf9212296fd7e7f9fe23bb1a4282d366b88db7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 21:31:52 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(memory):=20=E8=AE=B0=E5=BF=86=E6=95=B4?= =?UTF-8?q?=E7=90=86=E8=B0=83=E4=BC=98=E9=9D=A2=E6=9D=BF=E8=AF=BB=E5=86=99?= =?UTF-8?q?=EF=BC=88=E4=B8=89=E6=80=81=20+=20=E7=83=AD=E7=94=9F=E6=95=88?= =?UTF-8?q?=20+=20=E9=9D=A2=E6=9D=BF=E8=A1=A8=E5=8D=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 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 通过。 --- frontend/web/src/admin/modules/Memories.tsx | 3 + .../MemoryTidyDryRunCard.render.test.ts | 40 +- .../admin/modules/MemoryTidyDryRunCard.tsx | 70 ++- .../MemoryTidyTuningCard.render.test.ts | 124 +++++ .../admin/modules/MemoryTidyTuningCard.tsx | 273 +++++++++ .../admin/modules/memoryTidyTuning.test.ts | 167 ++++++ .../web/src/admin/modules/memoryTidyTuning.ts | 152 +++++ frontend/web/src/lib/api.ts | 9 + frontend/web/src/lib/api/adminMemories.ts | 75 +++ .../config/runtimeconfig/memorytidy_save.go | 150 +++++ internal/runtime/memory/preinsert_store.go | 20 + internal/runtime/memory/preinsert_tuning.go | 30 + .../runtime/memory/tidy_service_tuning.go | 143 +++++ .../memory/tidy_service_tuning_test.go | 213 +++++++ .../services/gateway/admin_memory_tuning.go | 331 +++++++++++ .../gateway/admin_memory_tuning_file.go | 275 +++++++++ .../gateway/admin_memory_tuning_test.go | 524 ++++++++++++++++++ internal/services/gateway/routes.go | 10 + 18 files changed, 2604 insertions(+), 5 deletions(-) create mode 100644 frontend/web/src/admin/modules/MemoryTidyTuningCard.render.test.ts create mode 100644 frontend/web/src/admin/modules/MemoryTidyTuningCard.tsx create mode 100644 frontend/web/src/admin/modules/memoryTidyTuning.test.ts create mode 100644 frontend/web/src/admin/modules/memoryTidyTuning.ts create mode 100644 frontend/web/src/lib/api/adminMemories.ts create mode 100644 internal/config/runtimeconfig/memorytidy_save.go create mode 100644 internal/runtime/memory/tidy_service_tuning.go create mode 100644 internal/runtime/memory/tidy_service_tuning_test.go create mode 100644 internal/services/gateway/admin_memory_tuning.go create mode 100644 internal/services/gateway/admin_memory_tuning_file.go create mode 100644 internal/services/gateway/admin_memory_tuning_test.go diff --git a/frontend/web/src/admin/modules/Memories.tsx b/frontend/web/src/admin/modules/Memories.tsx index 06f95a4..e7811a6 100644 --- a/frontend/web/src/admin/modules/Memories.tsx +++ b/frontend/web/src/admin/modules/Memories.tsx @@ -9,6 +9,7 @@ import { Button, Surface, Tag } from "../../components/primitives"; import { MemoryTidyFactsCard } from "./ConfigFactsCards"; import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard"; import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard"; +import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard"; const scopeTone: Record = { user: "accent", @@ -191,6 +192,8 @@ export function MemoriesModule(): React.ReactNode { + + diff --git a/frontend/web/src/admin/modules/MemoryTidyDryRunCard.render.test.ts b/frontend/web/src/admin/modules/MemoryTidyDryRunCard.render.test.ts index 6ffe6b9..1bd3ce8 100644 --- a/frontend/web/src/admin/modules/MemoryTidyDryRunCard.render.test.ts +++ b/frontend/web/src/admin/modules/MemoryTidyDryRunCard.render.test.ts @@ -25,6 +25,8 @@ import { MemoryTidyDryRunBody, TIDY_DRY_RUN_DISCLAIMER, TIDY_DRY_RUN_ERROR_HINT, + TIDY_RECALL_UNAVAILABLE, + TIDY_RECALL_UNAVAILABLE_HINT, type TidyDryRunState, } from "./MemoryTidyDryRunCard"; @@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)"); ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」"); ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)"); ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时"); -ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)"); +ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)"); ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)"); // ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ───────────────────── @@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/ ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)"); ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」"); + +// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ──────── +// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量 +// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。 +// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。 +const recallCases: Array<[string, string]> = [ + ["embedding", "语义向量"], + ["lexical", "已降级"], + ["mixed", "部分分区降级"], + ["", "无候选可比"], + ["unregistered_mode", "未登记取值"], +]; +for (const [mode, want] of recallCases) { + const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } }); + ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`); +} +// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。 +const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } }); +ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来"); +ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)"); +ok(!has(noCandidateHtml, "召回来源: = { merge: "并入", @@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R ); } +/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。 + * + * 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没 + * 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会 + * 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**): + * + * - `embedding` —— 语义向量召回真的用上了(好态); + * - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少); + * - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增); + * - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成 + * 一个空标签 —— 那会让人以为字段坏了)。 + * + * ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错 + * (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有 + * `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许 + * 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */ +export function recallSourceLabel(mode: string): string { + switch (mode) { + case "embedding": + return "召回来源:语义向量(embedding)"; + case "lexical": + return "召回来源:词项余弦(未接向量,已降级)"; + case "mixed": + return "召回来源:部分分区降级(其余走向量)"; + case "": + return "召回来源:无候选可比(本轮未做召回)"; + default: + return `召回来源:${mode}(未登记取值)`; + } +} + +/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */ +export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" { + switch (mode) { + case "embedding": + return "accent"; + case "lexical": + case "mixed": + return "warn"; + default: + return "neutral"; + } +} + /** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode { const vetoReasons = result.vetoReasons ?? {}; @@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
算完了,一行都没写 - 召回来源:{result.recallMode} + {recallSourceLabel(result.recallMode)} 扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "} {result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions} @@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode { return (
试算失败:{state.message} + {state.code === TIDY_RECALL_UNAVAILABLE ? ( + {TIDY_RECALL_UNAVAILABLE_HINT} + ) : null} {TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。 @@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode { }) .catch((e: unknown) => { log("memory.tidy.dryRun", "error"); - setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" }); + setState({ + kind: "error", + message: e instanceof Error ? e.message : "试算失败", + code: e instanceof ApiError ? e.code : undefined, + }); }); }; diff --git a/frontend/web/src/admin/modules/MemoryTidyTuningCard.render.test.ts b/frontend/web/src/admin/modules/MemoryTidyTuningCard.render.test.ts new file mode 100644 index 0000000..7a7b398 --- /dev/null +++ b/frontend/web/src/admin/modules/MemoryTidyTuningCard.render.test.ts @@ -0,0 +1,124 @@ +// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。 +// +// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts): +// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」); +// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」; +// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。 +// +// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \ +// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories"; +import { + MemoryTidyTuningBody, + TIDY_TUNING_COLD_LABEL, + TIDY_TUNING_DISCLAIMER, + TIDY_TUNING_HOT_LABEL, +} from "./MemoryTidyTuningCard"; +import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */ +function fixtureView(overrides: Partial = {}): AdminMemoryTidyTuningView { + return { + path: "config/memory-tidy.json", + present: true, + hot: false, + hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效", + specs: [ + { key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }, + { key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" }, + { key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }, + { key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }, + { key: "ingestGate", kind: "bool", fallback: "code-default" }, + { key: "autoApply", kind: "bool", fallback: "code-default" }, + { key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" }, + ], + values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true }, + envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 }, + ...overrides, + }; +} + +function renderBody(view: AdminMemoryTidyTuningView, draft: Record, errors: Record = {}, extra: Record = {}): string { + return renderToStaticMarkup( + createElement(MemoryTidyTuningBody, { + view, + draft, + errors, + busy: false, + receipt: null, + savedView: null, + onDraftChange: () => {}, + onSave: () => {}, + ...extra, + } as never) + ); +} + +const view = fixtureView(); +const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } }); + +// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ─────────────────────────────────── +for (const key of Object.keys(TUNING_LABELS)) { + ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`); + ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`); +} +ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」"); +ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点"); +ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)"); +ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来"); +ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec"); +ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认"); +ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)"); + +// ── ② 热生效与否:两种口径必须分得清 ─────────────────────────────────────── +ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」"); +ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值"); +ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值"); + +const coldHtml = renderBody(view, draftFromView(view), {}, { + receipt: "已保存:平时整理间隔。", + savedView: view, // hot: false +}); +ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」"); +ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」"); +ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因"); + +// ── ③ 坏值就地报错并拦住保存 ─────────────────────────────────────────────── +const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, { + intervalSec: "平时整理间隔 必须 ≥ 1 秒", +}); +ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案"); +ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)"); +ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用"); +ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径"); +ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)"); + +// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。 +const missing = fixtureView({ values: {} }); +const missingHtml = renderBody(missing, draftFromView(missing)); +ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」"); +ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)"); + +// eslint-disable-next-line no-console +console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/MemoryTidyTuningCard.tsx b/frontend/web/src/admin/modules/MemoryTidyTuningCard.tsx new file mode 100644 index 0000000..82974ef --- /dev/null +++ b/frontend/web/src/admin/modules/MemoryTidyTuningCard.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useState } from "react"; +import { + adminMemoryTidyTuning, + adminMemoryTidyTuningSave, + type AdminMemoryTidyTuningView, +} from "../../lib/api/adminMemories"; +import { Button, Surface, Tag } from "../../components/primitives"; +import { Notice } from "../components"; +import { useAuditStore } from "../audit"; +import type { TuningDraft } from "./memoryTidyTuning"; +import { + TUNING_LABELS, + buildTuningPatch, + draftFromView, + fallbackLabel, + patchSummary, + secondsHint, + unitLabel, +} from "./memoryTidyTuning"; + +/** + * 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源: + * internal/services/gateway/admin_memory_tuning.go。 + * + * 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件 + * 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时 + * 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。 + * + * 三条口径落在文案里,因为它们是这张卡的产品承诺: + * 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。 + * 每个键都写清「跟随谁」,不做含糊的「未设置」; + * 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿 + * **不发请求**(后端那道 400 仍在,只是不让主人白跑); + * 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成 + * 「已生效」。 + * + * 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件 + * 负责取数 + 表单草稿 + 保存。 + */ + +/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */ +export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底"; + +/** 热生效与「要重启」两种文案(测试分别钉住)。 */ +export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)"; +export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效"; + +/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */ +export interface TuningBodyProps { + view: AdminMemoryTidyTuningView; + draft: TuningDraft; + errors: Record; + busy: boolean; + /** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */ + receipt: string | null; + /** 保存后回显的真值(后端**重新读盘**的结果)。 */ + savedView: AdminMemoryTidyTuningView | null; + onDraftChange: (key: string, value: string) => void; + onSave: () => void; +} + +/** hotLabel 返回「这次到底生没生效」的诚实文案。 */ +export function hotLabel(view: AdminMemoryTidyTuningView): string { + return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL; +} + +/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */ +function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string { + const envValue = view.envValues?.[key]; + const base = fallbackLabel({ key, kind: "int", fallback }); + return envValue === undefined ? base : `${base}(当前 ${envValue})`; +} + +/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */ +function FieldRow({ + spec, + value, + error, + fallback, + valueHint, + onChange, +}: { + spec: AdminMemoryTidyTuningView["specs"][number]; + value: string; + error: string; + fallback: string; + valueHint: string; + onChange: (next: string) => void; +}): React.ReactNode { + const label = TUNING_LABELS[spec.key] ?? spec.key; + const inputCls = + "w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"; + return ( +
+
+ {label} + {spec.kind === "bool" ? ( + + ) : ( + onChange(e.target.value)} + /> + )} + {valueHint ? {valueHint} : null} + {spec.key} +
+
+ {value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`} + {spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""} +
+ {error ?
⚠ {error}
: null} +
+ ); +} + +/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */ +export function MemoryTidyTuningBody({ + view, + draft, + errors, + busy, + receipt, + savedView, + onDraftChange, + onSave, +}: TuningBodyProps): React.ReactNode { + const hasErrors = Object.keys(errors).length > 0; + return ( +
+
+ {TIDY_TUNING_DISCLAIMER} + {view.path} + + {view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"} + +
+ {view.specs.map((spec) => ( + onDraftChange(spec.key, next)} + /> + ))} +
+ + {hasErrors ? ( + 有坏值,未提交(后端一个字都不会改) + ) : null} +
+ {receipt ? {receipt} : null} + {savedView ? ( +
+ 保存后回显(从磁盘重新读回的真值): + {savedView.specs.map((spec) => { + const v = savedView.values[spec.key]; + return ( +
+ {spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)} +
+ ); + })} +
{hotLabel(savedView)}
+ {savedView.hot ? null : ( +
{savedView.hotError ?? "运行期未接线可热生效的实现"}
+ )} +
+ ) : null} +

+ 只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。 + 清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。 +

+
+ ); +} + +/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */ +export function MemoryTidyTuningCard(): React.ReactNode { + const [view, setView] = useState(null); + const [draft, setDraft] = useState({}); + const [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(false); + const [fail, setFail] = useState(null); + const [receipt, setReceipt] = useState(null); + const [savedView, setSavedView] = useState(null); + const log = useAuditStore((s) => s.log); + + const reload = useCallback(() => { + setFail(null); + return adminMemoryTidyTuning() + .then((next) => { + setView(next); + setDraft(draftFromView(next)); + }) + .catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败")); + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const onDraftChange = useCallback((key: string, value: string) => { + setDraft((prev) => ({ ...prev, [key]: value })); + setReceipt(null); + }, []); + + const onSave = useCallback(() => { + if (!view) return; + const check = buildTuningPatch(view, draft); + setErrors(check.errors); + if (Object.keys(check.errors).length > 0) return; + setBusy(true); + setFail(null); + adminMemoryTidyTuningSave(check.patch) + .then((next) => { + // 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。 + setView(next); + setDraft(draftFromView(next)); + setSavedView(next); + setErrors({}); + setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`); + log("memory.tidy.tuning", "ok"); + }) + .catch((e: unknown) => { + setFail(e instanceof Error ? e.message : "保存面板配置失败"); + log("memory.tidy.tuning", "error"); + }) + .finally(() => setBusy(false)); + }, [view, draft, log]); + + return ( + +
+

记忆整理调优(面板配置)

+ GET / PUT /api/v1/admin/memories/tidy/tuning +
+ {fail ? {fail} : null} + {view === null ? ( + {fail ? "读取失败" : "读取中…"} + ) : ( + + )} +
+ ); +} diff --git a/frontend/web/src/admin/modules/memoryTidyTuning.test.ts b/frontend/web/src/admin/modules/memoryTidyTuning.test.ts new file mode 100644 index 0000000..a1b32d5 --- /dev/null +++ b/frontend/web/src/admin/modules/memoryTidyTuning.test.ts @@ -0,0 +1,167 @@ +// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。 +// +// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 / +// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者 +// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里, +// 因此不需要渲染就能钉住。 +// +// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \ +// && node /tmp/cyrene-memory-tidy-tuning.test.cjs +// +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import type { + AdminMemoryTidyTuningSpec, + AdminMemoryTidyTuningView, +} from "../../lib/api/adminMemories"; +import { + buildTuningPatch, + draftFromView, + fallbackLabel, + fieldError, + patchSummary, + secondsHint, +} from "./memoryTidyTuning"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function eq(got: T, want: T, what: string): void { + checks += 1; + if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`); +} + +function deepEq(got: unknown, want: unknown, what: string): void { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + checks += 1; + if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`); +} + +/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */ +function spec(key: string, kind: string, extra: Partial = {}): AdminMemoryTidyTuningSpec { + return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec; +} + +/** view 造一份 GET 响应形状的 fixture。 */ +function view(values: Record, envValues?: Record): AdminMemoryTidyTuningView { + return { + path: "config/memory-tidy.json", + present: true, + hot: false, + hotError: "运行期没有接线可热生效的实现", + specs: [ + spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }), + spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }), + spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }), + spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }), + spec("ingestGate", "bool", { fallback: "code-default" }), + spec("autoApply", "bool", { fallback: "code-default" }), + spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }), + ], + values, + envValues, + }; +} + +// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。 +{ + const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false })); + eq(draft.intervalSec, "10800", "草稿回填盘上值"); + eq(draft.ingestGate, "false", "布尔键回填为字符串"); + eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)"); + eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)"); +} + +// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。 +{ + const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false }); + const draft = draftFromView(base); + const untouched = buildTuningPatch(base, draft); + deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)"); + deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误"); + + const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" }); + deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)"); + deepEq(changed.errors, {}, "合法值 ⇒ 无错误"); +} + +// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底); +// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。 +{ + const base = view({ intervalSec: 3600, tempTtlSec: 604800 }); + const draft = draftFromView(base); + const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" }); + deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)"); + const alreadyEmpty = view({ ingestGate: true }); + const mix = view({ ingestGate: true }); + const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" }); + deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)"); +} + +// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。 +{ + const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 }); + const draft = draftFromView(base); + const cases: Array<[string, string, string]> = [ + ["intervalSec", "abc", "非整数"], + ["intervalSec", "0", "低于下界(0 会让整理空转)"], + ["intervalSec", "-60", "负数"], + ["quietStartHour", "24", "钟点越上界"], + ["quietEndHour", "-1", "钟点越下界"], + ["tempTtlSec", "1.5", "小数不是整数"], + ]; + for (const [key, raw, why] of cases) { + const res = buildTuningPatch(base, { ...draft, [key]: raw }); + ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`); + ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`); + } + // 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。 + const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" }); + ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处"); + eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)"); + // 布尔键只认三个取值。 + eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错"); + eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法"); +} + +// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。 +{ + eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法"); + eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法"); + eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法"); +} + +// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。 +{ + eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })), + "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案"); + eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })), + "跟随面板里的 intervalSec", "面板内兜底文案"); + eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案"); + eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示"); + eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示"); + eq(secondsHint(0), "", "非正数不提示"); + eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案"); + eq(patchSummary({}), "没有需要改的键", "空补丁回执"); +} + +// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。 +{ + const base = view({ intervalSec: 3600 }); + const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " }); + deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍"); + const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" }); + ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数"); +} + +console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/memoryTidyTuning.ts b/frontend/web/src/admin/modules/memoryTidyTuning.ts new file mode 100644 index 0000000..1b92d64 --- /dev/null +++ b/frontend/web/src/admin/modules/memoryTidyTuning.ts @@ -0,0 +1,152 @@ +import type { + AdminMemoryTidyTuningPatch, + AdminMemoryTidyTuningSpec, + AdminMemoryTidyTuningView, +} from "../../lib/api/adminMemories"; + +/* ---------------------------------------------------------------------------- + * 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。 + * + * 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接 + * 用 node 跑断言(见 memoryTidyTuning.test.ts)。 + * + * 三条不可走样的口径: + * 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套 + * 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把 + * spec 翻成**草稿的校验规则**与中文文案。 + * 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本 + * 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果 + * 完全不同,因此这里必须分开。 + * 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等 + * 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。 + * -------------------------------------------------------------------------- */ + +/** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */ +export const TUNING_LABELS: Record = { + intervalSec: "平时整理间隔", + quietIntervalSec: "静默时段整理间隔", + quietStartHour: "静默时段开始(本地钟点)", + quietEndHour: "静默时段结束(本地钟点)", + ingestGate: "同步入库闸(写入时当场审查)", + autoApply: "轮末自动落地(高置信候选直接改记忆)", + tempTtlSec: "临时记忆存活时长", +}; + +/** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */ +export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string { + if (spec.fallback.startsWith("env:")) { + return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`; + } + if (spec.fallback.startsWith("panel:")) { + return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`; + } + return "跟随代码默认"; +} + +/** 单位的中文说明。 */ +export function unitLabel(spec: AdminMemoryTidyTuningSpec): string { + if (spec.unit === "sec") return "秒"; + if (spec.unit === "hour") return "本地钟点(0-23)"; + return ""; +} + +/** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */ +export function secondsHint(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return ""; + if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`; + if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`; + return `≈ ${seconds} 秒`; +} + +/** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */ +export type TuningDraft = Record; + +/** 草稿的校验结果。 */ +export interface TuningDraftCheck { + /** 键 → 就地错误文案(空对象 = 可以保存)。 */ + errors: Record; + /** 真正要提交的三态补丁(只含改动过的键)。 */ + patch: AdminMemoryTidyTuningPatch; +} + +/** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */ +export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft { + const draft: TuningDraft = {}; + for (const spec of view.specs) { + const value = view.values[spec.key]; + if (value === undefined || value === null) { + draft[spec.key] = ""; + continue; + } + draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value); + } + return draft; +} + +/** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */ +export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string { + const text = raw.trim(); + if (text === "") return ""; + if (spec.kind === "bool") { + return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`; + } + if (!/^-?\d+$/.test(text)) { + return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`; + } + const n = Number(text); + if (spec.min !== undefined && n < spec.min) { + return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`; + } + if (spec.max !== undefined && n > spec.max) { + return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`; + } + return ""; +} + +/** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */ +export function buildTuningPatch( + view: AdminMemoryTidyTuningView, + draft: TuningDraft +): TuningDraftCheck { + const errors: Record = {}; + const patch: AdminMemoryTidyTuningPatch = {}; + for (const spec of view.specs) { + const raw = draft[spec.key] ?? ""; + const err = fieldError(spec, raw); + if (err) { + errors[spec.key] = err; + continue; + } + const current = view.values[spec.key]; + const text = raw.trim(); + if (text === "") { + // 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。 + if (current === undefined || current === null) continue; + // 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。 + patch[spec.key] = null; + continue; + } + if (spec.kind === "bool") { + const next = text === "true"; + if (typeof current === "boolean" && current === next) continue; + patch[spec.key] = next; + continue; + } + const next = Number(text); + if (typeof current === "number" && current === next) continue; + patch[spec.key] = next; + } + return { errors, patch }; +} + +/** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */ +export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] { + return Object.keys(patch).sort(); +} + +/** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */ +export function patchSummary(patch: AdminMemoryTidyTuningPatch): string { + const keys = patchKeys(patch); + if (keys.length === 0) return "没有需要改的键"; + return keys.map((k) => TUNING_LABELS[k] ?? k).join("、"); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 4389556..65429ff 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -155,3 +155,12 @@ export { adminTidyCandidatesApplyBatch, adminTidyRun, } from "./api/adminMemoryTidyApply"; +export { + adminMemoryTidyTuning, + adminMemoryTidyTuningSave, +} from "./api/adminMemories"; +export type { + AdminMemoryTidyTuningPatch, + AdminMemoryTidyTuningSpec, + AdminMemoryTidyTuningView, +} from "./api/adminMemories"; diff --git a/frontend/web/src/lib/api/adminMemories.ts b/frontend/web/src/lib/api/adminMemories.ts new file mode 100644 index 0000000..929a03d --- /dev/null +++ b/frontend/web/src/lib/api/adminMemories.ts @@ -0,0 +1,75 @@ +import { request } from "./core"; + +/* ---------------------------------------------------------------------------- + * 记忆整理**面板调优**(切片 MEMPANEL / M4)。真源: + * internal/services/gateway/admin_memory_tuning.go(形状与错误码) + * internal/services/gateway/admin_memory_tuning_file.go(取值规则 specs) + * + * GET /admin/memories/tidy/tuning 读:盘上 7 键值 + 取值规则 + * PUT /admin/memories/tidy/tuning 写:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / + * 非法值 = 400 且盘上不动;`null` = 清掉该键) + * + * 两条都是 writeAuth(owner|admin)——与同族的 `/admin/memories` 同档:它是私有记忆面 + * 的运行期旋钮,不是给 viewer 的监控投影。 + * + * ⚠️ 失败走 `error.code`,**绝不**回一份「已保存」的假成功: + * - `invalid_payload` 400:值非法 / 未知键(**盘上一个字节都没动**) + * - `tuning_corrupt` 409:盘上那份配置文件读不走 ⇒ 拒绝覆盖(要人工修/删) + * - `tuning_unavailable` 500:数据目录未接线 + * - `tuning_write_failed` 500:落盘失败(盘上内容未改动) + * + * 响应里的 `hot` 是**如实**的:true = 保存后当场生效;false = 已落盘,但运行期没接到 + * 可热生效的实现(`hotError` 说明原因),此时界面必须写「重启后端后生效」。 + * -------------------------------------------------------------------------- */ + +/** 调的落点(面板配置文件的**相对**路径,形如 `config/memory-tidy.json`)。 */ +const TUNING_PATH = "/admin/memories/tidy/tuning"; + +/** 一个可写键的取值规则(后端下发,面板不另抄一份枚举/上下界)。 */ +export interface AdminMemoryTidyTuningSpec { + /** 键名,如 `intervalSec` / `ingestGate`。 */ + key: string; + /** `int` | `bool`(面板据此选控件类型)。 */ + kind: string; + /** `sec` | `hour`(仅 int 键;面板据此写单位)。 */ + unit?: string; + min?: number; + max?: number; + /** 缺位时生效值从哪来:`env:MEMORY_TIDY_*` / `panel:` / `code-default`。 */ + fallback: string; +} + +/** GET / PUT 的 data(PUT 的响应是**重新读盘**的结果,即真值回显)。 */ +export interface AdminMemoryTidyTuningView { + /** 面板配置文件相对 `$DATA_DIR` 的路径。 */ + path: string; + /** 文件是否已存在且有内容(false = 还没播种,后端下次启动会自动创建)。 */ + present: boolean; + /** 本次结果是否**当场生效**。false = 已落盘,要等后端重启(见 hotError)。 */ + hot: boolean; + /** `hot:false` 时的原因。 */ + hotError?: string; + specs: AdminMemoryTidyTuningSpec[]; + /** **盘上**的值:键缺位 ⇒ 该键不出现(= 跟随兜底),不是 0。 */ + values: Record; + /** 缺位时**下一档(`.env`)**的进程生效值(与 /admin/config-facts 同源)。 */ + envValues?: Record; +} + +/** 写补丁:只放**真的要改**的键;`null` = 清掉该键(回到下一档兜底)。 */ +export type AdminMemoryTidyTuningPatch = Record; + +/** GET /api/v1/admin/memories/tidy/tuning —— 读盘上的调优值 + 取值规则。 */ +export function adminMemoryTidyTuning(): Promise { + return request(TUNING_PATH); +} + +/** PUT /api/v1/admin/memories/tidy/tuning —— 三态补丁写盘(并尽量热生效)。 */ +export function adminMemoryTidyTuningSave( + patch: AdminMemoryTidyTuningPatch +): Promise { + return request(TUNING_PATH, { + method: "PUT", + body: JSON.stringify(patch), + }); +} diff --git a/internal/config/runtimeconfig/memorytidy_save.go b/internal/config/runtimeconfig/memorytidy_save.go new file mode 100644 index 0000000..1a5802d --- /dev/null +++ b/internal/config/runtimeconfig/memorytidy_save.go @@ -0,0 +1,150 @@ +// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。 +// +// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次 +// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板 +// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短 +// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径** +// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。 +// +// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy +// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。 +// +// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法): +// +// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份 +// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样 +// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。 +// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent +// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的 +// 手写排版不该因为一次空操作被重排。 +// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回 +// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体 +// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。 +// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、 +// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个 +// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。 +// +// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成 +// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生 +// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。 +package runtimeconfig + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + // memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。 + memoryTidySaveFileMode = 0o600 + // memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。 + memoryTidySaveDirMode = 0o755 +) + +// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进 +// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。 +// +// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API +// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉 +// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。 +// +// 返回 (tuning, wrote, err): +// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」); +// - wrote 报告这次是否真的写了字节(内容语义未变时为 false); +// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示 +// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。 +func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) { + doc, err := readMemoryTidyDocForPatch(path) + if err != nil { + return MemoryTidyTuning{}, false, err + } + before := canonicalMemoryTidyJSON(doc) + merged := make(map[string]json.RawMessage, len(doc)+len(set)) + for key, value := range doc { + merged[key] = value + } + for key, value := range set { + merged[key] = value + } + for _, key := range clear { + delete(merged, key) + } + raw, err := json.MarshalIndent(merged, "", " ") + if err != nil { + return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err) + } + wrote := false + if !bytes.Equal(raw, before) { + if err := writeMemoryTidyTuningFile(path, raw); err != nil { + return MemoryTidyTuning{}, false, err + } + wrote = true + } + tuning, err := LoadMemoryTidyTuning(path) + if err != nil { + // 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取 + // 路径读走),如实报错,绝不回一份「成功」。 + return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err) + } + return tuning, wrote, nil +} + +// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。 +// +// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误); +// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy +// (口径 3:拒绝覆盖)。 +func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]json.RawMessage{}, nil + } + return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err) + } + if strings.TrimSpace(string(raw)) == "" { + return map[string]json.RawMessage{}, nil + } + // 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事 + // 就是自相矛盾(写入方会据此把一份坏配置盖掉)。 + if _, err := decodeMemoryTidy(raw, path); err != nil { + return nil, err + } + doc := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err) + } + return doc, nil +} + +// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径), +// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等 +// (= 走正常落盘路径),不会误判成「没变」。 +func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte { + raw, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil + } + return raw +} + +// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。 +func writeMemoryTidyTuningFile(path string, raw []byte) error { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil { + return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err) + } + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil { + return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("runtimeconfig: rename %s: %w", path, err) + } + return nil +} diff --git a/internal/runtime/memory/preinsert_store.go b/internal/runtime/memory/preinsert_store.go index 46e72c6..45b8fac 100644 --- a/internal/runtime/memory/preinsert_store.go +++ b/internal/runtime/memory/preinsert_store.go @@ -8,6 +8,8 @@ import ( "fmt" "strings" "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 本文件是入库前审查(design/23 §15.1 D3)的存储层:候选读取、软合并落定、 @@ -89,6 +91,24 @@ func (s *Store) ingestReviewer() *PreInsertReviewer { return s.review } +// ApplyIngestPanelTuning 把面板值**当场**叠到已接入的入库前审查上(切片 MEMPANEL / M4)。 +// +// 网关握着的是记忆存储(`deps.Memories`),而不是审查服务本身,因此这条转调就是面板 +// 写端点触达审查侧的**唯一**通道(不为此新开依赖面:没有新的 Deps 字段、没有新的 +// Server 字段)。 +// +// 未接入审查(`MEMORY_REVIEW_ENABLED=false`)时是**空操作 + nil**:那时两个键本来 +// 就不生效,运行期没有任何旧值要改,返回错误只会让一次完全正常的保存报「热生效失败」。 +// 指针的读写走既有的 `reviewMu`(与 AttachIngestReviewer 同一把锁),不会与并发接入 +// 交手。 +func (s *Store) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error { + reviewer := s.ingestReviewer() + if reviewer == nil { + return nil + } + return reviewer.ApplyPanelTuning(panel) +} + // enqueueReview 把刚落库的条目投入审查队列。非阻塞:队列满时审查方自己记 // WARN(它才是唯一知道队列状态的一侧),写入方绝不因此阻塞或失败。 func (s *Store) enqueueReview(item MemoryItem) { diff --git a/internal/runtime/memory/preinsert_tuning.go b/internal/runtime/memory/preinsert_tuning.go index 316f486..a22c405 100644 --- a/internal/runtime/memory/preinsert_tuning.go +++ b/internal/runtime/memory/preinsert_tuning.go @@ -68,3 +68,33 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int { } return *v } + +// ApplyPanelTuning 把面板值**当场**叠到已经在跑的审查服务上(切片 MEMPANEL / M4)。 +// +// 它只碰面板管得到、且**在 `.env` 里结构性没有出处**的两个键(`ingestGate` / +// `tempTtlSec`)—— 因此重算起点就是这两个字段的**零值**(中档为空),不需要一份 +// 「`.env` 快照」,也不会因为重算而误伤别的键:`Enabled` / `QueueSize` / `ScanLimit` / +// `Timeout` / `Threshold` / T1 四件套一律**原样不动**(它们来自部署配置,不是面板旋钮; +// 面板端点也拒收它们)。 +// +// 口径与装配期逐字一致(`wireMemoryReview` → `NewPreInsertReviewer`):面板写了就用, +// 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码 +// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。 +// +// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` / +// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个 +// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长 +// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成 +// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。 +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 +} diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go new file mode 100644 index 0000000..97bcb8d --- /dev/null +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -0,0 +1,143 @@ +package memory + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件是**整理自动档的运行期调优面**(切片 MEMPANEL / M4):把 +// `$DATA_DIR/config/memory-tidy.json` 的面板旋钮**当场**叠到已经在跑的 TidyService 上, +// 使主人点一次「保存」就生效,而不必重启后端。 +// +// 为什么需要它(主人 2026-10-07 两条口径,逐字): +// +// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走 +// 面板配置文件。这些键优先从面板配置上取」 +// 「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到 +// 了静默期稍微长一点」 +// +// 在这之前,面板文件只在**装配期**被读一次(`wireMemoryTidy` → `NewTidyService`), +// 四个调度旋钮与 autoApply 被**拷进**服务的 `cfg` 字段后就不再变 —— 面板改完文件, +// 跑着的进程一个字节都不会变。 +// +// 三条不可走样的口径: +// +// 1. **重算而不是叠加**:`base` 是 `.env` 层(由组合根用 `TidyConfigFromConfig` +// 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` > +// 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到 +// `.env` 档,而不是留在上一次的面板值上。 +// 2. **心跳要重排**:心跳正睡在「用**旧**周期算出来」的那一觉里(`nextDelay` 只在 +// 每一拍之前现算),最长可到旧周期。不重排的话,主人把周期从 24 小时改成 3 小时 +// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop + +// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是 +// 已经到点」,于是缩短周期往往**当场**补跑一轮)。 +// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次 +// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。 +// +// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound` +// 与手动轮 / 试算 `DryRun` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu → +// Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个 +// 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后 +// 已经全部结束,因此也不与写入交手。 + +// memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。 +// +// 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了 +// 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期), +// 绝不静默假装重排成功。 +const memoryTidyRescheduleTimeout = 5 * time.Second + +// ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于 +// 下一次调度判定,只是当前那次等待没有被作废)。 +var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out") + +// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 +// +// 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是 +// 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。 +func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error { + next := tidyPanelTuning(base, panel, s.log) + + // 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意 + // 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop + // 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。 + s.runMu.Lock() + defer s.runMu.Unlock() + + s.mu.Lock() + unchanged := s.cfg == next + s.mu.Unlock() + + if unchanged { + // 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。 + s.applyTidyConfigLocked(next) + return nil + } + + reschedErr := s.rescheduleTidyHeartbeat() + s.applyTidyConfigLocked(next) + return reschedErr +} + +// tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。 +// +// 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 → +// ApplyTidyScheduleTuning → normalizeTidyConfig),只是可以随时再跑一次。刻意做成 +// 一个函数而不是把两处各写一遍逻辑,是为了让「优先级链」只有一份可读的写法: +// 将来把组合根那一段改成调用它,两处就彻底合一(组合根那个文件不在本切片 scope 内, +// 因此这里先留一个等价的实现,并已在回报里登记)。 +func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig { + cfg := base + cfg.DisableAutoApply = panel.AutoApplyOff() + if panel.AutoApplyPerRound > 0 { + cfg.AutoApplyPerRound = panel.AutoApplyPerRound + } + cfg = ApplyTidyScheduleTuning(cfg, panel, log) + return normalizeTidyConfig(cfg) +} + +// applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让 +// 心跳停下): +// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源; +// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。 +// +// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。 +func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) { + s.mu.Lock() + s.cfg = cfg + s.mu.Unlock() + s.tidier.cfg = cfg +} + +// rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。 +// +// 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经 +// 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默 +// 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。 +// +// 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在 +// tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop` +// 收尾(见 wire_services.go),因此不依赖父 ctx 取消。 +func (s *TidyService) rescheduleTidyHeartbeat() error { + s.mu.Lock() + running := s.running + s.mu.Unlock() + if !running { + return nil + } + stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout) + defer cancel() + if err := s.Stop(stopCtx); err != nil { + s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out", + "timeout", memoryTidyRescheduleTimeout.String(), "error", err) + s.Start(context.Background()) + return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err) + } + s.Start(context.Background()) + return nil +} diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go new file mode 100644 index 0000000..19a8822 --- /dev/null +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -0,0 +1,213 @@ +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 的转调没有落到审查服务上") + } +} diff --git a/internal/services/gateway/admin_memory_tuning.go b/internal/services/gateway/admin_memory_tuning.go new file mode 100644 index 0000000..2a3160f --- /dev/null +++ b/internal/services/gateway/admin_memory_tuning.go @@ -0,0 +1,331 @@ +package gateway + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" +) + +// 本文件承载记忆整理「面板调优」的**管理读写端点**(契约待塔登记,见回报): +// +// GET /api/v1/admin/memories/tidy/tuning 读:盘上的 7 键值 + 取值规则 +// PUT /api/v1/admin/memories/tidy/tuning 写:三态补丁 + 原子落盘 + 热生效 +// +// 它回答的是主人的两句原话(逐字,见 M4 mission Context): +// +// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走 +// 面板配置文件。这些键优先从面板配置上取」 +// 「频率必须调 可以调 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到了 +// 静默期稍微长一点」 +// +// 此前 `$DATA_DIR/config/memory-tidy.json` **只有读路径**(`cmd/cyrene/wire_memory_tidy.go` +// 在装配期读一次 + 启动播种):主人要改频率只能 SSH 改文件 + 重启后端。本切片把 +// 「读回来 → 改 → 落盘」这条面板路径补上,**零新 env 键**(`.env` 已冻结, +// design/13 §2.1)、**不新开文件家族**。 +// +// 四条不可走样的口径: +// +// 1. **三态**(硬要求,与契约里 `plugin.saveProvider` 同族语义):键**缺位** ⇒ 不动; +// **合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**。 +// `null` 是这一族的清除哨兵(= 删掉该键、回到下一档兜底)。三态的实现与 +// 「非法值不落盘」的证据在 `admin_memory_tuning_file.go` 的 +// `parseMemoryTidyTuningPatch`(解析阶段就拒,落盘只在解析成功之后发生)。 +// 2. **写端点带审计**(动作 `memory.tidy.tuning`),且**不改** `.env`、不重启后端。 +// 3. **热生效**:保存后把新值当场叠到运行期(可选能力,见 MemoryTidyTuningApplier)。 +// 能力不存在或失败时,响应 `hot:false` + `hotError`,面板**如实**说「已落盘、 +// 要重启后端」——绝不把「落盘了」说成「生效了」。 +// 4. **诚实回话**:写成功后的响应是**重新读盘**的结果(真值回显),不是「我以为 +// 写成了什么」;盘上文件解不开时读/写**都**拒绝(`409 tuning_corrupt`)并说明 +// 运行期此刻正回落 `.env`/代码默认。 +// +// 权限:GET 与 PUT 都走 **writeAuth(owner|admin)**(与本族其它记忆端点 +// `/admin/memories`、`POST /admin/memories/{id}/revert` 同档)——它是私有记忆面的 +// 运行期旋钮,不是给 viewer 的监控投影。 + +// memoryTuningLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。 +const memoryTuningLogSource = "gateway/memory-tuning" + +// memoryTuningAuditAction 是写端点落审计的动作名(与 memory.tidy.apply / +// memory.tidy.reject 同一族命名)。 +const memoryTuningAuditAction = "memory.tidy.tuning" + +// memoryTuningMaxBodyBytes 是写端点请求体的墙钟上限。这个文件是给人改的小配置 +// (11 个标量键),正常请求体不到 200 字节;给上限只为让畸形/超大请求不占内存, +// 不是业务上限。 +const memoryTuningMaxBodyBytes = 16 << 10 + +// MemoryTidyTuningApplier 是「把面板值当场叠到**整理自动档**」的可选能力。 +// +// 它**不是**必需依赖:组合根注入的整理服务(`deps.MemoryTidy` = `*memory.TidyService`) +// 实现了它 ⇒ 保存后立刻生效;没实现 ⇒ 值照旧落盘、但要等后端重启,响应里 +// `hot:false` 如实说出来(口径 3)。 +// +// base 是**`.env` 层**(由 `memory.TidyConfigFromConfig` 从进程生效配置投影而来, +// 与 wireMemoryTidy 用的是同一个唯一实现点);panel 是刚落盘的那一份。实现必须按 +// 「面板 > `.env` > 代码默认」逐键重算,且**缺位键保持 base 的值** —— 也就是 +// wireMemoryTidy 当初那一遍的同一口径,只是可以随时再跑一次。 +type MemoryTidyTuningApplier interface { + ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error +} + +// MemoryIngestTuningApplier 同理,服务于**同步入库闸 / 入库前审查**一侧 +// (`ingestGate` / `tempTtlSec`)——它的持有者是记忆存储,因此由 `deps.Memories` +// 那个出口解析(网关不为此新开依赖面)。 +type MemoryIngestTuningApplier interface { + ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error +} + +// AdminMemoryTidyTuningView 是 GET / PUT `/admin/memories/tidy/tuning` 的 data。 +type AdminMemoryTidyTuningView struct { + // Path 是面板配置文件的落点(**相对 `$DATA_DIR`**,如 `config/memory-tidy.json`): + // 面板据此如实告诉主人改的是哪个文件,而不必把部署绝对路径下发。 + Path string `json:"path"` + // Present 报告那份文件是否已存在且有内容。false = 还没播种(后端**下次启动** + // 会自动按当时生效值创建它,见 runtimeconfig.SeedMemoryTidy)。 + Present bool `json:"present"` + // Hot 报告这次结果是否**当场生效**(true)。false = 已落盘,但要等后端重启 —— + // 面板必须照实说,不得显示成「已生效」。GET 里恒为 false:只读不改变任何东西, + // 「生效」这个问题在只读路径上没有意义。 + Hot bool `json:"hot"` + // HotError 在「已保存、但没能在运行期立刻生效」时给出原因(`hot:false` 时才有)。 + HotError string `json:"hotError,omitempty"` + // Specs 是这 7 个键的**取值规则**(类型/单位/上下界/兜底来源),随读下发 ⇒ + // 面板的表单与就地校验都由它驱动,不会出现「面板允许、后端 400」的两套规则。 + Specs []memoryTidyTuningSpec `json:"specs"` + // Values 是**盘上**的值:键缺位 ⇒ 整个键不出现(= 跟随兜底),不编一个 0 —— + // 「写了 0」(坏值,运行期会 WARN 回落)与「没写」在面板上必须看得出区别。 + Values map[string]any `json:"values"` + // EnvValues 是缺位时**下一档(`.env`)**的进程生效值(只读下发):面板据此写 + // 「留空 ⇒ 跟随 `.env` 的 86400」,而不必自己猜一个数。与 + // GET /admin/config-facts 同源(都取 `s.cfg`,网关绝不直接 fopen `.env`)。 + // 某个键**连 `.env` 档也没有值**(未填 / 结构性无此键)时它不出现。 + EnvValues map[string]int `json:"envValues,omitempty"` +} + +// adminMemoryTidyTuningRead 是 GET 的处理函数:只读、不审计(与 +// /admin/config-facts、tidy 试算同一档:读事实不改状态)。 +func (s *Server) adminMemoryTidyTuningRead(w http.ResponseWriter, _ *http.Request) { + path, ok := s.memoryTidyTuningPath() + if !ok { + writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定")) + return + } + view, err := s.buildMemoryTidyTuningView(path) + if err != nil { + s.writeTuningCorrupt(w, err) + return + } + writeJSON(w, http.StatusOK, view) +} + +// adminMemoryTidyTuningWrite 是 PUT 的处理函数:三态补丁 → 原子落盘 → 热生效 → +// 审计。顺序是刻意的:**校验全部发生在落盘之前**(口径 1),落盘之后才谈生效。 +func (s *Server) adminMemoryTidyTuningWrite(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, memoryTuningMaxBodyBytes)) + if err != nil { + s.auditClaims(r, memoryTuningAuditAction, "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败")) + return + } + patch, err := parseMemoryTidyTuningPatch(body) + if err != nil { + // 非法值/未知键:**盘上一个字节都不动**(解析阶段就退出,写盘在下面)。 + s.auditClaims(r, memoryTuningAuditAction, "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err)) + return + } + path, ok := s.memoryTidyTuningPath() + if !ok { + s.auditClaims(r, memoryTuningAuditAction, "error") + writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定")) + return + } + // 落盘(补丁 + 原子写)交给 runtimeconfig 家族:它同时是「盘上那份能不能被 + // 运行期读走」的**唯一**判定点(读不走的文件拒绝覆盖,见 ErrInvalidMemoryTidy)。 + if !patch.empty() { + if err := s.commitMemoryTidyTuningPatch(path, patch); err != nil { + s.auditClaims(r, memoryTuningAuditAction, "error") + s.writeTuningWriteFailure(w, err) + return + } + } + view, err := s.buildMemoryTidyTuningView(path) + if err != nil { + // 刚落盘就解不开:这是本端点的编程/磁盘错误,如实回 500,绝不回一个 + // 「已保存」的假成功。 + s.auditClaims(r, memoryTuningAuditAction, "error") + s.writeTuningCorrupt(w, err) + return + } + hot, hotErr := s.applyMemoryTidyTuningHot(path) + view.Hot = hot + if hotErr != nil { + view.HotError = hotErr.Error() + } + s.auditClaims(r, memoryTuningAuditAction, "ok") + writeJSON(w, http.StatusOK, view) +} + +// commitMemoryTidyTuningPatch 把补丁交给 runtimeconfig 家族原子落盘。值一个都没变 +// 时不落盘(「保存同样的值」不该在盘上留一次无意义的改写)。 +func (s *Server) commitMemoryTidyTuningPatch(path string, patch memoryTidyTuningPatch) error { + _, wrote, err := runtimeconfig.PatchMemoryTidyTuning(path, patch.set, patch.clear) + if err != nil { + return err + } + log := logger.WithSource(s.log, memoryTuningLogSource) + if !wrote { + log.Info("memory tidy tuning unchanged (no bytes written)", "path", path, "keys", patch.changed) + return nil + } + log.Info("memory tidy tuning saved", "path", path, "keys", patch.changed) + return nil +} + +// buildMemoryTidyTuningView 读盘并组装面板视图(口径 4:响应就是**盘上真值**)。 +func (s *Server) buildMemoryTidyTuningView(path string) (AdminMemoryTidyTuningView, error) { + tuning, err := runtimeconfig.LoadMemoryTidyTuning(path) + if err != nil { + return AdminMemoryTidyTuningView{}, err + } + present, err := memoryTidyTuningPresent(path) + if err != nil { + return AdminMemoryTidyTuningView{}, err + } + return AdminMemoryTidyTuningView{ + Path: runtimeconfig.MemoryTidyConfigRelPath, + Present: present, + Specs: memoryTidyTuningSpecs(), + Values: memoryTidyTuningValues(tuning), + EnvValues: s.memoryTidyTuningEnvValues(), + }, nil +} + +// memoryTidyTuningPresent 报告面板配置文件是否已存在且**有内容**(全空白 = 还没配过, +// 与 runtimeconfig.LoadMemoryTidyTuning 的口径一致)。 +func memoryTidyTuningPresent(path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("读取 %s:%w", path, err) + } + return len(strings.TrimSpace(string(raw))) > 0, nil +} + +// memoryTidyTuningEnvValues 返回缺位时的**下一档(`.env`)**进程生效值。 +// +// 只取 `s.cfg`(配置层已消化「进程环境 → `.env` → 声明默认」):网关**绝不** +// 直接 fopen `.env`,否则面板看到的是文件原文而不是运行实例在用的数。 +// +// 三处刻意省略(省略 = 「这一档也没值,继续落到代码默认」,而不是编一个 0): +// - `MEMORY_TIDY_INTERVAL_SEC` ≤ 0:非正数是坏值(运行期会 WARN 并回落), +// 报出来会让人以为「留空就得到 0 秒」; +// - 安静时段两端**都为 0**:0/0 是「未填」,运行期会回落到设计默认窗口, +// 此时报 0/0 等于把「未填」谎报成一个午夜窗口。 +// +// 其余四个键(`quietIntervalSec` / `ingestGate` / `autoApply` / `tempTtlSec`)在 +// `.env` 里**结构性没有出处**(`.env` 已冻结,design/13 §2.1),因此不存在第二档, +// 它们的下一档就是代码默认(specs 里的 fallback 已说明)。 +func (s *Server) memoryTidyTuningEnvValues() map[string]int { + out := map[string]int{} + if s.cfg == nil { + return out + } + if v := s.cfg.MemoryTidy.IntervalSec; v > 0 { + out["intervalSec"] = v + } + if start, end := s.cfg.MemoryTidy.QuietStartHour, s.cfg.MemoryTidy.QuietEndHour; start != 0 || end != 0 { + out["quietStartHour"] = start + out["quietEndHour"] = end + } + return out +} + +// memoryTidyTuningPath 解析面板配置文件的绝对落点(`$DATA_DIR/config/memory-tidy.json`)。 +// +// 数据目录的来源与图片上传端点同一口径:优先已解析的 `deps.DataDir`,为空时回退 +// `cfg.DataDir`;两者都空(未接线)时报 false,调用方如实回 `tuning_unavailable`, +// 绝不猜一个目录去写。 +func (s *Server) memoryTidyTuningPath() (string, bool) { + dataDir := s.dataDir + if dataDir == "" && s.cfg != nil { + dataDir = s.cfg.DataDir + } + if dataDir == "" { + return "", false + } + return runtimeconfig.DefaultMemoryTidyPath(dataDir), true +} + +// applyMemoryTidyTuningHot 把刚落盘的面板值当场叠到运行期(口径 3)。 +// +// 返回 (hot, err):hot=false 表示这次**没能在运行期生效**(能力未接线,或叠的过程 +// 报错),err 说明原因。两者只用于如实回话,**不影响**「已经落盘」这个事实 —— +// 值不会因为热生效失败而丢。 +// +// 能力用类型断言探测(可选能力,与 `plugin` 侧的可选接口同一手法):网关**不为此** +// 新增 Deps 字段/Server 字段 —— 整理服务本来就在 `s.memoryTidy` 里,审查器挂在 +// `deps.Memories` 的实现上。这样「有没有热生效」是一件可观测的事实,而不是一句 +// 靠不住的断言。 +func (s *Server) applyMemoryTidyTuningHot(path string) (hot bool, hotErr error) { + tuning, err := runtimeconfig.LoadMemoryTidyTuning(path) + if err != nil { + return false, fmt.Errorf("重读面板配置失败:%v", err) + } + applied := false + if applier, ok := s.memoryTidy.(MemoryTidyTuningApplier); ok { + base := memory.TidyConfig{} + if s.cfg != nil { + base = memory.TidyConfigFromConfig(s.cfg.MemoryTidy) + } + if err := applier.ApplyPanelTuning(base, tuning); err != nil { + return false, fmt.Errorf("整理自动档未能热生效:%v", err) + } + applied = true + } + if applier, ok := s.memories.(MemoryIngestTuningApplier); ok { + if err := applier.ApplyIngestPanelTuning(tuning); err != nil { + return false, fmt.Errorf("入库审查未能热生效:%v", err) + } + applied = true + } + if !applied { + return false, fmt.Errorf("运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效") + } + return true, nil +} + +// writeTuningCorrupt 把「盘上那份读不走」映射成 409 tuning_corrupt,并把**运行期的 +// 实际后果**写进文案:此刻它正整体回落 `.env`/代码默认(见 runtimeconfig 的读取口径), +// 而面板**拒绝**在这个文件上覆盖 —— 坏文件请人工修复或删除。 +func (s *Server) writeTuningCorrupt(w http.ResponseWriter, cause error) { + logger.WithSource(s.log, memoryTuningLogSource).Warn( + "memory tidy tuning file unusable", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause) + writeError(w, apperr.Wrap(apperr.ErrConflict, "tuning_corrupt", + "面板配置文件 %s 解不开(运行期正回落 .env / 代码默认);面板拒绝在一个读不走的文件上覆盖,请人工修复或删除它:%v", + runtimeconfig.MemoryTidyConfigRelPath, cause)) +} + +// writeTuningWriteFailure 把落盘失败如实分类:**盘上那份读不走**(ErrInvalidMemoryTidy) +// 是 409(拒绝覆盖,不是故障),其余(IO/权限/重读不一致)是 500。两种都带出处, +// 绝不回一个「已保存」的假成功。 +func (s *Server) writeTuningWriteFailure(w http.ResponseWriter, cause error) { + if errors.Is(cause, runtimeconfig.ErrInvalidMemoryTidy) { + s.writeTuningCorrupt(w, cause) + return + } + logger.WithSource(s.log, memoryTuningLogSource).Error( + "memory tidy tuning write failed", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause) + writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_write_failed", + "面板配置文件 %s 落盘失败(盘上内容未改动):%v", runtimeconfig.MemoryTidyConfigRelPath, cause)) +} diff --git a/internal/services/gateway/admin_memory_tuning_file.go b/internal/services/gateway/admin_memory_tuning_file.go new file mode 100644 index 0000000..ade22a0 --- /dev/null +++ b/internal/services/gateway/admin_memory_tuning_file.go @@ -0,0 +1,275 @@ +package gateway + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件是记忆整理「面板调优」的**键规格与三态校验**层(文件读写与原子落盘在 +// `internal/config/runtimeconfig` 家族里,见 memorytidy_save.go —— 落点是那一族的 +// 职责,网关只负责 API 契约这一侧的取值规则)。 +// +// 它管的是 `$DATA_DIR/config/memory-tidy.json` 里主人能调的 7 个旋钮(design/13 §2.1: +// 调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码默认值,不进 `.env`)。 +// +// 三态与清除哨兵(与契约里 `plugin.saveProvider` 同族语义): +// - 键**没出现** ⇒ 不动它(`changed` 里也不出现); +// - 值为 `null` ⇒ 从面板文件里**删掉**这个键(回到下一档兜底:`.env` → 代码默认); +// - 值合法 ⇒ 采用; +// - 值非法(类型不对 / 超界 / 不是 JSON 对象 / 有不认识的键)⇒ 一律**在解析阶段** +// 被拒(调用方回 400 且盘上一个字节都不动),**绝不静默忽略** —— 静默忽略会让 +// 主人以为改了而实际没改(把 `quietEndHour` 拼成 `quietEnd` 就正是这种形态)。 + +const ( + // tuningKindInt / tuningKindBool 是本切片管得到的两种键型。 + tuningKindInt = "int" + tuningKindBool = "bool" + // tuningUnitSec / tuningUnitHour 是 int 键的单位(面板据此写「秒 / 点」)。 + tuningUnitSec = "sec" + tuningUnitHour = "hour" + // tuningSecMin 是三个秒键的下界:**必须为正**(≤0 的周期不是「没配」,而是一个 + // 会让整理退化成空转的值;运行期口径同样把非正数当坏值回落,见 + // memory.ApplyTidyScheduleTuning 与 runtimeconfig.MemoryTidyTuning 的注释)。 + tuningSecMin = 1 + // tuningHourMin / tuningHourMax 是钟点键的取值域(本地钟点 0..23)。 + tuningHourMin = 0 + tuningHourMax = 23 + + // tuningCodeDefault 是「缺位时落到代码默认」这一类兜底来源的标识(面板文案用)。 + // 它**只说来源、不带数值**:数值的唯一真源在 memory 侧(normalizeTidyConfig / + // normalizeReviewConfig),网关不另抄一份默认值 —— 抄一份就是第二个真源。 + tuningCodeDefault = "code-default" +) + +// memoryTidyTuningSpec 是一个可写键的**取值规则**(唯一真源)。 +// +// 它既驱动写侧校验,也随 GET 下发给面板(`specs`):面板据此渲染表单与就地报错, +// 因此不会出现「面板允许、后端 400」的两套规则(与 /admin/llm 下发 +// reasoningKinds / personaProviderOptions 同一手法)。 +type memoryTidyTuningSpec struct { + Key string `json:"key"` + // Kind ∈ int | bool;面板据此选输入控件类型。 + Kind string `json:"kind"` + // Unit ∈ sec | hour(仅 int 键):面板据此写单位,不自己猜。 + Unit string `json:"unit,omitempty"` + // Min / Max 是闭区间上下界(缺省 = 该端无约束)。 + Min *int `json:"min,omitempty"` + Max *int `json:"max,omitempty"` + // Fallback 报告这个键**缺位(或面板 `null` 清除)时**生效值从哪来: + // - `env:MEMORY_TIDY_*` ⇒ 回落 `.env` 的同名键(进程生效值见 + // GET /admin/config-facts 的同名事实行); + // - `panel:` ⇒ 回落面板文件里另一个键; + // - `code-default` ⇒ 回落代码默认。 + // 它只给**来源**,不给数值:数值的真源在各自那一档(见 tuningCodeDefault)。 + Fallback string `json:"fallback"` +} + +// memoryTidyTuningSpecs 是面板可写 7 键的**唯一清单**(顺序 = 面板显示顺序)。 +// +// 为什么是这 7 个:主人要能调**整理频率**(白天短、静默期长)与**入库审查的两个 +// 开关/时长**,而 `.env` 已冻结(design/13 §2.1)⇒ 这些旋钮只能落面板配置文件, +// 面板必须能**写**它。 +// +// 刻意**不在**这里的两类键: +// - `t1Judge` / `t1PerWindow` / `t1WindowSec` / `autoApplyPerRound`:同文件的其它 +// 旋钮,本切片的面板卡片不呈现,写端点也不接受它们(未知键一律 400,免得主人 +// 以为改了、其实没动)。它们仍**原样保留**在文件里(补丁语义)。 +func memoryTidyTuningSpecs() []memoryTidyTuningSpec { + secMin := tuningSecMin + hourMin := tuningHourMin + hourMax := tuningHourMax + return []memoryTidyTuningSpec{ + { + Key: "intervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, + Fallback: "env:MEMORY_TIDY_INTERVAL_SEC", + }, + { + Key: "quietIntervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, + Fallback: "panel:intervalSec", + }, + { + Key: "quietStartHour", Kind: tuningKindInt, Unit: tuningUnitHour, + Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_START_HOUR", + }, + { + Key: "quietEndHour", Kind: tuningKindInt, Unit: tuningUnitHour, + Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_END_HOUR", + }, + {Key: "ingestGate", Kind: tuningKindBool, Fallback: tuningCodeDefault}, + {Key: "autoApply", Kind: tuningKindBool, Fallback: tuningCodeDefault}, + {Key: "tempTtlSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: tuningCodeDefault}, + } +} + +// memoryTidyTuningManagedKeys 返回可写键的键名(specs 顺序),供错误文案与审计用。 +func memoryTidyTuningManagedKeys() []string { + specs := memoryTidyTuningSpecs() + out := make([]string, 0, len(specs)) + for _, s := range specs { + out = append(out, s.Key) + } + return out +} + +// memoryTidyTuningPatch 是一次写请求**已校验**的补丁。 +// +// set / clear 是交给 runtimeconfig.PatchMemoryTidyTuning 的动作(clear = 删掉该键 +// ⇒ 回到下一档兜底);changed 是本次真正出现的键(specs 顺序,含被清除的),空表示 +// 一个键都没提交(此时**不写盘**,文件字节保持不变)。 +type memoryTidyTuningPatch struct { + set map[string]json.RawMessage + clear []string + changed []string +} + +// empty 报告这次请求没有提交任何受管的键。 +func (p memoryTidyTuningPatch) empty() bool { return len(p.changed) == 0 } + +// parseMemoryTidyTuningPatch 把请求体解析成一次已校验的补丁(三态的唯一实现点)。 +// +// 未知键也归入「非法」:本端点**不静默忽略**任何提交上来的键 —— 静默忽略会让主人 +// 以为改了而实际没改。 +func parseMemoryTidyTuningPatch(body []byte) (memoryTidyTuningPatch, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return memoryTidyTuningPatch{}, fmt.Errorf("请求体必须是一个 JSON 对象:%w", err) + } + if raw == nil { + // JSON 的 `null` 解到 map 上是 nil 且**不报错**:它不是一个「提交了 0 个键」 + // 的请求,而是一个形状不对的请求,如实拒掉(空补丁请用 `{}`)。 + return memoryTidyTuningPatch{}, errors.New("请求体必须是一个 JSON 对象(收到 null)") + } + specs := memoryTidyTuningSpecs() + known := make(map[string]memoryTidyTuningSpec, len(specs)) + for _, s := range specs { + known[s.Key] = s + } + if err := rejectUnknownTuningKeys(raw, known); err != nil { + return memoryTidyTuningPatch{}, err + } + patch := memoryTidyTuningPatch{set: map[string]json.RawMessage{}} + for _, s := range specs { + value, ok := raw[s.Key] + if !ok { + continue + } + patch.changed = append(patch.changed, s.Key) + if isJSONNull(value) { + patch.clear = append(patch.clear, s.Key) + continue + } + if err := validateTuningValue(s, value); err != nil { + return memoryTidyTuningPatch{}, err + } + patch.set[s.Key] = value + } + return patch, nil +} + +// rejectUnknownTuningKeys 拒绝请求体里任何不受管的键(含拼错的键)。 +func rejectUnknownTuningKeys(raw map[string]json.RawMessage, known map[string]memoryTidyTuningSpec) error { + unknown := make([]string, 0) + for k := range raw { + if _, ok := known[k]; !ok { + unknown = append(unknown, k) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return fmt.Errorf("不认识的键:%s(本端点只写 %s;同文件其它旋钮请人工编辑)", + strings.Join(unknown, "、"), strings.Join(memoryTidyTuningManagedKeys(), "、")) +} + +// validateTuningValue 校验一个键的值(类型 + 取值域)。错误文案直接回给面板显示, +// 因此带上**收到的原始值**,让主人一眼看出该改什么。 +func validateTuningValue(spec memoryTidyTuningSpec, raw json.RawMessage) error { + switch spec.Kind { + case tuningKindBool: + var b bool + if err := json.Unmarshal(raw, &b); err != nil { + return fmt.Errorf("%s 必须是 true 或 false(收到 %s)", spec.Key, clipJSON(raw)) + } + return nil + case tuningKindInt: + var n int + if err := json.Unmarshal(raw, &n); err != nil { + return fmt.Errorf("%s 必须是整数(收到 %s)", spec.Key, clipJSON(raw)) + } + if spec.Min != nil && n < *spec.Min { + return fmt.Errorf("%s 必须 ≥ %d(%s),收到 %d", spec.Key, *spec.Min, tuningUnitLabel(spec), n) + } + if spec.Max != nil && n > *spec.Max { + return fmt.Errorf("%s 必须 ≤ %d(%s),收到 %d", spec.Key, *spec.Max, tuningUnitLabel(spec), n) + } + return nil + default: + // 规格表自己写错了型别(编程错误,不是客户端错):如实报出来,不猜一种校验。 + return fmt.Errorf("%s 的键规格未定义(kind=%q)", spec.Key, spec.Kind) + } +} + +// tuningUnitLabel 返回单位的中文说明(错误文案用)。 +func tuningUnitLabel(spec memoryTidyTuningSpec) string { + switch spec.Unit { + case tuningUnitSec: + return "秒" + case tuningUnitHour: + return "本地钟点 0-23" + default: + return "整数" + } +} + +// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。 +func clipJSON(raw json.RawMessage) string { + const maxClip = 40 + s := strings.TrimSpace(string(raw)) + if len(s) <= maxClip { + return s + } + return s[:maxClip] + "…" +} + +// isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。 +func isJSONNull(raw json.RawMessage) bool { + return strings.TrimSpace(string(raw)) == "null" +} + +// memoryTidyTuningValues 把盘上的调优结构投影成面板要的 7 键值表:**键缺位 ⇒ 整个 +// 键不出现**(面板据此渲染「未设置、跟随兜底」),而不是回一个编出来的 0 —— +// 「0 秒」与「没配」在面板上必须看得出区别。 +// +// 它刻意**不**回显同文件的其它键(t1Judge 等):那些不在本卡片的编辑范围,混进来 +// 只会让人以为这张卡能改它们。 +func memoryTidyTuningValues(t runtimeconfig.MemoryTidyTuning) map[string]any { + out := map[string]any{} + if t.IntervalSec != nil { + out["intervalSec"] = *t.IntervalSec + } + if t.QuietIntervalSec != nil { + out["quietIntervalSec"] = *t.QuietIntervalSec + } + if t.QuietStartHour != nil { + out["quietStartHour"] = *t.QuietStartHour + } + if t.QuietEndHour != nil { + out["quietEndHour"] = *t.QuietEndHour + } + if t.IngestGate != nil { + out["ingestGate"] = *t.IngestGate + } + if t.AutoApply != nil { + out["autoApply"] = *t.AutoApply + } + if t.TempTtlSec != nil { + out["tempTtlSec"] = *t.TempTtlSec + } + return out +} diff --git a/internal/services/gateway/admin_memory_tuning_test.go b/internal/services/gateway/admin_memory_tuning_test.go new file mode 100644 index 0000000..b422cfd --- /dev/null +++ b/internal/services/gateway/admin_memory_tuning_test.go @@ -0,0 +1,524 @@ +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("没有数据目录时不该解析出落点") + } +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 17e6ac1..517af3b 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -259,6 +259,16 @@ func (s *Server) registerAdminRoutes( // 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。 mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject))) mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun))) + // 记忆整理「面板调优」的读写(切片 MEMPANEL / M4;契约由塔登记): + // 读 .../memories/tidy/tuning 回显 `$DATA_DIR/config/memory-tidy.json` 里本卡片 + // 管的 7 个旋钮 + 它们的取值规则(specs);写同一条路径是**三态补丁** + // (键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘 + // + 审计(memory.tidy.tuning)。两条都走 writeAuth(owner|admin)—— 与同族的 + // GET /admin/memories、POST /admin/memories/{id}/revert 同档:它是私有记忆面的 + // 运行期旋钮,不是给 viewer 的监控投影;也**不是** owner-only(写的是面板配置 + // 文件,不是身份/凭据面)。见 admin_memory_tuning.go。 + mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead))) + mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite))) mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit))) mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession))) mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag))) From 27ee0a81491be9dc6c47980b33e088128e7836ab Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 21:34:45 +0800 Subject: [PATCH 2/7] =?UTF-8?q?test(memory):=20=E6=8B=86=E5=88=86=E8=B0=83?= =?UTF-8?q?=E4=BC=98=E5=86=99=E7=AB=AF=E7=82=B9=E6=B5=8B=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E5=8E=9F=E6=96=87=E4=BB=B6=20524=20=E8=A1=8C=20>=20AGENTS=20?= =?UTF-8?q?=C2=A710=20=E7=9A=84=20500=20=E8=A1=8C=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 守卫用例(非法值不落盘 / 坏文件不覆盖 / 同值不重写 / 热生效两种结果)移到 admin_memory_tuning_guard_test.go,helper 与读侧用例留在 admin_memory_tuning_test.go; 口径不变,两个文件都 ≤300 行。 --- .../gateway/admin_memory_tuning_guard_test.go | 263 ++++++++++++++++++ .../gateway/admin_memory_tuning_test.go | 245 ---------------- 2 files changed, 263 insertions(+), 245 deletions(-) create mode 100644 internal/services/gateway/admin_memory_tuning_guard_test.go diff --git a/internal/services/gateway/admin_memory_tuning_guard_test.go b/internal/services/gateway/admin_memory_tuning_guard_test.go new file mode 100644 index 0000000..c5e9e3e --- /dev/null +++ b/internal/services/gateway/admin_memory_tuning_guard_test.go @@ -0,0 +1,263 @@ +package gateway + +import ( + "context" + "errors" + "net/http" + "os" + "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" +) + +// 本文件是记忆整理「面板调优」写端点的**守卫用例**(helper 与读侧用例见 +// admin_memory_tuning_test.go):三态里最要紧的那两条(非法值绝不落盘、坏文件绝不 +// 覆盖)与热生效的两种结果(成功 / 如实报失败)都在这里逐条钉死。 +// +// 拆成两个文件只为守 AGENTS §10 的文件长度上限,口径与另一半完全同源。 + +// 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("没有数据目录时不该解析出落点") + } +} diff --git a/internal/services/gateway/admin_memory_tuning_test.go b/internal/services/gateway/admin_memory_tuning_test.go index b422cfd..2128cd8 100644 --- a/internal/services/gateway/admin_memory_tuning_test.go +++ b/internal/services/gateway/admin_memory_tuning_test.go @@ -1,9 +1,7 @@ package gateway import ( - "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "os" @@ -14,7 +12,6 @@ import ( "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`) @@ -280,245 +277,3 @@ func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) 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("没有数据目录时不该解析出落点") - } -} From e9e7b7dcb264cf4a3bc7782c1dbc7310f3e3ae58 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 21:35:51 +0800 Subject: [PATCH 3/7] =?UTF-8?q?test(memory):=20memory-tidy.json=20?= =?UTF-8?q?=E5=86=99=E8=B7=AF=E5=BE=84=EF=BC=88=E8=A1=A5=E4=B8=81=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=85=B6=E5=AE=83=E9=94=AE=20/=20=E5=80=BC=E6=B2=A1?= =?UTF-8?q?=E5=8F=98=E4=B8=8D=E8=90=BD=E7=9B=98=20/=20=E5=9D=8F=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8B=92=E5=86=99=20/=20=E5=8E=9F=E5=AD=90=E5=86=99?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔批准的 scope 里点名了 internal/config/runtimeconfig/memorytidy_save_test.go, 补上这四个口径的包内单测(网关侧另有端到端用例覆盖三态与审计)。 --- .../runtimeconfig/memorytidy_save_test.go | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 internal/config/runtimeconfig/memorytidy_save_test.go diff --git a/internal/config/runtimeconfig/memorytidy_save_test.go b/internal/config/runtimeconfig/memorytidy_save_test.go new file mode 100644 index 0000000..adc0cd4 --- /dev/null +++ b/internal/config/runtimeconfig/memorytidy_save_test.go @@ -0,0 +1,151 @@ +package runtimeconfig + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +// 本文件锁定 `memory-tidy.json` **写路径**(PatchMemoryTidyTuning)的四条口径: +// ① 补丁语义:只改提交的键,盘上其它键(t1Judge 等)**原样保留**; +// ② 值没变 ⇒ 一个字节都不写(连 mtime 都不动); +// ③ 盘上那份**读不走**时拒绝覆盖(ErrInvalidMemoryTidy),且**盘上不动**; +// ④ 原子写:temp + rename,成功之后不留 `.tmp`,文件权限 0600(Windows 不校验)。 + +// patchTuning 是测试用的补丁构造:键 → 原样 JSON 值。 +func patchTuning(t *testing.T, pairs map[string]any) map[string]json.RawMessage { + t.Helper() + out := make(map[string]json.RawMessage, len(pairs)) + for k, v := range pairs { + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %s: %v", k, err) + } + out[k] = raw + } + return out +} + +// TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys 补丁只动提交的键,其余键原样保留; +// 返回值是**重新读盘**的结果(真值),不是「我以为写成了什么」。 +func TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys(t *testing.T) { + t.Parallel() + path := writeMemoryTidyFile(t, `{"t1Judge": false, "t1PerWindow": 120, "intervalSec": 3600}`) + tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 7200}), nil) + if err != nil { + t.Fatalf("patch: %v", err) + } + if !wrote { + t.Fatal("值变了却没写盘") + } + if tuning.IntervalSec == nil || *tuning.IntervalSec != 7200 { + t.Fatalf("返回值不是读回的真值:%+v", tuning.IntervalSec) + } + if tuning.T1Judge == nil || *tuning.T1Judge { + t.Fatalf("没提交的 t1Judge 被动了:%+v", tuning.T1Judge) + } + doc := map[string]json.RawMessage{} + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode: %v", err) + } + if string(doc["intervalSec"]) != "7200" || string(doc["t1PerWindow"]) != "120" { + t.Fatalf("盘上内容 = %s", raw) + } + if string(doc["t1Judge"]) != "false" { + t.Fatalf("不受管的 t1Judge 被抹掉了:%s", raw) + } + // 清除哨兵:clear 里的键从盘上消失(= 回到下一档兜底)。 + if _, _, err := PatchMemoryTidyTuning(path, nil, []string{"intervalSec"}); err != nil { + t.Fatalf("clear: %v", err) + } + doc = map[string]json.RawMessage{} + raw, _ = os.ReadFile(path) + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := doc["intervalSec"]; ok { + t.Fatalf("clear 之后盘上仍有 intervalSec:%s", raw) + } + if _, ok := doc["t1Judge"]; !ok { + t.Fatalf("clear 抹掉了别的键:%s", raw) + } +} + +// TestPatchMemoryTidyTuning_NoChangeWritesNothing 值没变 ⇒ 不写盘、不重排格式。 +func TestPatchMemoryTidyTuning_NoChangeWritesNothing(t *testing.T) { + t.Parallel() + path := writeMemoryTidyFile(t, `{"intervalSec": 3600}`) + before, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil) + if err != nil { + t.Fatalf("patch: %v", err) + } + if wrote { + t.Fatal("值没变却写了盘") + } + after, _ := os.ReadFile(path) + if string(after) != string(before) { + t.Fatalf("值没变却改了盘上内容:\nbefore=%s\nafter=%s", before, after) + } + if tuning.IntervalSec == nil || *tuning.IntervalSec != 3600 { + t.Fatalf("没写盘也要回读真值:%+v", tuning.IntervalSec) + } +} + +// TestPatchMemoryTidyTuning_RefusesUnreadableFile 盘上那份运行期读不走时拒绝覆盖: +// 返回 ErrInvalidMemoryTidy(调用方据此回 409),且**盘上一个字节都不动**。 +func TestPatchMemoryTidyTuning_RefusesUnreadableFile(t *testing.T) { + t.Parallel() + for _, body := range []string{`{"intervalSec": "3600"}`, `not json at all`, `[1, 2]`} { + body := body + t.Run(body, func(t *testing.T) { + path := writeMemoryTidyFile(t, body) + _, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil) + if !errors.Is(err, ErrInvalidMemoryTidy) { + t.Fatalf("err = %v, want ErrInvalidMemoryTidy", err) + } + if wrote { + t.Fatal("读不走的文件被写入了") + } + raw, _ := os.ReadFile(path) + if string(raw) != body { + t.Fatalf("坏文件被改动了:%s", raw) + } + }) + } +} + +// TestPatchMemoryTidyTuning_CreatesMissingFileAtomically 文件缺失 ⇒ 建目录并原子写出 +// (temp + rename):成功之后不留 `.tmp`,权限 0600(Windows 不校验)。 +func TestPatchMemoryTidyTuning_CreatesMissingFileAtomically(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "config", memoryTidyFileName) + tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"tempTtlSec": 604800}), nil) + if err != nil { + t.Fatalf("patch: %v", err) + } + if !wrote { + t.Fatal("缺失文件时应当写出") + } + if tuning.TempTtlSec == nil || *tuning.TempTtlSec != 604800 { + t.Fatalf("回读真值 = %+v", tuning.TempTtlSec) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("原子写留下了临时文件:err=%v", err) + } + if info, err := os.Stat(path); err != nil { + t.Fatalf("stat: %v", err) + } else if info.Mode().Perm() != memoryTidySaveFileMode && runtime.GOOS != "windows" { + t.Fatalf("文件权限 = %v, want %v", info.Mode().Perm(), memoryTidySaveFileMode) + } +} From a87fbeb49470a6ec2948a337f0017e2c41486726 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 22:11:41 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(memory):=20=E9=9D=A2=E6=9D=BF=E8=B0=83?= =?UTF-8?q?=E4=BC=98=E5=BC=95=E5=85=A5=E7=9A=84=E4=B8=A4=E5=A4=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9C=9F=E5=86=99=E5=85=A5=E9=9D=A2=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88-race=20=E4=BF=AE=E5=89=8D=E7=BA=A2=20=E2=86=92=20?= =?UTF-8?q?=E4=BF=AE=E5=90=8E=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、审查档(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)。 --- internal/runtime/memory/preinsert.go | 39 ++++++- internal/runtime/memory/preinsert_gate.go | 2 +- internal/runtime/memory/preinsert_tuning.go | 16 ++- .../runtime/memory/tidy_service_tuning.go | 109 ++++++++---------- .../memory/tidy_service_tuning_test.go | 97 ++++++++++++++++ 5 files changed, 193 insertions(+), 70 deletions(-) diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go index 589aaba..9acbbff 100644 --- a/internal/runtime/memory/preinsert.go +++ b/internal/runtime/memory/preinsert.go @@ -174,7 +174,20 @@ type PreInsertReviewer struct { // 推进时钟(见 preinsert_t1_limit.go)。 t1Gate *t1RateGate - mu sync.Mutex + // mu 保护 running / cancel 两个生命周期字段,以及**运行期可变的那两个配置 + // 字段**(`DisableIngestGate` / `TempTTL`,面板调优会改它们,见 + // preinsert_tuning.go 的 ApplyPanelTuning)。 + // + // 它用 RWMutex 而不是 Mutex,是因为后者在**写入路径**上被高频读:同步入库闸 + // 每写一条记忆就要问一次 `gateEnabled()`,第五态 `defer` 还要问一次 + // `deferDeadline()`;读锁之间不互斥,因此这两处不会因为面板保存而互相排队。 + // + // 🔴 口径(切片 MEMPANEL / M4 立):**谁在运行期写 cfg,谁就必须让读它的地方 + // 也持这把锁**。今天运行期只写上面那两个字段,因此只有读这两个字段的两处 + // (`gateEnabled` / `deferDeadline`)走 `tuningSnapshot`;其余读点读的是构造期 + // 冻结的字段,不与写方交手,故**没有**一并加锁(不给热路径加无谓的锁)。 + // 将来面板再接管别的字段,那几处读点必须与它一起收。 + mu sync.RWMutex running bool cancel context.CancelFunc wg sync.WaitGroup @@ -208,10 +221,32 @@ func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer }, nil } +// tuningSnapshot 在读锁下取一份配置快照。 +// +// 为什么需要它(切片 MEMPANEL / M4):面板调优(preinsert_tuning.go 的 +// `ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段;在这条特性之前 `cfg` 是 +// 构造期冻结的,读侧无锁本来安全。凡是读这两个字段的地方因此都必须与写方同步, +// 否则就是「写方持锁、读方不持锁」的数据竞争(`go test -race` 可复现)。 +// +// 取**整份**快照而不是逐个字段读,是为了让一次判定看到的是**同一个版本**的配置 +// (两个字段分别读可能落到两次不同的写入之间)。 +// +// 它只做一次字段拷贝、**不调用任何别的函数**,因此不会引入新的锁序:写方 +// (ApplyPanelTuning)持写锁时也只做字段赋值,不会再去等别的锁。 +func (r *PreInsertReviewer) tuningSnapshot() ReviewConfig { + r.mu.RLock() + defer r.mu.RUnlock() + return r.cfg +} + // gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。 // 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。 func (r *PreInsertReviewer) gateEnabled() bool { - return r != nil && r.cfg.Enabled && !r.cfg.DisableIngestGate + if r == nil { + return false + } + cfg := r.tuningSnapshot() + return cfg.Enabled && !cfg.DisableIngestGate } // gateScanLimit 返回同步闸实际使用的候选上限:配置值与其默认值的较小者 —— diff --git a/internal/runtime/memory/preinsert_gate.go b/internal/runtime/memory/preinsert_gate.go index 564d261..efd9bc5 100644 --- a/internal/runtime/memory/preinsert_gate.go +++ b/internal/runtime/memory/preinsert_gate.go @@ -237,7 +237,7 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in // 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此 // 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。 func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time { - return now.UTC().Add(r.cfg.TempTTL) + return now.UTC().Add(r.tuningSnapshot().TempTTL) } // failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。 diff --git a/internal/runtime/memory/preinsert_tuning.go b/internal/runtime/memory/preinsert_tuning.go index a22c405..32b09e7 100644 --- a/internal/runtime/memory/preinsert_tuning.go +++ b/internal/runtime/memory/preinsert_tuning.go @@ -81,11 +81,17 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int { // 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码 // 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。 // -// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` / -// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个 -// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长 -// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成 -// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。 +// 🔴 并发口径(本方法引入的运行期写入面,已连同读侧一起收口):`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() diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index 97bcb8d..0b5b2e4 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -2,10 +2,8 @@ package memory import ( "context" - "errors" "fmt" "log/slog" - "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) @@ -40,48 +38,59 @@ import ( // 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。 // // 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound` -// 与手动轮 / 试算 `DryRun` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu → -// Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个 -// 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后 -// 已经全部结束,因此也不与写入交手。 - -// memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。 +// 与手动轮 / 试算 `DryRun` 都持它),而它排除的是**轮次**;调度读(`nextDelay` / +// `dueNow` 读 `s.cfg`)不受 runMu 保护,因此写 `s.cfg` 必须发生在「没有任何 goroutine +// 读它」的窗口里 ⇒ 顺序铁律是 **先停、再写、后起**: // -// 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了 -// 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期), -// 绝不静默假装重排成功。 -const memoryTidyRescheduleTimeout = 5 * time.Second - -// ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于 -// 下一次调度判定,只是当前那次等待没有被作废)。 -var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out") +// Stop(等到心跳与清扫两只后台件都退出)→ 写 cfg → Start(新循环读到的是新配置) +// +// 三条都是实测踩出来的,不是推演: +// - **不能「先拿 runMu 再 Stop」**:心跳若正卡在 runMu 上准备开跑一轮,Stop 等的就是 +// 一个被我挡在锁门口的 goroutine ⇒ 活锁到超时; +// - **不能「Stop 完先 Start 再写」**:新循环立刻就会读 `s.cfg` 重算下一拍,与写入并排 +// ⇒ `go test -race` 直接报竞争,而且那一拍还可能按**旧**配置算出来; +// - **Stop 不设超时**:超时分支里 Wait 仍在飞,紧接着 `Start` 的 `wg.Add` 会踩上 +// `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律(交叉调用 Start/Stop +// 在 `-race` 下 `panic: sync: WaitGroup is reused before previous Wait has returned`)。 +// 等它排空的最坏代价是「等正在跑的那一轮跑完」,而这条路径是人点一次保存触发的, +// 不持 runMu ⇒ 不会与「心跳要开跑一轮」互相锁死。 // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 // -// 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是 -// 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。 +// 返回 nil 表示**值已生效**(心跳在跑时也已被重排,下一拍按新周期算);返回错误时 +// 值**没有**落到运行期(落盘那一步早已完成,调用方据此如实回 `hot:false` + 原因)。 +// +// 顺序与三条由来见文件头:**先停 → 再写 → 后起**。 func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error { next := tidyPanelTuning(base, panel, s.log) - // 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意 - // 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop - // 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。 - s.runMu.Lock() - defer s.runMu.Unlock() - s.mu.Lock() + running := s.running unchanged := s.cfg == next s.mu.Unlock() - if unchanged { - // 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。 - s.applyTidyConfigLocked(next) + // 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写。 + // 刻意**不**走下面那条写路径:写一个「相同的值」也是写,照样会与调度读 + // 构成竞争,而它一点收益都没有。 return nil } - reschedErr := s.rescheduleTidyHeartbeat() - s.applyTidyConfigLocked(next) - return reschedErr + // 心跳在跑 ⇒ 先让两只后台件(心跳 + 到期清扫)停稳:`s.cfg` 的调度读不持锁, + // 写入必须落在一个「没有读者」的窗口里。**Stop 期间不持 runMu**(否则心跳若正 + // 卡在 runMu 上等开跑,会互相等死)。 + if running { + if err := s.Stop(context.Background()); err != nil { + return fmt.Errorf("memory/tidy: stop heartbeat before tuning: %w", err) + } + } + s.applyTidyConfig(next) + if running { + // 重新起后台件:`Stop` 已经把 running 置为 false,不重启就等于让定时整理 + // **与到期清扫一起**悄悄停摆。父 ctx 用 Background(本对象没有保存父 ctx 的 + // 字段,那在 tidy_service.go 里),关停链本来就由组合根显式 `Stop` 收尾。 + s.Start(context.Background()) + } + return nil } // tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。 @@ -101,43 +110,19 @@ func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log return normalizeTidyConfig(cfg) } -// applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让 -// 心跳停下): -// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源; +// applyTidyConfig 把新配置落到**两个**持有者上: +// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源(`s.mu` 下写); // - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。 // // 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。 -func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) { +// +// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它),因为调用方 +// (ApplyPanelTuning)在 `Stop` 期间**不能**持它。前提仍是「心跳已停」:调用方负责。 +func (s *TidyService) applyTidyConfig(cfg TidyConfig) { + s.runMu.Lock() + defer s.runMu.Unlock() s.mu.Lock() s.cfg = cfg s.mu.Unlock() s.tidier.cfg = cfg } - -// rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。 -// -// 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经 -// 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默 -// 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。 -// -// 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在 -// tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop` -// 收尾(见 wire_services.go),因此不依赖父 ctx 取消。 -func (s *TidyService) rescheduleTidyHeartbeat() error { - s.mu.Lock() - running := s.running - s.mu.Unlock() - if !running { - return nil - } - stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout) - defer cancel() - if err := s.Stop(stopCtx); err != nil { - s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out", - "timeout", memoryTidyRescheduleTimeout.String(), "error", err) - s.Start(context.Background()) - return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err) - } - s.Start(context.Background()) - return nil -} diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index 19a8822..d7d6827 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -1,6 +1,7 @@ package memory import ( + "sync" "testing" "time" @@ -211,3 +212,99 @@ func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) { t.Fatal("经 Store 的转调没有落到审查服务上") } } + +// TestPreInsertReviewer_PanelTuningRaceWithGateReads 是**并发回归哨兵**(切片 +// MEMPANEL / M4 的快修)。 +// +// 背景:面板调优(`ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段 +// (`DisableIngestGate` / `TempTTL`)。在这条特性之前 `cfg` 是构造期冻结的,读侧 +// 无锁本来安全;有了运行期写入之后,凡是读这两个字段的地方都必须与写方同步 —— +// 否则就是「写方持锁、读方不持锁」的数据竞争。读侧两处分别是: +// - `gateEnabled()`(同步入库闸,记忆**写入路径**上调用); +// - `deferDeadline()`(第五态 `defer` 落 `expires_at`,写入路径与异步审查都会调)。 +// +// 它**只在 `-race` 下有意义**:不带 `-race` 时它只是一次并发冒烟(两边都跑完即通过), +// 有没有竞争交给 race detector 判。跑法: +// +// go test -race -count=1 -run TestPreInsertReviewer_PanelTuningRaceWithGateReads ./internal/runtime/memory/ +func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) { + store := newTestStore(t) + reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()}) + if err != nil { + t.Fatalf("NewPreInsertReviewer: %v", err) + } + const rounds = 200 + var wg sync.WaitGroup + wg.Add(3) + // 写方:模拟面板保存(每次改两个面板键)。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + panel := runtimeconfig.MemoryTidyTuning{ + IngestGate: tuningBool(i%2 == 0), + TempTtlSec: tuningInt(3600 + i), + } + if err := reviewer.ApplyPanelTuning(panel); err != nil { + t.Errorf("ApplyPanelTuning: %v", err) + return + } + } + }() + // 读方一:同步入库闸的开关判定(写入路径)。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + _ = reviewer.gateEnabled() + } + }() + // 读方二:第五态的到期时刻。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + _ = reviewer.deferDeadline(time.Now()) + } + }() + wg.Wait() +} + +// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。 +// +// 背景(实测踩出来的,不是推演):`s.cfg` 的**调度读**(`nextDelay` / `dueNow`) +// 不持锁,因此「面板调优写 cfg」必须落在一个**没有任何 goroutine 读它**的窗口里。 +// 曾经的顺序 `Stop → Start → 写` 在这里会被 -race 抓住:新起的循环立刻读 `s.cfg` +// 重算下一拍,与写入并排(而且那一拍还可能按**旧**配置算出来)。正确顺序是 +// `Stop → 写 → Start`。 +// +// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine +// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让 +// **真心跳**当读者(连跑多轮,每轮都要停一次、起一次),与生产形态一致。 +// +// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法: +// +// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithLiveHeartbeat ./internal/runtime/memory/ +func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) { + 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) }) + + const rounds = 25 + for i := 0; i < rounds; i++ { + panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)} + if err := svc.ApplyPanelTuning(base, panel); err != nil { + t.Fatalf("ApplyPanelTuning(%d): %v", i, err) + } + } + // 每轮重排之后心跳都必须还活着(`Stop` 把 running 置为 false,忘了重启就会静默停摆), + // 且最后一轮的周期真的生效在运行期。 + svc.mu.Lock() + running := svc.running + svc.mu.Unlock() + if !running { + t.Fatal("连着保存多次之后心跳必须仍在跑") + } + if want := 3600 + rounds - 1; svc.cfg.IntervalSec != want { + t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want) + } +} From 97c0c1b94a57f551b27e3219c31b828d1637d9a6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 22:17:22 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(memory):=20=E8=B0=83=E4=BC=98=E9=87=8D?= =?UTF-8?q?=E6=8E=92=E6=94=B9=E4=B8=BA=E5=94=A4=E9=86=92=E9=80=9A=E9=81=93?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=20Stop/Start=EF=BC=88=E6=B6=88?= =?UTF-8?q?=E7=81=AD=E4=B8=8E=E5=88=B0=E6=9C=9F=E6=B8=85=E6=89=AB=E7=9A=84?= =?UTF-8?q?=E8=80=A6=E5=90=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按塔的裁定把「重排心跳」从生命周期操作改成唤醒通道,调优路径上一个 Start/Stop 都没有了。 - tidy_service.go:TidyService 新增 wake(cap 1 + 非阻塞发)与 wakeMu/wakeCancel; loop 的等待段抽成 waitForTick,三态判定(睡满 / 被调优唤醒 / 停);被唤醒只 重算下一拍、**绝不**跑轮。调度读(nextDelay / dueNow)改走 scheduleSnapshot, 在同一把锁下同取「cfg + lastRun」,于是运行期写 cfg 不必再把后台件停下来。 Start / Stop / wg / running 与 Enabled 早退语义**一个字节没动**。 - tidy_service_tuning.go:ApplyPanelTuning 变成「持 runMu 写 cfg → 发一次唤醒」。 唤醒 = 一次性标记(保证不丢:心跳不在等时标记留在缓冲里)**加上**取消当前那次 等待(保证立刻生效,否则 24h→3h 要等原来那一觉睡完)。 两条冲突就此消失,各配一条可复现断言: - **面板保存不再触发到期清扫、也不重置清扫 ticker**:新用例在保存前后各造一行已 到期的记忆,保存之后那行必须**还在**、清扫审计计数必须不变;该用例自带活对照 (启动清扫会删掉第一行),因此「行还在」不是量具失灵。若保存走 Stop/Start,清扫 的「启动即扫一轮」会立刻把它删掉。 - **调优路径上不再存在任何 Start 调用**(结构上消灭交叉 Start/Stop):包内生产代码 已 grep 不到任何 `.Start(` / `.Stop(` 调用,Start 与 Stop 各只剩组合根那一处调用点 (`cmd/cyrene/main.go` / `cmd/cyrene/wire_services.go`),M3 注释里那条「Start/Stop 各只调用一次」的不变式因此重新成立;交叉场景由带真心跳的调优用例在 `-race` 下跑过 (不再 panic)。 热生效没退化:原来那条「23h30m → 30m」的断言按新机制重写 —— 用一个「听得懂 ctx 取消」的假等待看两觉的时长(保存前 23h30m、保存当场的第二觉 30m),并补上「唤醒不 跑轮(lastRun 不前移)」「唤醒后 running 仍为 true」。 变异自证(都跑过再还原,`diff` 逐字一致): - 去掉 `notifyReschedule` 调用 ⇒「被唤醒后的第二觉」等不到 ⇒ 用例红; - 只留标记、去掉「取消当前等待」那一半 ⇒ 同一条红(两半都必需)。 M3 的守卫 `expire_loop_test.go` **一行未动**:它的假 wait 只认 ctx,父 ctx 取消时 返回 false,在新机制下语义不变(无人唤醒时照样把心跳停住)。 --- internal/runtime/memory/tidy_service.go | 71 ++++++-- .../runtime/memory/tidy_service_tuning.go | 169 ++++++++++++----- .../memory/tidy_service_tuning_test.go | 170 ++++++++++++++++-- 3 files changed, 328 insertions(+), 82 deletions(-) diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index ee9cb31..df59f54 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -63,8 +63,23 @@ type TidyService struct { now func() time.Time // wait 是心跳的等待原语:返回 false 表示 ctx 已取消。生产是 time.Timer; // 测试注入假等待(推进假时钟后立刻返回),因此补跑判据可以确定性验证。 + // + // 🔴 它的返回值语义**没有变**(`false` 仍表示「这一觉没睡成就结束了」): + // 面板调优的重排**不是**靠它自报,而是靠下面那个可被取消的子 ctx —— 于是注入的 + // 假等待(不认识 wake 的那些)在测试里语义逐字不变(见 loop 的等待段)。 wait func(ctx context.Context, d time.Duration) bool + // wake 是「面板调优改了配置 ⇒ 把这一觉重排一次」的一次性标记:**有缓冲(cap 1) + // + 非阻塞发**,见 tidy_service_tuning.go 的 notifyReschedule。写端跑在面板的 + // HTTP 保存路径上,因此**绝不允许阻塞**;满了丢弃是**正确**语义(通道里那一个 + // 标记表达的就是「有一次待处理的重排」,重算是幂等的)。 + wake chan struct{} + // wakeMu 保护 wakeCancel(= 取消「当前这一次等待」的手段;两次等待之间是 nil)。 + // 它是 wake 的**中断**那一半:只有标记没有中断的话,新周期要等这一觉(可能是一天) + // 睡完才生效 —— 那正是主人把 24h 改成 3h 时看到的「改了跟没改一样」。 + wakeMu sync.Mutex + wakeCancel context.CancelFunc + // runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发 // 若撞在一起,后者排队而不是并发改写候选表。 runMu sync.Mutex @@ -86,6 +101,7 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) { log: tidier.log, now: tidier.now, wait: waitTidyTimer, + wake: make(chan struct{}, 1), runs: newTidyRunRegistry(defaultTidyRunTTL), }, nil } @@ -280,11 +296,19 @@ func (s *TidyService) loop(ctx context.Context) { defer s.wg.Done() s.catchUp(ctx) for { - if !s.wait(ctx, s.nextDelay()) { - return - } - if ctx.Err() != nil { + // 上一轮攒下的「调优唤醒」标记在这里清掉:它全部的含义就是「把下一拍重算 + // 一遍」,因此必须在重算**之前**清空 —— 否则重算完发现标记还在,会自旋。 + s.drainWake() + switch s.waitForTick(ctx) { + case tidyTickStopped: + // 父 ctx 被取消(Stop / 进程退出),或注入的假等待自报「停」:既有语义。 return + case tidyTickWokenByTuning: + // 被**面板调优**唤醒:这一觉是按**旧**周期算出来的,现在作废 ⇒ 只重算 + // 下一拍,**绝不**因此跑一轮(唤醒不是「到点了」,判据仍只有 dueNow)。 + s.log.Debug("memory tidy heartbeat rescheduled by panel tuning", + "now_hour", s.now().Hour()) + continue } // 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过 // 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的 @@ -330,21 +354,19 @@ func (s *TidyService) catchUp(ctx context.Context) { // nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。 func (s *TidyService) dueNow() (bool, string) { now := s.now() - s.mu.Lock() - last := s.lastRun - s.mu.Unlock() + cfg, last := s.scheduleSnapshot() if last.IsZero() { - if s.cfg.inQuietHours(now) { + if cfg.inQuietHours(now) { return true, "no_history_in_quiet_window" } return false, "" } - if now.Sub(last) >= s.cfg.intervalAt(now) { + if now.Sub(last) >= cfg.intervalAt(now) { return true, "interval_elapsed" } - if s.cfg.inQuietHours(now) { - if start := s.cfg.currentQuietStart(now); last.Before(start) { + if cfg.inQuietHours(now) { + if start := cfg.currentQuietStart(now); last.Before(start) { return true, "quiet_window_not_run" } } @@ -368,18 +390,18 @@ func (s *TidyService) dueNow() (bool, string) { // 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。 func (s *TidyService) nextDelay() time.Duration { now := s.now() - s.mu.Lock() - last := s.lastRun - s.mu.Unlock() + cfg, last := s.scheduleSnapshot() + // lastRun 与 cfg 必须在**同一把锁**下取到同一版本(见 scheduleSnapshot), + // 否则一次面板保存可能让这里拿到「新周期 + 旧历史」的混合体。 var next time.Time if last.IsZero() { - next = s.cfg.nextQuietStart(now) - if s.cfg.quietWindowDisabled() { - next = now.Add(s.cfg.intervalAt(now)) + next = cfg.nextQuietStart(now) + if cfg.quietWindowDisabled() { + next = now.Add(cfg.intervalAt(now)) } } else { - next = last.Add(s.cfg.intervalAt(now)) + next = last.Add(cfg.intervalAt(now)) } delay := next.Sub(now) if delay < minTidyTickDelay { @@ -388,6 +410,19 @@ func (s *TidyService) nextDelay() time.Duration { return delay } +// scheduleSnapshot 在锁下同时取「调度配置」与「上次运行时刻」。 +// +// 🔴 为什么要有它(切片 MEMPANEL / M4 立):`cfg` 在这条特性之前是启动期冻结的, +// 因此调度读(`nextDelay` / `dueNow`)直接读字段本来安全;面板调优开了「运行期改 +// cfg」这条路之后,调度读**必须**与写方(tidy_service_tuning.go 的 ApplyPanelTuning) +// 同步,否则就是数据竞争(`-race` 可复现)。两样东西一起取,是为了让一次判定看到 +// 的是**同一个版本**(分开取可能落在两次不同的写入之间)。 +func (s *TidyService) scheduleSnapshot() (TidyConfig, time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg, s.lastRun +} + // runRound 跑一轮并把结果与审计留痕。单写者:runMu 串行化心跳与手动触发。 func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, error) { s.runMu.Lock() diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index 0b5b2e4..b21d0ce 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -29,70 +29,149 @@ import ( // 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` > // 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到 // `.env` 档,而不是留在上一次的面板值上。 -// 2. **心跳要重排**:心跳正睡在「用**旧**周期算出来」的那一觉里(`nextDelay` 只在 -// 每一拍之前现算),最长可到旧周期。不重排的话,主人把周期从 24 小时改成 3 小时 -// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop + -// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是 -// 已经到点」,于是缩短周期往往**当场**补跑一轮)。 -// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次 -// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。 +// 2. **心跳要重排,但不许碰生命周期**:心跳正睡在「用**旧**周期算出来」的那一觉里 +// (`nextDelay` 只在每一拍之前现算),最长可到旧周期 —— 不重排的话,主人把周期从 +// 24 小时改成 3 小时时,新周期要等那一觉睡完才生效,等于「改了跟没改一样」。 +// 重排的做法是**唤醒**(`notifyReschedule`:一次性标记 + 取消当前那次等待), +// **不是** `Stop` + `Start`。这条是本切片修正后的形态,理由见下面「为什么不是 +// Stop/Start」。 +// 3. **失败不吞值**:重排不成立时值**照样**落到运行期(下一次调度判定就用它), +// 调用方据实回报 —— 绝不假装热生效,也绝不让保存白写。 // -// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound` -// 与手动轮 / 试算 `DryRun` 都持它),而它排除的是**轮次**;调度读(`nextDelay` / -// `dueNow` 读 `s.cfg`)不受 runMu 保护,因此写 `s.cfg` 必须发生在「没有任何 goroutine -// 读它」的窗口里 ⇒ 顺序铁律是 **先停、再写、后起**: +// 为什么不是 `Stop` + `Start`(早先那版就是它,被塔的 review 打回):`Stop` 会连同 +// `Start` 里起的**另一只后台件**(到期清扫 `expirySweepLoop`)一起取消,而清扫是 +// **启动即扫一轮**的形态 ⇒ 每次面板保存都会顺带触发一次真实删行 + 逐行审计,还会把 +// 清扫的 ticker 重置(`design/23 §19.1` 的「到期后 ≤ 一个清扫周期」因此被推后)。 +// 更要命的是「停止中重启」这件事本身:`Stop` 的超时分支里 `wg.Wait` 仍在飞,紧接着 +// `Start` 的 `wg.Add` 会踩上 `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律 +// (交叉调用 Start/Stop 在 `-race` 下 `panic: sync: WaitGroup is reused before previous +// Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。 // -// Stop(等到心跳与清扫两只后台件都退出)→ 写 cfg → Start(新循环读到的是新配置) -// -// 三条都是实测踩出来的,不是推演: -// - **不能「先拿 runMu 再 Stop」**:心跳若正卡在 runMu 上准备开跑一轮,Stop 等的就是 -// 一个被我挡在锁门口的 goroutine ⇒ 活锁到超时; -// - **不能「Stop 完先 Start 再写」**:新循环立刻就会读 `s.cfg` 重算下一拍,与写入并排 -// ⇒ `go test -race` 直接报竞争,而且那一拍还可能按**旧**配置算出来; -// - **Stop 不设超时**:超时分支里 Wait 仍在飞,紧接着 `Start` 的 `wg.Add` 会踩上 -// `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律(交叉调用 Start/Stop -// 在 `-race` 下 `panic: sync: WaitGroup is reused before previous Wait has returned`)。 -// 等它排空的最坏代价是「等正在跑的那一轮跑完」,而这条路径是人点一次保存触发的, -// 不持 runMu ⇒ 不会与「心跳要开跑一轮」互相锁死。 +// 并发口径:写 `s.cfg` 与调度读(`nextDelay` / `dueNow`)由 `s.mu` 互斥 +// (调度读走 `scheduleSnapshot`,与本次改动的同一把锁下取同一版本);写 `tidier.cfg` +// 与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、试算都持它)。因此这里只需要 +// 「持 runMu 写 → 发一次唤醒」,不与任何既有锁序冲突。 // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 // -// 返回 nil 表示**值已生效**(心跳在跑时也已被重排,下一拍按新周期算);返回错误时 -// 值**没有**落到运行期(落盘那一步早已完成,调用方据此如实回 `hot:false` + 原因)。 -// -// 顺序与三条由来见文件头:**先停 → 再写 → 后起**。 +// 返回 nil 表示**值已生效,且心跳已被告知重排**(下一拍按新周期算)。当前实现里 +// 它**不会失败**(错误返回保留给将来的校验),因此网关侧的 `hot` 语义就是「值已叠进 +// 运行期」。 func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error { next := tidyPanelTuning(base, panel, s.log) s.mu.Lock() - running := s.running unchanged := s.cfg == next s.mu.Unlock() if unchanged { - // 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写。 - // 刻意**不**走下面那条写路径:写一个「相同的值」也是写,照样会与调度读 - // 构成竞争,而它一点收益都没有。 + // 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写,也 + // 不必唤醒心跳。刻意**不**走下面那条写路径:写一个「相同的值」也是写, + // 照样会与调度读构成竞争,而它一点收益都没有。 return nil } - // 心跳在跑 ⇒ 先让两只后台件(心跳 + 到期清扫)停稳:`s.cfg` 的调度读不持锁, - // 写入必须落在一个「没有读者」的窗口里。**Stop 期间不持 runMu**(否则心跳若正 - // 卡在 runMu 上等开跑,会互相等死)。 - if running { - if err := s.Stop(context.Background()); err != nil { - return fmt.Errorf("memory/tidy: stop heartbeat before tuning: %w", err) - } - } + // 写运行期(两处持有者),再把「这一觉是按旧周期算的」这件事作废。 s.applyTidyConfig(next) - if running { - // 重新起后台件:`Stop` 已经把 running 置为 false,不重启就等于让定时整理 - // **与到期清扫一起**悄悄停摆。父 ctx 用 Background(本对象没有保存父 ctx 的 - // 字段,那在 tidy_service.go 里),关停链本来就由组合根显式 `Stop` 收尾。 - s.Start(context.Background()) - } + s.notifyReschedule() + s.log.Info("memory tidy panel tuning applied", + "interval_sec", next.IntervalSec, "quiet_interval_sec", next.QuietIntervalSec, + "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", next.QuietStartHour, next.QuietEndHour), + "auto_apply", !next.DisableAutoApply, "source", "admin panel") return nil } +// notifyReschedule 通知心跳「周期变了 ⇒ 把这一觉重排一次」。 +// +// 两件事,缺一不可: +// - **标记**(非阻塞发一个有缓冲的通道):保证这次通知**不会丢** —— 如果心跳此刻 +// 不在等(正在跑一轮、或还没 Start),标记会留在缓冲里,等它下一次「等」之前被 +// 清掉并重算。cap 1 + `default` 是刻意的:调用方在**面板保存的 HTTP 路径**上, +// 绝不能阻塞;而通道里那一个标记表达的就是「有一次待处理的重排」,重算是幂等的, +// 因此满了直接丢弃是**正确**语义(丢的只是重复通知,不是改动)。 +// - **中断**(取消当前那次等待):保证这次通知**立刻**生效 —— 只有标记的话,新的 +// 24h→3h 要等原来那一觉(可能是一天)睡完才落地,那正是「改了跟没改一样」。 +// +// 两件事都不碰生命周期(不 Start / 不 Stop / 不动 wg 与 running),因此到期清扫不会 +// 被顺带停掉或重启(见文件头「为什么不是 Stop + Start」)。 +func (s *TidyService) notifyReschedule() { + select { + case s.wake <- struct{}{}: + default: + } + s.wakeMu.Lock() + cancel := s.wakeCancel + s.wakeMu.Unlock() + if cancel != nil { + cancel() + } +} + +// armWake 登记「取消当前这一次等待」的手段;下一次调优保存靠它把这一觉当场作废。 +func (s *TidyService) armWake(cancel context.CancelFunc) { + s.wakeMu.Lock() + s.wakeCancel = cancel + s.wakeMu.Unlock() +} + +// disarmWake 撤掉登记(每次等待结束都要做,且必须在取消子 ctx **之前**)—— +// 否则下一次等待会被上一次的取消误伤。 +func (s *TidyService) disarmWake() { + s.wakeMu.Lock() + s.wakeCancel = nil + s.wakeMu.Unlock() +} + +// drainWake 清掉待处理的唤醒标记(非阻塞)。心跳在**重算下一拍之前**调它,否则 +// 标记会让「被唤醒 ⇒ 重算 ⇒ 又被唤醒」变成自旋。 +func (s *TidyService) drainWake() { + select { + case <-s.wake: + default: + } +} + +// tidyTickOutcome 是一次「等下一拍」的结局。 +type tidyTickOutcome int + +const ( + // tidyTickWaited:睡满了(或注入的假等待自报可以继续)⇒ 照常做 dueNow 判定。 + tidyTickWaited tidyTickOutcome = iota + // tidyTickWokenByTuning:被面板调优的重排打断 ⇒ 只重算下一拍,**不跑轮**。 + tidyTickWokenByTuning + // tidyTickStopped:父 ctx 被取消,或注入的假等待自报「停」⇒ 退出循环。 + tidyTickStopped +) + +// waitForTick 睡到下一拍,返回这次等待的结局(见 tidyTickOutcome)。 +// +// 三步的顺序不能变: +// 1. **先登记**取消手段(armWake)—— 否则调优那一下没法作废这一觉; +// 2. 调**注入的** `wait`(生产是 waitTidyTimer;测试可注入假等待),但给它的是一份 +// **可取消的子 ctx** ⇒ 唤醒 = 取消它。`wait` 的返回语义因此**一个字都没改**: +// `false` 仍表示「没睡成就结束了」(生产实现里它只在 ctx 结束时返回 false)。 +// 3. 无论结果如何都撤掉登记并回收子 ctx。 +// +// 判定顺序是刻意的:**先看父 ctx**,再看「子 ctx 是不是被取消」(= 调优唤醒)。反过来 +// 的话,Stop 的那一下会被读成「一次唤醒」,心跳就静默没了。 +func (s *TidyService) waitForTick(ctx context.Context) tidyTickOutcome { + wctx, wake := context.WithCancel(ctx) + s.armWake(wake) + waited := s.wait(wctx, s.nextDelay()) + s.disarmWake() + woken := wctx.Err() != nil + wake() + switch { + case ctx.Err() != nil: + return tidyTickStopped + case woken: + return tidyTickWokenByTuning + case !waited: + return tidyTickStopped + default: + return tidyTickWaited + } +} + // tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。 // // 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 → diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index d7d6827..293e892 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -1,6 +1,8 @@ package memory import ( + "context" + "strings" "sync" "testing" "time" @@ -76,16 +78,61 @@ func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) { } } -// TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳 -// 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。 -func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) { - t.Parallel() +// tuneWaitProbe 是一个「听得懂 ctx 取消」的假等待:记录每次被要求的时长,并在 +// ctx 结束时立刻返回 false —— 与生产的 `waitTidyTimer` 同语义。因此它既能被 +// **调优唤醒**打断(唤醒 = 取消这一次等待的子 ctx),也能被 Stop 停住。 +type tuneWaitProbe struct { + mu sync.Mutex + delays []time.Duration + calls chan time.Duration +} + +func newTuneWaitProbe() *tuneWaitProbe { + return &tuneWaitProbe{calls: make(chan time.Duration, 32)} +} + +func (p *tuneWaitProbe) wait(ctx context.Context, d time.Duration) bool { + p.mu.Lock() + p.delays = append(p.delays, d) + p.mu.Unlock() + select { + case p.calls <- d: + default: + } + select { + case <-ctx.Done(): + return false + case <-time.After(30 * time.Second): + // 兜底:用例失败时才可能走到这里(正常路径都由取消叫醒)。 + return false + } +} + +// awaitCall 等下一次「等待」被调用并返回它要求的时长(超时即 FAIL)。 +func (p *tuneWaitProbe) awaitCall(t *testing.T, step string) time.Duration { + t.Helper() + select { + case d := <-p.calls: + return d + case <-time.After(5 * time.Second): + t.Fatalf("等心跳走到「%s」超时(循环没走到预期阶段)", step) + return 0 + } +} + +// TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence 钉住**热生效的新机制** +// (唤醒,而不是 Stop/Start): +// +// ① 保存前那一觉是按**旧**周期算的(15:00 起、上次 14:30、周期 24h ⇒ 等 23h30m); +// ② 保存**当场**把那一觉作废并按新周期重排(下一觉 = 30m),不必等旧周期睡完; +// ③ 唤醒**不跑轮**(`lastRun` 不前移 —— 到没到点仍只由 dueNow 判); +// ④ 心跳**没有**被停过:`running` 仍为 true,等待由**同一个**循环继续(不是重启)。 +func TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence(t *testing.T) { 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) @@ -97,29 +144,114 @@ func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) { t.Fatal("未 Start 时不该因为保存而启动心跳") } - // 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。 - // 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。 - svc.Start(nil) - t.Cleanup(func() { _ = svc.Stop(nil) }) + probe := newTuneWaitProbe() + svc.wait = probe.wait 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) + svc.Start(nil) + t.Cleanup(func() { _ = svc.Stop(nil) }) + + if got := probe.awaitCall(t, "第一觉"); got != 23*time.Hour+30*time.Minute { + t.Fatalf("第一觉 = %s, want 23h30m(按旧周期)", got) } - // 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有 - // 30 分钟),而不是继续睡原来那 23.5 小时。 + + // 保存:周期 24h → 1h。这一觉必须**当场**作废并重排到 30 分钟。 if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil { t.Fatalf("ApplyPanelTuning(running): %v", err) } + if got := probe.awaitCall(t, "被唤醒后的第二觉"); got != 30*time.Minute { + t.Fatalf("第二觉 = %s, want 30m(仍按旧周期说明没被唤醒)", got) + } + svc.mu.Lock() - running = svc.running + last, runningAfter := svc.lastRun, svc.running + svc.mu.Unlock() + if !runningAfter { + t.Fatal("唤醒后心跳必须仍在跑(唤醒不是停表)") + } + if !last.Equal(at.Add(-30 * time.Minute)) { + t.Fatalf("唤醒跑了轮:lastRun = %v, want 14:30(唤醒只重排,不跑轮)", last) + } +} + +// TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows 钉住「重排不碰生命周期」: +// 面板保存**不再**顺带触发一次到期清扫,也**不再**重启清扫的 ticker。 +// +// 判据用行为而不是实现:保存前后各造一行已到期的记忆,保存之后它必须**还在**。 +// 若保存走了 Stop/Start(早先那版就是),`expirySweepLoop` 的「启动即扫一轮」会 +// 立刻把它删掉 —— 这正是塔要证伪的那条副作用。 +func TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows(t *testing.T) { + ctx := context.Background() + now := tidyTestTime(t) + store := newTestStore(t) + base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} + + var ( + mu sync.Mutex + audits []string + ) + svc := newTidyServiceForTest(t, store, base, nil, fixedTidyClock(now), + func(_, what, result string) { + mu.Lock() + defer mu.Unlock() + audits = append(audits, what+" "+result) + }) + // 假等待:把心跳停在 ctx 上(本轮只关心清扫那一路,心跳不搅局)。 + svc.wait = func(ctx context.Context, _ time.Duration) bool { + <-ctx.Done() + return false + } + expireAudits := func() int { + mu.Lock() + defer mu.Unlock() + n := 0 + for _, a := range audits { + if strings.HasPrefix(a, expireAuditVerb+" ") { + n++ + } + } + return n + } + + // ① 先证明清扫确实在跑:启动清扫会把这一行删掉。 + first := addItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: "启动清扫应当删掉它", ExpiresAt: now.Add(-time.Minute), + }) + svc.Start(ctx) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = svc.Stop(stopCtx) + }) + waitUntilRowGone(t, store, first.ID) + afterStart := expireAudits() + + // ② 再造一行已到期的,这次没有任何「启动清扫」来删它。 + pending := addItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: "面板保存不该把它删掉", ExpiresAt: now.Add(-time.Minute), + }) + + // ③ 面板保存(值真的变了,因此一定会走重排)。 + if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil { + t.Fatalf("ApplyPanelTuning: %v", err) + } + // 给「万一真被触发了」留出显形窗口:启动清扫只需一次 SQLite 往返(微秒级)。 + time.Sleep(200 * time.Millisecond) + + if !memoryRowExists(t, store, pending.ID) { + t.Fatal("面板保存触发了到期清扫(那行被删了)—— 重排不该碰生命周期") + } + if got := expireAudits(); got != afterStart { + t.Fatalf("面板保存后多出了清扫审计:%d ⇒ %d", afterStart, got) + } + 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) + t.Fatal("面板保存不该把心跳停掉") } } From 4df8d6f53aabd1d7b66c19c257a677dc6ebcabbc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 22:33:07 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(memory):=20=E6=8C=89=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=89=AB=E6=B8=85=20s.cfg=20=E7=9A=84=E5=85=B6=E4=BD=99?= =?UTF-8?q?=E6=97=A0=E9=94=81=E8=AF=BB=EF=BC=88P1=EF=BC=89+=20=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E6=96=87=E6=A1=88=E6=8C=89=20rune=20=E6=88=AA?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review P1:applyTidyConfig 在运行期**整体覆写** s.cfg,而我只把调度读收进了持锁 访问器 —— 同一字段还有几处无锁读,其中两处是**活端点**: - TidyService.DryRun 的开关判定(在取 runMu **之前**)⇒ POST …/tidy/dry-run 与 ?async=1 那条会起后台 goroutine 的版本(真机 90s 级); - TidyService.ManualRun 的开关判定(同样在 runMu 之前)⇒ POST …/tidy/run; - (原本也漏的)Enabled() 与 Start 的启动日志块。 按**字段**重扫了一遍(不按文件):现在生产代码里对 s.cfg 的读**只剩**三个持锁访问器 内部(enabled / cfgSnapshot / scheduleSnapshot),写点只有 applyTidyConfig 一处; s.tidier.cfg 的读点全在持 runMu 的轮次 / 试算路径上(含异步版经 DryRun)。 新增第三条并发回归哨兵:真并发跑「试算 + 手动真跑」两个活端点 × 面板保存。 修前红/修后绿:撤回 DryRun 的同步 ⇒ 报「写 applyTidyConfig × 读 DryRun」;撤回 ManualRun 的同步 ⇒ 报「写 applyTidyConfig × 读 ManualRun」;两次变异都逐字还原 (diff 0 差异)。 顺手两条(塔指定): - 那条并发哨兵的 doc 与实现相反(还写着「正确顺序是 Stop → 写 → Start」),已改成 唤醒机制下的说法 —— 与「注释与实现不符」同类,不留旧说法。 - 非法值的回显文案原按**字节**截断(s[:40]),中文会被切成半截(非法 UTF-8,经 JSON 编码变成 U+FFFD 替换符)。改成按 **rune** 截断,直接复用同包既有的 clipRunes;用例取值刻意让截断点落在多字节字符中间(JSON 引号 + 一个 ASCII 前缀 错开对齐),并做过变异复验:改回按字节 ⇒ 用例红(切出半个汉字)。 --- internal/runtime/memory/tidy_dryrun.go | 6 +- internal/runtime/memory/tidy_service.go | 45 ++++++++---- .../runtime/memory/tidy_service_tuning.go | 10 +-- .../memory/tidy_service_tuning_test.go | 68 +++++++++++++++++-- .../gateway/admin_memory_tuning_file.go | 15 ++-- .../gateway/admin_memory_tuning_guard_test.go | 52 ++++++++++++++ 6 files changed, 167 insertions(+), 29 deletions(-) diff --git a/internal/runtime/memory/tidy_dryrun.go b/internal/runtime/memory/tidy_dryrun.go index 14e0dc3..d7c977a 100644 --- a/internal/runtime/memory/tidy_dryrun.go +++ b/internal/runtime/memory/tidy_dryrun.go @@ -142,7 +142,11 @@ func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) { // 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式 // 选择,试算不绕过它,也不回一份空候选假装「没有建议」。 func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) { - if !s.cfg.Enabled { + // 走 `enabled()` 而不是裸读字段:这个判断在取 runMu **之前**,而面板调优能在运行期 + // 覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning)⇒ 该字段的每个读点 + // 都得与写方同步,否则 `POST …/tidy/dry-run`(含 `?async=1` 那条后台版)与保存并发 + // 时就是数据竞争(`-race` 可复现)。 + if !s.enabled() { return TidyDryRun{}, ErrTidyDisabled } if ctx == nil { diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index df59f54..c214551 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -122,7 +122,26 @@ func waitTidyTimer(ctx context.Context, d time.Duration) bool { func (s *TidyService) SessionID() string { return TidySessionID } // Enabled 报告整理是否被配置开启(design/23 §4 的 MEMORY_TIDY_ENABLED)。 -func (s *TidyService) Enabled() bool { return s.cfg.Enabled } +// +// 它走 `enabled()` 而不是直接读字段:`Enabled` 是 `s.cfg` 里的一个字段,而面板调优 +// 能在**运行期**整体覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning), +// 因此该字段的**每一个**读点都必须与写方同步(`s.mu`)。 +func (s *TidyService) Enabled() bool { return s.enabled() } + +// enabled 在读锁下报告整理开关。它是 `s.cfg.Enabled` 的**唯一**安全读法 +// (口径见 scheduleSnapshot 与上面 Enabled 的注释)。 +func (s *TidyService) enabled() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg.Enabled +} + +// cfgSnapshot 在读锁下取一份配置快照:凡是不需要 lastRun 的读点用它。 +func (s *TidyService) cfgSnapshot() TidyConfig { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg +} // Start 启动心跳。它是幂等的:第二次调用是空操作。配置关闭时只记一条日志 // 并立即返回(关闭是显式选择,绝不静默)。ctx(可为 nil)是所有内部工作的 @@ -133,7 +152,7 @@ func (s *TidyService) Enabled() bool { return s.cfg.Enabled } // 整理被配置关掉时清扫也不跑(与 `preinsert_gc.go` 的清扫随审查服务启停同口径), // `Stop` 会一并取消并 join 它。 func (s *TidyService) Start(ctx context.Context) { - if !s.cfg.Enabled { + if !s.enabled() { s.log.Info("memory tidy disabled by configuration", "env", "MEMORY_TIDY_ENABLED") return } @@ -180,16 +199,18 @@ func (s *TidyService) Start(ctx context.Context) { go s.loop(cctx) go s.expirySweepLoop(cctx) + // 启动日志逐项取一次快照(面板调优能在运行期覆写 cfg,因此这些字段也不能裸读)。 + cfg := s.cfgSnapshot() s.log.Info("memory tidy started", - "interval", s.cfg.Interval.String(), - "quiet_interval", s.cfg.QuietInterval.String(), - "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour), - "recall_threshold", s.cfg.RecallThreshold, - "scan_limit", s.cfg.ScanLimit, - "compare_limit", s.cfg.CompareLimit, - "candidate_limit", s.cfg.CandidateLimit, - "auto_apply", s.cfg.autoApplyEnabled(), - "auto_apply_per_round", s.cfg.AutoApplyPerRound, + "interval", cfg.Interval.String(), + "quiet_interval", cfg.QuietInterval.String(), + "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour), + "recall_threshold", cfg.RecallThreshold, + "scan_limit", cfg.ScanLimit, + "compare_limit", cfg.CompareLimit, + "candidate_limit", cfg.CandidateLimit, + "auto_apply", cfg.autoApplyEnabled(), + "auto_apply_per_round", cfg.AutoApplyPerRound, "rule_version", TidyRuleVersion, "session_id", TidySessionID, "last_run_at", s.lastRunLabel()) @@ -277,7 +298,7 @@ func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) { // 由 tidy_auto.go 直接落地,其余仍等人点(候选 apply / 按 run 批量 apply)。 // 关掉自动档只需 `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`。 func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) { - if !s.cfg.Enabled { + if !s.enabled() { return TidyStats{}, ErrTidyDisabled } return s.runRound(ctx, "manual") diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index b21d0ce..c25ba08 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -47,10 +47,12 @@ import ( // (交叉调用 Start/Stop 在 `-race` 下 `panic: sync: WaitGroup is reused before previous // Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。 // -// 并发口径:写 `s.cfg` 与调度读(`nextDelay` / `dueNow`)由 `s.mu` 互斥 -// (调度读走 `scheduleSnapshot`,与本次改动的同一把锁下取同一版本);写 `tidier.cfg` -// 与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、试算都持它)。因此这里只需要 -// 「持 runMu 写 → 发一次唤醒」,不与任何既有锁序冲突。 +// 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被 +// 运行期整体覆写,它的**每一个读点**都必须与写方同步,因此所有读点都收进了持锁访问器 +// (`enabled()` / `cfgSnapshot()` / `scheduleSnapshot()`:调度读 `nextDelay` / +// `dueNow`、两个活端点的开关判定 `DryRun` / `ManualRun`、以及 `Enabled()` 与 `Start` +// 的启动日志)。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、 +// 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。 // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 // diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index 293e892..22d78df 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -2,6 +2,7 @@ package memory import ( "context" + "errors" "strings" "sync" "testing" @@ -401,15 +402,18 @@ func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) { // TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。 // -// 背景(实测踩出来的,不是推演):`s.cfg` 的**调度读**(`nextDelay` / `dueNow`) -// 不持锁,因此「面板调优写 cfg」必须落在一个**没有任何 goroutine 读它**的窗口里。 -// 曾经的顺序 `Stop → Start → 写` 在这里会被 -race 抓住:新起的循环立刻读 `s.cfg` -// 重算下一拍,与写入并排(而且那一拍还可能按**旧**配置算出来)。正确顺序是 -// `Stop → 写 → Start`。 +// 背景(实测踩出来的,不是推演):面板调优会在运行期**整体覆写** `s.cfg`,因此该字段 +// 的**每一个**读点都必须与写方同步。心跳那一路的读点(`nextDelay` / `dueNow`)现在走 +// `scheduleSnapshot()`(同一把锁下同取「cfg + lastRun」)—— 这条哨兵钉的就是它:早先 +// 那版把写入放在 `Start` **之后**,新起的循环立刻读 `s.cfg` 重算下一拍,与写入并排 +// (而且那一拍还可能按**旧**配置算出来),`-race` 会当场报出来。 // // 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine // 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让 -// **真心跳**当读者(连跑多轮,每轮都要停一次、起一次),与生产形态一致。 +// **真心跳**当读者(连跑多轮,每轮都换一次周期),与生产形态一致。 +// +// 与它同族的另两条哨兵:审查档(`PreInsertReviewer` 的并发读写)与试算/手动跑那两个 +// **活端点**(`TestTidyService_PanelTuningRaceWithDryRunGuard`)。 // // 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法: // @@ -440,3 +444,55 @@ func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) { t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want) } } + +// TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵 +// (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与 +// **试算端点的开关判定**并发。 +// +// 为什么单独要这一条:`TidyService.DryRun` 的 `if !s.cfg.Enabled` 发生在**取 runMu +// 之前**,因此它不受 runMu 保护 —— 而 `POST /admin/memories/tidy/dry-run`(含 +// `?async=1` 那条会起后台 goroutine、真机上跑 90s 级的版本)正是这条读点的活端点。 +// 只把「调度读」收进持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。 +// +// 它**只在 `-race` 下有意义**(不带 -race 时是一次并发冒烟 + 结果断言)。跑法: +// +// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithDryRunGuard ./internal/runtime/memory/ +func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) { + base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} + svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil) + + const rounds = 40 + var wg sync.WaitGroup + wg.Add(2) + // 写方:模拟面板保存(每次换一个周期,一定走整体覆写 s.cfg)。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)} + if err := svc.ApplyPanelTuning(base, panel); err != nil { + t.Errorf("ApplyPanelTuning: %v", err) + return + } + } + }() + // 读方:两个**活端点**交替 —— 试算(POST …/tidy/dry-run)与手动真跑 + // (POST …/tidy/run)。两者的开关判定都在取 runMu **之前**,因此都在这个字段上 + // 与写方交手;只覆盖其中一个就等于漏了另一个。 + go func() { + defer wg.Done() + for i := 0; i < rounds; i++ { + if i%2 == 0 { + if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) { + t.Errorf("DryRun: %v", err) + return + } + continue + } + if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) { + t.Errorf("ManualRun: %v", err) + return + } + } + }() + wg.Wait() +} diff --git a/internal/services/gateway/admin_memory_tuning_file.go b/internal/services/gateway/admin_memory_tuning_file.go index ade22a0..9776bc7 100644 --- a/internal/services/gateway/admin_memory_tuning_file.go +++ b/internal/services/gateway/admin_memory_tuning_file.go @@ -227,14 +227,17 @@ func tuningUnitLabel(spec memoryTidyTuningSpec) string { } } +// tuningValueClipRunes 是错误文案里回显「收到的值」的 rune 上限。 +const tuningValueClipRunes = 40 + // clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。 +// +// 🔴 按 **rune** 截断,**不按字节**:值里完全可能是中文(主人就爱填中文),按字节切 +// 会把一个多字节字符切成两半 —— 回显里因此出现半截 UTF-8(JSON 编码后变成 U+FFFD +// 替换符),而这条文案的用途正是让人**看清自己填错了什么**。手法与同包的 +// `clipRunes`(notifications.go)同源,直接复用而不是再写一份。 func clipJSON(raw json.RawMessage) string { - const maxClip = 40 - s := strings.TrimSpace(string(raw)) - if len(s) <= maxClip { - return s - } - return s[:maxClip] + "…" + return clipRunes(strings.TrimSpace(string(raw)), tuningValueClipRunes) } // isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。 diff --git a/internal/services/gateway/admin_memory_tuning_guard_test.go b/internal/services/gateway/admin_memory_tuning_guard_test.go index c5e9e3e..24aa56a 100644 --- a/internal/services/gateway/admin_memory_tuning_guard_test.go +++ b/internal/services/gateway/admin_memory_tuning_guard_test.go @@ -2,11 +2,14 @@ package gateway import ( "context" + "encoding/json" "errors" "net/http" "os" + "strings" "testing" "time" + "unicode/utf8" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" @@ -261,3 +264,52 @@ func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) { t.Fatal("没有数据目录时不该解析出落点") } } + +// TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe 「非法值」的文案会把**收到的 +// 值**回显出来(这是它的用途:让人看清自己填错了什么),而主人完全可能填中文 —— +// 回显必须按 rune 截断:按**字节**切会把一个汉字切成半截(非法 UTF-8,经 JSON 编码后 +// 变成 U+FFFD 替换符),等于把「你填了什么」显示成乱码。 +// +// 取值刻意让截断点**落在多字节字符中间**:JSON 值自带引号(1 字节),再让首个字符是 +// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,第 40 字节正好切在第 13 个 +// 汉字的**第三个字节**上。若只堆汉字(引号 1 + 3n 字节),40 会恰好落在 rune 边界上, +// 那样这条用例会**假绿**(实测过:按字节截断也照样通过)。 +func TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe(t *testing.T) { + t.Parallel() + + // ① 纯函数层:截断结果必须是合法 UTF-8(这一条与 JSON 层无关,最直接)。 + long := json.RawMessage(`"a` + strings.Repeat("三", 60) + `"`) + clipped := clipJSON(long) + if !utf8.ValidString(clipped) { + t.Fatalf("clipJSON 切出了非法 UTF-8:%q", clipped) + } + if strings.ContainsRune(clipped, utf8.RuneError) { + t.Fatalf("clipJSON 结果里有替换符:%q", clipped) + } + + // ② 端点层:400 文案里不许出现替换符,且开头几个字仍要如实回显。 + env := newTuneEnv(t, Deps{}) + before := env.seed(t, `{"intervalSec": 86400}`) + payload := `a` + strings.Repeat("三", 60) + resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"quietStartHour": "`+payload+`"}`) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + assertErrorCode(t, body, "invalid_payload") + var eobj struct { + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil { + t.Fatalf("decode error: %v (body=%s)", err, body) + } + if !utf8.ValidString(eobj.Message) || strings.ContainsRune(eobj.Message, utf8.RuneError) { + t.Fatalf("回显里出现了替换符/非法 UTF-8(半个汉字被切了):%q", eobj.Message) + } + if !strings.Contains(eobj.Message, "a三三三") { + t.Fatalf("文案应当仍回显开头几个字:%q", eobj.Message) + } + if after := env.bytes(t); string(after) != string(before) { + t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after) + } +} From f6c47b0ae18059921638c510da5ce2503f9edb14 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 22:50:38 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(memory):=20=E6=B8=85=E6=8E=89=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E4=B8=8E=E5=AE=9E=E7=8E=B0=E7=9B=B8=E5=8F=8D=E7=9A=84?= =?UTF-8?q?=E6=97=A7=E8=AF=B4=E6=B3=95=20+=20=E5=B9=B6=E5=8F=91=E5=93=A8?= =?UTF-8?q?=E5=85=B5=E6=94=B9=E6=88=90=E7=AA=97=E5=8F=A3=E9=87=8D=E5=8F=A0?= =?UTF-8?q?=EF=BC=88=E5=8F=98=E5=BC=82=208/8=20=E7=BA=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review P2 两条。 ① 旧说法没删净(同类第 N 次:改了 A,同一主张还留在 B / C)。这次按「**这句主张的所有 落点**」重扫了一遍我这一片里含 Stop / Start / 重启 / 心跳已停 的注释,改掉两处与实现 正面打架的: - applyTidyConfig 的注释还写着「调用方(ApplyPanelTuning)在 Stop 期间不能持 runMu」 「前提仍是心跳已停」—— 而同文件的文件头刚写过「调优路径上一个 Start/Stop 都没有」, 同一文件里两段话自相矛盾; - 整理档那条并发哨兵的循环注释还写着「Stop 把 running 置为 false,忘了重启就会静默 停摆」,而该循环里根本没有 Stop/Start(改成「唤醒不是停表」,并把 Stop/Start 那版 明确标成旧版)。 顺带把文件头「所有读点都收进访问器」的说法改准:此外还有一处**同一把锁下**的相等比较 (ApplyPanelTuning 里的 s.cfg == next),它本身不需要访问器。 ② 新哨兵是概率性守卫:固定 40 轮时撤回 DryRun 同步只红 4/8(写方那几十次覆写在微秒级 就跑完,读方还在慢端点里打转)⇒ 文档规定的 -count=1 会漏掉约一半回归。改成**两侧各自 跑满同一个 300ms 墙钟窗口**(写窗口与读窗口必然重叠,不靠快慢概率),读方两个活端点各 起两路;写方每轮换一个值(否则 ApplyPanelTuning 会被「配置没变」的早退挡掉,根本不写)。 实测:撤回 DryRun 同步红 **8/8**、撤回 ManualRun 同步红 **8/8**(各 8 次 -race -count=1); 干净 tip `-race -count=5` 全绿、三条哨兵一起 `-count=3` 全绿;该哨兵耗时 0.35s(原 0.18s)。 顺带把 clipJSON 用例注释里那句改准:40 字节处被切掉的是第 13 个汉字的**第三个字节** (前两个字节被留下),而不是先前写的「第三个字节上」。 --- .../runtime/memory/tidy_service_tuning.go | 14 ++-- .../memory/tidy_service_tuning_test.go | 82 +++++++++++++------ .../gateway/admin_memory_tuning_guard_test.go | 7 +- 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/internal/runtime/memory/tidy_service_tuning.go b/internal/runtime/memory/tidy_service_tuning.go index c25ba08..8b3eedd 100644 --- a/internal/runtime/memory/tidy_service_tuning.go +++ b/internal/runtime/memory/tidy_service_tuning.go @@ -48,10 +48,11 @@ import ( // Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。 // // 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被 -// 运行期整体覆写,它的**每一个读点**都必须与写方同步,因此所有读点都收进了持锁访问器 -// (`enabled()` / `cfgSnapshot()` / `scheduleSnapshot()`:调度读 `nextDelay` / -// `dueNow`、两个活端点的开关判定 `DryRun` / `ManualRun`、以及 `Enabled()` 与 `Start` -// 的启动日志)。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、 +// 运行期整体覆写,它的**每一个**读点都必须与写方同步。做法是读写都在 `s.mu` 下:读点 +// 收进三个访问器 `enabled()`(开关)/ `cfgSnapshot()`(整份)/ `scheduleSnapshot()` +// (整份 + lastRun),覆盖调度读 `nextDelay` / `dueNow`、两个活端点的开关判定 +// `DryRun` / `ManualRun`、`Enabled()` 与 `Start` 的启动日志;此外 `ApplyPanelTuning` +// 里还有一处**同一把锁下**的相等比较(`s.cfg == next`),它本身不需要访问器。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、 // 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。 // ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。 @@ -197,8 +198,9 @@ func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log // // 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。 // -// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它),因为调用方 -// (ApplyPanelTuning)在 `Stop` 期间**不能**持它。前提仍是「心跳已停」:调用方负责。 +// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它)—— 调优路径**不等 +// 任何后台件停**:`s.cfg` 的读点全部持 `s.mu`(见文件头),`tidier.cfg` 的读点全部持 +// `runMu`,因此这把锁足以让写入与每一轮的读不交手,不需要(也不再)停心跳。 func (s *TidyService) applyTidyConfig(cfg TidyConfig) { s.runMu.Lock() defer s.runMu.Unlock() diff --git a/internal/runtime/memory/tidy_service_tuning_test.go b/internal/runtime/memory/tidy_service_tuning_test.go index 22d78df..6058bf4 100644 --- a/internal/runtime/memory/tidy_service_tuning_test.go +++ b/internal/runtime/memory/tidy_service_tuning_test.go @@ -432,7 +432,8 @@ func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) { t.Fatalf("ApplyPanelTuning(%d): %v", i, err) } } - // 每轮重排之后心跳都必须还活着(`Stop` 把 running 置为 false,忘了重启就会静默停摆), + // 每轮重排之后心跳都必须还活着(唤醒**不是**停表:走 Stop/Start 那版才会把 + // running 置为 false,忘了重启就静默停摆 —— 现在这条路径上根本没有 Start/Stop), // 且最后一轮的周期真的生效在运行期。 svc.mu.Lock() running := svc.running @@ -447,12 +448,21 @@ func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) { // TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵 // (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与 -// **试算端点的开关判定**并发。 +// **试算 / 手动跑两个活端点的开关判定**并发。 // -// 为什么单独要这一条:`TidyService.DryRun` 的 `if !s.cfg.Enabled` 发生在**取 runMu -// 之前**,因此它不受 runMu 保护 —— 而 `POST /admin/memories/tidy/dry-run`(含 -// `?async=1` 那条会起后台 goroutine、真机上跑 90s 级的版本)正是这条读点的活端点。 -// 只把「调度读」收进持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。 +// 为什么单独要这一条:`TidyService.DryRun` 与 `TidyService.ManualRun` 的 +// `if !s.enabled()` 都发生在**取 runMu 之前**,不受 runMu 保护 —— 它们正是 +// `POST /admin/memories/tidy/dry-run`(含 `?async=1` 那条会起后台 goroutine、真机上 +// 跑 90s 级的版本)与 `POST /admin/memories/tidy/run` 这条读点。只把「调度读」收进 +// 持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。 +// +// 形态是**两个墙钟窗口完全重叠**的连续调用(不是固定轮数)—— 这一点是被实测逼出来的: +// 早先用「写方 40 轮 / 读方 40 轮」时,写方那 40 次覆写在微秒级就跑完了,读方还在慢 +// 端点里打转,于是「撤回 DryRun 同步」这个变异只红 4/8(`-count=1` 会漏掉约一半的 +// 回归)。改成两侧各自跑满同一个 300ms 窗口后,变异 8/8 必红(实测数字见回报)。 +// +// 写方每轮都换一个值:`ApplyPanelTuning` 对「配置没变」会早退(那是它的省事路径), +// 一直写同一个值就根本不会触发 `s.cfg` 的覆写,这条哨兵也就测不到东西。 // // 它**只在 `-race` 下有意义**(不带 -race 时是一次并发冒烟 + 结果断言)。跑法: // @@ -461,13 +471,20 @@ func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) { base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil) - const rounds = 40 + const raceWindow = 300 * time.Millisecond + stop := make(chan struct{}) var wg sync.WaitGroup - wg.Add(2) - // 写方:模拟面板保存(每次换一个周期,一定走整体覆写 s.cfg)。 + + // 写方:连续覆写 s.cfg(每次换一个周期)。 + wg.Add(1) go func() { defer wg.Done() - for i := 0; i < rounds; i++ { + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)} if err := svc.ApplyPanelTuning(base, panel); err != nil { t.Errorf("ApplyPanelTuning: %v", err) @@ -475,24 +492,41 @@ func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) { } } }() - // 读方:两个**活端点**交替 —— 试算(POST …/tidy/dry-run)与手动真跑 - // (POST …/tidy/run)。两者的开关判定都在取 runMu **之前**,因此都在这个字段上 - // 与写方交手;只覆盖其中一个就等于漏了另一个。 - go func() { + + // 读方:两个活端点各起两路(撞上写窗口的机会更多;两路都不改语义)。 + reader := func(name string, call func() error) { defer wg.Done() - for i := 0; i < rounds; i++ { - if i%2 == 0 { - if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) { - t.Errorf("DryRun: %v", err) - return - } - continue + for { + select { + case <-stop: + return + default: } - if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) { - t.Errorf("ManualRun: %v", err) + if err := call(); err != nil { + t.Errorf("%s: %v", name, err) return } } - }() + } + dryRun := func() error { + if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) { + return err + } + return nil + } + manualRun := func() error { + if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) { + return err + } + return nil + } + wg.Add(4) + go reader("DryRun", dryRun) + go reader("DryRun", dryRun) + go reader("ManualRun", manualRun) + go reader("ManualRun", manualRun) + + time.Sleep(raceWindow) + close(stop) wg.Wait() } diff --git a/internal/services/gateway/admin_memory_tuning_guard_test.go b/internal/services/gateway/admin_memory_tuning_guard_test.go index 24aa56a..785ace7 100644 --- a/internal/services/gateway/admin_memory_tuning_guard_test.go +++ b/internal/services/gateway/admin_memory_tuning_guard_test.go @@ -271,9 +271,10 @@ func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) { // 变成 U+FFFD 替换符),等于把「你填了什么」显示成乱码。 // // 取值刻意让截断点**落在多字节字符中间**:JSON 值自带引号(1 字节),再让首个字符是 -// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,第 40 字节正好切在第 13 个 -// 汉字的**第三个字节**上。若只堆汉字(引号 1 + 3n 字节),40 会恰好落在 rune 边界上, -// 那样这条用例会**假绿**(实测过:按字节截断也照样通过)。 +// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,于是 s[:40] 覆盖「引号 + a + +// 12 个完整汉字」(2 + 36 = 38 字节)再切掉第 13 个汉字的**第三个字节**(它的前两个 +// 字节被留下)⇒ 那半个汉字成了非法 UTF-8。若只堆汉字(引号 1 + 3n 字节),40 会恰好 +// 落在 rune 边界上,那样这条用例会**假绿**(实测过:按字节截断也照样通过)。 func TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe(t *testing.T) { t.Parallel()