Files
AskaEth f56d74c363 test(channelbridge): 准入三条用例改按「这条消息续出的臂」断言(M33 r2 复审 p2)
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 全绿。
2026-10-24 23:36:41 +08:00

513 lines
19 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 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())
}