Files
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
2026-10-04 14:37:37 +08:00

112 lines
4.2 KiB
Go
Raw Permalink 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 history
import (
"context"
"errors"
"testing"
)
// 本文件锁定 Android 协同 §G4 的存储半边:客户端幂等键(client_msg_id)由
// (session_id, client_msg_id) 的**部分唯一索引**原子裁决,因此判重不是「先查
// 再插」的竞态写法,而是「写入被数据库拒绝,然后读回既有那一行」。
// 部分(WHERE client_msg_id != '')是必须的:否则同一会话的所有空键行会互相
// 冲突,等于禁止一个会话出现第二条消息。
func TestAppendFullDedupesClientMsgID(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
first, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "同一段", ClientMsgID: "cm-1"})
if err != nil {
t.Fatalf("第一次写入:%v", err)
}
// 第二次:换一个 id(像客户端重发时服务端会另铸一个),同一个 clientMsgId。
second, err := st.AppendFull(ctx, Message{ID: "重发时的新 id", SessionID: MainSessionID, Role: RoleUser, Content: "同一段", ClientMsgID: "cm-1"})
if err != nil {
t.Fatalf("第二次写入:%v", err)
}
if second.ID != first.ID {
t.Fatalf("第二次返回的 id = %q,want 既有那条 %q(唯一索引裁决,不新写一行)", second.ID, first.ID)
}
if second.ClientMsgID != "cm-1" {
t.Fatalf("第二次返回的消息 = %+v,want 回填的 clientMsgId", second)
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("行数 = %d, want 1(同一个 clientMsgId 只落一条)", len(msgs))
}
if msgs[0].ClientMsgID != "cm-1" {
t.Fatalf("读回的消息 = %+v,want clientMsgId=cm-1(读接口可见)", msgs[0])
}
// 省略幂等键(空串)不参与约束:同一会话的两条空键消息都必须写得进去。
for i := 0; i < 2; i++ {
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "无键"}); err != nil {
t.Fatalf("第 %d 条空键写入:%v", i+1, err)
}
}
msgs, err = st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("行数 = %d, want 3(空 clientMsgId 绝不受唯一索引约束)", len(msgs))
}
}
func TestAppendDedupesClientMsgIDPerSession(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
// 幂等键只在**会话内**唯一:两个会话用同一个键互不影响
// (client_msg_id 由客户端生成,不同会话之间没有可比性)。
main, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "主会话", ClientMsgID: "cm-x"})
if err != nil {
t.Fatalf("主会话写入:%v", err)
}
sub := MainSessionID + ":sub-1"
other, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "子会话", ClientMsgID: "cm-x"})
if err != nil {
t.Fatalf("子会话写入:%v", err)
}
if other.ID == main.ID {
t.Fatalf("子会话返回了主会话的行 %q,want 各自一条", main.ID)
}
}
func TestFindByClientMsgID(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "定位我", ClientMsgID: "cm-find"})
if err != nil {
t.Fatalf("写入:%v", err)
}
got, err := st.FindByClientMsgID(ctx, MainSessionID, "cm-find")
if err != nil {
t.Fatalf("按幂等键读取:%v", err)
}
if got.ID != full.ID || got.ClientMsgID != "cm-find" {
t.Fatalf("读回 = %+v,want id=%q clientMsgId=cm-find", got, full.ID)
}
// 没见过的键与空键都必须是明确的 ErrMessageNotFound(编排器据此判定「不是
// 重复」,而不是把查询错误当成重复)。
for _, key := range []string{"cm-missing", ""} {
if _, err := st.FindByClientMsgID(ctx, MainSessionID, key); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("键 %q 的错误 = %v,want wrap of ErrMessageNotFound", key, err)
}
}
// 会话也必须匹配:同一个键在别的会话里不存在。
if _, err := st.FindByClientMsgID(ctx, MainSessionID+":other", "cm-find"); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("跨会话查询的错误 = %v,want wrap of ErrMessageNotFound", err)
}
}