fix(channelbridge): 车道键与会话键同源 + 主人私聊溢出区保序(M22 复审)

复审 r1 的 P1-1 被证实:车道键(a.Name()+ConversationKey,含频道与账号维度)与会话键
(platform \x00 channelID)是两套没有共同来源的判据,于是 kook 同一 guild 的两个 text
channel、以及同平台两个账号对同一对端,都会得到「一个 session、两条车道、两个回合并发」
—— 两条 run 共享 sessionID,checkpoint 行 id 互相覆盖 / flow 桶被先收尾者删掉。

- P1-1 车道键与会话键同源:`internal/channel/session` 导出 ChannelKey / Key(channelID
  与原缓存键派生**原样**导出,manager 内部改为走同一个 sessionKey,语义一字未改);
  `laneKey` 现在逐字是 `session.Key(msg)`(主人私聊仍是 `main|web` 这个目的地键),
  **去掉 a.Name() 维度**。同平台多账号对同一对端因此共用一条车道(它们是同一个 session),
  出站路由不受影响:回复取每条消息自己的 platform/ChannelID 与它自己的适配器。
- P2-1 溢出区保序:`spill` 非空后新条目一律追加 spill、不再回写 `ch`,修掉复审实测的
  `[m1 m2 m3 m5 m4]` 超车(现为到达顺序)。
- P2-2 口径修正:lane_test 文件头改指 `TestLaneRejectsNewestWhenFull`(语义是「门前拒收
  最新、队列不动」);laneKey 注释改写「多账号必须共用一条车道」并写明取舍。
- P2-3 生产可见性:丢弃 WARN 带上 `dropped_stale_total` / `dropped_overflow_total` 累计
  计数(不新开端点);主人私聊积压改成按倍率 16/32/64… 告警,不再只响一次。

断言:TestLaneKeyNeverCollidesAcrossSessions(表驱动,含 kook c1/c2@同 guild、
telegram conf0/conf1@同对端两行 + 不同群仍并行的控制行)、
TestLaneOwnerPrivateSpillKeepsArrivalOrder(逐条放行,钉住到达顺序)、
internal/channel/session/keys_test.go(键派生与会话缓存键形状)。
This commit is contained in:
AskaEth committed 2026-10-24 20:08:28 +08:00
1 parent e91b868a5c
commit e3e8878ead
5 files changed
+403 -37

No files matched your search

+175 -3
View File
@@ -3,10 +3,13 @@ package channelbridge
// 本文件是 **M22 切片 LANEQUEUE** 的断言集:每条不变量至少一条断言,见 lane.go 顶部的
// 四条不变量清单。
//
// - ① 顺序:同一会话内回复顺序 = 到达顺序(TestLaneSerializesOneSessionInArrivalOrder);
// - ② 一致性:同一会话绝无两个回合同时跑,不同会话可并行(上一条 + TestLaneRunsDifferentSessionsInParallel);
// - ① 顺序:同一会话内回复顺序 = 到达顺序(TestLaneSerializesOneSessionInArrivalOrder;
// 主人私聊溢出区另有一条 TestLaneOwnerPrivateSpillKeepsArrivalOrder);
// - ② 一致性:同一会话绝无两个回合同时跑,不同会话可并行
// (TestLaneSerializesOneSessionInArrivalOrder + TestLaneRunsDifferentSessionsInParallel
// + TestLaneKeyNeverCollidesAcrossSessions —— 后者钉住「车道键与会话键同源」);
// - ③ 完整性:被丢的只有「超龄」与「背压」两类,两者都留计数 + 审计 + 日志
// (TestLaneDropsStaleInboundAndTracesIt / TestLaneDropsOldestOnOverflow),
// (TestLaneDropsStaleInboundAndTracesIt / TestLaneRejectsNewestWhenFull),
// 主人私聊与非聊天事件另有豁免(TestLaneStaleGateExemptions);
// - ④ 最新性:排队中的消息在**开跑之后**才装配上下文,因此看得到前一轮刚落的历史
// (TestLaneTurnSeesContextAssembledAtStart)。
@@ -406,6 +409,7 @@ func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) {
// TestLaneRejectsNewestWhenFull 断言 ③ 的第二类丢弃:车道满时**在门前**拒收这一条
// (队列里已有的条目一条都不动 —— M23 的 takeAll 依赖队列完整),且拒收留痕
// (计数 + 审计)。深度 1 时:m1 在跑、m2 已排上,m3 被拒收。
// 注意语义是「拒收最新、队列不动」,**不是**「挤掉最旧」(复审 P2-2 的口径澄清)。
func TestLaneRejectsNewestWhenFull(t *testing.T) {
t.Parallel()
@@ -677,3 +681,171 @@ func (h *laneHistory) Recent(_ context.Context, sessionID string, n int) ([]hist
}
return append([]history.Message(nil), rows...), nil
}
// kookGroupMsg 是一条 kook 群消息的**生产形状**:`ChannelID` 是 text channel(频道),
// `GroupID` 是 guild。发送者是白名单里的主人且这条**指向她本人**(`AddressedToMe`,
// 结构化字段),因此它必定进回合 —— 与真人「主人在群里 @ 她」同一条路。
//
// 它会进回合而**不受**非主人群聊的最小打扰分级影响,这正是我们要的:本用例要观察的
// 只有「车道怎么分」,不是「要不要回答」。
func kookGroupMsg(channelID, groupID, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "kook",
ChannelID: channelID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: "100",
Sender: adapter.Actor{Role: adapter.RoleOwner},
Content: content,
ContentType: adapter.ContentText,
AddressedToMe: true,
}
}
// TestLaneKeyNeverCollidesAcrossSessions 钉住复审 P1-1 的修法:**车道键与会话键同源**
// (`laneKey` 逐字就是 `session.Key`),因此「同一个 session」绝不可能有两条车道。
//
// 表里必须含复审给出的两条**真实反例**(它们在旧实现 `a.Name()+"|"+ConversationKey`
// 下会分成两条车道、两个回合并发):
// - kook:同一 guild 的两个 text channel(c1/c2)—— 会话层按 `group:<GroupID>` 判成
// 同一个群会话,旧键却按 `ChannelID` 拆开;
// - telegram:同一平台两个账号对同一对端(`telegram:conf0` / `telegram:conf1`)——
// 会话键只含 platform + channel,旧键却把账号名算进车道。
//
// 断言方式是**行为级**的(不只看键字符串):第一条回合卡住时,第二条必须还在排队;
// 而控制行(不同群)必须能真的并行,证明本修法没有把所有会话串成一条车道。
func TestLaneKeyNeverCollidesAcrossSessions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// 两条消息:同一 session 的必须共用一条车道;控制行则是两个不同 session。
first, second adapter.UnifiedMessage
// adapterNames 是两个账号的注册名(多账号场景不同,单账号/单频道场景相同)。
firstAdapter, secondAdapter string
// sessionOwner 是会话替身报告的身份车道:主人消息 true、陌生人私聊 false
// (与真实 SessionManager 由 actor 派生的口径一致;它决定最小打扰分级的分支)。
sessionOwner bool
// wantParallel 为 true 表示这两条属于**不同** session,必须能同时跑。
wantParallel bool
}{
{
name: "kook: 同 guild 的两个 text channel 是同一个群会话",
first: kookGroupMsg("c1", "g1", "a"),
second: kookGroupMsg("c2", "g1", "b"),
firstAdapter: "kook:conf0",
secondAdapter: "kook:conf0",
sessionOwner: true,
},
{
name: "telegram: 同平台两个账号对同一对端是同一个会话",
first: strangerMsg("200", "a"),
second: strangerMsg("200", "b"),
firstAdapter: "telegram:conf0",
secondAdapter: "telegram:conf1",
sessionOwner: false,
},
{
name: "控制组: 不同群仍是不同会话(必须能并行)",
first: kookGroupMsg("c1", "g1", "a"),
second: kookGroupMsg("c2", "g2", "b"),
firstAdapter: "kook:conf0",
secondAdapter: "kook:conf0",
sessionOwner: true,
wantParallel: true,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate } // 每个回合都卡住,便于观察「谁在跑」
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: c.sessionOwner},
Owner: testOwnerProfile("kook", "100"),
}, nil)
first := &laneAdapter{name: c.firstAdapter}
second := &laneAdapter{name: c.secondAdapter}
b.onMessage(context.Background(), c.first, first)
chat.waitRuns(t, 1, 5*time.Second) // 第一条已在跑(并卡住)
b.onMessage(context.Background(), c.second, second)
if c.wantParallel {
chat.waitRuns(t, 2, 3*time.Second) // 不同会话:第二条立刻开跑
} else {
// 同一会话:第二条必须还在排队 —— 给它足够的时间「如果会跑就跑起来」。
assertRunCountStays(t, chat, 1, 200*time.Millisecond)
}
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
})
}
}
// assertRunCountStays 在窗口期内反复确认回合数没有超过 want(用于断言「第二条没跑起来」)。
func assertRunCountStays(t *testing.T, c *laneChatter, want int, window time.Duration) {
t.Helper()
deadline := time.Now().Add(window)
for time.Now().Before(deadline) {
if n := c.runCount(); n > want {
t.Fatalf("turns started = %d, want at most %d: the second message must wait in the same lane", n, want)
}
time.Sleep(time.Millisecond)
}
}
// TestLaneOwnerPrivateSpillKeepsArrivalOrder 断言不变量 ①对**溢出区**同样成立:
// 主人私聊车道满了以后,新到的条目必须排在溢出区**末尾**,绝不能因为 `ch` 腾出一个位置
// 就超车到已在排队的条目前面。
//
// 复审实测的旧行为是 `[m1 m2 m3 m5 m4]`(到达顺序 m1..m5):m4 落进 spill 后,m1 结束让
// `ch` 空出一格,m5 于是先进 `ch` 而先跑。修法是「spill 非空后一律追加 spill」。
func TestLaneOwnerPrivateSpillKeepsArrivalOrder(t *testing.T) {
t.Parallel()
release := make(chan struct{}, 8)
chat := &laneChatter{}
chat.onRun = func(string) { <-release } // 每个回合都由测试逐个放行
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
LaneDepth: 2,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("m1"), ad)
chat.waitRuns(t, 1, 5*time.Second) // m1 在跑(卡住)
b.onMessage(context.Background(), ownerMsg("m2"), ad)
b.onMessage(context.Background(), ownerMsg("m3"), ad) // 深度 2:ch 满
b.onMessage(context.Background(), ownerMsg("m4"), ad) // 落 spill
release <- struct{}{} // 放 m1 走
chat.waitRuns(t, 2, 5*time.Second) // m2 开跑 ⇒ ch 空出一格
b.onMessage(context.Background(), ownerMsg("m5"), ad) // 关键交错:此刻新条目到达
for i := 0; i < 4; i++ { // 逐条放行剩下的 m2..m5
release <- struct{}{}
}
chat.waitRuns(t, 5, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
got := chat.userTexts()
want := []string{"m1", "m2", "m3", "m4", "m5"}
if len(got) != len(want) {
t.Fatalf("turns = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v (spill must never be overtaken)", got, want)
}
}
if stats := b.LaneStats(); stats.DroppedOverflow != 0 || stats.DroppedStale != 0 {
t.Fatalf("owner private must never be dropped: %+v", stats)
}
}