Merge branch 'feat/m33-testhealth-fix-test-double-races-and' into dev
This commit is contained in:
commit
210c8b999f
11 files changed
+496
-111
No files matched your search
@@ -81,6 +81,18 @@ func candidateIDs(cands []ingestCandidate) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// measureGateBudget 是墙钟实测用例显式给闸的**总墙钟预算**。
|
||||
//
|
||||
// 这个用例要量的是「闸真扫真判」时写入路径多花多少墙钟,不是「闸会不会超预算」——
|
||||
// 后者由 preinsert_gate_test.go 的预算/fail-open 用例守。旧形状沿用默认
|
||||
// defaultReviewGateBudget(150ms):负载一高,一次候选召回(SQLite 查询 +
|
||||
// context.WithTimeout)就会超时 ⇒ 闸 fail-open ⇒ GateFailed++/GateChecked 少一条,
|
||||
// 用例的「每轮都真扫真判且不合并」前置断言因此炸(本轮空载也 2/3 复现)。
|
||||
//
|
||||
// 本机实测该场景单次闸耗时是**毫秒级**(p95 约 30ms);5s 是该量级的 ~150 倍,
|
||||
// 只放开预算这条闸门,被测量的工作量一字不改,因此 p50/p95/批均值仍然可比。
|
||||
const measureGateBudget = 5 * time.Second
|
||||
|
||||
// TestIngestGate_Add墙钟实测_闸开vs关 给出**报告里要那个数字**:闸开与闸关时
|
||||
// `Add` 的墙钟 p50/p95(多轮取样,最坏可复现场景:每轮都扫满候选且都不合并)。
|
||||
//
|
||||
@@ -131,7 +143,7 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
|
||||
base := tidyTestTime(t)
|
||||
var reviewer *PreInsertReviewer
|
||||
if gateOn {
|
||||
reviewer = newGateReviewer(t, store, ReviewConfig{}, nil)
|
||||
reviewer = newGateReviewer(t, store, ReviewConfig{GateBudget: measureGateBudget}, nil)
|
||||
store.AttachIngestReviewer(reviewer)
|
||||
}
|
||||
for i := 0; i < seeded; i++ {
|
||||
@@ -149,8 +161,10 @@ func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Du
|
||||
}
|
||||
total := time.Since(batchStart)
|
||||
if gateOn {
|
||||
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateChecked != seeded+rounds {
|
||||
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并):%+v", s)
|
||||
// 「实测场景成立」= 每一轮都真扫真判、不合并、也**没有 fail-open**。预算本身由
|
||||
// measureGateBudget 放开,因此 GateFailed 非零只可能是候选源不可用等真异常。
|
||||
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateFailed != 0 || s.GateChecked != seeded+rounds {
|
||||
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并、且不 fail-open):%+v", s)
|
||||
}
|
||||
}
|
||||
return lat, total
|
||||
|
||||
@@ -18,10 +18,19 @@ import (
|
||||
// 过期记录)⇒ 本用例在 `Used != receipt.Used` 处变红。
|
||||
func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
// worker 卡住 ⇒ 排进去的那一轮不会把槽取走,`pending` 因此可确定地断言。
|
||||
// 让自排轮卡在 Think 里:worker 因此**忙**着,后面排进去的那一轮不会被取走,
|
||||
// `pending` 才能确定地断言。
|
||||
//
|
||||
// 🔴 卡住 Think 只能让 Running 恒真,**不足以**占住待跑槽:worker 是先 take(把槽取空)
|
||||
// 再进 Think 的。旧写法只卡 Think 就断言「排一轮后 Pending=true」,实际是在赌
|
||||
// 「WakeNow 返回得比 worker 取槽快」—— -race 下 worker 常常先跑赢,Pending 已经是
|
||||
// false(本轮实测 3/3 必红)。因此这里改成:第一轮先让 worker 忙起来,第二轮才断言
|
||||
// 待跑槽被占用 —— 断言口径(快照如实报告待跑槽)一字未改,只是把它放到可确定的相位上。
|
||||
w := newWakeThinker()
|
||||
w.holdSelfWake = make(chan struct{})
|
||||
b, _ := startSelfWake(t, Config{SelfWakeLimit: 2, SelfWakeWindow: time.Hour}, w)
|
||||
// 放行卡住的那一轮,好让 Stop 的 t.Cleanup 能排空 worker。
|
||||
defer close(w.holdSelfWake)
|
||||
|
||||
// 启动即有一轮启动轮在跑(tickLoop 上的 TickImmediately),但它不占自排槽。
|
||||
before := mustStatus(t, b)
|
||||
@@ -54,21 +63,38 @@ func TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot(t *testing.T) {
|
||||
if !receipt.Accepted {
|
||||
t.Fatalf("收据 = %+v, want Accepted", receipt)
|
||||
}
|
||||
// 等这一轮真的被 worker 取走并卡进 Think(= 槽已空、worker 已忙):这是下一段的**前置件**,
|
||||
// 等不到说明 worker 没被调度(那是另一个失败,由此处明确报出)。
|
||||
w.waitFor(t, 2, 5*time.Second)
|
||||
after := mustStatus(t, b)
|
||||
if after.Used != receipt.Used || after.Used != 1 {
|
||||
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 1(同一口径)", after.Used, receipt.Used)
|
||||
}
|
||||
if !after.Pending {
|
||||
t.Fatalf("排入后待跑槽应报告占用:%+v", after)
|
||||
|
||||
// worker 正忙 ⇒ 这一轮留在待跑槽里:快照必须如实报告 Pending。
|
||||
pending, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "占住待跑槽"})
|
||||
if err != nil {
|
||||
t.Fatalf("WakeNow 占槽: %v", err)
|
||||
}
|
||||
if !pending.Accepted {
|
||||
t.Fatalf("收据 = %+v, want Accepted(worker 正忙,槽是空的)", pending)
|
||||
}
|
||||
occupied := mustStatus(t, b)
|
||||
if !occupied.Pending {
|
||||
t.Fatalf("排入后待跑槽应报告占用:%+v", occupied)
|
||||
}
|
||||
if occupied.Used != pending.Used || occupied.Used != 2 {
|
||||
t.Fatalf("快照 Used = %d, 收据 Used = %d, want 2(同一口径,槽满=额度用尽)",
|
||||
occupied.Used, pending.Used)
|
||||
}
|
||||
|
||||
// 第二次撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
|
||||
// 撞上满槽 ⇒ 合并(不消耗额度),快照的 Used 不得变。
|
||||
if _, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "再排一轮"}); err != nil {
|
||||
t.Fatalf("WakeNow 合并: %v", err)
|
||||
}
|
||||
merged := mustStatus(t, b)
|
||||
if merged.Used != 1 {
|
||||
t.Fatalf("合并后 Used = %d, want 1(合并不额外占额度)", merged.Used)
|
||||
if merged.Used != 2 {
|
||||
t.Fatalf("合并后 Used = %d, want 2(合并不额外占额度,停在 Limit)", merged.Used)
|
||||
}
|
||||
if merged.Limit != 2 {
|
||||
t.Fatalf("合并后 Limit = %d, want 2", merged.Limit)
|
||||
|
||||
@@ -480,23 +480,43 @@ func TestWakeNow_ZeroLimitFallsBackToDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSelfWakeGate_WindowExpiry 覆盖「窗口过期后额度回来」。session.RateLimiter 内部
|
||||
// 用 time.Now()(design/12 §N C.5 已登记的取舍:不为自排再自造第二套滑窗),因此这里
|
||||
// 用极小窗口 + 真实短等,只取下界判断(等过窗口必放行),不依赖上界精度。
|
||||
// 用 time.Now()(design/12 §N C.5 已登记的取舍:不为自排再自造第二套滑窗),因此窗口
|
||||
// **无法注入假时钟** —— 这里只能把余量放大到与真实机器负载相称,断言口径不变:
|
||||
// 窗口内第二次必被拒、等过窗口必放行且 Used 回到 1。
|
||||
//
|
||||
// 为什么是 6s(旧值 120ms):
|
||||
// - 「窗口内第二次」这条断言要求「第一次受理 → worker 真的进入两轮(waitFor)→ 第二次
|
||||
// 调用」整段落在窗口内;waitFor 那一段是纯调度等待,73 个测试包并行抢核时会被拉长,
|
||||
// 120ms 一被拉长就必红(本轮其他证人实测红;本机 12 核上 30~60 次带载复跑未复现,
|
||||
// 所以这里按**余量**加固而不是拿复现驱动)。窗口取 6s ⇒ 给 waitFor 的 4s 上界留下
|
||||
// 2s 余量:哪怕 waitFor 用满上界,也仍在窗口内,失败点因此只会是「worker 没被调度」
|
||||
// 而不是一条含义被负载改写的窗口断言。
|
||||
// - 到期等待不再是盲睡 3×窗口(放大后那是 18s 白等):以**第一次受理时刻**为基准,
|
||||
// 等到「窗口 + 250ms 结算余量」,既保留「等过窗口必放行」的下界语义,又不浪费墙钟。
|
||||
func TestSelfWakeGate_WindowExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
const window = 120 * time.Millisecond
|
||||
const (
|
||||
window = 6 * time.Second
|
||||
// scheduleWait 是「worker 已被调度」这个前置件的上界,必须小于 window。
|
||||
scheduleWait = 4 * time.Second
|
||||
// expirySettle 是窗口到期后的结算余量(RateLimiter 用 t.After(cutoff) 严格比较)。
|
||||
expirySettle = 250 * time.Millisecond
|
||||
)
|
||||
w := newWakeThinker()
|
||||
b, _ := startSelfWake(t, Config{SelfWakeLimit: 1, SelfWakeWindow: window}, w)
|
||||
|
||||
firstAt := time.Now()
|
||||
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "第一次"}); err != nil || !got.Accepted {
|
||||
t.Fatalf("第一次收据 = %+v err = %v, want 排上", got, err)
|
||||
}
|
||||
w.waitFor(t, 2, 3*time.Second)
|
||||
w.waitFor(t, 2, scheduleWait)
|
||||
if got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口内第二次"}); err != nil || got.Accepted {
|
||||
t.Fatalf("窗口内第二次收据 = %+v err = %v, want 被上限拒绝", got, err)
|
||||
}
|
||||
|
||||
time.Sleep(3 * window)
|
||||
if wait := window + expirySettle - time.Since(firstAt); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
got, err := b.WakeNow(ownerCtx(), SelfWakeRequest{Reason: "窗口过了"})
|
||||
if err != nil {
|
||||
t.Fatalf("窗口过后 WakeNow: %v", err)
|
||||
|
||||
@@ -59,6 +59,13 @@ func (f *fakeAdapter) Capabilities() adapter.Capabilities
|
||||
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
|
||||
|
||||
// stubChatter 记录传给 agent 循环的会话 id 与消息。
|
||||
//
|
||||
// 不加锁是**有意的**:用到它的 21 个用例全部走同步便捷路径
|
||||
// (`b.process → processFrom → runBatch → Loop.Run`,见 inbound.go 的 processFrom 注释),
|
||||
// 写与读发生在同一个 goroutine 上,没有跨 goroutine 的 happens-before 缺口
|
||||
// (M33 实测:`-race` 数十次跑、含把锁删掉的变异,DATA RACE 恒为 0)。
|
||||
// 🔴 若将来有用例把它放上车道(laneWorker)再读这些字段,**必须**加锁(sync.Mutex)
|
||||
// 并补一条 -race 用例 —— 届时的竞态是真的,不能只靠运气。
|
||||
type stubChatter struct {
|
||||
sessionID string
|
||||
messages []model.Message
|
||||
@@ -75,6 +82,9 @@ func (s *stubChatter) Run(ctx context.Context, sessionID string, msgs []model.Me
|
||||
}
|
||||
|
||||
// stubSessions 记录入站消息并返回固定会话。
|
||||
//
|
||||
// 与 stubChatter 同理:只在同步路径上被调用,因此不加锁;若将来从车道 worker 触碰,
|
||||
// 必须补锁 + 补一条 -race 用例。
|
||||
type stubSessions struct {
|
||||
msg adapter.UnifiedMessage
|
||||
owner *user.OwnerProfile
|
||||
|
||||
@@ -46,6 +46,17 @@ func coalesceOutboundReceipt(platform, groupID string) adapter.UnifiedMessage {
|
||||
return msg
|
||||
}
|
||||
|
||||
// latestDuration 取请求时长序列里**最新**的一个。
|
||||
//
|
||||
// 用例关心的是「刚发的那条消息之后 worker 续的臂」,而不是历史第几条:臂的次数取决于
|
||||
// worker 什么时候把消息收进本批(调度决定),按下标取会引入时序假设。
|
||||
func latestDuration(d []time.Duration) time.Duration {
|
||||
if len(d) == 0 {
|
||||
return 0
|
||||
}
|
||||
return d[len(d)-1]
|
||||
}
|
||||
|
||||
// waitTimerCalls 等到定时器工厂至少被请求 n 次,返回到目前为止的**请求时长序列**。
|
||||
func waitTimerCalls(t *testing.T, f *coalesceBridge, n int) []time.Duration {
|
||||
t.Helper()
|
||||
@@ -60,22 +71,43 @@ func waitTimerCalls(t *testing.T, f *coalesceBridge, n int) []time.Duration {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fireLatest 按**最后一个**待定定时器自己的时长推进时钟并触发它。
|
||||
// fireLatest 把注入时钟推进到**最近一次**请求的截止时刻并触发它。
|
||||
//
|
||||
// 为什么是最后一个:窗口被重置/续臂时,旧的定时器通道留在那里(不会被触发),
|
||||
// 为什么是最近一次:窗口被重置/续臂时,旧的定时器通道留在那里(再没人监听),
|
||||
// 真正决定发车时刻的是**最近一次**请求的那个。
|
||||
func (t *coalesceTimers) fireLatest(tb *testing.T, clock *laneClock) {
|
||||
//
|
||||
// 为什么按**截止时刻**推进而不是「再推一个请求时长」:worker 可能已经在这段时间里
|
||||
// 重新续过臂(例如一条系统通知落在窗口中途),把时长再累加一次会让时钟越过绝对上限,
|
||||
// 循环头于是走「到点直接发车、不 takeAll」的分支;按截止时刻推进只表示「时间走到了
|
||||
// 它约定的那个点」。
|
||||
//
|
||||
// 为什么要重试:登记与触发之间隔着 worker 的一次循环 —— 万一它刚续了新臂,被触发的
|
||||
// 那个通道就没人接(时钟已经推了,却没人醒)。这里在 done() 未成立、仍有待触发定时器时
|
||||
// 继续触发,直到 worker 真的发车;断言口径由调用方守。
|
||||
func (t *coalesceTimers) fireLatest(tb *testing.T, clock *laneClock, done func() bool) {
|
||||
tb.Helper()
|
||||
t.mu.Lock()
|
||||
if len(t.pending) == 0 {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
t.mu.Lock()
|
||||
if len(t.pending) == 0 {
|
||||
t.mu.Unlock()
|
||||
if done != nil && done() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
continue
|
||||
}
|
||||
timer := t.pending[len(t.pending)-1]
|
||||
t.pending = t.pending[:len(t.pending)-1]
|
||||
t.mu.Unlock()
|
||||
tb.Fatalf("no pending coalesce timer to fire")
|
||||
clock.advanceTo(timer.at)
|
||||
timer.ch <- timer.at
|
||||
if done != nil && done() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
timer := t.pending[len(t.pending)-1]
|
||||
t.pending = t.pending[:len(t.pending)-1]
|
||||
t.mu.Unlock()
|
||||
clock.advance(timer.d)
|
||||
timer.ch <- clock.Now()
|
||||
tb.Fatalf("fireLatest: 3s 内没有把窗口推到发车(calls=%d)", t.callCount())
|
||||
}
|
||||
|
||||
// TestCoalesceActionFrameDoesNotResetWindow 断言**准入在窗口等待阶段就生效**:
|
||||
@@ -91,24 +123,23 @@ func TestCoalesceActionFrameDoesNotResetWindow(t *testing.T) {
|
||||
Loop: chat,
|
||||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||||
})
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
// 第一条聊天消息 ⇒ 一个完整窗口(2.0s)。
|
||||
if d := waitTimerCalls(t, f, 1); d[0] != 2000*time.Millisecond {
|
||||
t.Fatalf("first window = %s, want 2s", d[0])
|
||||
// 投递用 sendSettled:它等到 worker 把这条收进本批、并**为它**续好臂才返回。
|
||||
// 这样「哪一次续臂属于哪条消息」是确定的 —— 只按「至少 N 次请求 + 取最新」仍有
|
||||
// 缝:worker 可能在帧之后才补上 m1 的续臂,最新那一次就是 2s(假红)。
|
||||
if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")); d != 2000*time.Millisecond {
|
||||
// 第一条聊天消息 ⇒ 一个完整窗口(2.0s)。
|
||||
t.Fatalf("first window = %s, want 2s", d)
|
||||
}
|
||||
|
||||
// 窗口走到 1.5s 时来了一条平台帧。
|
||||
f.clock.advance(1500 * time.Millisecond)
|
||||
f.send(coalesceActionFrame("telegram", "-20", "notice.group_recall"))
|
||||
|
||||
// 🔴 关键断言(状态,不是结果):下一次请求的**剩余**时长仍是 500ms。
|
||||
// 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。
|
||||
d := waitTimerCalls(t, f, 2)
|
||||
if d[1] != 500*time.Millisecond {
|
||||
t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d[1])
|
||||
if d := f.sendSettledAndDuration(t, coalesceActionFrame("telegram", "-20", "notice.group_recall")); d != 500*time.Millisecond {
|
||||
// 🔴 关键断言(状态,不是结果):这条帧续出的窗口**剩余**仍是 500ms。
|
||||
// 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。
|
||||
t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d)
|
||||
}
|
||||
|
||||
f.timers.fireLatest(t, f.clock)
|
||||
f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 })
|
||||
f.waitRuns(t, 1)
|
||||
f.waitIdle(t)
|
||||
|
||||
@@ -134,19 +165,20 @@ func TestCoalesceSystemNoticeDoesNotJoinBatch(t *testing.T) {
|
||||
Loop: chat,
|
||||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||||
})
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
|
||||
waitTimerCalls(t, f, 1)
|
||||
// 每条都等「worker 收进本批 + 为它续好臂」再继续(见 sendSettledAndDuration):
|
||||
// 只按「至少 N 次请求 + 取最新」有缝 —— worker 可能在通知之后才补上 m1/m2 的
|
||||
// 续臂,最新那一次就是 2s,用例假红。
|
||||
f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
|
||||
|
||||
f.clock.advance(1500 * time.Millisecond)
|
||||
f.send(coalesceActionFrame("telegram", "-20", "notice.group_increase"))
|
||||
// 与 action 帧同理:通知也不许重置计时(剩余仍是 500ms)。
|
||||
// 变异(准入失效)⇒ 这里拿到 2s,本用例红。
|
||||
if d := waitTimerCalls(t, f, 2); d[1] != 500*time.Millisecond {
|
||||
t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d[1])
|
||||
if d := f.sendSettledAndDuration(t, coalesceActionFrame("telegram", "-20", "notice.group_increase")); d != 500*time.Millisecond {
|
||||
t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d)
|
||||
}
|
||||
|
||||
f.timers.fireLatest(t, f.clock)
|
||||
f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 })
|
||||
f.waitRuns(t, 1)
|
||||
f.waitIdle(t)
|
||||
|
||||
@@ -209,16 +241,15 @@ func TestCoalesceChatStillResetsWindow(t *testing.T) {
|
||||
Loop: chat,
|
||||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||||
})
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
if d := waitTimerCalls(t, f, 1); d[0] != 2000*time.Millisecond {
|
||||
t.Fatalf("first window = %s, want 2s", d[0])
|
||||
if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1")); d != 2000*time.Millisecond {
|
||||
t.Fatalf("first window = %s, want 2s", d)
|
||||
}
|
||||
f.clock.advance(1500 * time.Millisecond)
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
|
||||
if d := waitTimerCalls(t, f, 2); d[1] != 2000*time.Millisecond {
|
||||
t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d[1])
|
||||
// 同 action-frame 用例:读的是**这条消息**续出的臂(sendSettledAndDuration 保证归属)。
|
||||
if d := f.sendSettledAndDuration(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m2")); d != 2000*time.Millisecond {
|
||||
t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d)
|
||||
}
|
||||
f.timers.fireLatest(t, f.clock)
|
||||
f.timers.fireLatest(t, f.clock, func() bool { return chat.runCount() >= 1 })
|
||||
f.waitRuns(t, 1)
|
||||
if got := userTextsIn(chat.runAt(0).messages); len(got) != 2 {
|
||||
t.Fatalf("batch user messages = %v, want both chat messages", got)
|
||||
|
||||
@@ -88,8 +88,8 @@ func TestContinuationAfterTurnEnds(t *testing.T) {
|
||||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||||
})
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
|
||||
f.timers.advanceAndFire(t, f.clock) // 第一批到点发车
|
||||
chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住)
|
||||
f.advanceAndFireUntil(t) // 第一批到点发车
|
||||
chat.waitRuns(t, 1, 3*time.Second) // 第一轮在跑(卡住)
|
||||
for i := 0; i < 3; i++ {
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestContinuationNoDuplicateAndNoLoss(t *testing.T) {
|
||||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||||
})
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
|
||||
f.timers.advanceAndFire(t, f.clock) // 第一批到点发车
|
||||
f.advanceAndFireUntil(t) // 第一批到点发车
|
||||
chat.waitRuns(t, 1, 3*time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("c%d", i)))
|
||||
@@ -247,7 +247,7 @@ func TestCoalesceStopsHonestly(t *testing.T) {
|
||||
f.b.Start()
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
|
||||
f.timers.take(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册)
|
||||
f.timers.waitRegistered(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册)
|
||||
|
||||
f.b.Stop(context.Background())
|
||||
|
||||
|
||||
@@ -68,24 +68,46 @@ func newCoalesceClock() *laneClock {
|
||||
//
|
||||
// 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它
|
||||
// (TestOwnerPrivateNeverCreatesWindow 断言 calls==0)。
|
||||
//
|
||||
// **触发语义按「截止时刻」而不是「先进先出队列」**:每次请求记下「注册时的注入时钟 + 时长」,
|
||||
// 测试推钟时**只把时钟推进到当前最早的截止时刻**,然后触发到期的那一个。
|
||||
// 为什么不能按队列逐个推(旧写法):`coalesceWait` 每个循环迭代都会重新 `after(wait)`,
|
||||
// 于是每条新到的消息都会留下一个**再没人监听**的旧定时器;旧写法把它们也逐个触发,
|
||||
// 每次照样推一个「请求时长」——实测 12 条消息内攒到 5 个,时钟被推 +10s(窗口 2s、
|
||||
// 绝对上限 6s)⇒ 时钟越过绝对上限 ⇒ 循环头在**没有 takeAll** 的分支上直接发车,
|
||||
// 队列尾部留在下一批(「12 条 → 2 轮」)。按截止时刻推进让「推了多少时间」= 「真的过了
|
||||
// 多少时间」,旧定时器过期后被触发也不会把时钟推过头。
|
||||
type coalesceTimers struct {
|
||||
mu sync.Mutex
|
||||
calls []time.Duration
|
||||
pending []coalesceTimer
|
||||
now func() time.Time
|
||||
// depth 返回「车道上还没被 worker 取走的条目数」(含 ch/spill/front)。
|
||||
// 推钟前必须等它归零,见 pump 的说明。
|
||||
depth func() int
|
||||
// seq 是单调请求序号:worker 每次重新挂定时器都会 +1,因此「比某个序号更大的请求」
|
||||
// 才可能是它此刻**真正在等**的那一个(旧的都已被它放弃)。
|
||||
seq int64
|
||||
}
|
||||
|
||||
// coalesceTimer 是一次定时器请求:通道 + 当时请求的时长(测试按它推进注入时钟,
|
||||
// 因此「到点」与「请求的时长」永远一致,不存在测试把时钟推多了的伪影)。
|
||||
// coalesceTimer 是一次定时器请求:通道 + 请求时长 + **截止时刻**(注册时的注入时钟 + d)。
|
||||
type coalesceTimer struct {
|
||||
ch chan time.Time
|
||||
d time.Duration
|
||||
ch chan time.Time
|
||||
d time.Duration
|
||||
at time.Time
|
||||
seq int64
|
||||
}
|
||||
|
||||
func (t *coalesceTimers) after(d time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
at := time.Time{}
|
||||
if t.now != nil {
|
||||
at = t.now().Add(d)
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.calls = append(t.calls, d)
|
||||
t.pending = append(t.pending, coalesceTimer{ch: ch, d: d})
|
||||
t.seq++
|
||||
t.pending = append(t.pending, coalesceTimer{ch: ch, d: d, at: at, seq: t.seq})
|
||||
t.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
@@ -104,47 +126,138 @@ func (t *coalesceTimers) durations() []time.Duration {
|
||||
return append([]time.Duration(nil), t.calls...)
|
||||
}
|
||||
|
||||
// take 取走**最早一个尚未触发**的定时器(等它有请求为止)。
|
||||
func (t *coalesceTimers) take(tb *testing.T, timeout time.Duration) coalesceTimer {
|
||||
tb.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
t.mu.Lock()
|
||||
if len(t.pending) > 0 {
|
||||
timer := t.pending[0]
|
||||
t.pending = t.pending[1:]
|
||||
t.mu.Unlock()
|
||||
return timer
|
||||
}
|
||||
t.mu.Unlock()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount())
|
||||
return coalesceTimer{}
|
||||
}
|
||||
|
||||
// advanceAndFire 按**该定时器请求的时长**推进注入时钟,然后触发它
|
||||
// (= 「静默窗口正好到点」)。
|
||||
func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock) {
|
||||
tb.Helper()
|
||||
timer := t.take(tb, 3*time.Second)
|
||||
clock.advance(timer.d)
|
||||
timer.ch <- clock.Now()
|
||||
}
|
||||
|
||||
// tryTake 非阻塞地取走最早一个尚未触发的定时器(没有就返回 false)。
|
||||
func (t *coalesceTimers) tryTake() (coalesceTimer, bool) {
|
||||
// nextDue 取走**截止最早**的那一个未触发定时器(没有就返回 false)。
|
||||
func (t *coalesceTimers) nextDue() (coalesceTimer, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if len(t.pending) == 0 {
|
||||
return coalesceTimer{}, false
|
||||
}
|
||||
timer := t.pending[0]
|
||||
t.pending = t.pending[1:]
|
||||
idx := 0
|
||||
for i := 1; i < len(t.pending); i++ {
|
||||
if t.pending[i].at.Before(t.pending[idx].at) {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
timer := t.pending[idx]
|
||||
t.pending = append(t.pending[:idx], t.pending[idx+1:]...)
|
||||
return timer, true
|
||||
}
|
||||
|
||||
// pump 反复「有定时器就按它请求的时长推进时钟并触发」,直到 done() 成立(或 3s 超时)。
|
||||
// waitRegistered 等到「至少有一次定时器请求挂着」(= worker 确实在等窗口)。
|
||||
//
|
||||
// 它只做**存在性检查**,不触发、也不消耗定时器:旧夹具里有调用方用 take 来证明
|
||||
// 「它在等窗口」,却把那个定时器丢掉了 —— 按截止时刻推进之后那会丢掉唯一活着的那个。
|
||||
// seqNow 返回当前请求序号(投递前记下它,之后用 waitFresh 等「这次投递之后新挂的」定时器)。
|
||||
func (t *coalesceTimers) seqNow() int64 {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.seq
|
||||
}
|
||||
|
||||
// waitFresh 等到「序号大于 after 的定时器已挂上」= worker 已经把这次投递收进本批并重新
|
||||
// 开始等窗口。旧请求(已被 worker 放弃、再没人监听)不算。
|
||||
func (t *coalesceTimers) waitFresh(tb *testing.T, after int64, timeout time.Duration) {
|
||||
tb.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
t.mu.Lock()
|
||||
fresh := false
|
||||
for _, tm := range t.pending {
|
||||
if tm.seq > after {
|
||||
fresh = true
|
||||
break
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if fresh {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
tb.Fatalf("等不到投递之后新挂的定时器(seq>%d, calls=%d)", after, t.callCount())
|
||||
}
|
||||
|
||||
func (t *coalesceTimers) waitRegistered(tb *testing.T, timeout time.Duration) {
|
||||
tb.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
t.mu.Lock()
|
||||
n := len(t.pending)
|
||||
t.mu.Unlock()
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount())
|
||||
}
|
||||
|
||||
// advanceAndFire 把注入时钟推进到**当前所有未触发请求里最晚的那个截止时刻**,
|
||||
// 然后触发它们(= 「这些窗口全部到点」)。
|
||||
//
|
||||
// 推进量因此恒等于「真的过了多久」,不会再出现旧写法里「按每个请求时长累加」的推过头。
|
||||
func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock, done func() bool) {
|
||||
tb.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
// 与 pump 同一条不变量:先把队列排空再推钟(否则循环头会按窗口到点发车,
|
||||
// 而那条分支不 takeAll,尾部消息会落到下一批)。
|
||||
for t.depth != nil && t.depth() > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
for time.Now().Before(deadline) {
|
||||
due, ok := t.allDue()
|
||||
if ok {
|
||||
clock.advanceTo(due)
|
||||
// 为什么要重试:worker 可能在「登记」与「触发」之间续了新臂,
|
||||
// 被触发的旧通道于是没人接(时钟推了、没人醒)。done 成立即收手。
|
||||
if done == nil || done() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if done != nil && done() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if done != nil && done() {
|
||||
return
|
||||
}
|
||||
tb.Fatalf("advanceAndFire: 3s 内没有把窗口推到发车(calls=%d)", t.callCount())
|
||||
}
|
||||
|
||||
// advanceAndFireUntil 推进并触发窗口,直到**回合数比调用时多一个**(或超时)。
|
||||
func (f *coalesceBridge) advanceAndFireUntil(t *testing.T) {
|
||||
t.Helper()
|
||||
before := f.chat.runCount()
|
||||
f.timers.advanceAndFire(t, f.clock, func() bool { return f.chat.runCount() > before })
|
||||
}
|
||||
|
||||
// allDue 取走全部未触发请求,把时钟推到其中最晚的截止时刻,并逐个触发。
|
||||
// 返回被推到的时间点(没有任何请求时 ok=false)。
|
||||
func (t *coalesceTimers) allDue() (time.Time, bool) {
|
||||
t.mu.Lock()
|
||||
pending := t.pending
|
||||
t.pending = nil
|
||||
t.mu.Unlock()
|
||||
if len(pending) == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
latest := pending[0]
|
||||
for _, tm := range pending[1:] {
|
||||
if tm.at.After(latest.at) {
|
||||
latest = tm
|
||||
}
|
||||
}
|
||||
for _, tm := range pending {
|
||||
tm.ch <- latest.at
|
||||
}
|
||||
return latest.at, true
|
||||
}
|
||||
|
||||
// pump 反复「把时钟推进到最早截止时刻并触发它」,直到 done() 成立(或超时)。
|
||||
//
|
||||
// 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限
|
||||
// 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」,
|
||||
@@ -156,14 +269,23 @@ func (f *coalesceBridge) pump(t *testing.T, done func() bool) {
|
||||
if done() {
|
||||
return
|
||||
}
|
||||
if timer, ok := f.timers.tryTake(); ok {
|
||||
f.clock.advance(timer.d)
|
||||
timer.ch <- f.clock.Now()
|
||||
if timer, ok := f.timers.nextDue(); ok {
|
||||
// 🔴 **推钟前必须等队列排空**:时钟一旦前进,`coalesceWait` 的循环头就可能
|
||||
// 直接按「窗口到点」发车(那条分支不 takeAll),还没被 worker 收进本批的
|
||||
// 尾部消息就会落到下一批 —— 「12 条 → 2 轮」正是这么来的。
|
||||
// 这里等的是**状态**(车道上没有待取条目),不是拿 sleep 掩盖时序:
|
||||
// worker 一旦看到队列有货就会收(select 的 l.ch 分支),所以排空必然发生。
|
||||
if !f.awaitQueueDrained(deadline) {
|
||||
break
|
||||
}
|
||||
f.clock.advanceTo(timer.at)
|
||||
timer.ch <- timer.at
|
||||
continue
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("condition never met within 3s (runs=%d, stats=%+v)", f.chat.runCount(), f.b.CoalesceStats())
|
||||
t.Fatalf("condition never met within 3s (runs=%d, queued=%d, stats=%+v)",
|
||||
f.chat.runCount(), f.queueDepth(), f.b.CoalesceStats())
|
||||
}
|
||||
|
||||
// coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话
|
||||
@@ -215,7 +337,8 @@ func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge {
|
||||
t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop)
|
||||
}
|
||||
clock := newCoalesceClock()
|
||||
timers := &coalesceTimers{}
|
||||
timers := &coalesceTimers{now: clock.Now}
|
||||
_ = timers
|
||||
if opts.Now == nil {
|
||||
opts.Now = clock.Now
|
||||
}
|
||||
@@ -232,6 +355,7 @@ func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge {
|
||||
}
|
||||
ad := &laneAdapter{name: "telegram"}
|
||||
b.Add(ad)
|
||||
timers.depth = func() int { return queueDepthOf(b) }
|
||||
return &coalesceBridge{b: b, chat: chat, ad: ad, clock: clock, timers: timers}
|
||||
}
|
||||
|
||||
@@ -274,8 +398,115 @@ func (f *coalesceBridge) waitRuns(t *testing.T, n int) {
|
||||
f.chat.waitRuns(t, n, 3*time.Second)
|
||||
}
|
||||
|
||||
// runTexts 返回每个已开跑回合的 user 文本(按开跑顺序)。只用于失败信息:
|
||||
// 「12 条 → 2 轮」这类断言失守时,得先看清每条落到了哪一批,才能判断是夹具相位问题
|
||||
// 还是合并真的拆了批。
|
||||
func runTexts(c *laneChatter) [][]string {
|
||||
n := c.runCount()
|
||||
out := make([][]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, userTextsIn(c.runAt(i).messages))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitIdle 等到车道全部退休。
|
||||
func (f *coalesceBridge) waitIdle(t *testing.T) {
|
||||
t.Helper()
|
||||
waitLaneIdle(t, f.b, 2*time.Second)
|
||||
// 「等车道全部退休」必须在**推动窗口**的前提下等:worker 若正停在一个还没到点的
|
||||
// 窗口里,就永远走不到「队列空 ⇒ 退休」那一行。这里因此一边等一边把挂着的定时器
|
||||
// 按截止时刻触发(与 pump 同一条语义),判据仍是 waitLaneIdle 自己那套断言。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if f.b.LaneStats().ActiveLanes == 0 {
|
||||
return
|
||||
}
|
||||
if timer, ok := f.timers.nextDue(); ok {
|
||||
f.clock.advanceTo(timer.at)
|
||||
timer.ch <- timer.at
|
||||
continue
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
waitLaneIdle(t, f.b, time.Millisecond)
|
||||
}
|
||||
|
||||
// awaitQueueDrained 等到车道上没有待取条目(= worker 已把该收的都收进本批)。
|
||||
// 返回 false 表示到 deadline 都没排空(由调用方的断言/超时报出来)。
|
||||
func (f *coalesceBridge) awaitQueueDrained(deadline time.Time) bool {
|
||||
for time.Now().Before(deadline) {
|
||||
if f.queueDepth() == 0 {
|
||||
return true
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return f.queueDepth() == 0
|
||||
}
|
||||
|
||||
// queueDepth 返回所有车道里「还没被 worker 取走」的条目数(ch + spill + front)。
|
||||
func (f *coalesceBridge) queueDepth() int { return queueDepthOf(f.b) }
|
||||
|
||||
func queueDepthOf(b *Bridge) int {
|
||||
if b == nil || b.lanes == nil {
|
||||
return 0
|
||||
}
|
||||
b.lanes.mu.Lock()
|
||||
defer b.lanes.mu.Unlock()
|
||||
n := 0
|
||||
for _, l := range b.lanes.lanes {
|
||||
n += len(l.ch) + len(l.spill) + len(l.front)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// sendSettled 投递一条,并等 worker 把它收进本批(车道排空)后才返回。
|
||||
//
|
||||
// 给「投递与注入时钟交替推进」的用例用(如每隔 500ms 来一条的绝对上限用例):
|
||||
// 时钟只能表示「时间过去了」,所以必须保证每条都已经到了 worker 手里再走时间,
|
||||
// 否则「到点发车」会漏掉还没被取走的尾部(实测批次 8/12)。
|
||||
// 注意:**不要**在需要验证「队列能装下整批、不溢出」的用例里用它 —— 那里要的是
|
||||
// 真的把消息压进队列。
|
||||
func (f *coalesceBridge) sendSettled(t *testing.T, msg adapter.UnifiedMessage) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
before := f.timers.seqNow()
|
||||
f.send(msg)
|
||||
if !f.awaitQueueDrained(deadline) {
|
||||
t.Fatalf("投递后车道没有排空(queued=%d)", f.queueDepth())
|
||||
}
|
||||
// 还要等 worker **为这次投递重新挂上定时器**(= 它已回到 select 里等窗口)。
|
||||
// 必须按序号判新鲜度:pending 里可能还躺着此前那些「再没人监听」的旧请求,
|
||||
// 拿它们当「已就绪」会让下一步推钟落在两轮循环之间(定时器换了新的、旧的被触发没人接)。
|
||||
f.timers.waitFresh(t, before, time.Until(deadline))
|
||||
}
|
||||
|
||||
// waitRows 等到会话落了至少 want 行指定角色的记录(回合是异步跑完再落库的,
|
||||
// 读历史前必须等它真的写完 —— 否则断言会读到「12 user + 0 assistant」的半成品)。
|
||||
func (h *laneHistory) waitRows(tb *testing.T, sessionID, role string, want int, timeout time.Duration) {
|
||||
tb.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
n := 0
|
||||
for _, row := range h.snapshot(sessionID) {
|
||||
if row.Role == role {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
tb.Fatalf("等了 %s 仍只有 %d 行 %s(want %d)", timeout, len(h.snapshot(sessionID)), role, want)
|
||||
}
|
||||
|
||||
// sendSettledAndDuration 投递一条并返回**这条消息之后 worker 续出的那次窗口时长**。
|
||||
//
|
||||
// 它把「投递 → worker 收进本批 → 为它续臂」做成一个确定的相位:续臂用请求序号认领
|
||||
// (`seq > 投递前`),因此不会把此前那条消息的续臂误当成这一次的 —— 只按「至少 N 次
|
||||
// 请求 + 取最新」在负载下仍会拿到上一条的时长(实测 -race -count=200 有 2/200 假红)。
|
||||
func (f *coalesceBridge) sendSettledAndDuration(t *testing.T, msg adapter.UnifiedMessage) time.Duration {
|
||||
t.Helper()
|
||||
f.sendSettled(t, msg)
|
||||
return latestDuration(f.timers.durations())
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestOwnerPrivateIgnoresEnabledConfig(t *testing.T) {
|
||||
|
||||
// 对照组:同一条配置对群聊**生效**(定时器被请求并按点到发车)。
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "群里的消息"))
|
||||
f.timers.advanceAndFire(t, f.clock)
|
||||
f.advanceAndFireUntil(t)
|
||||
close(gate)
|
||||
chat.waitRuns(t, 2, 3*time.Second)
|
||||
f.waitIdle(t)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestCoalesceWaitsForQuietThenFlushes(t *testing.T) {
|
||||
}
|
||||
// 窗口内的第二条:计时必须重置(新定时器 = 从 m2 起再等满一个窗口)。
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
|
||||
f.timers.advanceAndFire(t, f.clock)
|
||||
f.advanceAndFireUntil(t)
|
||||
f.waitRuns(t, 1)
|
||||
|
||||
if n := chat.runCount(); n != 1 {
|
||||
@@ -65,7 +65,7 @@ func TestCoalesceFlushesOnWindowExpiry(t *testing.T) {
|
||||
if n := chat.runCount(); n != 0 {
|
||||
t.Fatalf("turns started = %d before the window expired, want 0", n)
|
||||
}
|
||||
f.timers.advanceAndFire(t, f.clock)
|
||||
f.advanceAndFireUntil(t)
|
||||
f.waitRuns(t, 1)
|
||||
|
||||
if n := chat.runCount(); n != 1 {
|
||||
@@ -99,11 +99,15 @@ func TestCoalesceTwelveBurstOneTurn(t *testing.T) {
|
||||
f.pump(t, func() bool { return chat.runCount() >= 1 })
|
||||
|
||||
if got := chat.runCount(); got != 1 {
|
||||
t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst", got)
|
||||
t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst(各批=%v stats=%+v)",
|
||||
got, runTexts(chat), f.b.CoalesceStats())
|
||||
}
|
||||
if got := userTextsIn(chat.runAt(0).messages); len(got) != n {
|
||||
t.Fatalf("batch user messages = %d, want %d: %v", len(got), n, got)
|
||||
}
|
||||
// 回合是异步跑完再落库的:先等 assistant 行出现,否则读到的是半成品
|
||||
// (实测「12 user + 0 assistant」)。
|
||||
hist.waitRows(t, "sess--20", "assistant", 1, 5*time.Second)
|
||||
rows := hist.snapshot("sess--20")
|
||||
userRows, assistantRows := 0, 0
|
||||
turnIDs := map[string]struct{}{}
|
||||
@@ -285,14 +289,16 @@ func TestCoalesceMaxWaitForcesFlush(t *testing.T) {
|
||||
Loop: chat,
|
||||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||||
})
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
// 每条投递后都等 worker 收走再推进时钟:时钟只表示「时间过去了」,它一转,
|
||||
// 循环头就可能直接按到点发车(那条分支不 takeAll),没被收走的尾部会漏到下一批。
|
||||
f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||||
for i := 1; i < 12; i++ {
|
||||
f.clock.advance(500 * time.Millisecond)
|
||||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1)))
|
||||
f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1)))
|
||||
}
|
||||
// 最后那个定时器请求的正是「距离 6s 绝对上限还差多少」——按它推完最后 500ms,
|
||||
// 循环头因此看见 maxLeft<=0 并按 max_wait 发车(滑动窗口被一条条重置也压不住它)。
|
||||
f.timers.advanceAndFire(t, f.clock)
|
||||
f.advanceAndFireUntil(t)
|
||||
f.waitRuns(t, 1)
|
||||
if n := chat.runCount(); n != 1 {
|
||||
t.Fatalf("turns started = %d, want 1 (max wait forces one flush)", n)
|
||||
|
||||
@@ -204,6 +204,18 @@ func (c *laneClock) advance(d time.Duration) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// advanceTo 把时钟推到 t(只前进不后退)。
|
||||
//
|
||||
// 窗口夹具用它表达「时间走到了某个截止时刻」:推进量 = 真的过了多久,
|
||||
// 与「按请求时长累加」相比不会把时钟推过头(见 coalesceTimers 的说明)。
|
||||
func (c *laneClock) advanceTo(t time.Time) {
|
||||
c.mu.Lock()
|
||||
if t.After(c.t) {
|
||||
c.t = t
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// laneRecorder 收集 onRejected 审计(并发安全)。
|
||||
type laneRecorder struct {
|
||||
mu sync.Mutex
|
||||
@@ -379,8 +391,13 @@ func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) {
|
||||
clock := newLaneClock()
|
||||
rec := &laneRecorder{}
|
||||
chat := &laneChatter{}
|
||||
// 两条都投递完再让时钟越过阈值:第一轮**卡住**等这个信号,车道因此不会在两条之间
|
||||
// 退休(退休后第二条会进一条新车,arrivedAt 取的是推进后的时钟 ⇒ 它永远不超龄,
|
||||
// 用例假红)。这不是放宽断言 —— 「第二条超龄被丢」仍然由 stats/审计/日志三处断言。
|
||||
bothQueued := make(chan struct{})
|
||||
chat.onRun = func(user string) {
|
||||
if user == "first" {
|
||||
<-bothQueued
|
||||
// 第一轮跑着的时候时间越过阈值:排在后面的那条等到自己开跑时已超龄。
|
||||
clock.advance(defaultLaneStaleAfter + time.Second)
|
||||
}
|
||||
@@ -397,6 +414,7 @@ func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) {
|
||||
|
||||
b.onMessage(context.Background(), strangerMsg("200", "first"), ad)
|
||||
b.onMessage(context.Background(), strangerMsg("200", "stale by the time it runs"), ad)
|
||||
close(bothQueued)
|
||||
chat.waitRuns(t, 1, 5*time.Second)
|
||||
waitLaneIdle(t, b, 2*time.Second)
|
||||
|
||||
|
||||
@@ -20,9 +20,38 @@ import (
|
||||
// 带闸门的工具包装、以及等待与断言助手。夹具只搭台子,不写断言语义 —— 读共享 / 写独占的
|
||||
// 判定一律由**真实的**进程内占用表给出(tool.NewFileClaimer),不拿自造的假表去测。
|
||||
|
||||
// claimProbeTimeout 是「第一路必须进入闸门 / 一路必须结束」这类断言的等待上限:足够宽
|
||||
// 以免依赖机器速度,又足够短以免偶发死锁拖成整包超时。
|
||||
const claimProbeTimeout = 2 * time.Second
|
||||
// claimProbeTimeout 是「第一路必须进入闸门 / 一路必须结束」这类断言的等待上限。
|
||||
//
|
||||
// 这些等待**不含时间语义**:runLeg 起的那一路只要被调度到,就会立刻 close(entered)
|
||||
// 或把结果写进 chan ——「等多久」与被测行为无关,唯一要防的是真死锁时永远等下去。
|
||||
// 旧值 2s 却是一个**真实时间预算**:73 个测试包并行抢核时,一个刚起的 goroutine 被
|
||||
// 饿上几秒并非不可想象(本轮其他证人据此报过假红;本机 12 核上 4 路并发 + 全树并行
|
||||
// 压测未复现,故按余量加固,不拿复现驱动)。
|
||||
//
|
||||
// 现取值:默认 60s,即旧值的 30 倍余量(与「整树并行」量级相称:正常调度是微秒级,
|
||||
// 需要 60s 才轮到调度意味着机器已经不可用);同时由 claimProbeWait 按 t.Deadline
|
||||
// 的剩余预算收缩,真死锁仍以**断言失败**暴露,不会拖成整包超时。
|
||||
const claimProbeTimeout = 60 * time.Second
|
||||
|
||||
// claimProbeFloor 是探针等待的下限:即使已经接近 t.Deadline,也至少等这么久再判失败
|
||||
// (「断言失败 + 原因」比「整包超时」可读得多)。
|
||||
const claimProbeFloor = 5 * time.Second
|
||||
|
||||
// claimProbeWait 返回本次探针的等待上限:默认 claimProbeTimeout,接近 t.Deadline 时
|
||||
// 收缩到「剩余预算的一半」,使等待永远发生在包超时之内。
|
||||
func claimProbeWait(t *testing.T) time.Duration {
|
||||
t.Helper()
|
||||
wait := claimProbeTimeout
|
||||
if dl, ok := t.Deadline(); ok {
|
||||
if half := time.Until(dl) / 2; half < wait {
|
||||
wait = half
|
||||
}
|
||||
}
|
||||
if wait < claimProbeFloor {
|
||||
wait = claimProbeFloor
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// legResult 是一路调用的结果。
|
||||
type legResult struct {
|
||||
@@ -228,7 +257,7 @@ func awaitEnter(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(claimProbeTimeout):
|
||||
case <-time.After(claimProbeWait(t)):
|
||||
t.Fatal("第一路没有进入执行")
|
||||
}
|
||||
}
|
||||
@@ -239,7 +268,7 @@ func awaitDone(t *testing.T, ch <-chan legResult) legResult {
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res
|
||||
case <-time.After(claimProbeTimeout):
|
||||
case <-time.After(claimProbeWait(t)):
|
||||
t.Fatal("这一路没有结束")
|
||||
return legResult{}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user