P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态): - coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d, 推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并 按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、 绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。 - 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle): 时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。 - sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker 放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。 - fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致 推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。 - 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库; 准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设); stale 用例两条都投完再推时钟(车道不会在两条之间退休)。 P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器, 退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径 (b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑 (含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁 并补 -race 用例。 证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮 18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿; 全树 go build/vet/test -count=1 全绿。
267 lines
11 KiB
Go
267 lines
11 KiB
Go
package channelbridge
|
||
|
||
// coalesce_continuation_test.go 是 M23 的**回合中续跑**(⑤)与两个边界(陈旧度叠加、
|
||
// 停机诚实)的断言集,对应蓝图 §12 的 #11/#12/#13/#14/#17。
|
||
//
|
||
// ⑤ 的实现要点(蓝图 §5):**不存在 "turn in flight" 标志** —— 车道的单消费者性质
|
||
// 就是那个容器。回合在跑时 worker 不在循环头,新消息落进同一个队列;回合结束回到
|
||
// 循环头,takeAll 把这段期间攒下的整片取出 ⇒ **一轮续跑**(不是 N 轮、也不是 0 条)。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestCoalesceMultiGroupIsolation(#11)断言作用域:两个群各 3 条交错到达 ⇒ 两批、
|
||
// 各自 3 条、互不串窗;同一平台的**两个账号**对同一个群则共用一条车道(与会话键同源,
|
||
// 是一个会话而不是两个 —— 见 lane.go 的 laneKey 取舍说明)。
|
||
func TestCoalesceMultiGroupIsolation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &laneChatter{}
|
||
f := newCoalesceBridge(t, Options{
|
||
Loop: chat,
|
||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||
})
|
||
for i := 0; i < 3; i++ {
|
||
f.send(coalesceGroupMsg("telegram", "-1", "9", "张三", fmt.Sprintf("a%d", i)))
|
||
f.send(coalesceGroupMsg("telegram", "-2", "9", "张三", fmt.Sprintf("b%d", i)))
|
||
}
|
||
f.pump(t, func() bool { return chat.runCount() >= 2 })
|
||
f.waitIdle(t)
|
||
|
||
if got := chat.runCount(); got != 2 {
|
||
t.Fatalf("turns started = %d, want 2 (one per group)", got)
|
||
}
|
||
seen := map[string]int{}
|
||
for i := 0; i < chat.runCount(); i++ {
|
||
texts := userTextsIn(chat.runAt(i).messages)
|
||
if len(texts) != 3 {
|
||
t.Fatalf("batch %d has %d messages, want 3 (%v)", i, len(texts), texts)
|
||
}
|
||
prefix := texts[0][:1]
|
||
for _, text := range texts {
|
||
if text[:1] != prefix {
|
||
t.Fatalf("batch %d mixes groups: %v", i, texts)
|
||
}
|
||
seen[prefix]++
|
||
}
|
||
}
|
||
if seen["a"] != 3 || seen["b"] != 3 {
|
||
t.Fatalf("messages per group = %v, want a:3 b:3", seen)
|
||
}
|
||
|
||
// 多账号:同一个群的两个账号(同 platform / 同 channel)是**同一个会话** ⇒ 一条车道、
|
||
// 一批;回复走最后一条消息自己的适配器(车道本身不持有账号级状态)。
|
||
other := &laneAdapter{name: "telegram:conf1"}
|
||
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "9", "张三", "c0"), f.ad)
|
||
f.b.onMessage(context.Background(), coalesceGroupMsg("telegram", "-3", "10", "李四", "c1"), other)
|
||
before := chat.runCount()
|
||
f.pump(t, func() bool { return chat.runCount() > before })
|
||
f.waitIdle(t)
|
||
if got := chat.runCount(); got != before+1 {
|
||
t.Fatalf("turns started = %d, want %d (two accounts of one group = one lane, one batch)", got, before+1)
|
||
}
|
||
if got := userTextsIn(chat.runAt(before).messages); len(got) != 2 {
|
||
t.Fatalf("multi-account batch = %v, want both messages in one turn", got)
|
||
}
|
||
}
|
||
|
||
// TestContinuationAfterTurnEnds(#12)断言⑤:回合进行中到达 3 条 ⇒ 回合结束后**一轮**
|
||
// 续跑吃掉 3 条(不是 3 轮、不是 0 条)。
|
||
func TestContinuationAfterTurnEnds(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
gate := make(chan struct{})
|
||
chat := &laneChatter{}
|
||
chat.onRun = func(user string) {
|
||
if user == "first" {
|
||
<-gate
|
||
}
|
||
}
|
||
f := newCoalesceBridge(t, Options{
|
||
Loop: chat,
|
||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||
})
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
|
||
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)))
|
||
}
|
||
// 模拟「第一轮花了 100ms」:因此这 3 条的到达时刻**早于**上一轮结束时刻,
|
||
// 它们才是「回合中到达」的续跑(真实时钟下这一步自然成立)。
|
||
f.clock.advance(100 * time.Millisecond)
|
||
close(gate)
|
||
|
||
f.pump(t, func() bool { return chat.runCount() >= 2 })
|
||
f.waitIdle(t)
|
||
|
||
if got := chat.runCount(); got != 2 {
|
||
t.Fatalf("turns started = %d, want 2 (the first one + exactly one continuation)", got)
|
||
}
|
||
if got := userTextsIn(chat.runAt(1).messages); strings.Join(got, ",") != "c0,c1,c2" {
|
||
t.Fatalf("continuation batch = %v, want [c0 c1 c2]", got)
|
||
}
|
||
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 {
|
||
t.Fatalf("continuations_total = %d, want 1", stats.ContinuationsTotal)
|
||
}
|
||
}
|
||
|
||
// TestContinuationNoDuplicateAndNoLoss(#13)断言⑤的「不重不丢」:每条消息的落库行恰好
|
||
// 1 条、每个 turn_id 恰好 1 条助手回复、`continuations_total==1`。
|
||
func TestContinuationNoDuplicateAndNoLoss(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
gate := make(chan struct{})
|
||
chat := &laneChatter{}
|
||
chat.onRun = func(user string) {
|
||
if user == "first" {
|
||
<-gate
|
||
}
|
||
}
|
||
hist := newLaneHistory()
|
||
f := newCoalesceBridge(t, Options{
|
||
Loop: chat,
|
||
Sessions: coalesceGroupSessions{},
|
||
History: hist,
|
||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||
})
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "first"))
|
||
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)))
|
||
}
|
||
f.clock.advance(100 * time.Millisecond)
|
||
close(gate)
|
||
f.pump(t, func() bool { return chat.runCount() >= 2 })
|
||
f.waitIdle(t)
|
||
|
||
userRows := map[string]int{}
|
||
assistantPerTurn := map[string]int{}
|
||
for _, row := range hist.snapshot("sess--20") {
|
||
switch row.Role {
|
||
case "user":
|
||
userRows[row.Content]++
|
||
case "assistant":
|
||
assistantPerTurn[row.TurnID]++
|
||
}
|
||
}
|
||
for _, text := range []string{"first", "c0", "c1", "c2"} {
|
||
if userRows[text] != 1 {
|
||
t.Fatalf("user row %q persisted %d times, want exactly 1", text, userRows[text])
|
||
}
|
||
}
|
||
if len(userRows) != 4 {
|
||
t.Fatalf("distinct user rows = %d, want 4: %v", len(userRows), userRows)
|
||
}
|
||
for turnID, n := range assistantPerTurn {
|
||
if n != 1 {
|
||
t.Fatalf("turn %s has %d assistant rows, want exactly 1", turnID, n)
|
||
}
|
||
}
|
||
if stats := f.b.CoalesceStats(); stats.ContinuationsTotal != 1 || stats.CoalescedDroppedTotal != 0 {
|
||
t.Fatalf("stats = %+v, want one continuation and zero coalesced drops", stats)
|
||
}
|
||
}
|
||
|
||
// TestCoalesceWithStaleGate(#14)断言与陈旧度闸的叠加:批内 2 条超龄 + 1 条新鲜 ⇒
|
||
// 只跑新鲜的 1 条,超龄的逐条留痕;整批都超龄 ⇒ 不发车 + all_stale 计数。
|
||
//
|
||
// 顺序要求(蓝图 §5):**先**陈旧度**后**组上下文,且绝不让「批里最新一条新鲜」把超龄
|
||
// 的旧条也塞进上下文(M22「太旧不补答」优先)。
|
||
func TestCoalesceWithStaleGate(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
rec := &laneRecorder{}
|
||
chat := &laneChatter{}
|
||
f := newCoalesceBridge(t, Options{
|
||
Loop: chat,
|
||
LaneDepth: 16,
|
||
OnRejected: rec.record,
|
||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||
})
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old1"))
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "old2"))
|
||
// 两条排着的时候时间越过陈旧度阈值,然后来了第三条新鲜的。
|
||
f.clock.advance(defaultLaneStaleAfter + time.Second)
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "fresh"))
|
||
// worker 可能已经把前面两条按「绝对上限已过」自行发车了(全超龄 ⇒ 不发车),
|
||
// 也可能把三条并成一批 —— 两种时序下都要把这一批推到发车,因此按窗口触发(而不是
|
||
// 假定它「自己会跑」,那是调度时序,不是被测语义)。
|
||
f.pump(t, func() bool { return chat.runCount() >= 1 })
|
||
f.waitIdle(t)
|
||
|
||
if got := chat.runCount(); got != 1 {
|
||
t.Fatalf("turns started = %d, want 1 (only the fresh message may run)", got)
|
||
}
|
||
if got := userTextsIn(chat.runAt(0).messages); strings.Join(got, ",") != "fresh" {
|
||
t.Fatalf("batch user messages = %v, want [fresh] only", got)
|
||
}
|
||
if stats := f.b.LaneStats(); stats.DroppedStale != 2 {
|
||
t.Fatalf("dropped_stale = %d, want 2 (each stale message traced individually)", stats.DroppedStale)
|
||
}
|
||
if reasons := rec.reasons(); len(reasons) != 2 {
|
||
t.Fatalf("audit reasons = %v, want two lane_stale entries", reasons)
|
||
}
|
||
if stats := f.b.CoalesceStats(); stats.CoalescedDroppedTotal != 0 {
|
||
t.Fatalf("coalesced_dropped_total = %d, want 0 (stale drops are M22's gate, not coalescing)", stats.CoalescedDroppedTotal)
|
||
}
|
||
|
||
// 整批都超龄:一条都不发车,但留一条可查的 all_stale 痕迹。
|
||
allStale := newCoalesceBridge(t, Options{
|
||
Loop: &laneChatter{},
|
||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||
})
|
||
allStale.send(coalesceGroupMsg("telegram", "-30", "9", "张三", "gone"))
|
||
// 时钟越过陈旧度阈值:循环头看见「绝对上限已过」⇒ 直接按 max_wait 发车,
|
||
// 逐条判完发现整批都超龄 ⇒ 一条都不跑(这一步根本不建定时器)。
|
||
allStale.clock.advance(defaultLaneStaleAfter + time.Second)
|
||
allStale.waitIdle(t)
|
||
if got := allStale.chat.runCount(); got != 0 {
|
||
t.Fatalf("turns started = %d for an all-stale batch, want 0", got)
|
||
}
|
||
if stats := allStale.b.CoalesceStats(); stats.BatchesAllStaleTotal != 1 {
|
||
t.Fatalf("batches_all_stale = %d, want 1 (stats=%+v)", stats.BatchesAllStaleTotal, stats)
|
||
}
|
||
}
|
||
|
||
// TestCoalesceStopsHonestly(#17)断言停机诚实:桥在窗口内停止时,那一批**不会静默
|
||
// 消失** —— 有带 source 的 WARN(count=N)、`abandoned_at_stop_total` 增加。
|
||
func TestCoalesceStopsHonestly(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
logs := &safeBuffer{}
|
||
chat := &laneChatter{}
|
||
f := newCoalesceBridge(t, Options{
|
||
Loop: chat,
|
||
Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
|
||
})
|
||
// 走真实的生命周期:Start 建 b.ctx,车道 worker 与投递都用它,Stop 才取消得到它。
|
||
f.b.Start()
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
|
||
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
|
||
f.timers.waitRegistered(t, 3*time.Second) // 确认它真的在等窗口(定时器已注册)
|
||
|
||
f.b.Stop(context.Background())
|
||
|
||
if got := chat.runCount(); got != 0 {
|
||
t.Fatalf("turns started = %d after stop, want 0", got)
|
||
}
|
||
if stats := f.b.CoalesceStats(); stats.AbandonedAtStopTotal != 2 {
|
||
t.Fatalf("abandoned_at_stop_total = %d, want 2", stats.AbandonedAtStopTotal)
|
||
}
|
||
out := logs.String()
|
||
if !strings.Contains(out, "coalesce batch abandoned at bridge stop") ||
|
||
!strings.Contains(out, "source=services/channelbridge/coalesce") ||
|
||
!strings.Contains(out, "count=2") {
|
||
t.Fatalf("stop left no honest trace in the log:\n%s", out)
|
||
}
|
||
}
|