Skip to content

fix(channel): 新聊天同时到达的消息只创建一个会话 - #1719

Open
Fodesu wants to merge 5 commits into
felinics:mainfrom
Fodesu:fix-channel-session-create-race
Open

Fodesu wants to merge 5 commits into
felinics:mainfrom
Fodesu:fix-channel-session-create-race

Conversation

@Fodesu

@Fodesu Fodesu commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Author

  • Human
  • Agent

Type

  • bug
  • feat
  • test

Summary

一个新聊天同时收到多条消息时,会被拆成多个会话。入站流程先读 route 的活跃会话指针,没有时再新建会话并改写指针,三步之间没有互斥,中间还隔着 ACL 和命令检查。渠道首次接入带积压、bot 刚进一个活跃的群、一次转发多条消息时,这些消息会各自建一个会话,指针只留下最后写入的那个。其余会话里的消息之后不再进入上下文;discuss 模式下每个会话还各自起一个 worker、各自判断是否发言。

新增 ThreadCoordinator.CreateIfAbsent:有可用的活跃会话就直接返回,没有才新建。同一进程内按 route 用 singleflight 合并并发的创建;跨进程时用新增的 CompareAndSetRouteActiveSession 只从读取时的值改写指针,条件写入失败的一方删除自己创建的会话,改用已选中的会话。条件写入失败后重新读取 route,最多 3 轮:新选中的会话存在就用它,已不存在就再写一次。读取已选中的会话出错时不替换,直接返回错误。条件写入报错时可能已经提交,因此先重新读取 route,已指向新会话就返回它,读取失败则保留新会话。调用方放弃等待时立即返回,创建在后台继续;创建会执行 SessionStart hook,所以不设总时长上限。入站在没有活跃会话时改用它,拿到别人建的会话时重新检查 skill 支持。/new 仍然无条件新建;原来的 EnsureActive 不再有调用方,已删除。

修复不会合并已经被拆开的旧会话。跨进程竞争中被删除的会话在创建时已经发布了 session_created、执行过 SessionStart hook;删除不发布事件,Web 侧边栏刷新前会多显示一个会话。

Related Issues

  • 压测于 Linear MEMOH-190 时发现。

Validation

  • internal/channel/route 新增 6 个用例:20 个调用方同时进来只建 1 个;跨进程条件写入失败时改用对方的会话并删除自己创建的;对方选中的会话随即被删时重试;已选中的会话读取出错时不替换;条件写入已提交但返回错误时返回并保留新会话;调用方放弃等待时立即返回。依次去掉 singleflight、忽略条件写入结果、只尝试 1 次、把读取错误当作会话不存在、条件写入报错后不重新读取、忽略调用方的 ctx,对应用例失败。用例在 -race 下连续 20 次通过。
  • internal/channel/inbound 新增用例:私聊里的 /alpha 拿到别人建的 discuss 会话时,回复不支持且不启动 turn;去掉重新检查后用例失败。
  • 新增 Postgres 集成测试 TestPostgresRouteCompareAndSetMovesOnlyFromExpectedSession,覆盖期望值匹配、不匹配、空指针、空指针已被他人写入四种情况;route 锁顺序测试新增删除 bot 与条件写入并发的用例,去掉条件写入里目标会话的 FOR KEY SHARE 后该用例报死锁。均在临时库上通过。internal/chat/message(含 route 锁顺序测试)、internal/arch、internal/channel/inbound、cmd/internal/channel、internal/handlers 测试和 golangci-lint 通过。
  • memoh-dev 环境(server 与 channel 分进程,已加载本分支代码),用假的 Telegram Bot API 和 mock 模型:一个新私聊同时进 100 条,改前 100 个会话,改后 1 个;10 个新群各进 100 条,改后每个群 1 个会话,模型调用 20 次,没有死锁和警告。两轮 review 修改后各重跑一次,结果相同:新私聊 100 条 1 个会话;10 个新群各 100 条(30% 带图)每个群 1 个会话,没有会话被删除。
  • 真实 Telegram(新建的测试 bot 和群):改前,接入一个带积压的新群,94 条群消息分进 45 个会话,活跃会话里只有 15 条,54 次模型调用、最多同时 46 次。改后,换一个没处理过这个群的 Memoh bot 接入带 103 条积压的同一个群,合并相册后 22 条入站消息全部进了 1 个会话,4 次模型调用、最多同时 1 次。改后这一轮同时涌入的消息较少(相册合并后 22 条),且在 review 修改之前。

Screenshots / Recordings

改动没有可见界面,验证方式见 Validation。

Human QA

  • Human QA passed

入站流程先读 route 的活跃会话指针,没有时再新建会话并改写指针,三步之间
没有互斥。渠道首次接入带积压、或一次转发多条消息时,同一个新聊天的消息会
各自建一个会话,指针只留下最后写入的那个,其余会话里的消息之后不再进入
上下文,每个会话还各自调用模型。

新增 CreateIfAbsent:同一进程内按 route 合并并发的创建;跨进程时指针只从
读取时的值改写,没抢到的一方删掉自己建的会话,改用已选中的会话。入站在
没有活跃会话时改用它;/new 仍然无条件新建。
@github-actions github-actions Bot added bug Fixes a bug or unexpected behavior change:server Changes backend code, configuration, or API contracts size:S PR size uses the larger of added or deleted lines, excluding generated files labels Oct 31, 2026
两个 fake 合成一个,测试并入 thread_test.go;并发用例改用 channel 控制先后,
不再依赖计时;「指针指向已删除会话」并入跨进程用例,去掉逻辑直接的
「已有会话直接返回」用例。
条件写入失败后会重新读取 route:选中的会话已不存在时,改为从新的值再写一次,
最多 3 次,不再沿用指针没有指向的会话;条件写入报错时返回错误并删掉刚建的会话。
只有选中的会话不存在时才新建,其他读取错误直接返回,避免把仍在用的会话换掉。

共享的创建改用 DoChan:调用方 context 结束时立即返回,共享的那次最多 30 秒。
删除没有调用方的 EnsureActive 和 EnsureActiveSession。补充 inbound 走
CreateSessionIfAbsent 的断言,以及加入别人建的 discuss 会话时拒绝 skill 的用例。
共享的创建会执行 SessionStart hook,工作区冷启动时可能超过 30 秒,
超时后条件写入和删除都会失败。改为不设总时长上限,等待的调用方仍按
自己的 ctx 返回。

条件写入报错时可能已经提交。报错后重新读取 route:已指向新建的会话
就返回它;读取失败时保留会话,只返回错误。
CompareAndSetRouteActiveSession 沿用 SetRouteActiveSession 的加锁写法。
锁顺序测试的 helper 改为传入切换函数,新增删除 bot 与条件写入并发的用例;
去掉条件写入中目标会话的 FOR KEY SHARE 后该用例报死锁。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Fixes a bug or unexpected behavior change:server Changes backend code, configuration, or API contracts size:S PR size uses the larger of added or deleted lines, excluding generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant