主人 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 包。
413 lines
16 KiB
Go
413 lines
16 KiB
Go
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("默认额度下第一次就该放行")
|
||
}
|
||
}
|