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) } }