Files
Cyrene_v2/internal/runtime/memory/tidy_dryrun_async_test.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

312 lines
11 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 memory
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A):
//
// ① **零副作用照旧**:异步跑完后 memory_items / memory_tidy_candidates /
// memory_tidy_runs **逐行不变**(与同步试算同一条硬约束,且用的是既有的
// 逐行快照比对,不是另一套断言);
// ② **结果 = 同步结果**:异步取回的候选与同一批数据上的同步试算逐字段一致;
// ③ **同时只允许 1 个在跑**:第二个请求复用既有 runId,不另起一轮;
// ④ **有界且诚实**:完成后有 TTL(30 分钟)、进程重启后旧 runId 如实失效
// (ErrTidyRunNotFound),未知 runId 同样如此 —— 绝不假装还在跑;
// ⑤ **失败带真实原因**:语义召回不可用 / 配置关闭 / 超时都回失败态 + 原始错误,
// 绝不回一份空候选冒充「没有建议」。
// advClock 是可由测试推进的时钟(并发安全:后台 goroutine 也会读它)。
type advClock struct {
mu sync.Mutex
at time.Time
}
func newAdvClock(at time.Time) *advClock { return &advClock{at: at} }
func (c *advClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *advClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.at = c.at.Add(d)
}
// gateEmbedder 是「卡在召回里不放」的 embedder:它让一轮停在 embedding 调用上,
// 用来验「同时只允许 1 个在跑」。release 关闭后所有调用立即返回单位向量。
type gateEmbedder struct{ release chan struct{} }
func (g gateEmbedder) Embed(ctx context.Context, texts []string) ([]embedding.Vector, error) {
select {
case <-g.release:
case <-ctx.Done():
return nil, ctx.Err()
}
out := make([]embedding.Vector, 0, len(texts))
for range texts {
out = append(out, embedding.Vector{1, 0})
}
return out, nil
}
func (gateEmbedder) Dim() int { return 2 }
// hungEmbedder 模拟「embedding 服务半死不活」:一直等到 ctx 被取消。
type hungEmbedder struct{}
func (hungEmbedder) Embed(ctx context.Context, _ []string) ([]embedding.Vector, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (hungEmbedder) Dim() int { return 2 }
// waitTidyRun 轮询到想要的终态;超时或意外失败即让用例失败。
func waitTidyRun(t *testing.T, svc *TidyService, runID string, want TidyAsyncStatus) TidyAsyncDryRun {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
run, err := svc.DryRunResult(runID)
if err != nil {
t.Fatalf("DryRunResult(%s): %v", runID, err)
}
if run.Status == want {
return run
}
if run.Status == TidyAsyncFailed && want != TidyAsyncFailed {
t.Fatalf("运行提前失败:%v", run.Err)
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("等待 %s 超时", want)
return TidyAsyncDryRun{}
}
// TestTidyAsyncDryRun_ZeroSideEffectsAndMatchesSync 是判据 ①②:异步路径**复用**
// 同步试算的单测口径 —— 跑完后三张表逐行不变,且取回的候选与同步试算逐字段一致。
func TestTidyAsyncDryRun_ZeroSideEffectsAndMatchesSync(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
before := snapshotTidyTables(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
if handle.RunID == "" || handle.StartedAt.IsZero() {
t.Fatalf("句柄必须带 runId 与 startedAt:%+v", handle)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncDone)
if run.Result == nil {
t.Fatal("done 必须带 result(形状 = 同步试算的结果)")
}
if len(run.Result.Candidates) == 0 {
t.Fatalf("夹具应当产出候选(否则这条断言是空转):%+v", run.Result.Stats)
}
sync, err := svc.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
got := sortedSignatures(run.Result.Candidates)
want := sortedSignatures(sync.Candidates)
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("异步候选与同步试算不一致:\n async=%v\n sync =%v", got, want)
}
if run.Result.RecallMode != sync.RecallMode || run.Result.Stats.Compared != sync.Stats.Compared {
t.Fatalf("异步与同步的观测口径不一致:%+v vs %+v", run.Result, sync)
}
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "memory_items", before.items, after.items)
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
}
// TestTidyAsyncDryRun_OnlyOneRunAtATimeReusesRunID 是判据 ③:同时只允许 1 个在跑,
// 第二个请求**复用**既有 runId(契约 §4.12.1 给的「如实拒绝或复用」中选了复用)。
func TestTidyAsyncDryRun_OnlyOneRunAtATimeReusesRunID(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
gate := gateEmbedder{release: make(chan struct{})}
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), gate, time.Now, nil)
first, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
second, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun(第二次): %v", err)
}
if second.RunID != first.RunID {
t.Fatalf("在跑期间第二个请求必须复用 runId(同时只允许 1 个):%q vs %q", second.RunID, first.RunID)
}
if !second.StartedAt.Equal(first.StartedAt) {
t.Fatalf("复用必须是**同一次**运行:startedAt %v vs %v", second.StartedAt, first.StartedAt)
}
run, err := svc.DryRunResult(first.RunID)
if err != nil {
t.Fatalf("DryRunResult: %v", err)
}
if run.Status != TidyAsyncRunning {
t.Fatalf("还没释放闸门就报 %q,want running", run.Status)
}
close(gate.release)
done := waitTidyRun(t, svc, first.RunID, TidyAsyncDone)
if done.Result == nil {
t.Fatal("完成后必须有结果")
}
// 上一轮跑完之后,新请求才是**新的一次**运行(runId 不重复用)。
third, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun(第三次): %v", err)
}
if third.RunID == first.RunID {
t.Fatal("上一轮已结束,新请求必须是一次新运行(runId 必须不同)")
}
waitTidyRun(t, svc, third.RunID, TidyAsyncDone)
}
// TestTidyAsyncDryRun_TTLExpiryIsHonest 是判据 ④(TTL):TTL 内可查,超时后如实
// 查不到,绝不假装还在跑。
func TestTidyAsyncDryRun_TTLExpiryIsHonest(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
clock := newAdvClock(time.Now())
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), clock.now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
waitTidyRun(t, svc, handle.RunID, TidyAsyncDone)
clock.advance(defaultTidyRunTTL - time.Minute)
if _, err := svc.DryRunResult(handle.RunID); err != nil {
t.Fatalf("TTL 内必须还能查到:%v", err)
}
clock.advance(2 * time.Minute)
if _, err := svc.DryRunResult(handle.RunID); !errors.Is(err, ErrTidyRunNotFound) {
t.Fatalf("超 TTL 后必须如实报 ErrTidyRunNotFound,got %v", err)
}
}
// TestTidyAsyncDryRun_RestartDropsRunIDs 是判据 ④(重启语义):结果只活在进程
// 内存里,**同一份存储上新建的实例**(= 进程重启)查不到旧 runId。
func TestTidyAsyncDryRun_RestartDropsRunIDs(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
first := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
handle, err := first.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
waitTidyRun(t, first, handle.RunID, TidyAsyncDone)
restarted := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
if _, err := restarted.DryRunResult(handle.RunID); !errors.Is(err, ErrTidyRunNotFound) {
t.Fatalf("重启后旧 runId 必须如实失效(注册表是进程内存态),got %v", err)
}
}
// TestTidyAsyncDryRun_UnknownRunIDIsHonest 钉住「未知 runId」的诚实语义。
func TestTidyAsyncDryRun_UnknownRunIDIsHonest(t *testing.T) {
t.Parallel()
store := newTestStore(t)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, time.Now, nil)
for _, id := range []string{"", "run-nonexistent", "../../etc/passwd"} {
if _, err := svc.DryRunResult(id); !errors.Is(err, ErrTidyRunNotFound) {
t.Fatalf("DryRunResult(%q) err = %v, want ErrTidyRunNotFound", id, err)
}
}
}
// TestTidyAsyncDryRun_FailureCarriesRealCause 是判据 ⑤:语义召回不可用时失败态带
// **真实原因**(ErrTidyRecallUnavailable),且没有 result —— 面板因此不会把
// 「没算出来」读成「算过了,没有建议」。
func TestTidyAsyncDryRun_FailureCarriesRealCause(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
before := snapshotTidyTables(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80),
failingEmbedder{err: errors.New("dial tcp 127.0.0.1:11434: 目标计算机积极拒绝")}, time.Now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncFailed)
if !errors.Is(run.Err, ErrTidyRecallUnavailable) {
t.Fatalf("失败原因 = %v, want ErrTidyRecallUnavailable", run.Err)
}
if run.Result != nil {
t.Fatal("失败态不得带 result(那会把「没算出来」伪装成结果)")
}
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "memory_items", before.items, after.items)
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
}
// TestTidyAsyncDryRun_DisabledFailsHonestly 钉住「配置关闭」在异步路径上的口径:
// 仍然如实回 runId(不假装排队成功),失败原因就是 ErrTidyDisabled —— 与同步
// 路径同一个错误,网关据此投影成同一张失败码表。
func TestTidyAsyncDryRun_DisabledFailsHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
cfg := tidyTestCfg(0.80)
cfg.Enabled = false
svc := newTidyServiceForTest(t, store, cfg, dryRunFixtureEmbedder(), time.Now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncFailed)
if !errors.Is(run.Err, ErrTidyDisabled) {
t.Fatalf("关闭时失败原因 = %v, want ErrTidyDisabled", run.Err)
}
}
// TestTidyAsyncDryRun_TimeoutFailsHonestly 钉住墙钟上限:embedding 卡死到超时 ⇒
// 失败态里的错误链含 context.DeadlineExceeded(网关据此投影成 tidy_timeout / 召回
// 不可用,见 gateway 的同一张映射表)。
func TestTidyAsyncDryRun_TimeoutFailsHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), hungEmbedder{}, time.Now, nil)
handle, err := svc.StartDryRun(300 * time.Millisecond)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncFailed)
if !errors.Is(run.Err, context.DeadlineExceeded) {
t.Fatalf("超时的错误链必须含 context.DeadlineExceeded,got %v", run.Err)
}
}