feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变
【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)
【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。
【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。
.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
This commit is contained in:
1 parent
fe4c702f87
commit
6e28b38b08
37 files changed
+4011
-75
No files matched your search
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
|
||||
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
|
||||
// 键做成只读事实展示(记忆整理 8 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
|
||||
// (AGENTS §10 与主人红线):
|
||||
//
|
||||
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
|
||||
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
|
||||
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
|
||||
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 12 个非敏感调参键,响应里连
|
||||
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 13 个非敏感调参键,响应里连
|
||||
// 凭据类键的「是否已配置」都不出现。
|
||||
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
|
||||
//
|
||||
@@ -78,6 +78,7 @@ type configFactSpec struct {
|
||||
|
||||
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
|
||||
// 后安静时段与后台思考心跳)。键名必须与 `.env.example` 逐字一致(测试钉住)。
|
||||
// 条数:记忆整理 8 + 后台思考心跳 3 + 安静时段 2 = 13。
|
||||
func configFactSpecs() []configFactSpec {
|
||||
return []configFactSpec{
|
||||
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
|
||||
@@ -101,6 +102,12 @@ func configFactSpecs() []configFactSpec {
|
||||
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
|
||||
}},
|
||||
// 两两比对的性能护栏(切片 MT-A,2026-10-06):此前是写死的常量 400,
|
||||
// 459 条的分区被截到 400 ⇒ compared < scanned。开放成 env 键后,面板能看到
|
||||
// 「这项是不是还是默认值」,运维放大配额也不必改代码。
|
||||
{"MEMORY_TIDY_COMPARE_LIMIT", func(c *config.Config) string {
|
||||
return strconv.Itoa(c.MemoryTidy.CompareLimit)
|
||||
}},
|
||||
// 后台思考心跳(design/12 §N A4):安静窗口只降低周期性心跳的频率,
|
||||
// 而 env 是**启动期**读的 ⇒ 面板照实列出「生效值 + 声明默认」并写明改需重启。
|
||||
{"ENABLE_BACKGROUND_THINKING", func(c *config.Config) string {
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
|
||||
|
||||
// TestAdminConfigFacts_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条
|
||||
// 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` /
|
||||
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 12。键名与
|
||||
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 13。键名与
|
||||
// `.env.example` 逐字一致那一半由 TestAdminConfigFacts_KeysMatchEnvExample 钉。
|
||||
func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -117,8 +117,8 @@ func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
|
||||
cfg.Background.IntervalSec = 7200
|
||||
cfg.Background.QuietIntervalSec = 0 // ≤0 = 关闭降频;事实面必须如实报 0
|
||||
_, _, facts := getConfigFacts(t, cfg, "")
|
||||
if len(facts.Facts) != 12 {
|
||||
t.Fatalf("facts = %d, want 12(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
|
||||
if len(facts.Facts) != 13 {
|
||||
t.Fatalf("facts = %d, want 13(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"ENABLE_BACKGROUND_THINKING": "false",
|
||||
@@ -147,7 +147,8 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
|
||||
"MEMORY_TIDY_QUIET_END_HOUR": "7",
|
||||
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
|
||||
"MEMORY_TIDY_SCAN_LIMIT": "5000",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
|
||||
"MEMORY_TIDY_CANDIDATE_LIMIT": "200",
|
||||
"MEMORY_TIDY_COMPARE_LIMIT": "2000",
|
||||
"ENABLE_BACKGROUND_THINKING": "true",
|
||||
"THINK_INTERVAL_SEC": "900",
|
||||
"THINK_QUIET_INTERVAL_SEC": "3600",
|
||||
@@ -157,7 +158,7 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
|
||||
defaultCfg := &config.Config{
|
||||
MemoryTidy: config.MemoryTidyConfig{
|
||||
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
|
||||
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
|
||||
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
|
||||
},
|
||||
Background: config.BackgroundConfig{
|
||||
Enabled: true, IntervalSec: 900, QuietIntervalSec: 3600,
|
||||
@@ -309,7 +310,7 @@ func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
|
||||
// `.env.example` —— 12 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
|
||||
// `.env.example` —— 13 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
|
||||
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
|
||||
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2,7 +2,6 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -41,9 +40,18 @@ const tidyDryRunTimeout = 3 * time.Minute
|
||||
// MemoryTidyDryRunner 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。
|
||||
// 组合根注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份
|
||||
// 配置,因此试算给出的建议与今晚 23:00 那一轮是同一口径。
|
||||
//
|
||||
// 契约 v0.76(切片 MT-A)在同一出口上加了**加性**的两个方法(异步试算的启动与
|
||||
// 查询):它们不改变 DryRun 的语义,只是把「跑很久的那一轮」挪到后台。
|
||||
type MemoryTidyDryRunner interface {
|
||||
// DryRun 跑一轮整理规则并返回候选,零副作用。
|
||||
DryRun(ctx context.Context) (memory.TidyDryRun, error)
|
||||
// StartDryRun 启动一次异步试算并立即返回句柄(契约 v0.76 §4.12.1)。
|
||||
// timeout 是这次运行的墙钟上限(非正取实现侧的兜底值)。
|
||||
StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error)
|
||||
// DryRunResult 查询一次异步试算的状态;runId 未知(含进程重启后)返回
|
||||
// memory.ErrTidyRunNotFound。
|
||||
DryRunResult(runID string) (memory.TidyAsyncDryRun, error)
|
||||
}
|
||||
|
||||
// AdminTidyDryRunCandidate 是试算响应里的一条候选(契约 v0.75 §4.12)。
|
||||
@@ -97,7 +105,15 @@ type AdminTidyDryRunResponse struct {
|
||||
|
||||
// adminMemoryTidyDryRun 是「试算整理建议」的处理函数。它只读语义、不审计
|
||||
// (见文件头注释第 4 条),失败一律如实作答。
|
||||
//
|
||||
// 带 `?async=1` 时改走**加性异步**(契约 v0.76 §4.12.1,见
|
||||
// admin_memory_tidy_async.go):立刻回 202 + runId,结果由 B15 轮询取回。
|
||||
// 不带该参数时这条函数的行为**逐字不变** —— 老客户端(含现有面板)零影响。
|
||||
func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get(tidyAsyncQueryParam) == tidyAsyncTriggerValue {
|
||||
s.adminMemoryTidyDryRunAsync(w, r)
|
||||
return
|
||||
}
|
||||
if s.memoryTidy == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
|
||||
return
|
||||
@@ -113,34 +129,16 @@ func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, adminTidyDryRunResponse(result))
|
||||
}
|
||||
|
||||
// writeTidyDryRunError 把试算失败映射成**诚实**的错误码。用户可见的 message
|
||||
// 只说明「哪一类原因」,原始错误(可能含内部地址/凭据片段)只进日志。
|
||||
// writeTidyDryRunError 把试算失败映射成**诚实**的 HTTP 错误响应。映射表在
|
||||
// tidyDryRunAppError(同步与异步共用的唯一映射点,见 admin_memory_tidy_async.go)。
|
||||
func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, memory.ErrTidyDisabled):
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_disabled",
|
||||
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false),试算同样不上报候选",
|
||||
HTTPCode: http.StatusConflict,
|
||||
})
|
||||
case errors.Is(err, memory.ErrTidyRecallUnavailable):
|
||||
s.log.Error("memory tidy dry-run recall unavailable", "err", err)
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_recall_unavailable",
|
||||
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
|
||||
HTTPCode: http.StatusInternalServerError,
|
||||
})
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
s.log.Error("memory tidy dry-run timed out", "err", err)
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_timeout",
|
||||
Message: "记忆整理试算超时(未产出候选)",
|
||||
HTTPCode: http.StatusServiceUnavailable,
|
||||
})
|
||||
default:
|
||||
s.log.Error("memory tidy dry-run failed", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败"))
|
||||
failed := tidyDryRunAppError(err)
|
||||
// 配置关闭是**显式选择**,不算故障:与既有口径一致,不为它记 ERROR 日志。
|
||||
// 其余失败(召回不可用 / 超时 / 内部错)都要在日志里留原始错误。
|
||||
if failed.Code != "tidy_disabled" {
|
||||
s.log.Error("memory tidy dry-run failed", "code", failed.Code, "err", err)
|
||||
}
|
||||
writeError(w, failed)
|
||||
}
|
||||
|
||||
// adminTidyDryRunResponse 把领域结果投影成线上结构。它**不复制**任何阈值/上限:
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件承载记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A)。
|
||||
//
|
||||
// 为什么有它:真机一轮试算是 **89.9 秒 / 744 条**(同量级的那轮 784 条更慢),
|
||||
// 而抬高护栏(compare 400→2000 / candidate 20→200)只会让它更慢 —— 同步 HTTP
|
||||
// 会被反代或客户端超时切断。异步把「跑」与「取」拆开:`?async=1` 立刻回
|
||||
// `202 + runId`,客户端轮询 B15 取结果。
|
||||
//
|
||||
// 五条不可走样的口径:
|
||||
//
|
||||
// 1. **加性**:不带 `async` 时走的是**原封不动**的同步路径(B14)—— 老客户端
|
||||
// 逐字不变。异步是另一条入口,不是替换。
|
||||
// 2. **结果形状只有一份**:`done` 时的 `result` 就是 §4.12 同步响应的 `data`
|
||||
// (同一个 `adminTidyDryRunResponse` 投影函数),绝不另造第二份 JSON。
|
||||
// 3. **运行活在进程内存**(见 memory.TidyService.StartDryRun):不落库、不新开
|
||||
// 端口;TTL 30 分钟、同时只允许 1 个在跑(第二个请求复用既有 runId);
|
||||
// **进程重启后旧 runId 如实失效** ⇒ `404 tidy_run_not_found`,绝不假装还在跑。
|
||||
// 4. **失败码沿用 §4.12**:`failed` 状态的 `error.code` 与同步路径同一张表
|
||||
// (tidyDryRunAppError 是**唯一**映射点),message 同样只说明原因类别,
|
||||
// 原始内部错误只进日志。
|
||||
// 5. **只算不写 ⇒ 不审计**:与 B14 同门同口径,也不进 owner-only 的写路径。
|
||||
|
||||
// tidyAsyncQueryParam 是触发异步试算的查询参数名(契约 v0.76 §4.12.1)。
|
||||
// 只有字面量 "1" 触发异步:`?async=0` / 缺省 / 其它值一律走同步(老客户端语义)。
|
||||
const tidyAsyncQueryParam = "async"
|
||||
|
||||
// tidyAsyncTriggerValue 是触发异步的字面量(契约登记的就是 `?async=1`)。
|
||||
const tidyAsyncTriggerValue = "1"
|
||||
|
||||
// AdminTidyDryRunAccepted 是 `POST …/tidy/dry-run?async=1` 的 202 响应
|
||||
// (契约 v0.76 §4.12.1)。`runId` 是**不透明**的进程内标识(客户端只用它轮询,
|
||||
// 不要解析其结构);`startedAt` 是这次运行真正开始的墙钟时刻(UTC RFC3339)——
|
||||
// 复用既有运行时,它是那次运行的时间,不是本次请求的时间。
|
||||
type AdminTidyDryRunAccepted struct {
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
|
||||
// AdminTidyRunError 是 `failed` 状态的诚实失败原因:`code` 沿用 §4.12 的失败码表。
|
||||
type AdminTidyRunError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// AdminTidyDryRunStatusResponse 是 B15 轮询响应(契约 v0.76 §4.12.1):
|
||||
// `{status, result?, error?}`,`done` 时 `result` 与 §4.12 同步响应的 `data`
|
||||
// **逐键同形**(含 `candidates` 恒非 null)。
|
||||
type AdminTidyDryRunStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
// Result 只在 status == "done" 时出现;形状 = §4.12 的 data。
|
||||
Result *AdminTidyDryRunResponse `json:"result,omitempty"`
|
||||
// Error 只在 status == "failed" 时出现。
|
||||
Error *AdminTidyRunError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// adminMemoryTidyDryRunStatus 是 B15 的处理函数:查询一次异步试算的状态。
|
||||
// runId 未知(从未存在 / 超 TTL / 进程重启过)⇒ `404 tidy_run_not_found`。
|
||||
func (s *Server) adminMemoryTidyDryRunStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.memoryTidy == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("runId")
|
||||
run, err := s.memoryTidy.DryRunResult(runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, memory.ErrTidyRunNotFound) {
|
||||
// 如实说「查不到」:进程内存态不跨重启,旧 runId 就是不存在。
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_run_not_found",
|
||||
Message: "该试算运行不存在或已过期(结果只活在进程内存:TTL 30 分钟,进程重启即失效)",
|
||||
HTTPCode: http.StatusNotFound,
|
||||
})
|
||||
return
|
||||
}
|
||||
s.log.Error("memory tidy dry-run status failed", "run_id", runID, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算状态查询失败"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminTidyDryRunStatusResponse(run))
|
||||
}
|
||||
|
||||
// adminMemoryTidyDryRunAsync 是 `?async=1` 的处理函数:启动一次后台试算并立刻回
|
||||
// 202(契约 v0.76 §4.12.1)。**不读 body**(与同步路径同:端点自己不带旋钮)。
|
||||
//
|
||||
// 它只做两件事:登记运行 + 回 runId。真正的计算在 memory.TidyService 的后台
|
||||
// goroutine 上跑(它不绑定本请求的上下文 —— 请求一返回连接就结束了,绑上去会
|
||||
// 当场取消这次运行)。
|
||||
func (s *Server) adminMemoryTidyDryRunAsync(w http.ResponseWriter, r *http.Request) {
|
||||
if s.memoryTidy == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
|
||||
return
|
||||
}
|
||||
handle, err := s.memoryTidy.StartDryRun(tidyDryRunTimeout)
|
||||
if err != nil {
|
||||
// 同步的诚实口径照旧:连「排队」都失败时不回 202 —— 那会是一句空承诺。
|
||||
s.writeTidyDryRunError(w, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("memory tidy dry-run accepted",
|
||||
"run_id", handle.RunID, "started_at", handle.StartedAt.UTC().Format(time.RFC3339))
|
||||
writeJSON(w, http.StatusAccepted, AdminTidyDryRunAccepted{
|
||||
RunID: handle.RunID,
|
||||
Status: string(memory.TidyAsyncRunning),
|
||||
StartedAt: handle.StartedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// adminTidyDryRunStatusResponse 把领域侧的异步状态投影成线上结构。`running` 时
|
||||
// 两个载荷键都缺席(**不能**回一个空 result 冒充「跑完了但没有建议」)。
|
||||
func adminTidyDryRunStatusResponse(run memory.TidyAsyncDryRun) AdminTidyDryRunStatusResponse {
|
||||
out := AdminTidyDryRunStatusResponse{Status: string(run.Status)}
|
||||
if run.Result != nil {
|
||||
projected := adminTidyDryRunResponse(*run.Result)
|
||||
out.Result = &projected
|
||||
}
|
||||
if run.Err != nil {
|
||||
failed := tidyDryRunAppError(run.Err)
|
||||
out.Error = &AdminTidyRunError{Code: failed.Code, Message: failed.Message}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tidyDryRunAppError 把试算失败映射成**诚实**的错误码,是同步(HTTP 状态码)与
|
||||
// 异步(failed 状态的 code/message)**唯一**的映射点 —— 两条路径的失败口径因此
|
||||
// 不可能漂移。用户可见的 message 只说明「哪一类原因」,原始错误(可能含内部地址
|
||||
// 或凭据片段)只进日志。
|
||||
func tidyDryRunAppError(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, memory.ErrTidyRecallUnavailable):
|
||||
return &apperr.AppError{
|
||||
Code: "tidy_recall_unavailable",
|
||||
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
|
||||
HTTPCode: http.StatusInternalServerError,
|
||||
}
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return &apperr.AppError{
|
||||
Code: "tidy_timeout",
|
||||
Message: "记忆整理试算超时(未产出候选)",
|
||||
HTTPCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
default:
|
||||
return apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A):
|
||||
//
|
||||
// ① `?async=1` ⇒ 202 + `{runId,status:"running",startedAt}`;**不带**它时逐字走
|
||||
// 原同步路径(老客户端零影响);
|
||||
// ② 轮询 `done` 时的 `result` 与 §4.12 同步响应的 `data` **逐字节同形**
|
||||
// (同一套 JSON,不是第二份);
|
||||
// ③ `failed` 的 `error.code` 与同步路径同一张表;未知/过期 runId ⇒ `404
|
||||
// tidy_run_not_found`(**绝不假装还在跑**);
|
||||
// ④ 鉴权与 B14 同档(owner/admin;viewer 403、匿名 401),未接线 ⇒ 500
|
||||
// `tidy_unavailable` —— 一律**不**回空候选数组冒充「没有建议」;
|
||||
// ⑤ 真机链路:真实 `*memory.TidyService` 上「202 → 轮询 → done」跑通,且
|
||||
// **一行都不写**(三张表调用前后逐行一致)。
|
||||
|
||||
// tidyRunAcceptedEnvelope 是 202 响应的解码壳。
|
||||
type tidyRunAcceptedEnvelope struct {
|
||||
Data struct {
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
} `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// tidyRunStatusEnvelope 是轮询响应的解码壳(result 保留原始字节,供同形断言)。
|
||||
type tidyRunStatusEnvelope struct {
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
} `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// tidyRunRawEnvelope 只留 data 原始字节(比对同形用)。
|
||||
type tidyRunRawEnvelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// postTidyDryRunAsync 调一次 `?async=1` 并解码 202 壳。
|
||||
func postTidyDryRunAsync(t *testing.T, deps Deps, role auth.Role) (int, []byte, tidyRunAcceptedEnvelope, *auth.JWTService, string) {
|
||||
t.Helper()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
token := ""
|
||||
if role != "" {
|
||||
token = signAny(t, jwt, "acct", role)
|
||||
}
|
||||
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
|
||||
var out tidyRunAcceptedEnvelope
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, out, jwt, ts.URL
|
||||
}
|
||||
|
||||
// getTidyRunStatus 查一次异步状态。
|
||||
func getTidyRunStatus(t *testing.T, baseURL, runID, token string) (int, []byte, tidyRunStatusEnvelope) {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodGet, baseURL+"/api/v1/admin/memories/tidy/dry-run/"+runID, nil, token)
|
||||
var out tidyRunStatusEnvelope
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, out
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_AcceptedShape 是判据 ①:`?async=1` 立刻回 202 +
|
||||
// runId/status/startedAt,**不跑**同步试算(不占连接),且墙钟上限与同步同口径。
|
||||
func TestAdminMemoryTidyDryRunAsync_AcceptedShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
startedAt := time.Date(2026, 10, 6, 1, 2, 3, 0, time.UTC)
|
||||
fake := &fakeTidyDryRunner{handle: memory.TidyDryRunHandle{RunID: "run-abc", StartedAt: startedAt}}
|
||||
status, raw, env, _, _ := postTidyDryRunAsync(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
|
||||
if status != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202(body=%s)", status, raw)
|
||||
}
|
||||
if env.Data.RunID != "run-abc" || env.Data.Status != "running" {
|
||||
t.Fatalf("202 载荷不对:%+v", env.Data)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, env.Data.StartedAt); err != nil {
|
||||
t.Fatalf("startedAt 必须是 RFC3339:%q(%v)", env.Data.StartedAt, err)
|
||||
}
|
||||
if fake.startCalls != 1 {
|
||||
t.Fatalf("StartDryRun 调用次数 = %d, want 1", fake.startCalls)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Fatalf("异步入口不得再同步跑一遍:DryRun 调用次数 = %d", fake.calls)
|
||||
}
|
||||
if fake.timeoutGot != tidyDryRunTimeout {
|
||||
t.Fatalf("墙钟上限 = %s, want %s(与同步试算同一口径)", fake.timeoutGot, tidyDryRunTimeout)
|
||||
}
|
||||
if strings.Contains(string(raw), `"candidates"`) {
|
||||
t.Fatalf("202 不得夹带候选数组(结果只在轮询里取):%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_ResultIsByteIdenticalToSync 是判据 ②:轮询 `done`
|
||||
// 的 `result` 与同步端点的 `data` **逐字节一致** —— 客户端可以只写一份解析器。
|
||||
func TestAdminMemoryTidyDryRunAsync_ResultIsByteIdenticalToSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := fixtureTidyDryRun()
|
||||
fake := &fakeTidyDryRunner{
|
||||
result: result,
|
||||
handle: memory.TidyDryRunHandle{RunID: "run-1", StartedAt: time.Now()},
|
||||
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncDone, Result: &result},
|
||||
}
|
||||
deps := Deps{MemoryTidy: fake}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
token := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
_, syncRaw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
|
||||
var syncEnv tidyRunRawEnvelope
|
||||
if err := json.Unmarshal(syncRaw, &syncEnv); err != nil {
|
||||
t.Fatalf("decode sync: %v (%s)", err, syncRaw)
|
||||
}
|
||||
|
||||
status, raw, env := getTidyRunStatus(t, ts.URL, "run-1", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200(body=%s)", status, raw)
|
||||
}
|
||||
if env.Data.Status != "done" {
|
||||
t.Fatalf("status = %q, want done", env.Data.Status)
|
||||
}
|
||||
if env.Data.Error != nil {
|
||||
t.Fatalf("done 不得带 error:%+v", env.Data.Error)
|
||||
}
|
||||
if string(env.Data.Result) != string(syncEnv.Data) {
|
||||
t.Fatalf("result 必须与同步响应的 data 逐字节同形:\n poll=%s\n sync=%s", env.Data.Result, syncEnv.Data)
|
||||
}
|
||||
if !strings.Contains(string(env.Data.Result), `"candidates":[`) {
|
||||
t.Fatalf("result 里的 candidates 必须是非 null 数组:%s", env.Data.Result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_FailedCarriesSameCodeAsSync 是判据 ③:失败态的
|
||||
// `error.code` 与同步路径同一张映射表(这里用「语义召回不可用」),且不回候选数组。
|
||||
func TestAdminMemoryTidyDryRunAsync_FailedCarriesSameCodeAsSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyDryRunner{
|
||||
err: memory.ErrTidyRecallUnavailable,
|
||||
handle: memory.TidyDryRunHandle{RunID: "run-fail", StartedAt: time.Now()},
|
||||
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncFailed, Err: memory.ErrTidyRecallUnavailable},
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: fake})
|
||||
token := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
status, raw, env := getTidyRunStatus(t, ts.URL, "run-fail", token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200(失败在载荷里,不是 HTTP 错误)", status)
|
||||
}
|
||||
if env.Data.Status != "failed" || env.Data.Error == nil {
|
||||
t.Fatalf("失败态载荷不对:%s", raw)
|
||||
}
|
||||
if env.Data.Error.Code != "tidy_recall_unavailable" {
|
||||
t.Fatalf("error.code = %q, want tidy_recall_unavailable(与同步同一张表)", env.Data.Error.Code)
|
||||
}
|
||||
if len(env.Data.Result) != 0 {
|
||||
t.Fatalf("失败态不得带 result:%s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"candidates"`) {
|
||||
t.Fatalf("失败态不得出现候选数组(那是把「没算出来」谎报成「没有建议」):%s", raw)
|
||||
}
|
||||
|
||||
// 负对照:同步端点在同一个错误上给的是同一个 code。
|
||||
_, syncRaw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
|
||||
if !strings.Contains(string(syncRaw), `"tidy_recall_unavailable"`) {
|
||||
t.Fatalf("同步路径的失败码必须与异步一致:%s", syncRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_UnknownRunIsHonest404 是判据 ③(重启/过期语义):
|
||||
// 查不到的 runId 如实回 404,**不做**「还在跑」的假承诺。
|
||||
func TestAdminMemoryTidyDryRunAsync_UnknownRunIsHonest404(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: &fakeTidyDryRunner{}})
|
||||
token := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
status, raw, env := getTidyRunStatus(t, ts.URL, "run-gone", token)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404(body=%s)", status, raw)
|
||||
}
|
||||
if env.Error.Code != "tidy_run_not_found" {
|
||||
t.Fatalf("code = %q, want tidy_run_not_found", env.Error.Code)
|
||||
}
|
||||
if env.Data.Status != "" || strings.Contains(string(raw), `"running"`) {
|
||||
t.Fatalf("查不到时不得回任何状态(更不许假装还在跑):%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_RequiresOwnerOrAdmin 是判据 ④:两条端点的鉴权
|
||||
// 都与 B14 同档(owner/admin 可,viewer 403,匿名 401)。
|
||||
func TestAdminMemoryTidyDryRunAsync_RequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := fixtureTidyDryRun()
|
||||
deps := Deps{MemoryTidy: &fakeTidyDryRunner{
|
||||
result: result,
|
||||
handle: memory.TidyDryRunHandle{RunID: "run-rbac", StartedAt: time.Now()},
|
||||
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncDone, Result: &result},
|
||||
}}
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
|
||||
status, _, _, _, _ := postTidyDryRunAsync(t, deps, role)
|
||||
if status != http.StatusAccepted {
|
||||
t.Fatalf("POST async role %s = %d, want 202", role, status)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", signAny(t, jwt, "acct", role))
|
||||
if s != http.StatusOK {
|
||||
t.Fatalf("GET role %s = %d, want 200", role, s)
|
||||
}
|
||||
}
|
||||
if status, _, _, _, _ := postTidyDryRunAsync(t, deps, auth.RoleViewer); status != http.StatusForbidden {
|
||||
t.Fatalf("POST async viewer = %d, want 403", status)
|
||||
}
|
||||
if status, _, _, _, _ := postTidyDryRunAsync(t, deps, ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("POST async 匿名 = %d, want 401", status)
|
||||
}
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
|
||||
if s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", signAny(t, jwt, "acct", auth.RoleViewer)); s != http.StatusForbidden {
|
||||
t.Fatalf("GET viewer = %d, want 403", s)
|
||||
}
|
||||
if s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", ""); s != http.StatusUnauthorized {
|
||||
t.Fatalf("GET 匿名 = %d, want 401", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_UnwiredIsHonest 是判据 ④(未接线):两条端点都
|
||||
// 如实回 500 tidy_unavailable,且绝不回候选数组。
|
||||
func TestAdminMemoryTidyDryRunAsync_UnwiredIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, raw, env, _, _ := postTidyDryRunAsync(t, Deps{}, auth.RoleOwner)
|
||||
if status != http.StatusInternalServerError || env.Error.Code != "tidy_unavailable" {
|
||||
t.Fatalf("未接线 POST async = %d/%q, want 500/tidy_unavailable", status, env.Error.Code)
|
||||
}
|
||||
if strings.Contains(string(raw), `"candidates"`) {
|
||||
t.Fatalf("未接线时不得回候选数组:%s", raw)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
s, rawGet, getEnv := getTidyRunStatus(t, ts.URL, "run-x", signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if s != http.StatusInternalServerError || getEnv.Error.Code != "tidy_unavailable" {
|
||||
t.Fatalf("未接线 GET = %d/%q, want 500/tidy_unavailable(body=%s)", s, getEnv.Error.Code, rawGet)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRun_AsyncOnlyOnExplicitFlag 是判据 ①(加性):只有
|
||||
// `async=1` 才走异步;缺省、`async=0`、`async=true` 一律走原同步路径。
|
||||
func TestAdminMemoryTidyDryRun_AsyncOnlyOnExplicitFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, query := range []string{"", "?async=0", "?async=true", "?async="} {
|
||||
fake := &fakeTidyDryRunner{result: fixtureTidyDryRun()}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: fake})
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
ts.URL+"/api/v1/admin/memories/tidy/dry-run"+query, nil, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("query=%q status = %d, want 200(老客户端逐字不变)", query, resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"dryRun":true`) {
|
||||
t.Fatalf("query=%q 必须是同步响应:%s", query, raw)
|
||||
}
|
||||
if fake.calls != 1 || fake.startCalls != 0 {
|
||||
t.Fatalf("query=%q 走了异步路径:calls=%d startCalls=%d", query, fake.calls, fake.startCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tidyDBRows 把三张表的内容渲染成可比较的字符串行(逐行、按主键排序)。
|
||||
func tidyDBRows(t *testing.T, conn *sql.DB) []string {
|
||||
t.Helper()
|
||||
queries := []string{
|
||||
`SELECT id || '|' || persona_id || '|' || scope || '|' || kind || '|' || content || '|' || status FROM memory_items ORDER BY id`,
|
||||
`SELECT id || '|' || action || '|' || status || '|' || rule_version FROM memory_tidy_candidates ORDER BY id`,
|
||||
`SELECT id || '|' || scanned || '|' || candidates || '|' || rule_version FROM memory_tidy_runs ORDER BY id`,
|
||||
}
|
||||
out := make([]string, 0)
|
||||
for _, q := range queries {
|
||||
rows, err := conn.QueryContext(context.Background(), q)
|
||||
if err != nil {
|
||||
t.Fatalf("dump (%s): %v", q, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if err := rows.Scan(&line); err != nil {
|
||||
_ = rows.Close()
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
t.Fatalf("iterate: %v", err)
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_EndToEndIsZeroSideEffect 是判据 ⑤:真实
|
||||
// `*memory.TidyService` 上走完「202 → 轮询 → done」,结果与同步端点一致,且
|
||||
// **三张表逐行不变**(异步路径与同步路径同一条硬约束)。
|
||||
func TestAdminMemoryTidyDryRunAsync_EndToEndIsZeroSideEffect(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
store, err := memory.NewStore(ctx, conn, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("memory.NewStore: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
|
||||
for i, content := range []string{
|
||||
"主人常在深夜与助手聊天,有熬夜习惯",
|
||||
"主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
} {
|
||||
if _, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: content, Confidence: 0.9, CreatedAt: base.Add(time.Duration(i) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
svc, err := memory.NewTidyService(memory.TidyConfig{
|
||||
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
|
||||
RecallThreshold: 0.80, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
|
||||
}, memory.TidyDeps{Store: store, Log: gwTestLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidyService: %v", err)
|
||||
}
|
||||
|
||||
before := tidyDBRows(t, conn)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: svc})
|
||||
token := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202(body=%s)", resp.StatusCode, raw)
|
||||
}
|
||||
var accepted tidyRunAcceptedEnvelope
|
||||
if err := json.Unmarshal(raw, &accepted); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
runID := accepted.Data.RunID
|
||||
if runID == "" || accepted.Data.Status != "running" {
|
||||
t.Fatalf("202 载荷不对:%+v", accepted.Data)
|
||||
}
|
||||
|
||||
// 轮询到 done(真机一轮 90 秒量级;测试夹具是毫秒级)。
|
||||
var done tidyRunStatusEnvelope
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("轮询超时,最后状态 %q", done.Data.Status)
|
||||
}
|
||||
status, body, env := getTidyRunStatus(t, ts.URL, runID, token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("轮询 status = %d(body=%s)", status, body)
|
||||
}
|
||||
if env.Data.Status == "failed" {
|
||||
t.Fatalf("异步试算失败:%+v", env.Data.Error)
|
||||
}
|
||||
if env.Data.Status == "done" {
|
||||
done = env
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
var result AdminTidyDryRunResponse
|
||||
if err := json.Unmarshal(done.Data.Result, &result); err != nil {
|
||||
t.Fatalf("decode result: %v (%s)", err, done.Data.Result)
|
||||
}
|
||||
if !result.DryRun || result.RuleVersion != memory.TidyRuleVersion {
|
||||
t.Fatalf("result 元信息不对:%+v", result)
|
||||
}
|
||||
if len(result.Candidates) == 0 {
|
||||
t.Fatalf("夹具应当产出候选(否则这条用例是空转):%+v", result)
|
||||
}
|
||||
|
||||
// 同步端点在同一批数据上给出同一套结果(同一套规则、同一个投影函数)。
|
||||
_, syncRaw, syncEnv := postTidyDryRun(t, Deps{MemoryTidy: svc}, auth.RoleOwner)
|
||||
if syncEnv.Data.RuleVersion != result.RuleVersion || len(syncEnv.Data.Candidates) != len(result.Candidates) {
|
||||
t.Fatalf("异步与同步结果不一致:\n async=%+v\n sync =%+v(%s)", result, syncEnv.Data, syncRaw)
|
||||
}
|
||||
|
||||
after := tidyDBRows(t, conn)
|
||||
if strings.Join(before, "\n") != strings.Join(after, "\n") {
|
||||
t.Fatalf("试算有副作用(三张表必须逐行不变):\n before=%v\n after =%v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyDryRunAsync_RealServiceRestartDropsRunID 钉住「进程重启 ⇒
|
||||
// 旧 runId 如实失效」:同一份存储上换一个服务实例(= 重启),旧 runId 回 404。
|
||||
func TestAdminMemoryTidyDryRunAsync_RealServiceRestartDropsRunID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
store, err := memory.NewStore(ctx, conn, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("memory.NewStore: %v", err)
|
||||
}
|
||||
cfg := memory.TidyConfig{
|
||||
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
|
||||
RecallThreshold: 0.80, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
|
||||
}
|
||||
first, err := memory.NewTidyService(cfg, memory.TidyDeps{Store: store, Log: gwTestLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidyService: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: first})
|
||||
token := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202(body=%s)", resp.StatusCode, raw)
|
||||
}
|
||||
var accepted tidyRunAcceptedEnvelope
|
||||
if err := json.Unmarshal(raw, &accepted); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
|
||||
// 「重启」:同一份存储上的新实例(注册表是进程内存态,重启即空)。
|
||||
restarted, err := memory.NewTidyService(cfg, memory.TidyDeps{Store: store, Log: gwTestLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidyService(restart): %v", err)
|
||||
}
|
||||
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{MemoryTidy: restarted})
|
||||
status, body, env := getTidyRunStatus(t, ts2.URL, accepted.Data.RunID, signAny(t, jwt2, "acct", auth.RoleOwner))
|
||||
if status != http.StatusNotFound || env.Error.Code != "tidy_run_not_found" {
|
||||
t.Fatalf("重启后旧 runId = %d/%q, want 404/tidy_run_not_found(body=%s)", status, env.Error.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
@@ -23,10 +25,24 @@ import (
|
||||
|
||||
// fakeTidyDryRunner 是端点测试用的试算替身:按注入的结果/错误作答,并记录调用
|
||||
// 次数(用来钉住「关闭时连试算都不跑」)。
|
||||
//
|
||||
// 异步面(契约 v0.76 §4.12.1)同样以「可驱动的桩」呈现:HTTP 层的职责是把领域
|
||||
// 状态**如实投影**,注册表本身的行为(TTL / 并发 / 重启语义)由 memory 包的单测
|
||||
// 钉住,不在这里重复实现一遍。
|
||||
type fakeTidyDryRunner struct {
|
||||
result memory.TidyDryRun
|
||||
err error
|
||||
calls int
|
||||
|
||||
handle memory.TidyDryRunHandle
|
||||
startErr error
|
||||
startCalls int
|
||||
// timeoutGot 记录网关传给 StartDryRun 的墙钟上限(钉住「与同步同一口径」)。
|
||||
timeoutGot time.Duration
|
||||
|
||||
status memory.TidyAsyncDryRun
|
||||
statusErr error
|
||||
statusCalls int
|
||||
}
|
||||
|
||||
func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
@@ -34,6 +50,29 @@ func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *fakeTidyDryRunner) StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error) {
|
||||
f.startCalls++
|
||||
f.timeoutGot = timeout
|
||||
if f.startErr != nil {
|
||||
return memory.TidyDryRunHandle{}, f.startErr
|
||||
}
|
||||
return f.handle, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyDryRunner) DryRunResult(runID string) (memory.TidyAsyncDryRun, error) {
|
||||
f.statusCalls++
|
||||
if f.statusErr != nil {
|
||||
return memory.TidyAsyncDryRun{}, f.statusErr
|
||||
}
|
||||
if f.status.Status == "" {
|
||||
// 未登记的状态 = 查不到(真实实现里由内存注册表决定)。
|
||||
return memory.TidyAsyncDryRun{}, fmt.Errorf("%w: %s", memory.ErrTidyRunNotFound, runID)
|
||||
}
|
||||
out := f.status
|
||||
out.RunID = runID
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fixtureTidyDryRun 是一条两候选/含否决分布/含截断标签的完整试算结果。
|
||||
func fixtureTidyDryRun() memory.TidyDryRun {
|
||||
return memory.TidyDryRun{
|
||||
|
||||
@@ -195,7 +195,15 @@ func (s *Server) registerAdminRoutes(
|
||||
// 它是**只读语义**——跑同一套整理规则把候选直接回给面板、一行都不写;因此
|
||||
// 它不在 registerAdminWriteRoutes(那条路是 owner-only 的真实写入 + 审计)。
|
||||
// 试算属于「算给我看」,不是「改给我看」。
|
||||
//
|
||||
// 切片 MT-A(**v0.76**)在同一端点上加了**加性**异步:`?async=1` ⇒ 202 + runId,
|
||||
// 再用下面那条 GET 轮询;不带该参数时逐字走原同步路径。
|
||||
mux.Handle("POST /api/v1/admin/memories/tidy/dry-run", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRun)))
|
||||
// 异步试算的结果查询(切片 MT-A,**v0.76**):与 B14 **同门同档**(writeAuth =
|
||||
// owner/admin)—— 它是同一次计算的另一种取法,绝不比同步入口更宽;同样只读
|
||||
// 语义、同样**不审计**。结果只活在进程内存(TTL 30 分钟),进程重启后旧 runId
|
||||
// 如实 404。
|
||||
mux.Handle("GET /api/v1/admin/memories/tidy/dry-run/{runId}", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRunStatus)))
|
||||
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)))
|
||||
|
||||
Reference in new issue
Block a user