Files
Cyrene_v2/internal/runtime/memory/preinsert_t1_limit_test.go
AskaEth a684328b76
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): T1 加保守全局速率限流(防每天数百到上千次调用失控)
主人 2026-10-07 裁定:按最优解加全局限流。

- 新增 t1RateGate(滑动窗口、可注入时钟,~70 行);默认
  120 次 / 60 分钟(现网 700-1000 条新记忆/天 ≈ 25-42/小时,
  取 120 留约 3-5 倍余量)
- 过闸摆在「t1Enabled + 有灰区候选」两个守卫之后、发请求之前
  ⇒ 不调模型就不占额度;放行才记账
- 超限语义与既有失败路径同源:跳过 T1、决定仍是 create、零改写,
  只多一个 reason=t1_throttled 进留痕 + ReviewStats.T1Throttled 计数
- 配置键 t1PerWindow / t1WindowSec 放 $DATA_DIR/config/memory-tidy.json
  (零新 env 键,启动期读一次);坏配置回落默认 + WARN,不启动失败
- 每窗口只记一条 WARN(超限是预期行为,一天几百条会淹掉真故障)
- IngestRuleVersion 仍为 d3.2:判定规则集与解析契约没变,变的只是
  额度状态,reason 列已可分辨(升版会有 §17.6 记过的幂等键重复行副作用)

自研而非复用 session.NewRateLimiter 的理由(design/12 §N C.5 自己登记过
这条取舍):既有件 Allow() 内部写死 time.Now(),没有时钟注入缝,
而本切片必须能注入可控时钟;且复用会把 channel 层概念拖进 memory 包。
2026-10-07 16:25:54 +08:00

413 lines
16 KiB
Go
Raw Permalink 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"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 **T1 全局限速**(design/23 §15.4,preinsert_t1_limit.go)的测试:
// 超限即不调模型、额度只被真调用消耗、窗口过后恢复、限流路径零改写、日志收敛
// (每窗口一条 WARN),以及「≤0 不许被读成无上限」那条归一化红线。
//
// 三态落地 / 失败回落 / 开关口径在 preinsert_t1_test.go,调用次数有界与硬门隔离
// 在 preinsert_t1_bound_test.go(本文件不动它们,只加限流这一层)。
//
// 所有用例都**不**靠真实等待:窗口过期由可控时钟推进(下面 t1Clock),与
// t1JudgeStub「被调用即 panic」是同一思路 —— 把「不该发生的调用」变成立刻现形。
// t1Clock 是可推进的墙钟:限流的「窗口过后额度恢复」必须由它驱动,真实等待会让
// 测试变慢且不确定(窗口是墙钟量,不是逻辑量)。
type t1Clock struct {
mu sync.Mutex
at time.Time
}
func newT1Clock(at time.Time) *t1Clock { return &t1Clock{at: at} }
func (c *t1Clock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *t1Clock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.at = c.at.Add(d)
}
// newT1LimiterReviewer 构建「异步审查层 + 已接线 T1 + 可控时钟」的服务:
// 同步入库闸显式关掉(这批用例只验异步那一层,否则测到的是闸),其余走默认值;
// 限流额度由 cfg.T1PerWindow / cfg.T1Window 给出(非正即回落代码默认)。
func newT1LimiterReviewer(
t *testing.T, store *Store, emb embedding.Embedder, cfg ReviewConfig,
judge T1Judge, now func() time.Time, log *slog.Logger,
) *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
}
cfg.DisableIngestGate = true
if log == nil {
log = testLogger()
}
r, err := NewPreInsertReviewer(cfg, ReviewDeps{
Store: store, Embedder: emb, Judge: judge, Now: now, Log: log,
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// TestT1ThrottledSkipsModelCallAndFallsBackToCreate 是任务书 §5①:额度用尽后
// **不再发起模型调用**,且结论仍是 `create` + `reason=t1_throttled`(与既有失败
// 路径同语义:只改 reason、决定恒 create、零改写)。
//
// 桩回 `keep` 是刻意的:灰区条目因此每次都留在册,同一条可以被反复审 —— 于是
// 「第 N 次」这件事只由额度决定,与判定结果无关。
func TestT1ThrottledSkipsModelCallAndFallsBackToCreate(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
judge := &t1JudgeStub{reply: `{"action":"keep","target":0}`}
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(),
ReviewConfig{T1PerWindow: 2, T1Window: time.Hour}, judge.judge, nil, nil)
for i := 1; i <= 2; i++ {
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("第 %d 次 Review: %v", i, err)
}
if rev.Reason != t1ReasonKeep {
t.Fatalf("额度内第 %d 次的 reason = %q, want %q", i, rev.Reason, t1ReasonKeep)
}
}
if judge.count() != 2 {
t.Fatalf("额度内模型调用 = %d 次, want 2", judge.count())
}
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("超限那次 Review: %v", err)
}
if judge.count() != 2 {
t.Fatalf("超限后仍调了模型:共 %d 次, want 仍为 2", judge.count())
}
if rev.Decision != IngestCreate {
t.Fatalf("超限后决定 = %q, want create(限流不是报错,是这一轮先不判)", rev.Decision)
}
if rev.Reason != t1ReasonThrottled {
t.Fatalf("超限后 reason = %q, want %q", rev.Reason, t1ReasonThrottled)
}
st := r.Stats()
if st.T1Called != 2 || st.T1Decided != 2 || st.T1Throttled != 1 {
t.Fatalf("计数 = %+v, want T1Called=2 T1Decided=2 T1Throttled=1", st)
}
if label := reviewStatsLabel(st); !strings.Contains(label, "t1_throttled=1") {
t.Fatalf("统计串里看不到限流:%q", label)
}
}
// TestT1ThrottleRecoversAfterWindow 是任务书 §5②:额度在窗口过后**恢复**
// (限流不把窗口往后推)。时钟由注入的 t1Clock 推进,不靠真实等待。
func TestT1ThrottleRecoversAfterWindow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
clock := newT1Clock(tidyTestTime(t))
judge := &t1JudgeStub{reply: `{"action":"keep","target":0}`}
window := 30 * time.Minute
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(),
ReviewConfig{T1PerWindow: 1, T1Window: window}, judge.judge, clock.now, nil)
if rev, err := r.Review(ctx, fresh); err != nil || rev.Reason != t1ReasonKeep {
t.Fatalf("窗口内首次 = %+v err=%v, want keep", rev, err)
}
if rev, err := r.Review(ctx, fresh); err != nil || rev.Reason != t1ReasonThrottled {
t.Fatalf("窗口内第二次 = %+v err=%v, want %q", rev, err, t1ReasonThrottled)
}
// 差一秒钟到期:仍然超限(边界口径与既有滑窗件逐字相同:严格晚于 cutoff 才算在窗内)。
clock.advance(window - time.Second)
if rev, err := r.Review(ctx, fresh); err != nil || rev.Reason != t1ReasonThrottled {
t.Fatalf("窗口未满却恢复了额度:%+v err=%v", rev, err)
}
if judge.count() != 1 {
t.Fatalf("窗口未满时模型调用 = %d 次, want 1", judge.count())
}
// 推进过窗口:额度恢复,模型再次被调用。
clock.advance(2 * time.Second)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("窗口过后 Review: %v", err)
}
if judge.count() != 2 {
t.Fatalf("窗口过后模型调用 = %d 次, want 2(额度必须恢复)", judge.count())
}
if rev.Reason != t1ReasonKeep {
t.Fatalf("窗口过后的 reason = %q, want %q", rev.Reason, t1ReasonKeep)
}
if st := r.Stats(); st.T1Throttled != 2 {
t.Fatalf("超限计数 = %d, want 2", st.T1Throttled)
}
}
// TestT1ThrottledNeverTouchesMemory 是任务书 §5③:限流路径**只改 reason**,
// 记忆行一个字不改(与 TestReviewFailureNeverTouchesMemory 同一手法)。
//
// 守卫手法照 TestT1DisabledMakesZeroModelCalls:把桩换成「被调用即 panic」并回
// `merge` —— 限流一旦失效,要么当场 panic,要么真把两行改写掉,两种都在断言里现形。
func TestT1ThrottledNeverTouchesMemory(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
existing, fresh := seedT1GrayPair(t, store)
judge := &t1JudgeStub{reply: `{"action":"keep","target":0}`}
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(),
ReviewConfig{T1PerWindow: 1, T1Window: time.Hour}, judge.judge, nil, nil)
// 第一次:额度内,桩回 keep(不改写任何行)。
if _, err := r.Review(ctx, fresh); err != nil {
t.Fatalf("额度内 Review: %v", err)
}
if judge.count() != 1 {
t.Fatalf("额度内模型调用 = %d 次, want 1", judge.count())
}
// 第二次:额度已用尽。桩换成「被调用即 panic」并回 merge。
judge.mu.Lock()
judge.panics = true
judge.reply = `{"action":"merge","target":1}`
judge.mu.Unlock()
rev := t1ReviewGuarded(t, r, fresh)
if rev.Decision != IngestCreate || rev.Reason != t1ReasonThrottled {
t.Fatalf("超限那次 = %+v, want create + %q", rev, t1ReasonThrottled)
}
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive ||
got.mergedInto != "" || got.content != fixtureT1CodeViaChannel {
t.Fatalf("限流却改写了新行:%+v", got)
}
old := readReviewRow(t, store, existing.ID)
if old.status != tidyStatusActive || old.mergeCount != 1 || old.content != fixtureT1CodeAgreed {
t.Fatalf("限流却改了既有行:%+v", old)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("活跃行数 = %d, want 2(限流是「不调」,不是「调失败」)", n)
}
}
// TestT1ThrottleQuotaIsOnlySpentByRealCalls 钉住「额度只被真调用消耗」:
// 没有灰区候选的审查(本来就不调模型)一次额度都不占 —— 否则整库的普通新建
// 会把 T1 的额度白白吃光,限流就成了随机停机。
func TestT1ThrottleQuotaIsOnlySpentByRealCalls(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
_, fresh := seedT1GrayPair(t, store)
// 一条与灰区对**毫不相似**的条目:它落不到任何候选上 ⇒ 零灰区、零调用。
plain := seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(2*time.Hour), 0.9))
judge := &t1JudgeStub{reply: `{"action":"keep","target":0}`}
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(),
ReviewConfig{T1PerWindow: 1, T1Window: time.Hour}, judge.judge, nil, nil)
rev, err := r.Review(ctx, plain)
if err != nil {
t.Fatalf("零灰区那次 Review: %v", err)
}
if rev.Decision != IngestCreate || judge.count() != 0 {
t.Fatalf("零灰区那次 = %+v calls=%d,不该调模型", rev, judge.count())
}
if st := r.Stats(); st.T1Throttled != 0 {
t.Fatalf("零灰区那次被记成限流:%+v", st)
}
// 额度仍然完好在册:真需要判定的这一条照样调得到模型。
if rev, err := r.Review(ctx, fresh); err != nil || rev.Reason != t1ReasonKeep {
t.Fatalf("灰区那条 = %+v err=%v, want keep(额度被普通新建吃掉了)", rev, err)
}
if judge.count() != 1 {
t.Fatalf("模型调用 = %d 次, want 1", judge.count())
}
}
// TestT1ThrottleLogsOneWarnPerWindow 钉住日志口径:超限是**预期内的省钱行为**,
// 不是故障 ⇒ 每窗口只记一条 WARN,其余记 Debug;且日志里绝不出现记忆正文。
func TestT1ThrottleLogsOneWarnPerWindow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
judge := &t1JudgeStub{reply: `{"action":"keep","target":0}`}
limit := ReviewConfig{T1PerWindow: 1, T1Window: time.Hour}
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(), limit, judge.judge, nil, log)
// 三次审查、额度 1:第 1 次真调、第 2 次超限(本窗口第一条 WARN)、
// 第 3 次仍超限(降级成 Debug)—— 日志条数 2、其中 WARN 1。
for i := 0; i < 3; i++ {
if _, err := r.Review(ctx, fresh); err != nil {
t.Fatalf("第 %d 次 Review: %v", i, err)
}
}
logged := buf.String()
if n := strings.Count(logged, "t1 judge throttled"); n != 2 {
t.Fatalf("限流日志条数 = %d, want 2(两次超限各一条):%q", n, logged)
}
if n := strings.Count(logged, "level=WARN"); n != 1 {
t.Fatalf("限流 WARN 条数 = %d, want 1(每窗口一条):%q", n, logged)
}
for _, leaked := range []string{fixtureT1CodeAgreed, fixtureT1CodeViaChannel} {
if strings.Contains(logged, leaked) {
t.Fatalf("日志里泄漏了记忆正文 %q:%q", leaked, logged)
}
}
// 窗口排空之后「本窗口已报过」必须复位:新窗口的第一次超限仍然要 WARN。
clock := newT1Clock(tidyTestTime(t))
r2 := newT1LimiterReviewer(t, store, t1GrayEmbedder(), limit, judge.judge, clock.now, log)
for i := 0; i < 2; i++ {
if _, err := r2.Review(ctx, fresh); err != nil {
t.Fatalf("新一轮第 %d 次 Review: %v", i, err)
}
}
if n := strings.Count(buf.String(), "level=WARN"); n != 2 {
t.Fatalf("新窗口的 WARN 条数 = %d, want 2:%q", n, buf.String())
}
clock.advance(2 * time.Hour)
for i := 0; i < 2; i++ {
if _, err := r2.Review(ctx, fresh); err != nil {
t.Fatalf("过期轮第 %d 次 Review: %v", i, err)
}
}
if n := strings.Count(buf.String(), "level=WARN"); n != 3 {
t.Fatalf("跨窗口后的 WARN 条数 = %d, want 3(新窗口要重新报一次):%q", n, buf.String())
}
}
// TestT1DisabledLimitsNothingAndCallsNothing 是任务书 §5④:`t1Judge: false`
// (DisableT1)时**零模型调用**,限流器连碰都不碰 —— 既有那条「被调用即 panic」
// 的守卫测试(TestT1DisabledMakesZeroModelCalls)因此不受本切片影响。
func TestT1DisabledLimitsNothingAndCallsNothing(t *testing.T) {
t.Parallel()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
judge := &t1JudgeStub{panics: true, reply: `{"action":"merge","target":1}`}
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(),
ReviewConfig{DisableT1: true, T1PerWindow: 1, T1Window: time.Hour}, judge.judge, nil, nil)
rev := t1ReviewGuarded(t, r, fresh)
if judge.count() != 0 {
t.Fatalf("T1 关掉后仍调了 %d 次模型", judge.count())
}
if rev.Decision != IngestCreate || rev.Reason != "no_contained_match" {
t.Fatalf("T1 关掉后 = %+v, want create + T0 原始原因码(限流不该改这里的语义)", rev)
}
if st := r.Stats(); st.T1Called != 0 || st.T1Decided != 0 || st.T1Throttled != 0 {
t.Fatalf("T1 关掉后计数 = %+v, want 全零", st)
}
}
// TestT1RateGateIsConcurrencySafe 钉住闸的**并发安全**:审查可能来自后台工作
// 协程,也可能来自手动 Review。N 个协程同时抢 K 个额度 ⇒ **恰好**放行 K 个 ——
// 多放行就是超发模型调用(额度失效),少放行就是并发把额度吃掉(额度虚耗)。
func TestT1RateGateIsConcurrencySafe(t *testing.T) {
t.Parallel()
const (
limit = 7
workers = 32
)
gate := newT1RateGate(limit, time.Hour, newT1Clock(tidyTestTime(t)).now)
var (
wg sync.WaitGroup
mu sync.Mutex
allowed int
)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if ok, _ := gate.allow(); ok {
mu.Lock()
allowed++
mu.Unlock()
}
}()
}
wg.Wait()
if allowed != limit {
t.Fatalf("并发放行 = %d, want 恰为 %d", allowed, limit)
}
}
// TestNormalizeReviewConfigFillsT1ThrottleDefaults 是 design/12 §N C.5 G3 那条
// 红线的探针:额度**非正**必须被 normalize 回落成默认额 —— 绝不许带着 ≤0 进闸,
// 否则闸的「恒放行」会把「回落默认」静默变成「无上限」(构造处已保证闸在归一之后建)。
func TestNormalizeReviewConfigFillsT1ThrottleDefaults(t *testing.T) {
t.Parallel()
for _, cfg := range []ReviewConfig{
{},
{T1PerWindow: -1, T1Window: -time.Second},
{T1PerWindow: 7},
{T1Window: 3 * time.Minute},
} {
got := normalizeReviewConfig(cfg)
if got.T1PerWindow <= 0 {
t.Fatalf("normalize(%+v).T1PerWindow = %d,非正值直达闸会被读成「无上限」", cfg, got.T1PerWindow)
}
if got.T1Window <= 0 {
t.Fatalf("normalize(%+v).T1Window = %s,同上", cfg, got.T1Window)
}
}
if got := normalizeReviewConfig(ReviewConfig{}); got.T1PerWindow != defaultT1PerWindow ||
got.T1Window != defaultT1Window {
t.Fatalf("零值配置 = %d/%s, want %d/%s",
got.T1PerWindow, got.T1Window, defaultT1PerWindow, defaultT1Window)
}
if got := normalizeReviewConfig(ReviewConfig{T1PerWindow: 7, T1Window: 3 * time.Minute}); got.T1PerWindow != 7 ||
got.T1Window != 3*time.Minute {
t.Fatalf("显式额度被改写:%d/%s", got.T1PerWindow, got.T1Window)
}
// 构造后的生效值也必须已是默认额(而不是留 0 等运行时兜)。
store := newTestStore(t)
r := newT1LimiterReviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, nil, nil, nil)
if r.cfg.T1PerWindow != defaultT1PerWindow || r.cfg.T1Window != defaultT1Window {
t.Fatalf("NewPreInsertReviewer 的生效额度 = %d/%s, want 默认 %d/%s",
r.cfg.T1PerWindow, r.cfg.T1Window, defaultT1PerWindow, defaultT1Window)
}
if allowed, first := r.t1Gate.allow(); !allowed || first {
t.Fatal("默认额度下第一次就该放行")
}
}