package memory import ( "bytes" "context" "log/slog" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" ) // 本文件锁定期清扫的**接线与后台形态**(design/23 §19):挂点(AttachExpirySweep)、 // 随 Stop 可停、一轮只写一行汇总日志,以及「删行时同步清掉向量」这条 finding 的 // 集成断言。清扫自身的边界(哪些行删、哪些行留、审计长什么样)在 expire_test.go。 // TestExpirySweepOnce_一轮只写一行汇总:逐条的那一行进审计,日志里一轮只有一行 // (§19.4「一轮汇总只进日志,绝不逐条把正文刷进日志」)。 func TestExpirySweepOnce_一轮只写一行汇总(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) now := tidyTestTime(t) var buf bytes.Buffer var mu sync.Mutex audit := func(who, what, result string) { mu.Lock() defer mu.Unlock() _ = who _ = what _ = result } svc, err := NewTidyService(tidyTestCfg(0.80), TidyDeps{ Store: store, Log: slog.New(slog.NewTextHandler(&buf, nil)), Now: fixedTidyClock(now), Audit: audit, }) if err != nil { t.Fatalf("NewTidyService: %v", err) } for i := 0; i < 3; i++ { addItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: "日志里不该出现的正文片段", ExpiresAt: now.Add(-time.Minute), }) } svc.expirySweepOnce(ctx) lines := strings.Split(strings.TrimSpace(buf.String()), "\n") if len(lines) != 1 { t.Fatalf("一轮写入了 %d 行日志,want 1(只允许一行汇总):\n%s", len(lines), buf.String()) } if !strings.Contains(lines[0], "deleted=3") { t.Fatalf("汇总行没有计数:%q", lines[0]) } if strings.Contains(buf.String(), "日志里不该出现的正文片段") { t.Fatalf("日志里出现了正文 —— 逐条正文只许进审计的截断摘要:%q", buf.String()) } } // TestAttachExpirySweep_挂上即扫_随Stop可停 钉住**接线**:清扫挂到整理服务的 // 心跳 ctx 上(AttachExpirySweep),第一次心跳等待就启动、启动即扫一轮;ctx 取消 // 后它的 goroutine 必须退出 —— `Stop` 走的就是「cancel + wg.Wait」,这里用同一个 // 手法验证「可停」(§19.3-3)。 func TestAttachExpirySweep_挂上即扫_随Stop可停(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) now := tidyTestTime(t) row := addItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: "在心跳上被扫掉的到期行", ExpiresAt: now.Add(-time.Minute), }) var ( mu sync.Mutex audits []string ) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(now), func(who, what, result string) { mu.Lock() defer mu.Unlock() audits = append(audits, what+" "+result) }) // 假等待:不真等(真等会让用例挂 24h)。它只用来模拟「心跳调用了一次 wait」。 svc.wait = func(context.Context, time.Duration) bool { return true } svc.AttachExpirySweep() runCtx, cancel := context.WithCancel(ctx) defer cancel() if !svc.wait(runCtx, time.Hour) { t.Fatal("包装后的 wait 必须是透传的(假等待返回 true,包装必须照原样返回)") } waitUntilRowGone(t, store, row.ID) cancel() done := make(chan struct{}) go func() { svc.wg.Wait() close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("清扫 goroutine 没有随 ctx 取消退出 —— 「可停」不成立(Stop 会卡在 wg.Wait)") } mu.Lock() defer mu.Unlock() if len(audits) != 1 || !strings.HasPrefix(audits[0], expireAuditVerb+" ") { t.Fatalf("审计 = %v, want 一条 what=%s", audits, expireAuditVerb) } } // TestSweepExpiredMemories_同步清掉向量 是切片 M1 的 reviewer 交上来的那条 finding // 的断言:过期行被物理删除时,它的**向量也必须一起清掉** —— 否则向量留在索引里 // 占掉 idx.Search 的 top-K 预算,召回质量静默下降(行本身已被读侧闸挡住,不会泄漏)。 func TestSweepExpiredMemories_同步清掉向量(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) idx := newTestVectorIndex(t, 64) store.attachVectorIndex(idx) now := tidyTestTime(t) expired := addItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: "到期的向量条目", ExpiresAt: now.Add(-time.Minute), }) kept := addItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: "长期留着的向量条目", }) if !vectorIndexHas(t, ctx, idx, expired.ID) { t.Fatal("前置条件不成立:到期行的向量本来就该在索引里(写入侧没喂进去)") } if !vectorIndexHas(t, ctx, idx, kept.ID) { t.Fatal("前置条件不成立:保留行的向量本来就该在索引里") } if _, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, nil); err != nil { t.Fatalf("SweepExpiredMemories: %v", err) } if vectorIndexHas(t, ctx, idx, expired.ID) { t.Fatal("到期行删了、向量还在索引里 —— 它会占掉 top-K 预算(M1 reviewer 的 finding)") } if !vectorIndexHas(t, ctx, idx, kept.ID) { t.Fatal("保留行的向量被误清了(清算成了通杀)") } } // vectorIndexHas 用 n-gram 索引自带的 embedder 查一次,报告某 id 是否还在索引里。 func vectorIndexHas(t *testing.T, ctx context.Context, idx vector.Index, id string) bool { t.Helper() vecs, err := idx.Embed(ctx, []string{"到期的向量条目 长期留着的向量条目"}) if err != nil { t.Fatalf("embed query: %v", err) } hits, err := idx.Search(ctx, vecs[0], 50) if err != nil { t.Fatalf("search index: %v", err) } for _, h := range hits { if h.ID == id { return true } } return false } // TestClipExpireSummary 单钉摘要裁剪这件小事:空白归一(含换行)、按 rune 截断、 // 截断才加省略号。 func TestClipExpireSummary(t *testing.T) { t.Parallel() tests := []struct { name string in string want string }{ {name: "短文本原样", in: "谢谢啦", want: "谢谢啦"}, {name: "空白归一", in: "一\n\t二 三", want: "一 二 三"}, {name: "空文本", in: " ", want: ""}, { name: "超长按 rune 截断并省略号", in: strings.Repeat("很", expireAuditSummaryRunes+10), want: strings.Repeat("很", expireAuditSummaryRunes-1) + "…", }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := clipExpireSummary(tc.in); got != tc.want { t.Fatalf("clipExpireSummary(%q) = %q, want %q", tc.in, got, tc.want) } }) } } // waitUntilRowGone 轮询等一行被删掉(清扫跑在另一个 goroutine 上)。 func waitUntilRowGone(t *testing.T, store *Store, id string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if !memoryRowExists(t, store, id) { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("等不到行 %s 被清扫删除(清扫没跑起来?)", id) }