TestCoalesceSystemNoticeDoesNotJoinBatch 仍在用位置索引 d[1]:waitTimerCalls 只保证 「至少 2 次请求」,不保证顺序 —— 加压下会拿到 m1/m2 那次 2s 的续臂(实测 -race -count=200 有 2/200 假红,空载测不出来)。 改成与兄弟用例同一口径,但把相位做成确定的:新增 sendSettledAndDuration —— 投递后等 worker 把这条收进本批、**为它**续好臂(按请求序号认领),再返回那次续臂的 时长。仅用 latestDuration 仍有缝:worker 可能在帧/通知之后才补上一条消息的续臂, 最新那次就成了 2s。三条准入用例(action 帧 / 系统通知 / 聊天消息对照组)统一改用它。 断言口径未变:仍然断言「系统通知/action 帧不重置静默窗口(剩余 500ms)」、 「聊天消息重置成完整 2s」;waitTimerCalls 语义、请求次数均未改。 证据:-race -count=400 连跑 3 次(1200 次)全绿、DATA RACE=0; 兄弟两条 -count=200 全绿;整包 -race -count=3 全绿。
513 lines
19 KiB
Go
513 lines
19 KiB
Go
package channelbridge
|
||
|
||
// coalesce_fixture_test.go 是 M23(静默窗口合并 + 回合中续跑)全部用例共用的夹具:
|
||
//
|
||
// - **注入时钟**(laneClock):窗口与陈旧度共用同一个时间源(Options.Now),
|
||
// 因此「2.0s 静默」「6s 绝对上限」「30s 超龄」都能被确定性推进;
|
||
// - **注入定时器工厂**(coalesceTimers):它既让窗口等待可以被手动触发,
|
||
// **又是「主人私聊从未建窗口」的证据面** —— calls 必须为 0;
|
||
// - **群消息夹具**:非主人、带提及(⇒ 必然进回合)、同一群共用一个隔离会话。
|
||
//
|
||
// 夹具全部落在 *_test.go 里,不进生产二进制。
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// coalesceOffFor 是「把静默窗口关掉」的注入面:lane_test.go 的 M22 语义用例用它,
|
||
// 使那些用例仍以毫秒级钉住车道本身(顺序/串行/背压/陈旧度)。
|
||
func coalesceOffFor(string) *runtimeconfig.CoalescePolicy {
|
||
p := runtimeconfig.DefaultCoalescePolicy()
|
||
p.Enabled = false
|
||
return &p
|
||
}
|
||
|
||
// coalesceOn 返回一组「群聊与陌生人私聊都开窗口」的生效参数。
|
||
func coalesceOn(windowMs, maxWaitMs, maxBatch int) runtimeconfig.CoalescePolicy {
|
||
p := runtimeconfig.DefaultCoalescePolicy()
|
||
p.Enabled = true
|
||
p.WindowMs = windowMs
|
||
p.MaxWaitMs = maxWaitMs
|
||
p.MaxBatchMessages = maxBatch
|
||
p.ApplyToStrangers = true
|
||
p.StrangerWindowMs = windowMs
|
||
return p
|
||
}
|
||
|
||
// coalesceFor 把一组固定参数包成注入面(同一实例可被多条车道共用)。
|
||
func coalesceFor(p runtimeconfig.CoalescePolicy) func(string) *runtimeconfig.CoalescePolicy {
|
||
return func(string) *runtimeconfig.CoalescePolicy {
|
||
c := p
|
||
return &c
|
||
}
|
||
}
|
||
|
||
// newCoalesceClock 返回一个**本地正午**的注入时钟。
|
||
//
|
||
// 起点刻意避开免打扰时段(23-07):本文件测的是窗口合并,而凌晨的注入时钟会让
|
||
// 非主人群消息先在「免打扰」那一档被静默掉 —— 那样每条用例都变成了空断言
|
||
// (测试要观察的只是「什么时候发车」,不是「说不说」)。
|
||
func newCoalesceClock() *laneClock {
|
||
c := &laneClock{}
|
||
c.t = time.Date(2026, 5, 1, 12, 0, 0, 0, time.Local)
|
||
return c
|
||
}
|
||
|
||
// coalesceTimers 是注入的定时器工厂:记录每次请求的时长,交回一个由测试手动触发的
|
||
// 通道(带缓冲,测试的发送永远不会阻塞)。
|
||
//
|
||
// 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它
|
||
// (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 是一次定时器请求:通道 + 请求时长 + **截止时刻**(注册时的注入时钟 + d)。
|
||
type coalesceTimer struct {
|
||
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.seq++
|
||
t.pending = append(t.pending, coalesceTimer{ch: ch, d: d, at: at, seq: t.seq})
|
||
t.mu.Unlock()
|
||
return ch
|
||
}
|
||
|
||
// callCount 返回定时器被请求的总次数。
|
||
func (t *coalesceTimers) callCount() int {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return len(t.calls)
|
||
}
|
||
|
||
// durations 返回每次请求的时长(按请求顺序)。
|
||
func (t *coalesceTimers) durations() []time.Duration {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
return append([]time.Duration(nil), t.calls...)
|
||
}
|
||
|
||
// nextDue 取走**截止最早**的那一个未触发定时器(没有就返回 false)。
|
||
func (t *coalesceTimers) nextDue() (coalesceTimer, bool) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
if len(t.pending) == 0 {
|
||
return coalesceTimer{}, false
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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() 成立(或超时)。
|
||
//
|
||
// 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限
|
||
// 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」,
|
||
// 就会被调度时序(worker 什么时候启动)绊倒;pump 因此只看**结果条件**。
|
||
func (f *coalesceBridge) pump(t *testing.T, done func() bool) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if done() {
|
||
return
|
||
}
|
||
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, queued=%d, stats=%+v)",
|
||
f.chat.runCount(), f.queueDepth(), f.b.CoalesceStats())
|
||
}
|
||
|
||
// coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话
|
||
// (真实 SessionManager 就是这么派生的),因此「合成一轮」的落库行落在同一处。
|
||
type coalesceGroupSessions struct{}
|
||
|
||
func (coalesceGroupSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) {
|
||
return session.Session{
|
||
Platform: msg.Platform,
|
||
ChannelID: msg.ChannelID,
|
||
SessionID: "sess-" + msg.GroupID,
|
||
IsGroup: true,
|
||
MemWritable: false,
|
||
}, nil
|
||
}
|
||
|
||
// coalesceGroupMsg 是一条**非主人、指向她本人**的群消息:它因此必然进回合
|
||
// (不受最小打扰分级影响),是静默窗口最干净的夹具。
|
||
func coalesceGroupMsg(platform, groupID, sender, senderName, content string) adapter.UnifiedMessage {
|
||
return adapter.UnifiedMessage{
|
||
Platform: platform,
|
||
ChannelID: groupID,
|
||
ChannelType: adapter.ChannelGroup,
|
||
GroupID: groupID,
|
||
SenderID: sender,
|
||
SenderName: senderName,
|
||
Sender: adapter.Actor{Role: adapter.RoleMember},
|
||
Mentions: []adapter.Mention{{UserID: sender}},
|
||
Content: content,
|
||
ContentType: adapter.ContentText,
|
||
}
|
||
}
|
||
|
||
// coalesceBridge 是一个装好注入时钟与定时器工厂的桥接器(外加它的日志/历史/审计面)。
|
||
type coalesceBridge struct {
|
||
b *Bridge
|
||
chat *laneChatter
|
||
ad *laneAdapter
|
||
clock *laneClock
|
||
timers *coalesceTimers
|
||
}
|
||
|
||
// newCoalesceBridge 构造夹具:opts.Loop 必须是 *laneChatter(用例要读回合记录),
|
||
// 时钟与定时器工厂由夹具注入(opts.Now / opts.Coalesce.After 若已给出则不覆盖)。
|
||
func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge {
|
||
t.Helper()
|
||
chat, ok := opts.Loop.(*laneChatter)
|
||
if !ok {
|
||
t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop)
|
||
}
|
||
clock := newCoalesceClock()
|
||
timers := &coalesceTimers{now: clock.Now}
|
||
_ = timers
|
||
if opts.Now == nil {
|
||
opts.Now = clock.Now
|
||
}
|
||
opts.Coalesce.After = timers.after
|
||
if opts.Sessions == nil {
|
||
opts.Sessions = coalesceGroupSessions{}
|
||
}
|
||
if opts.Owner == nil {
|
||
opts.Owner = &user.OwnerProfile{}
|
||
}
|
||
b, err := New(opts)
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
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}
|
||
}
|
||
|
||
// snapshot 在锁内返回某个会话已落库的行(测试直接读 h.rows 会与车道 worker 的
|
||
// 写入构成数据竞争 —— -race 下必红,因此统一走这里)。
|
||
func (h *laneHistory) snapshot(sessionID string) []history.Message {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
return append([]history.Message(nil), h.rows[sessionID]...)
|
||
}
|
||
|
||
// send 投递一条消息(走真实的 onMessage 入口:Validate → 流控闸 → 车道)。
|
||
//
|
||
// ctx 取 b.ctx(与生产的读循环一致:`readLoop` 传的就是桥自己的生命周期 context),
|
||
// 这样 Stop 取消的车道 worker 与用例投递的消息用的是同一棵树 —— 否则「停机诚实」
|
||
// 这条用例根本验证不到真实路径(b.ctx 为 nil 时退回 Background)。
|
||
func (f *coalesceBridge) send(msg adapter.UnifiedMessage) {
|
||
ctx := f.b.ctx
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
f.b.onMessage(ctx, msg, f.ad)
|
||
}
|
||
|
||
// userTextsIn 返回一次回合的模型可见消息里的 user 文本(按顺序)。
|
||
func userTextsIn(msgs []model.Message) []string {
|
||
out := make([]string, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
if m.Role != model.RoleUser {
|
||
continue
|
||
}
|
||
out = append(out, lastText(m))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// waitRuns 等至少 n 个回合开跑(封装 chatter 的同名方法,省得到处传超时)。
|
||
func (f *coalesceBridge) waitRuns(t *testing.T, n int) {
|
||
t.Helper()
|
||
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()
|
||
// 「等车道全部退休」必须在**推动窗口**的前提下等: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())
|
||
}
|