Files
Cyrene_v2/internal/services/gateway/admin_memory_tidy_run.go
T
AskaEth 454ddb0b70
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。
根因两条(都在这片里修掉):
① **以前根本没有执行面** —— 只有只读试算,候选永远 pending
② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完

- 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并**
  (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计
  (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道)
- 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run),
  「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死**
- 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时)
- 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column)
- 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false)
⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行)
⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
2026-10-06 22:39:35 +08:00

101 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"errors"
"net/http"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件是记忆整理**手动真跑**入口(切片 TIDY-1):
// POST /api/v1/admin/memories/tidy/run —— 立刻按同一套规则跑一轮,写候选 + 写运行
// 留痕,并把「新增/合并/软删各多少 + 耗时」如实回执。审计由领域侧 TidyService 记
// `memory.tidy.run`(一次点击一条,本文件不重复)。
// adminMemoryTidyRun 处理 POST /api/v1/admin/memories/tidy/run:手动**真跑**一轮
// (与心跳、试算走同一套规则与配置;写候选 + 运行留痕)。它是「panic button」:
// 主人不必等到 23:00 就能让整理真的产出一批待定候选,再决定应用。
//
// 审计不在这里写 —— 领域侧 TidyService 已按 design §8 记 `memory.tidy.run`
// (result 里带 trigger=manual / run_id / 计数)。一次点击一条审计。
func (s *Server) adminMemoryTidyRun(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理服务未接线"))
return
}
runner, ok := s.memoryTidy.(MemoryTidyRunner)
if !ok {
writeError(w, &apperr.AppError{
Code: "tidy_run_unavailable",
Message: "记忆整理真跑入口未接线(只有只读试算可用)",
HTTPCode: http.StatusInternalServerError,
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), tidyRunTimeout)
defer cancel()
stats, err := runner.ManualRun(ctx)
if err != nil {
writeError(w, tidyRunAppError(err))
return
}
s.log.Info("memory tidy manual run finished",
"run_id", stats.RunID, "scanned", stats.Scanned, "candidates", stats.Candidates,
"duplicates", stats.Duplicates, "duration_ms", stats.DurationMS)
writeJSON(w, http.StatusOK, adminTidyRunResponse(stats))
}
// tidyRunAppError 把「真跑」失败映射成诚实的错误码。映射表与试算那张
// (tidyDryRunAppError)**分开**:试算的 500/409 文案讲的是「没产出候选」,
// 真跑讲的是「没跑成」—— 同一类别、不同事实,混用会让排障读到错的那句。
func tidyRunAppError(err error) *apperr.AppError {
switch {
case errors.Is(err, memory.ErrTidyDisabled):
return &apperr.AppError{
Code: "tidy_disabled",
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false)",
HTTPCode: http.StatusConflict,
}
case errors.Is(err, context.DeadlineExceeded):
return &apperr.AppError{
Code: "tidy_timeout",
Message: "记忆整理真跑超时(本轮未完整跑完,已跑部分仍写入了留痕)",
HTTPCode: http.StatusServiceUnavailable,
}
default:
return apperr.Wrap(apperr.ErrInternal, "tidy_run_failed", "记忆整理真跑失败")
}
}
// adminTidyRunResponse 投影真跑回执。
func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
byAction := stats.ByAction
if byAction == nil {
byAction = map[string]int{}
}
reasons := stats.VetoReasons
if reasons == nil {
reasons = map[string]int{}
}
return AdminTidyRunResponse{
RunID: stats.RunID,
Trigger: "manual",
RuleVersion: memory.TidyRuleVersion,
DurationMs: stats.DurationMS,
Partitions: stats.Partitions,
Scanned: stats.Scanned,
Compared: stats.Compared,
Pairs: stats.Pairs,
Vetoed: stats.Vetoed,
Errors: stats.Errors,
Truncated: stats.Truncated,
VetoReasons: reasons,
CandidatesCreated: stats.Candidates,
CandidatesDuplicate: stats.Duplicates,
CandidatesByAction: byAction,
}
}