Files
Cyrene_v2/internal/runtime/memory/expire_test.go
T
AskaEth a017ab6cf8 feat(memory): 临时记忆到期物理删除(有界清扫 + 同步清向量 + 删前审计)
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)。
2026-10-13 21:43:27 +08:00

312 lines
12 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"
"testing"
"time"
"unicode/utf8"
)
// 本文件锁定**临时期限到期处置的第 ② / ③ 步**(design/23 §19,切片 M3):
// 到期行的物理删除、删前审计、以及四条边界。第 ① 步(读侧隐藏)由
// timegate_test.go 覆盖,本文件只断言「真删掉」这一半。
//
// 四条边界逐条有断言,因为它们各自对应一次「删错就再也回不来」的事故:
//
// ① 空 `expires_at`(长期 / 日常记忆,含 M2 那条 INSERT-与-补写之间的中断窗口)
// **绝不删** —— 判据是 SQL 里的 `expires_at <> ''`;
// ② 未到期 **绝不删**;
// ③ 受限来源(群聊 / 陌生人)**不参与**,且不为它写审计;
// ④ 审计带的是**按 rune 截断的摘要**,绝不是正文全文(本仓唯一一处允许携带
// 内容片段的审计,边界限 `memory.tidy.expire` 一个动作)。
// memoryRowExists 报告一条记忆行是否还在。Store.Get 刻意不过任何读侧闸
// (store.go),因此它是「行在不在」的直接探针:过期行与已软删行都读得到。
func memoryRowExists(t *testing.T, store *Store, id string) bool {
t.Helper()
if _, err := store.Get(context.Background(), id); errors.Is(err, ErrNotFound) {
return false
} else if err != nil {
t.Fatalf("Get(%s): %v", id, err)
}
return true
}
// expiryFixture 建三条典型行:一条已到期、一条未到期、一条**不设过期**
// (`expires_at=”`,即长期 / 日常记忆的形态)。
type expiryFixture struct {
expired MemoryItem
future MemoryItem
longTerm MemoryItem
}
func seedExpiryFixture(t *testing.T, store *Store, now time.Time) expiryFixture {
t.Helper()
base := func() MemoryItem {
return MemoryItem{PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship}
}
expired := base()
expired.Content = "临时记忆:明天要去买咖啡豆"
expired.ExpiresAt = now.Add(-time.Hour)
future := base()
future.Content = "临时记忆:还没到期的那条"
future.ExpiresAt = now.Add(time.Hour)
longTerm := base()
longTerm.Content = "长期记忆:主人喜欢冰美式"
return expiryFixture{
expired: addItem(t, store, expired),
future: addItem(t, store, future),
longTerm: addItem(t, store, longTerm),
}
}
// TestSweepExpiredMemories_过期删_未到期与长期保留 是设计 §19.2 扫描集合的正例与
// 两条反例:只有「非空且已过去的 expires_at」的行被删,未到期行与不设过期的行
// 一律保留。
func TestSweepExpiredMemories_过期删_未到期与长期保留(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
fx := seedExpiryFixture(t, store, now)
var audited []ExpiredMemory
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, func(row ExpiredMemory) {
audited = append(audited, row)
})
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("deleted = %d, want 1(只有那一条到期的该删)", stats.Deleted)
}
if memoryRowExists(t, store, fx.expired.ID) {
t.Fatal("到期行没有被物理删除 —— 读侧闸只负责「看不见」,删除是这一步的事")
}
if !memoryRowExists(t, store, fx.future.ID) {
t.Fatal("未到期行被删了(扫描集合写错)")
}
if !memoryRowExists(t, store, fx.longTerm.ID) {
t.Fatal("空 expires_at(= 不设过期)的行被删了 —— 长期 / 日常记忆必须结构性进不了删除集合")
}
if len(audited) != 1 || audited[0].ID != fx.expired.ID {
t.Fatalf("审计行 = %+v, want 只这一条到期行 %s", audited, fx.expired.ID)
}
}
// TestSweepExpiredMemories_空expires_at绝不删 是**单独**钉住那条判据的用例:
// 它是 M2 登记的「INSERT 与补写 expires_at 之间若进程中断,该行会以 expires_at=”
// 留存」那条窗口的护栏 —— 方向是「宁可多留」,因此那种行**永远**不该被清扫删掉。
//
// 断言刻意写成「哪怕它是 active 且 created_at 很旧」也一样保留:清扫不看创建时间。
func TestSweepExpiredMemories_空expires_at绝不删(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
orphan := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "进程在补写 expires_at 之前中断留下的行",
// 刻意落在很早以前,证明清扫不看 created_at。
CreatedAt: now.Add(-1000 * time.Hour),
})
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, nil)
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Deleted != 0 {
t.Fatalf("deleted = %d, want 0(空 expires_at = 永不过期)", stats.Deleted)
}
if !memoryRowExists(t, store, orphan.ID) {
t.Fatal("空 expires_at 的行被删了 —— 那是「永不过期」,不是「过期很久」")
}
}
// TestSweepExpiredMemories_受限来源保留 是 §19.2 的第三条边界:群聊 / 陌生人来
// 源的临时记忆**不参与**到期删除(判据复用 nonOwnerSource,与整理 / 审查同一处
// 实现),且**不为它写审计** —— 删都没删,凭什么留一条「删了」的痕。
func TestSweepExpiredMemories_受限来源保留(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
restricted := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "群聊里冒出来的一条(历史遗留)",
SourceConversation: FormatSourceConversation("sess-telegram:group1", "m1"),
ExpiresAt: now.Add(-time.Hour),
})
owner := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "主人私聊里那条到期的",
SourceConversation: FormatSourceConversation("web", "m2"),
ExpiresAt: now.Add(-time.Hour),
})
var audited []string
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, func(row ExpiredMemory) {
audited = append(audited, row.ID)
})
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Restricted != 1 {
t.Fatalf("restricted = %d, want 1(受限来源要被识别并跳过)", stats.Restricted)
}
if stats.Deleted != 1 {
t.Fatalf("deleted = %d, want 1(只删主人链路那条)", stats.Deleted)
}
if !memoryRowExists(t, store, restricted.ID) {
t.Fatal("受限来源(群聊)的到期行被删了 —— §19.2 要求它不参与到期删除")
}
if memoryRowExists(t, store, owner.ID) {
t.Fatal("主人链路的到期行没有被删(受限判据把正常行也误伤了)")
}
if len(audited) != 1 || audited[0] != owner.ID {
t.Fatalf("审计 = %v, want 只有 %s(受限行不该留「删了」的痕)", audited, owner.ID)
}
}
// TestSweepExpiredMemories_只删在册行:已软删 / 已归档的过期行不参与 —— 它们不是
// 「到期的临时记忆」,是整理软动作留下的行,该不该清是另一件事(§19.2 第一条)。
func TestSweepExpiredMemories_只删在册行(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
merged := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "被软合并掉的过期行", ExpiresAt: now.Add(-time.Hour),
})
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET status = ? WHERE id = ?`, ingestStatusMerged, merged.ID); err != nil {
t.Fatalf("update status: %v", err)
}
stats, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, nil)
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if stats.Deleted != 0 {
t.Fatalf("deleted = %d, want 0(非在册行不参与)", stats.Deleted)
}
if !memoryRowExists(t, store, merged.ID) {
t.Fatal("已软删(status='merged')的行被到期清扫删了")
}
}
// TestSweepExpiredMemories_有界批次:一轮清扫的批数 × 单批上限是有界的,积压时
// 剩下的留给下一轮 —— 绝不「一次 DELETE 全表」(§19.3-3 的有界)。
func TestSweepExpiredMemories_有界批次(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
const rows = 5
ids := make([]string, 0, rows)
for i := 0; i < rows; i++ {
ids = append(ids, addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "过期行", ExpiresAt: now.Add(-time.Duration(i+1) * time.Minute),
}).ID)
}
policy := ExpirySweepPolicy{BatchSize: 2, MaxBatches: 2}
first, err := store.SweepExpiredMemories(ctx, now, policy, nil)
if err != nil {
t.Fatalf("first sweep: %v", err)
}
if first.Deleted != 2*2 || first.Batches != 2 {
t.Fatalf("一轮 = deleted %d / batches %d, want 4 / 2(有界:2 批 × 每批 2 行)",
first.Deleted, first.Batches)
}
second, err := store.SweepExpiredMemories(ctx, now, policy, nil)
if err != nil {
t.Fatalf("second sweep: %v", err)
}
if second.Deleted != 1 {
t.Fatalf("第二轮 deleted = %d, want 1(剩下的留给下一轮)", second.Deleted)
}
for _, id := range ids {
if memoryRowExists(t, store, id) {
t.Fatalf("两轮之后仍有到期行 %s 未删(有界不该变成永远删不完)", id)
}
}
}
// TestSweepExpiredMemories_审计在删之前且带截断摘要 是 §19.4 的核心断言:
//
// 1. **删前留痕**:审计回调被调用时那一行**还在**(顺序反了就事后无从追责);
// 2. **摘要按 rune 截断**:不超过 expireAuditSummaryRunes,且截断处留省略号;
// 3. **绝不含正文全文**:审计里找不到那条长正文;
// 4. 正文里的换行被归一成空格(审计行恒为一行,可解析)。
func TestSweepExpiredMemories_审计在删之前且带截断摘要(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
now := tidyTestTime(t)
longText := strings.Repeat("很长的临时记忆正文", 30) // 300 rune
row := addItem(t, store, MemoryItem{
PersonaID: "persona.b", Scope: ScopeProject, Kind: KindWorldEvent,
Content: "第一行\n\t第二行 " + longText,
ExpiresAt: now.Add(-time.Minute),
})
var (
results []string
existedAtAudit []bool
)
_, err := store.SweepExpiredMemories(ctx, now, ExpirySweepPolicy{}, func(r ExpiredMemory) {
existedAtAudit = append(existedAtAudit, memoryRowExists(t, store, r.ID))
results = append(results, expireAuditResult(r))
})
if err != nil {
t.Fatalf("SweepExpiredMemories: %v", err)
}
if len(results) != 1 {
t.Fatalf("审计行数 = %d, want 1", len(results))
}
if !existedAtAudit[0] {
t.Fatal("审计发生时那一行已经被删了 —— §19.3-3 要求「每行删除**前**先写审计」")
}
got := results[0]
if !strings.HasPrefix(got, "id="+row.ID+" persona_id=persona.b scope=project expires_at=") {
t.Fatalf("审计串缺字段:%q", got)
}
if strings.Contains(got, "\n") || strings.Contains(got, "\t") {
t.Fatalf("审计带换行 / 制表符,会破坏「一行一条」:%q", got)
}
if strings.Contains(got, longText) {
t.Fatalf("审计里出现了正文全文 —— 摘要必须被截断:%q", got)
}
_, summary, ok := strings.Cut(got, "summary=")
if !ok {
t.Fatalf("审计串没有 summary 段:%q", got)
}
if n := utf8.RuneCountInString(summary); n > expireAuditSummaryRunes {
t.Fatalf("摘要 %d rune,超过上限 %d:%q", n, expireAuditSummaryRunes, summary)
}
if !strings.HasSuffix(summary, "…") {
t.Fatalf("截断处应当留省略号(让人一眼看出被截过):%q", summary)
}
// 摘要必须是**归一化之后**正文的前缀(证明它是「截断」而不是别的东西)。
flat := strings.Join(strings.Fields(row.Content), " ")
if !strings.HasPrefix(flat, strings.TrimSuffix(summary, "…")) {
t.Fatalf("摘要不是(空白归一后的)正文前缀:%q", summary)
}
}