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)。
312 lines
12 KiB
Go
312 lines
12 KiB
Go
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)
|
||
}
|
||
}
|