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)。
This commit is contained in:
AskaEth committed 2026-10-13 21:43:27 +08:00
1 parent 80096c8bd1
commit a017ab6cf8
7 files changed
+1262 -6

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
package vector
import (
"context"
"fmt"
"strings"
)
// 本文件是**向量删除**(索引的清除侧)。动因是一条实测 finding(切片 M1 的
// 读侧时效闸交下来的):临时记忆到期后**行不在了、向量还在** —— 过期行的向量
// 仍留在 vec_items 里,占掉 idx.Search 的 top-K 预算,于是「语义检索前 K 名」
// 里有一半是永远不会被返回的死条目(读侧闸已挡住那些行),**召回质量静默下降**。
// 到期清扫(internal/runtime/memory/expire.go)在物理删行时同步调这里,把
// 向量一并清掉。
//
// 🔴 **为什么不是 Index 的必需方法**(Deleter 与 Index 并列、由调用方类型断言):
// Index 是本包对外的稠密索引抽象,加一个必需方法会**立刻打破所有既有实现** ——
// 包括别的包里的测试桩(例如 memory 包的 mockVectorIndex)。清除能力因此按
// **可选扩展**建模:两种生产后端(SQLiteIndex / PGVectorIndex)都实现它,
// 只读的桩可以不实现,调用方断言失败时退化为「不清向量」并留一条日志。
// 这与本仓既有的可选能力手法一致(例如 `a.vindex.(*vector.PGVectorIndex)`)。
// Deleter 是 Index 的**可选扩展**:把已不存在的 id 从索引里清掉。id 不存在时
// 视为成功(删不存在的向量本就是空操作),因此重复调用是幂等的。
//
// 调用方(memory 包)在物理删行之后调用它;**不是**读路径的一部分。
type Deleter interface {
Delete(ctx context.Context, ids ...string) error
}
// deleteVecItemsSQLTemplate 是两种后端共用的删除语句骨架:`%s` 是占位串
// (SQLite `?` / pgvector `$1,$2,...`)。表名是编译期常量,绝不来自输入。
const deleteVecItemsSQLTemplate = `DELETE FROM vec_items WHERE id IN (%s)`
// vecDeletePlaceholders 为 ids 生成删除语句的占位串与参数表。`dollar` 为 true
// 时用 Postgres 的 `$n` 形式,否则用 SQLite 的 `?`。
//
// 空白 id 被**跳过**而不是报错:删一个不存在的向量是空操作,不该让整批失败
// (Add 拒绝空 id,是因为那会写出一行不可寻址的数据;删除没有这个问题)。
// 全部为空时返回空占位串,调用方据此直接返回。
func vecDeletePlaceholders(ids []string, dollar bool) (string, []any) {
args := make([]any, 0, len(ids))
for _, id := range ids {
if strings.TrimSpace(id) == "" {
continue
}
args = append(args, id)
}
if len(args) == 0 {
return "", nil
}
var b strings.Builder
for i := range args {
if i > 0 {
b.WriteByte(',')
}
if dollar {
fmt.Fprintf(&b, "$%d", i+1)
} else {
b.WriteByte('?')
}
}
return b.String(), args
}
// Delete 把 ids 对应的向量从 SQLite 索引里删掉。id 不存在时无事发生(幂等);
// 空 ids 是空操作。ctx 被取消时中止。
func (s *SQLiteIndex) Delete(ctx context.Context, ids ...string) error {
placeholders, args := vecDeletePlaceholders(ids, false)
if placeholders == "" {
return nil
}
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(deleteVecItemsSQLTemplate, placeholders), args...); err != nil {
return fmt.Errorf("memory/vector: delete %d sqlite vectors: %w", len(args), err)
}
return nil
}
// Delete 把 ids 对应的向量从 pgvector 索引里删掉。语义与 SQLiteIndex.Delete
// 逐字相同(幂等、空 ids 空操作、ctx 取消即中止),只用 `$n` 占位。
func (p *PGVectorIndex) Delete(ctx context.Context, ids ...string) error {
placeholders, args := vecDeletePlaceholders(ids, true)
if placeholders == "" {
return nil
}
if _, err := p.db.ExecContext(ctx, fmt.Sprintf(deleteVecItemsSQLTemplate, placeholders), args...); err != nil {
return fmt.Errorf("memory/vector: delete %d pg vectors: %w", len(args), err)
}
return nil
}
@@ -0,0 +1,184 @@
package vector
import (
"context"
"os"
"testing"
)
// 本文件锁定向量的**清除侧**(Deleter):到期清扫删行时同步删向量,否则过期
// 向量会占掉 idx.Search 的 top-K 预算(切片 M1 的 reviewer 交上来的 finding)。
// TestSQLite_Delete_正常 覆盖删除的正例与三条边界:删掉的查不到、没删的还在、
// 删不存在的 id 幂等成功、空 ids 空操作。
func TestSQLite_Delete_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 3)
for _, id := range []string{"cat", "dog", "bird"} {
if err := s.Add(ctx, id, Vector{1, 0, 0}); err != nil {
t.Fatalf("Add(%s): %v", id, err)
}
}
if err := s.Delete(ctx, "cat"); err != nil {
t.Fatalf("Delete(cat): %v", err)
}
hits, err := s.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
for _, h := range hits {
if h.ID == "cat" {
t.Fatal("被删的向量还在索引里")
}
}
if len(hits) != 2 {
t.Fatalf("剩余命中 = %d, want 2(只该删掉那一条)", len(hits))
}
// 幂等:再删一次同一个 id 不报错(删不存在的行本就是空操作)。
if err := s.Delete(ctx, "cat"); err != nil {
t.Fatalf("重复 Delete 应当幂等成功: %v", err)
}
// 空 ids / 全是空白的 id:空操作,不报错。
if err := s.Delete(ctx); err != nil {
t.Fatalf("Delete() 空 ids 应当是无操作: %v", err)
}
if err := s.Delete(ctx, " "); err != nil {
t.Fatalf("Delete(空白 id) 应当是无操作: %v", err)
}
// 批删:一次给两个 id 走的是同一条占位串路径。
if err := s.Delete(ctx, "dog", "bird"); err != nil {
t.Fatalf("批量 Delete: %v", err)
}
hits, err = s.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search after batch delete: %v", err)
}
if len(hits) != 0 {
t.Fatalf("批量删后仍有 %d 条命中", len(hits))
}
}
// TestSQLite_Delete_取消的ctx_异常:ctx 已取消时删除直接报错,绝不静默成功。
func TestSQLite_Delete_取消的ctx_异常(t *testing.T) {
t.Parallel()
s := newInMemorySQLite(t, 3)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.Delete(ctx, "cat"); err == nil {
t.Fatal("已取消的 ctx 下 Delete 应当报错")
}
}
// TestVecDeletePlaceholders 钉住占位串与参数表的形状:SQLite 用 `?`、pgvector
// 用 `$n`,空白 id 被跳过,全空白时返回空占位串。
func TestVecDeletePlaceholders(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ids []string
dollar bool
wantSQL string
wantN int
}{
{name: "sqlite 单个", ids: []string{"a"}, wantSQL: "?", wantN: 1},
{name: "sqlite 三个", ids: []string{"a", "b", "c"}, wantSQL: "?,?,?", wantN: 3},
{name: "pg 两个", ids: []string{"a", "b"}, dollar: true, wantSQL: "$1,$2", wantN: 2},
{name: "跳过空白", ids: []string{"a", "", " ", "b"}, dollar: true, wantSQL: "$1,$2", wantN: 2},
{name: "全空", ids: []string{"", " "}, wantSQL: "", wantN: 0},
{name: "无参数", ids: nil, wantSQL: "", wantN: 0},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sql, args := vecDeletePlaceholders(tc.ids, tc.dollar)
if sql != tc.wantSQL {
t.Fatalf("占位串 = %q, want %q", sql, tc.wantSQL)
}
if len(args) != tc.wantN {
t.Fatalf("参数数 = %d, want %d", len(args), tc.wantN)
}
})
}
}
// TestSQLite_Delete_底层错误_异常:库已关闭时删除如实报错(而不是静默留下一堆
// 永远删不掉的向量)。
func TestSQLite_Delete_底层错误_异常(t *testing.T) {
t.Parallel()
ctx := context.Background()
db := openTestDB(t)
s, err := NewSQLite(ctx, db, 3, testLogger())
if err != nil {
t.Fatalf("NewSQLite: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
if err := s.Delete(ctx, "cat"); err == nil {
t.Fatal("库已关闭时 Delete 应当报错")
}
}
// TestPG_Delete_RequiresEnv 在设置了 DB_PG_URL 时跑一次真实的 pgvector 删除往返,
// 否则跳过(与 TestPG_RequiresEnv 同一口径:本环境默认没有 PG 服务)。
func TestPG_Delete_RequiresEnv(t *testing.T) {
url := os.Getenv("DB_PG_URL")
if url == "" {
t.Skip("DB_PG_URL not set; skipping real PostgreSQL+pgvector integration test")
}
ctx := context.Background()
idx, err := NewPG(ctx, url, 3, testLogger())
if err != nil {
t.Fatalf("NewPG: %v", err)
}
defer func() { _ = idx.Close() }()
if err := idx.Add(ctx, "delete-me", Vector{1, 0, 0}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := idx.Delete(ctx, "delete-me"); err != nil {
t.Fatalf("Delete: %v", err)
}
hits, err := idx.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
for _, h := range hits {
if h.ID == "delete-me" {
t.Fatal("pgvector 上被删的向量还在")
}
}
// 幂等。
if err := idx.Delete(ctx, "delete-me"); err != nil {
t.Fatalf("重复 Delete 应当幂等: %v", err)
}
// 空 ids / 空白 id 空操作。
if err := idx.Delete(ctx); err != nil {
t.Fatalf("空 ids: %v", err)
}
if err := idx.Delete(ctx, ""); err != nil {
t.Fatalf("空白 id: %v", err)
}
// 反例对照:确认上面的 Search 不是因为「整张表空了」才没有命中。
if err := idx.Add(ctx, "keep-me", Vector{1, 0, 0}); err != nil {
t.Fatalf("Add keep-me: %v", err)
}
hits, err = idx.Search(ctx, Vector{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search keep-me: %v", err)
}
found := false
for _, h := range hits {
if h.ID == "keep-me" {
found = true
}
}
if !found {
t.Fatal("对照组失败:没删的向量也查不到,说明 Search 本身没工作")
}
}