Merge branch 'feat/lanequeue-decouple-inbound-turns-and-add' into dev
This commit is contained in:
commit
fd8a62edf5
7 files changed
+1504
-28
No files matched your search
@@ -0,0 +1,54 @@
|
||||
package session
|
||||
|
||||
// 本文件把「消息 → 会话」的键派生**导出**给别的层复用(M22 复审 P1-1)。
|
||||
//
|
||||
// 为什么必须导出:会话的键一直是会话层自己算的(`channelID` + `platform \x00 channelID`),
|
||||
// 而渠道桥接器的**每会话回合车道**需要「什么算同一个会话」这个判据与它**同源**。
|
||||
// 各算各的会漂移,而且漂移的后果不是排序不好看,是**同一个 session 两条车道、两个回合
|
||||
// 并发**——两条 run 共享 sessionID,checkpoint 行 id `{sessionID}:{step}` 互相覆盖、
|
||||
// `loadCheckpoint` 可能续到另一条在途 run 的快照而重放工具调用,flow 桶
|
||||
// `p.graphs[sessionID]` 被先收尾者删掉(loop 没有会话级互斥,IM 侧也没有网关那层
|
||||
// `session_turn_fence` 兜底)。
|
||||
//
|
||||
// 两处真实反例(都来自复审实测):
|
||||
// - kook:同一 guild 的两个 text channel —— `ChannelID` 不同(c1/c2)但 `GroupID` 相同,
|
||||
// 会话层按 `group:<GroupID>` 判成**同一个** session;任何把 `ChannelID` 当会话判据的
|
||||
// 派生都会把它们拆成两条车道。
|
||||
// - 多账号:同一平台两个账号对同一对端 —— 账号名不同(`telegram:conf0`/`:conf1`)、
|
||||
// `platform` 常量相同、`ChannelID` 相同 ⇒ 也是**同一个** session;任何把「账号」当
|
||||
// 会话判据的派生都会把它们拆开。
|
||||
//
|
||||
// 因此本文件只做一件事:把既有的派生**原样**导出,语义一个字不改(`channelID` 仍是唯一
|
||||
// 的实现,见 manager.go)。
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
|
||||
// sessionKeySep 分隔 (platform, channel_id) 两个维度。用 NUL 是因为平台名与渠道 id 都
|
||||
// 不可能包含它,拼接因此不会产生歧义(`a` + "b" 与 `ab` + "" 不会撞键)。
|
||||
const sessionKeySep = "\x00"
|
||||
|
||||
// ChannelKey 返回一条入站消息所代表的**渠道**键(会话派生的第二个维度):
|
||||
// 群消息按 `group:<GroupID>`(加前缀使群不会与同 id 的私聊渠道撞键),私聊按
|
||||
// `ChannelID`,没有显式渠道 id 时退回 `SenderID`。
|
||||
//
|
||||
// 它就是 `SessionManager.GetOrCreate` 内部用的那一个函数(manager.go 的 `channelID`),
|
||||
// 导出只为让别的层(渠道桥接器的回合车道)能与会话键同源,**不得**在别处另写一份。
|
||||
// 返回空串表示这条消息没有可用的渠道(`GetOrCreate` 会以 `ErrInvalidChannel` 拒掉它)。
|
||||
func ChannelKey(msg adapter.UnifiedMessage) string { return channelID(msg) }
|
||||
|
||||
// Key 返回一条入站消息的**会话键** —— 与 `SessionManager` 的缓存键和
|
||||
// `channel_sessions` 表的唯一键**逐字相同**(`platform \x00 channelID`)。
|
||||
//
|
||||
// 它是本包对外唯一的「什么算同一个会话」判据:任何需要按会话串行/分桶的调用方都用它,
|
||||
// 不要自己拼 `platform|channel` 之类的近似键(那正是 M22 复审里「车道键与会话键不同源」
|
||||
// 的成因)。返回的键在渠道不可用时仍然非空(`platform\x00`),调用方若需要区分,
|
||||
// 用 ChannelKey 判空。
|
||||
func Key(msg adapter.UnifiedMessage) string {
|
||||
return sessionKey(msg.Platform, channelID(msg))
|
||||
}
|
||||
|
||||
// sessionKey 拼接 (platform, channel_id) 两维度。本包内所有「会话键」的**唯一**出处
|
||||
// (GetOrCreate / List / 导出的 Key 都经这里),因此不可能各自漂移。
|
||||
func sessionKey(platform, cid string) string {
|
||||
return platform + sessionKeySep + cid
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package session
|
||||
|
||||
// 本文件钉住 keys.go 导出的两个派生:`ChannelKey` 与会话层内部 `channelID` 是**同一个**
|
||||
// 实现,`Key` 与会话缓存键**逐字相同**。它们是「什么算同一个会话」的唯一定义 —— 车道
|
||||
// (internal/services/channelbridge/lane.go)按会话串行就靠它,两处一旦分叉,同一个
|
||||
// session 就会出现两条车道、两个回合并发(M22 复审 P1-1)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
func TestChannelKeyDerivations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
msg adapter.UnifiedMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "群:按 group:<GroupID>,与频道无关",
|
||||
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"},
|
||||
want: "group:g1",
|
||||
},
|
||||
{
|
||||
name: "群:缺 GroupID 时退回 ChannelID",
|
||||
msg: adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1"},
|
||||
want: "c1",
|
||||
},
|
||||
{
|
||||
name: "私聊:按 ChannelID",
|
||||
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, ChannelID: "200", SenderID: "200"},
|
||||
want: "200",
|
||||
},
|
||||
{
|
||||
name: "私聊:缺 ChannelID 时退回 SenderID",
|
||||
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "200"},
|
||||
want: "200",
|
||||
},
|
||||
{
|
||||
name: "私聊:两者都缺 ⇒ 空(调用方据此以 ErrInvalidChannel 拒掉)",
|
||||
msg: adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ChannelKey(c.msg); got != c.want {
|
||||
t.Fatalf("ChannelKey = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyIsTheSessionManagerCacheKey 钉住「导出的 Key 就是会话缓存键」:
|
||||
// 形状 `platform \x00 channelID`(NUL 分隔,杜绝拼接歧义),且两维度都参与 ——
|
||||
// 同平台不同渠道、不同平台同渠道都必须是**不同**的键。
|
||||
func TestKeyIsTheSessionManagerCacheKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
group1 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
|
||||
group1OtherChannel := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c2", GroupID: "g1"}
|
||||
group2 := adapter.UnifiedMessage{Platform: "kook", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g2"}
|
||||
otherPlatform := adapter.UnifiedMessage{Platform: "qq-obv11", ChannelType: adapter.ChannelGroup, ChannelID: "c1", GroupID: "g1"}
|
||||
|
||||
if got, want := Key(group1), "kook\x00group:g1"; got != want {
|
||||
t.Fatalf("Key = %q, want %q", got, want)
|
||||
}
|
||||
// 同一 guild 的两个 text channel 在会话层是**同一个**会话。
|
||||
if Key(group1) != Key(group1OtherChannel) {
|
||||
t.Fatalf("same guild channels must share one session key: %q vs %q", Key(group1), Key(group1OtherChannel))
|
||||
}
|
||||
// 不同群、不同平台必须是不同的会话。
|
||||
if Key(group1) == Key(group2) {
|
||||
t.Fatalf("different groups must not share a session key: %q", Key(group1))
|
||||
}
|
||||
if Key(group1) == Key(otherPlatform) {
|
||||
t.Fatalf("different platforms must not share a session key: %q", Key(group1))
|
||||
}
|
||||
// 渠道缺失时键仍非空,但 ChannelKey 能为空(调用方据此区分不可用渠道)。
|
||||
noChannel := adapter.UnifiedMessage{Platform: "telegram", ChannelType: adapter.ChannelDirect}
|
||||
if Key(noChannel) == "" || ChannelKey(noChannel) != "" {
|
||||
t.Fatalf("missing-channel case: Key=%q ChannelKey=%q", Key(noChannel), ChannelKey(noChannel))
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
|
||||
if cid == "" {
|
||||
return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel)
|
||||
}
|
||||
key := msg.Platform + "\x00" + cid
|
||||
key := sessionKey(msg.Platform, cid)
|
||||
|
||||
// 快路径:已绑定且车道与本次解析一致——只读锁即可返回。
|
||||
if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role {
|
||||
@@ -155,7 +155,7 @@ func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
|
||||
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
|
||||
return nil, fmt.Errorf("session: scan list: %w", err)
|
||||
}
|
||||
key := platform + "\x00" + cid
|
||||
key := sessionKey(platform, cid)
|
||||
if cached, ok := m.lookupCached(key); ok {
|
||||
sessions = append(sessions, cached)
|
||||
continue
|
||||
|
||||
Reference in new issue
Block a user