背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成 30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的 就是那一位,不动 N1~N7 的任何阈值。 产出侧 - temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown, 空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜 同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。 - llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累 同批其他候选。 落库 - 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT ''; down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚 (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与 留痕不分叉。 判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改) - one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀: 显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 —— 命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时 21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。 - durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7 (不许因为「正文看着像今天的事」就判 temp)。 - IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。 验证 - go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/... ./internal/db/... 全绿;memory -race 全绿。 - 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与 temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测: 摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。 - 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore), 新加一条迁移不再打红 0025/0026 的用例。
397 lines
17 KiB
Go
397 lines
17 KiB
Go
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.5`,M37 合入时是 `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 != IngestRuleVersion {
|
||
t.Fatalf("闸留痕规则版本 = %q, want %q(留痕版本必须与常量同源,不写死)", rev.RuleVer, IngestRuleVersion)
|
||
}
|
||
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("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
|
||
}
|
||
}
|
||
|
||
// TestIngestRuleVersionIsD35 钉住任务书验收点④:改判定规则必升版
|
||
// (design/23 §18.4),否则新旧留痕无法区分。
|
||
//
|
||
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
|
||
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
|
||
// 且不再依赖同步闸是否开启),因此按规矩升版。
|
||
//
|
||
// d3.5(切片 M45)给判定链补上「时间性元数据位」:`one_off` ⇒ temp、
|
||
// `durable` / `recurring` ⇒ long、`unknown` ⇒ 旧规则。N1~N7 的规则与阈值一字未改,
|
||
// 但同一段正文的结论会变(d3.4 只能靠词判),因此同样按规矩升版。
|
||
func TestIngestRuleVersionIsD35(t *testing.T) {
|
||
t.Parallel()
|
||
if IngestRuleVersion != "d3.5" {
|
||
t.Fatalf("IngestRuleVersion = %q, want d3.5(时间性元数据位接入判定链)", 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 赋值 —— 幂等键的口径变了")
|
||
}
|
||
}
|