Files
Cyrene_v2/internal/runtime/memory/preinsert_defer_test.go
T
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00

392 lines
17 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 (
"bytes"
"context"
"log/slog"
"os"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是**第五态 `defer`**(design/23 §18)与**临时 TTL 配置面**
// (design/03 §9.6)的验收用例集。它逐条覆盖任务书的验收点:
//
// 1. defer 落库带**短** `expires_at`(闸路径 + 异步 T1 路径);
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
// 4. `IngestRuleVersion` 升到 `d3.4`(改判定规则必升版,否则新旧留痕无法区分);
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
// 读侧只拿它做 SQL 判据,不回表 SELECT)。
func readIngestExpiry(t *testing.T, store *Store, id string) string {
t.Helper()
var exp string
if err := store.db.QueryRowContext(context.Background(),
`SELECT expires_at FROM memory_items WHERE id = ?`, id).Scan(&exp); err != nil {
t.Fatalf("read expires_at %s: %v", id, err)
}
return exp
}
// personaListHas 报告一条记忆是否出现在 ListByPersona(**过时效闸**)的结果里。
func personaListHas(t *testing.T, store *Store, personaID, id string) bool {
t.Helper()
items, err := store.ListByPersona(context.Background(), personaID)
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// newRealClockReviewer 构建一个用**真实墙钟**的审查服务(闸开着)。读侧时效闸比
// 的是真实当下,因此「这条临时记忆现在还看得见吗」这类断言必须用真实时钟,否则
// 固定夹具时刻(2026-10-01)算出来的有效期早就过去了。
func newRealClockReviewer(t *testing.T, store *Store, ttl time.Duration) *PreInsertReviewer {
t.Helper()
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
Threshold: 0.80, TempTTL: ttl,
}, ReviewDeps{
Store: store,
Now: func() time.Time { return time.Now() },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// deferFixtureItem 是一条**留存规则闸必然判 defer** 的条目(短寒暄,同分区、
// owner 来源)。
func deferFixtureItem(content string, at time.Time) MemoryItem {
return gateFixtureItem(content, at)
}
// ttlPanelPtr 造「面板里显式写了 tempTtlSec」的指针(nil 表示键没写)。
func ttlPanelPtr(v int) *int { return &v }
// TestIngestGateDeferWritesShortExpiresAt 是任务书验收点①的**闸路径**:
// 低价值条目照常 active 落库(一个字不丢),`expires_at` = 判定时刻 + 临时 TTL,
// 留痕是 `defer` / `t0_low_value` / 规则版本 `d3.4`。
func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
t.Parallel()
store := newTestStore(t)
ttl := time.Hour
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
want := expiryText(base.Add(ttl))
if exp := readIngestExpiry(t, store, got.ID); exp != want {
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 临时 TTL)", exp, want)
}
if exp := readIngestExpiry(t, store, got.ID); len(exp) != storedTimeLen {
t.Fatalf("expires_at 宽度 = %d 字节, want %d(读侧是纯文本比较,宽度必须同源)",
len(exp), storedTimeLen)
}
row := readReviewRow(t, store, got.ID)
if row.status != tidyStatusActive || row.content != "谢谢啦" {
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
}
if rev.RuleVer != "d3.4" {
t.Fatalf("闸留痕规则版本 = %q, want d3.4", rev.RuleVer)
}
if s := r.Stats(); s.GateDeferred != 1 {
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
}
}
// TestIngestGateDeferIsNotEnqueuedForAsyncReview 钉住「defer 已是终局判断」:
// 它**不投**异步审查队列 —— 再送语义审查只会把这条临时记忆并进一条长期记忆、
// 把临时性抹掉(design/23 §18.1 的落点)。
func TestIngestGateDeferIsNotEnqueuedForAsyncReview(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour}, nil)
store.AttachIngestReviewer(r)
seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
if n := len(r.queue); n != 0 {
t.Fatalf("defer 条目被投进了异步队列(队列长度 %d)—— defer 是终局判断,不该再送审", n)
}
// 对偶:正常条目仍照旧投队列(证明上面那条不是因为队列压根不工作)。
seedTidyItem(t, store, deferFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
if n := len(r.queue); n != 1 {
t.Fatalf("正常条目未被投进异步队列(队列长度 %d, want 1)", n)
}
}
// TestT1DeferWritesExpiresAtOnKeptNewRow 是验收点①的**异步 T1 路径**:T1 在
// **同一次调用**里回 `defer` ⇒ 新行保留、正文不动、既有行不动,只写短 TTL。
func TestT1DeferWritesExpiresAtOnKeptNewRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
existing, fresh := seedT1GrayPair(t, store)
ttl := 2 * time.Hour
judge := &t1JudgeStub{reply: `{"action":"defer","target":0,"reason":"只是一次性的信息"}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: ttl}, judge.judge, nil)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestDefer || rev.Reason != t1ReasonDefer {
t.Fatalf("verdict = %+v, want defer + %q", rev, t1ReasonDefer)
}
if judge.count() != 1 {
t.Fatalf("模型调用 = %d 次, want 恰为 1(第五态与关系判定同一次调用)", judge.count())
}
want := expiryText(tidyTestTime(t).Add(ttl))
if exp := readIngestExpiry(t, store, fresh.ID); exp != want {
t.Fatalf("expires_at = %q, want %q", exp, want)
}
row := readReviewRow(t, store, fresh.ID)
if row.status != tidyStatusActive || row.content != fixtureT1CodeViaChannel {
t.Fatalf("新行 = %+v, want 仍在册且承载自己的原文", row)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
t.Fatalf("既有行被改了:%+v(defer 只该碰新行的 expires_at 一列)", got)
}
}
// TestT1DeferWithTargetIsReplyInvalid 钉住 §18.2 的不变式「defer ≡ 真新 ∧ 短期」:
// 一个**指向候选**的 defer 自相矛盾(既说和它像、又说这条独立)⇒ 判非法回复、
// 回落 `create`(**宁可多留**),绝不猜。
func TestT1DeferWithTargetIsReplyInvalid(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
judge := &t1JudgeStub{reply: `{"action":"defer","target":1,"reason":"自相矛盾"}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: time.Hour}, judge.judge, nil)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Reason != t1ReasonReplyInvalid {
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonReplyInvalid)
}
if exp := readIngestExpiry(t, store, fresh.ID); exp != "" {
t.Fatalf("非法 defer 却写了 expires_at = %q(回落路径必须零改写)", exp)
}
}
// TestDeferExpiryHiddenByReadGateAndRollbackRestores 把第五态接到**读侧时效闸**
// (design/03 §9.6 第 ① 步隐藏)与**回滚**(§18.4:清空 expires_at 即升回长期)上:
// 未到期可见 ⇒ 置成过去即隐藏 ⇒ 清空又可见,且 `Get`(刻意不过闸)始终读得到。
func TestDeferExpiryHiddenByReadGateAndRollbackRestores(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
r := newRealClockReviewer(t, store, time.Hour)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", time.Now().UTC()))
if !personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("刚落的临时记忆应当在有效期内可见(TTL = 1h)")
}
if err := store.setIngestRetention(ctx, got.ID, RetentionTemp, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil {
t.Fatalf("setIngestRetention: %v", err)
}
if personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("已到期的临时记忆不该出现在读路径上(时效闸失效)")
}
if _, err := store.Get(ctx, got.ID); err != nil {
t.Fatalf("Get 刻意不过时效闸(撤销/清算要读得到过期行),却报错:%v", err)
}
// 回滚(design/23 §18.4):清空 expires_at ⇒ 升回长期,立即可见。
if err := store.setIngestRetention(ctx, got.ID, RetentionLong, ""); err != nil {
t.Fatalf("setIngestRetention(回滚): %v", err)
}
if !personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("清空 expires_at 后应当升回长期(回滚是无损的)")
}
if row := readReviewRow(t, store, got.ID); row.content != "谢谢啦" || row.status != tidyStatusActive {
t.Fatalf("回滚改了正文/状态:%+v", row)
}
}
// TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate 是写入/读取**同一套规范文本**
// 的钉子(塔在派工里点名要求补的那条)。
//
// 读侧闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite 时间函数),
// 所以写侧的布局一旦分叉,闸会**静默判错**:本仓历史事故里,`' '`(0x20) 分隔的
// `datetime('now')` 与 `'T'`(0x54) 分隔的列值比较,会让**任何当日行都「大于当下」**。
func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
t.Parallel()
at := tidyTestTime(t).UTC()
// 写入侧:defer 计划给出的文本必须就是 expiryText/formatTime 的产出。
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); got != formatTime(at) {
t.Fatalf("plan.expiresAt() = %q, want formatTime 的产出 %q", got, formatTime(at))
}
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); len(got) != storedTimeLen {
t.Fatalf("plan.expiresAt() 宽度 = %d, want %d", len(got), storedTimeLen)
}
// 零值 = 不设过期(长期/日常记忆)⇒ 空串,恒可见。
if got := (ingestGatePlan{}).expiresAt(); got != "" {
t.Fatalf("零值计划的 expiresAt() = %q, want 空串", got)
}
// 反证:同一瞬间换一种布局,文本序就与时间序脱钩(读侧判据随之失效)。
sqliteLayout := at.Format("2006-01-02 15:04:05")
if formatTime(at) == sqliteLayout {
t.Fatal("两种布局不该相同 —— 那这条反证就没有意义了")
}
if !(formatTime(at) > sqliteLayout) {
t.Fatalf("定宽布局应恒大于空格分隔布局('T' > ' '),实得 %q vs %q", formatTime(at), sqliteLayout)
}
if sqliteLayout[0:4] != formatTime(at)[0:4] {
t.Fatal("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
}
}
// TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版
// (design/23 §18.4),否则新旧留痕无法区分。
//
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
// 且不再依赖同步闸是否开启),因此按规矩升版。
func TestIngestRuleVersionIsD34(t *testing.T) {
t.Parallel()
if IngestRuleVersion != "d3.4" {
t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", IngestRuleVersion)
}
}
// TestApplyReviewTuningTempTTL 钉住验收点③:TTL 的三档优先级与**坏值绝不放行**。
//
// 三档优先级在 TTL 这一键上实际是「**面板 > 代码默认**」:`.env` 已冻结
// (design/13 §2.1),本键在那里**从来就没有出处** —— 中间那一档在结构上是空的,
// 这不是漏配(design/03 §9.6 写的就是「面板 > 代码默认」)。
func TestApplyReviewTuningTempTTL(t *testing.T) {
t.Parallel()
const fallbackSec = 604800 // 7 天(tempTtlSec)
cases := []struct {
name string
panel *int
wantTTL time.Duration
wantWarn bool
}{
{"键没写 ⇒ 代码默认", nil, 7 * 24 * time.Hour, false},
{"面板写正数 ⇒ 用面板值", ttlPanelPtr(3600), time.Hour, false},
{"面板写 0 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(0), 7 * 24 * time.Hour, true},
{"面板写负数 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(-3600), 7 * 24 * time.Hour, true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
got := normalizeReviewConfig(ApplyReviewTuning(
ReviewConfig{}, runtimeconfig.MemoryTidyTuning{TempTtlSec: tc.panel}, log))
if got.TempTTL != tc.wantTTL {
t.Fatalf("TempTTL = %v, want %v", got.TempTTL, tc.wantTTL)
}
hasWarn := strings.Contains(buf.String(), "临时记忆 TTL 不可用")
if hasWarn != tc.wantWarn {
t.Fatalf("WARN 出现 = %t, want %t(日志:%s)", hasWarn, tc.wantWarn, buf.String())
}
if got.TempTTL <= 0 {
t.Fatal("归一化后仍是非正 TTL —— 那会让每条 defer 记忆落库即过期")
}
})
}
// 代码默认值本身就是 design/03 §9.6 点名的 604800 秒。
if normalizeReviewConfig(ReviewConfig{}).TempTTL != time.Duration(fallbackSec)*time.Second {
t.Fatalf("代码默认 TTL = %v, want %ds", normalizeReviewConfig(ReviewConfig{}).TempTTL, fallbackSec)
}
if tempTtlSec != fallbackSec {
t.Fatalf("tempTtlSec = %d, want %d", tempTtlSec, fallbackSec)
}
}
// TestNewPreInsertReviewerNormalizesBadTempTTL 钉住「坏值不靠调用方自觉」:
// 任何绕过解析器、直接把 ≤0 塞进 ReviewConfig 的路径,也在构造期被归一化掉
// (与 T1 限流两项同一教训,design/12 §N C.5 G3)。
func TestNewPreInsertReviewerNormalizesBadTempTTL(t *testing.T) {
t.Parallel()
store := newTestStore(t)
for _, ttl := range []time.Duration{0, -time.Hour} {
r, err := NewPreInsertReviewer(ReviewConfig{Enabled: true, TempTTL: ttl}, ReviewDeps{
Store: store, Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
if got := r.cfg.TempTTL; got != 7*24*time.Hour {
t.Fatalf("ReviewConfig.TempTTL = %v 未回落默认,实得 %v", ttl, got)
}
}
}
// TestIngestRuleVersionIsNotInTheTidyIdempotencyKey 是**防漂移缝断言**,回答一条
// 跨机制的实体问题:`IngestRuleVersion` 由 d3.2 升到 d3.3(现为 d3.4)之后,**既有 pending 整理
// 候选会不会凭幂等键复制出重复行**?
//
// 答案:**不会**,因为这是两个独立的常量 ——
//
// IngestRuleVersion ("d3.4") 入库审查(本切片)→ 只进 memory_ingest_reviews.rule_version
// TidyRuleVersion ("t0.1") 整理(design/23 §17.6)→ **进整理候选的幂等键**
//
// 幂等键的算式是 `tidyIdempotencyKey`(tidy_candidate.go):hash(persona_id, scope,
// project_id, sorted(source_ids), action, **c.RuleVersion**),而候选的 RuleVersion 由
// 构造器写成 TidyRuleVersion。生产库实测(2026-10-13,只读):`memory_tidy_candidates`
// 的 64 条 pending 全部 `rule_version='t0.1'`,`memory_ingest_reviews` 里则是
// `d3.1` / `d3.2` —— 两张表**从不共享**这一个值。
//
// design/23 §17.6 记录过的那次「升版 ⇒ 既有候选凭新键复制一份重复行」是
// **TidyRuleVersion** 的语义(那也是它当时刻意不升的原因),不是这一个。
// 若将来有人把 IngestRuleVersion 接进那个键(或把两个常量合并),本用例会红。
func TestIngestRuleVersionIsNotInTheTidyIdempotencyKey(t *testing.T) {
t.Parallel()
if IngestRuleVersion == TidyRuleVersion {
t.Fatalf("两个版本号不该相等(都是 %q):它们分属入库审查与整理两条机制",
IngestRuleVersion)
}
src, err := os.ReadFile("tidy_candidate.go")
if err != nil {
t.Fatalf("读 tidy_candidate.go: %v", err)
}
text := string(src)
if !strings.Contains(text, "string(c.Action), c.RuleVersion") {
t.Fatal("整理候选幂等键的算式变了 —— 请复核 IngestRuleVersion 是否意外进了键")
}
if strings.Contains(text, "IngestRuleVersion") {
t.Fatal("tidy_candidate.go 里出现了 IngestRuleVersion:两个版本号开始互相污染")
}
if strings.Count(text, "TidyRuleVersion") < 2 {
t.Fatal("整理候选的 RuleVersion 不再由 TidyRuleVersion 赋值 —— 幂等键的口径变了")
}
}