主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能 进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清, 而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。 落法(三条硬规格,全部落地): ① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**), 本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍 余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。 🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)—— 远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步 路径上拍板。 ② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒ `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。 ③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 / 真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行 `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**, 而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行, revert 无从下手,违反 design/23 §8 的红线。 - **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落, 审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。 - **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到 「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。 - 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。 验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿; 我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与 `…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。 子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测 **闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。 ⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒ 「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新 100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态, SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
443 lines
18 KiB
Go
443 lines
18 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是**同步入库闸**(主人 2026-10-07 裁定「把记忆整理功能接到昔涟和记忆库
|
||
// 中间做审查,由整理功能进行入库」)的验收用例集:三态落库、确定性判据为主、
|
||
// 红线不误判、fail-open 不丢记忆、开关一键回退、revert 无损、墙钟有界。
|
||
//
|
||
// 它与 preinsert_test.go / preinsert_async_test.go 的分工:那两份测的是**异步**
|
||
// 那一层(落库之后由后台队列补审),本文件测的是**写路径上**的那一层。
|
||
// 闸开着时,重复行在 `Store.Add` 返回之前就已经是 `merged` —— 因此「异步队列
|
||
// 才把它折回」的旧断言必须改成「闸当场折回」(见 preinsert_async_test.go 的
|
||
// TestReviewIsNonBlockingAndAsyncPathWorks,它现在显式关闸来单独验异步层)。
|
||
|
||
// gateFixtureItem 构造同分区(persona.a / persona / 无项目)、owner 来源的待落库条目。
|
||
func gateFixtureItem(content string, at time.Time) MemoryItem {
|
||
return habitItem(content, at, 0.9)
|
||
}
|
||
|
||
// gateTestCorpus 生成「同主题、数值各异」的语料:它们互相的相似度很高(因此
|
||
// 候选扫描打满),但数值不同 ⇒ 数值否决 ⇒ 恒为 create。墙钟实测用它构造
|
||
// **最坏可复现场景**:每一轮都真扫满候选、真判完,且一次都不合并。
|
||
func gateTestCorpus(i int, at time.Time) MemoryItem {
|
||
return gateFixtureItem(fmt.Sprintf("主人常在第 %d 个时段与助手聊天,有熬夜习惯", i), at)
|
||
}
|
||
|
||
// newGateReviewer 构建「闸开着」的审查服务。candidates 非 nil 时注入候选源
|
||
// (测试用它精确控制候选集、注入慢源与坏源)。零值配置项按代码默认值补齐 ——
|
||
// **尤其是 GateBudget**:它留零值才能让「默认预算」这条被测试覆盖到。
|
||
func newGateReviewer(t *testing.T, store *Store, cfg ReviewConfig,
|
||
candidates func(context.Context, tidyPartition, string, int) ([]MemoryItem, error)) *PreInsertReviewer {
|
||
t.Helper()
|
||
cfg.Enabled = true
|
||
if cfg.QueueSize == 0 {
|
||
cfg.QueueSize = 8
|
||
}
|
||
if cfg.ScanLimit == 0 {
|
||
cfg.ScanLimit = 50
|
||
}
|
||
if cfg.Timeout == 0 {
|
||
cfg.Timeout = 5 * time.Second
|
||
}
|
||
if cfg.Threshold == 0 {
|
||
cfg.Threshold = 0.80
|
||
}
|
||
r, err := NewPreInsertReviewer(cfg, ReviewDeps{
|
||
Store: store,
|
||
Now: func() time.Time { return tidyTestTime(t) },
|
||
GateCandidates: candidates,
|
||
Log: testLogger(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// gateRecordFor 读回某一条记忆的闸留痕(按 item_id 过滤)。
|
||
func gateRecordFor(t *testing.T, store *Store, itemID string) IngestReview {
|
||
t.Helper()
|
||
reviews, err := store.ListIngestReviews(context.Background(), 50)
|
||
if err != nil {
|
||
t.Fatalf("ListIngestReviews: %v", err)
|
||
}
|
||
for _, rev := range reviews {
|
||
if rev.ItemID == itemID {
|
||
return rev
|
||
}
|
||
}
|
||
return IngestReview{}
|
||
}
|
||
|
||
// TestIngestGate_字面重复_新行落merged且active不变 是任务书必备用例①:
|
||
// 字面重复必须**当场**落 `merged`,active 计数不变、存活行 merge_count +1,
|
||
// 且正文一个字不丢(保住 revert)。
|
||
func TestIngestGate_字面重复_新行落merged且active不变(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
survivor := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
|
||
dup := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
|
||
|
||
got := readReviewRow(t, store, dup.ID)
|
||
if got.status != ingestStatusMerged || got.mergedInto != survivor.ID {
|
||
t.Fatalf("重复行未被闸当场折回:status=%q merged_into=%q,want merged/%s", got.status, got.mergedInto, survivor.ID)
|
||
}
|
||
if got.content != fixtureHabitShort {
|
||
t.Fatalf("闸下的行正文被改了:%q", got.content)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 1 {
|
||
t.Fatalf("active 计数 = %d, want 1(闸的意义就是 active 当场干净)", n)
|
||
}
|
||
if s := readReviewRow(t, store, survivor.ID); s.mergeCount != 2 {
|
||
t.Fatalf("存活行 merge_count = %d, want 2", s.mergeCount)
|
||
}
|
||
|
||
rev := gateRecordFor(t, store, dup.ID)
|
||
if rev.Decision != IngestSkip || rev.Reason != "exact_duplicate" || rev.TargetID != survivor.ID {
|
||
t.Fatalf("闸留痕 = %+v, want skip/exact_duplicate 指向存活行", rev)
|
||
}
|
||
if rev.SessionID != TidySessionID || rev.RuleVer != IngestRuleVersion {
|
||
t.Fatalf("闸留痕的会话/规则版本没登记:%+v", rev)
|
||
}
|
||
if rev.RecallMode != tidyRecallLexical {
|
||
t.Fatalf("闸留痕的召回来源 = %q, want %q(同步路径绝不调远端 embedding)", rev.RecallMode, tidyRecallLexical)
|
||
}
|
||
if s := r.Stats(); s.GateAbsorbed != 1 || s.GateChecked != 2 {
|
||
// 2 = 第一条(无候选 ⇒ 正常插入)+ 重复条(命中 ⇒ 吸收)。
|
||
t.Fatalf("闸计数 = %+v, want gate_checked=2 gate_absorbed=1", s)
|
||
}
|
||
// 闸挡下的那条**绝不再投异步队列**(否则同一件事两条路径):队列里只该有
|
||
// 第一条(它没有候选 ⇒ 正常插入,照旧走既有队列)。
|
||
if n := len(r.queue); n != 1 {
|
||
t.Fatalf("队列 = %d 条,want 1(重复的那条不该被再投一次)", n)
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
t.Fatalf("ctx: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_归一化等价_同样落merged 是任务书必备用例②:大小写/空白归一化
|
||
// 后等价的文本(dedupeKey 相等)必须和字面重复同一处理。
|
||
func TestIngestGate_归一化等价_同样落merged(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
survivor := seedTidyItem(t, store, gateFixtureItem("主人爱喝 ICED 美式", base))
|
||
dup := seedTidyItem(t, store, gateFixtureItem(" 主人爱喝 iced 美式 ", base.Add(time.Minute)))
|
||
|
||
got := readReviewRow(t, store, dup.ID)
|
||
if got.status != ingestStatusMerged || got.mergedInto != survivor.ID {
|
||
t.Fatalf("归一化等价未被闸折回:%+v(存活行 %s)", got, survivor.ID)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 1 {
|
||
t.Fatalf("active 计数 = %d, want 1", n)
|
||
}
|
||
if s := readReviewRow(t, store, survivor.ID); s.mergeCount != 2 {
|
||
t.Fatalf("存活行 merge_count = %d, want 2", s.mergeCount)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_真新_正常insert并走既有队列 是任务书必备用例③:确实新的条目
|
||
// 正常 insert(active),并且**照旧投既有异步队列**(两层而不是替代)。
|
||
func TestIngestGate_真新_正常insert并走既有队列(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
first := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
|
||
second := seedTidyItem(t, store, gateFixtureItem(fixtureEnergyRedBull, base.Add(time.Minute)))
|
||
|
||
for _, it := range []MemoryItem{first, second} {
|
||
got := readReviewRow(t, store, it.ID)
|
||
if got.status != tidyStatusActive || got.mergedInto != "" {
|
||
t.Fatalf("真新条目被改了落库形态:%+v", got)
|
||
}
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("active 计数 = %d, want 2", n)
|
||
}
|
||
if s := r.Stats(); s.GateAbsorbed != 0 || s.GateChecked != 2 {
|
||
t.Fatalf("闸计数 = %+v, want 只检查、不吸收", s)
|
||
}
|
||
if n := len(r.queue); n != 2 {
|
||
t.Fatalf("真新条目投队列 = %d, want 2(闸与异步队列是两层)", n)
|
||
}
|
||
if revs, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(revs) != 0 {
|
||
t.Fatalf("闸对「真新」不该留决定留痕(那段由异步队列负责):%+v err=%v", revs, err)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_红线_习惯与一次性事件绝不误合 是任务书必备用例④与
|
||
// design/23 §5.2 的红线:时效/一次性事件的条目即便高度相似也**绝不合并**
|
||
// (候选直接喂进判定层,因此这条测的是规则层本身,不是召回)。
|
||
func TestIngestGate_红线_习惯与一次性事件绝不误合(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
candidate string
|
||
wantVeto TidyVetoReason
|
||
}{
|
||
{"一次性事件", fixtureOneOffEvent, VetoTemporal},
|
||
{"有期限状态", fixtureHabitVacation, VetoTemporal},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
cand := gateFixtureItem(tc.candidate, base)
|
||
r := newGateReviewer(t, store, ReviewConfig{},
|
||
func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
|
||
return []MemoryItem{cand}, nil
|
||
})
|
||
store.AttachIngestReviewer(r)
|
||
|
||
got := seedTidyItem(t, store, gateFixtureItem(fixtureHabitPlain, base.Add(time.Minute)))
|
||
row := readReviewRow(t, store, got.ID)
|
||
if row.status != tidyStatusActive || row.mergedInto != "" {
|
||
t.Fatalf("%s 被误合了:%+v", tc.name, row)
|
||
}
|
||
if s := r.Stats(); s.GateAbsorbed != 0 {
|
||
t.Fatalf("%s 被闸吸收:%+v", tc.name, s)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_跨分区绝不合并 是任务书必备用例⑤:分区键(persona_id +
|
||
// scope + project_id)是硬门 —— 运行期 SQL 就把别的分区隔断,判定层再兜一次。
|
||
func TestIngestGate_跨分区绝不合并(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
otherPersona := gateFixtureItem(fixtureHabitShort, base)
|
||
otherPersona.PersonaID = "persona.b"
|
||
seedTidyItem(t, store, otherPersona)
|
||
otherScope := gateFixtureItem(fixtureHabitShort, base)
|
||
otherScope.Scope = ScopeUser
|
||
otherScopeItem := seedTidyItem(t, store, otherScope)
|
||
otherProject := gateFixtureItem(fixtureHabitShort, base)
|
||
otherProject.Scope = ScopeProject
|
||
otherProject.ProjectID = "proj.other"
|
||
otherProjectItem := seedTidyItem(t, store, otherProject)
|
||
|
||
mine := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
|
||
row := readReviewRow(t, store, mine.ID)
|
||
if row.status != tidyStatusActive || row.mergedInto != "" {
|
||
t.Fatalf("跨分区被合并了:%+v", row)
|
||
}
|
||
for _, id := range []string{otherScopeItem.ID, otherProjectItem.ID} {
|
||
if got := readReviewRow(t, store, id); got.status != tidyStatusActive {
|
||
t.Fatalf("别的分区的记忆被改写了:%+v", got)
|
||
}
|
||
}
|
||
|
||
// 纵深防御:把跨角色候选直接喂进判定层,硬门必须否决。
|
||
verdict := judgeIngest(mine, []ingestCandidate{{item: otherPersona, score: 0.99, mode: tidyRecallLexical}})
|
||
if verdict.Decision != IngestCreate || verdict.Veto != VetoPersona {
|
||
t.Fatalf("跨角色判定 = %+v, want create + persona 否决", verdict)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_failOpen_候选源不可用绝不丢记忆 是任务书必备用例⑥(其一):
|
||
// 候选源报错 ⇒ **照常插入 active** + 留痕(原因码),绝不静默丢。
|
||
func TestIngestGate_failOpen_候选源不可用绝不丢记忆(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{},
|
||
func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
|
||
return nil, errors.New("candidate source down")
|
||
})
|
||
store.AttachIngestReviewer(r)
|
||
|
||
item := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
|
||
row := readReviewRow(t, store, item.ID)
|
||
if row.status != tidyStatusActive || row.content != fixtureHabitShort {
|
||
t.Fatalf("fail-open 丢了记忆:%+v", row)
|
||
}
|
||
rev := gateRecordFor(t, store, item.ID)
|
||
if rev.Reason != gateTraceUnavailable || rev.Decision != IngestCreate {
|
||
t.Fatalf("fail-open 留痕 = %+v, want reason=%s", rev, gateTraceUnavailable)
|
||
}
|
||
if s := r.Stats(); s.GateFailed != 1 {
|
||
t.Fatalf("fail-open 计数 = %+v, want gate_failed=1", s)
|
||
}
|
||
if n := len(r.queue); n != 1 {
|
||
t.Fatalf("fail-open 的条目没投既有队列(%d 条):兜底那一层断了", n)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_failOpen_超预算绝不丢记忆 是任务书必备用例⑥(其二):
|
||
// 候选源慢到越过闸的总预算 ⇒ 同样是照常插入 + 留痕,且**绝不超预算返回**。
|
||
func TestIngestGate_failOpen_超预算绝不丢记忆(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, gateSlowCandidates(2*time.Second))
|
||
store.AttachIngestReviewer(r)
|
||
|
||
start := time.Now()
|
||
item := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
|
||
elapsed := time.Since(start)
|
||
|
||
row := readReviewRow(t, store, item.ID)
|
||
if row.status != tidyStatusActive || row.content != fixtureHabitShort {
|
||
t.Fatalf("超预算时丢了记忆:%+v", row)
|
||
}
|
||
rev := gateRecordFor(t, store, item.ID)
|
||
if rev.Reason != gateTraceBudgetExceeded {
|
||
t.Fatalf("超预算留痕 = %+v, want reason=%s", rev, gateTraceBudgetExceeded)
|
||
}
|
||
if elapsed > time.Second {
|
||
t.Fatalf("超预算的写入等了 %s,闸的预算没起作用", elapsed)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_开关关闭_回到只插不审 是任务书必备用例⑦:闸被显式关掉(或总
|
||
// 开关关闭)时,写入路径逐字回到改动之前 —— 正常 insert + 投既有异步队列。
|
||
func TestIngestGate_开关关闭_回到只插不审(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
enabled bool
|
||
disableGate bool
|
||
enqueue bool
|
||
}{
|
||
{"闸关(memory-tidy.json 的 ingestGate=false)", true, true, true},
|
||
{"总开关关(MEMORY_REVIEW_ENABLED=false)", false, false, false},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
// newGateReviewer 构建的是「闸开着」的服务,因此总开关关闭那一支
|
||
// 直接用构造函数建关闭态(它有独立的语义:不建、不接、不跑)。
|
||
var r *PreInsertReviewer
|
||
if tc.enabled {
|
||
r = newGateReviewer(t, store, ReviewConfig{
|
||
Enabled: tc.enabled, DisableIngestGate: tc.disableGate,
|
||
}, nil)
|
||
} else {
|
||
var err error
|
||
r, err = NewPreInsertReviewer(ReviewConfig{Enabled: false}, ReviewDeps{Store: store, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
}
|
||
store.AttachIngestReviewer(r)
|
||
|
||
seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
|
||
dup := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
|
||
|
||
row := readReviewRow(t, store, dup.ID)
|
||
if row.status != tidyStatusActive || row.mergedInto != "" {
|
||
t.Fatalf("闸关闭时仍改了落库形态:%+v", row)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("闸关闭时 active 计数 = %d, want 2", n)
|
||
}
|
||
if s := r.Stats(); s.GateChecked != 0 || s.GateAbsorbed != 0 {
|
||
t.Fatalf("闸关闭时闸仍跑了:%+v", s)
|
||
}
|
||
if revs, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(revs) != 0 {
|
||
t.Fatalf("闸关闭时不该有留痕:%+v err=%v", revs, err)
|
||
}
|
||
wantQueued := 0
|
||
if tc.enqueue {
|
||
wantQueued = 2
|
||
}
|
||
if n := len(r.queue); n != wantQueued {
|
||
t.Fatalf("投队列 = %d, want %d(关闸即回到改动之前的异步行为)", n, wantQueued)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_revert_能把闸下落的merged行复原 是任务书必备用例⑧:
|
||
// 闸落的是 `merged`(不是「不插入」)⇒ revert 无损复原。
|
||
func TestIngestGate_revert_能把闸下落的merged行复原(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
survivor := seedTidyItem(t, store, gateFixtureItem(fixtureHabitLong, base))
|
||
dup := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
|
||
if got := readReviewRow(t, store, dup.ID); got.status != ingestStatusMerged {
|
||
t.Fatalf("前置条件不成立(闸没折回):%+v", got)
|
||
}
|
||
|
||
if err := store.RevertMerged(ctx, dup.ID); err != nil {
|
||
t.Fatalf("RevertMerged: %v", err)
|
||
}
|
||
restored := readReviewRow(t, store, dup.ID)
|
||
if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != fixtureHabitShort {
|
||
t.Fatalf("revert 后 = %+v, want active 且原文无损", restored)
|
||
}
|
||
if s := readReviewRow(t, store, survivor.ID); s.mergeCount != 1 {
|
||
t.Fatalf("revert 后存活行 merge_count = %d, want 1(登记也一并摘掉)", s.mergeCount)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("revert 后 active 计数 = %d, want 2", n)
|
||
}
|
||
}
|
||
|
||
// TestIngestGate_Add墙钟有界 是任务书必备用例⑨:候选源慢过预算时,`Add` 的
|
||
// 墙钟仍由闸的预算封顶(注入慢审查器 ⇒ 断言不超预算)。
|
||
//
|
||
// 它**刻意不设 GateBudget**,走代码默认值 —— 这样「去掉预算」这条变异会把
|
||
// 它变红(见报告里的变异自证)。
|
||
func TestIngestGate_Add墙钟有界(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{}, gateSlowCandidates(2*time.Second))
|
||
store.AttachIngestReviewer(r)
|
||
|
||
start := time.Now()
|
||
seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
|
||
elapsed := time.Since(start)
|
||
|
||
budget := defaultReviewGateBudget
|
||
if elapsed > budget+500*time.Millisecond {
|
||
t.Fatalf("Add 等了 %s,越过闸预算 %s 太多:闸没有界", elapsed, budget)
|
||
}
|
||
if s := r.Stats(); s.GateFailed != 1 || s.GateChecked != 0 {
|
||
t.Fatalf("闸计数 = %+v, want 一条 fail-open", s)
|
||
}
|
||
}
|
||
|
||
// gateSlowCandidates 返回一个「慢候选源」:它一直等到 ctx 取消或 d 到点。
|
||
// 它**尊重 ctx**(生产里的候选源是 SQL 查询,同样尊重 ctx),因此预算真的
|
||
// 能把它截断;这是「预算有界」可确定性验证的前提。
|
||
func gateSlowCandidates(d time.Duration) func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
|
||
return func(ctx context.Context, _ tidyPartition, _ string, _ int) ([]MemoryItem, error) {
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
case <-time.After(d):
|
||
return nil, nil
|
||
}
|
||
}
|
||
}
|