design/23 §19 的第 ② / ③ 步:读侧时效闸(M1)只负责「看不见」,本切片负责 「真删掉」。三条护栏照 §19.3:删除是本包内唯一 DELETE 出口、有界(500 行/批 × 20 批)、删前留审计。 - internal/runtime/memory/expire.go:扫描集合写死为「expires_at ≠ '' ∧ expires_at ≤ now ∧ status='active' ∧ 来源非受限」。空 expires_at(长期/日常 记忆,含 M2 登记的「INSERT 与补写之间中断」窗口)结构性进不了集合;受限来源 复用 nonOwnerSource(与整理/审查同一处判据)。键集游标 (expires_at, rowid) 保证被跳过的行只读一次、扫描位置一定前移。审计串 = id + persona_id + scope + expires_at + 按 rune 截断(48)的摘要 —— 本仓唯一一处允许携带内容片段的审计。 - internal/runtime/memory/expire_loop.go:清扫循环挂在 TidyService 的生命周期上 (AttachExpirySweep 由组合根调用),随心跳 ctx 跑、随 Stop 停;一轮只写一行 汇总日志,逐条的那一行进审计。 - internal/runtime/memory/vector/delete.go:新增可选删除能力 vector.Deleter + 两个后端实现,删行时同步清向量(M1 reviewer 的 finding:过期向量占 top-K 预算)。 - cmd/cyrene/wire_memory_tidy.go:挂上清扫;顺带把已过时的「这十键」改成「这十一 键」(M2 加了第十一键 tempTtlSec)。
217 lines
7.1 KiB
Go
217 lines
7.1 KiB
Go
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)
|
||
}
|