Files
Cyrene_v2/internal/services/channelbridge/coalesce_continuation_test.go
T
AskaEth ca8577f05b feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
2026-10-24 21:11:22 +08:00

267 lines
11 KiB
Go
Raw 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_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.timers.advanceAndFire(t, f.clock) // 第一批到点发车
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.timers.advanceAndFire(t, f.clock) // 第一批到点发车
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.take(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)
}
}