feat(devtools): 假后端加上行挂起开关 --hold-replies,让「忙时被拒 / 排队 / 队列满」可稳定复现
为什么
客户端手里还挂着一条没发出去的乐观气泡时,收到「队列满 / 忙时被拒」会怎样,判据是
ChatViewModel.queueFullOutcome(本会话待发 0 条 ⇒ 刻意什么都不说、1 条 ⇒ 回滚 + 横幅、
≥2 条 ⇒ 一个气泡都不动、只提示「可能没发出去」)。但假后端收到上行 chat 或
POST /api/v1/chat 时会立刻把那条用户消息落库并广播 message_persisted,客户端拿
clientMsgId 一比就把乐观气泡收走(consumePendingFor)—— 一个往返之后待发列表就空了,
那三档里只剩「0 条」能凑出来。真后端忙时是先排队或先拒绝、不会立刻落库,所以这是假后端的
建模缺陷,本次修它。
加了什么(只动 devtools/fake-backend/,不碰 app/ devdocs/ 构建文件)
- 启动开关 --hold-replies(默认关):收到上行消息只记录、不落库、不广播 message_persisted。
上行帧照常进 out/upstream.jsonl、state.wsUpstreamByType 照常计数;回复路径
(--stream-chunk / --auto-reply / /__control/reply 自己的 persist)一概不受影响 ——
关的只是「上行那条的落库」这一件事。
- 运行期开关 POST /__control/hold-replies?on=1|0,便于在同一个进程里做「先挂着 → 再落库」两段。
- 显式补落库 POST /__control/persist?session=&id=<holdId|messageId|clientMsgId>&index=N:
不给选择器就按 FIFO 落最旧那条;落库时才盖 createdAt、才认领幂等键(与「队列满的判定发生在
认领之前」一致),广播的 message_persisted 与默认路径同形状。挂起列表在 GET /__control/state
的 heldMessages 里。
- 挂起的消息在挂起时先占号(msg-xxxxxx),所以 REST 回执里的 messageId 与之后真正落库的是同一个。
三处 message_persisted 广播点各自改成了什么
- 原 :657(WS 上行 chat 之后那条)与 原 :1235(REST POST /api/v1/chat 之后那条):受本开关控制,
挂起时整条跳过(仍记录上行帧)。
- 原 :773(send_reply_round 里助手消息的落库)刻意不动:那是回复路径,本来就有自己的 persist
开关,本开关不去关它 —— 任务里也明说不要把回复路径一并关掉。已实跑验证:开关打开时
reply?persist=0 不落库、reply?persist=1 照旧广播 message_persisted。
拒绝场景(任务第 2 项):不需要新造
/__control/pending-full(载荷 {sessionId,mode,max,clientMsgId?,messageId?},与客户端
PendingFullData 逐字对应)、/__control/error?code=chat_busy(带 clientMsgId/messageId)与能发
任意帧的 /__control/frame 早就在了,形状也不缺东西。本次只实跑复核(三帧都在探针那边看到原样帧体、
回执 delivered=1)并写进 README,没有改它们的形状。
默认行为未变
--hold-replies 默认关,state.holdReplies=false、heldMessages=[];关着时三处广播、add_message 的
id 分配(新增的 message_id 参数只在挂起补落库时给值)、REST 回执形状逐字与改动前一致。
验证(本机实跑,端口 18099/18098,绝不用真后端的 10942)
挂起档:ws_probe 发 chat 后 2 秒里零回帧、out/downstream.jsonl 里 message_persisted 计数 0,
而 upstream.jsonl 与 wsUpstreamByType.chat 都记到了;POST /__control/persist?id=local-1 之后才在
活着的连接上收到 message_persisted(delivered=1,id 正是挂起时先占的 msg-000001)。REST 路径同理:
/api/v1/chat 的 202 回执时刻下游流水没有那一帧,2 秒后 persist 才有(时间戳可对)。pending_full /
error{chat_busy} / __control/frame 三帧都投递成功。运行期 on=0 → 立刻落库、on=1 → 挂住,双向有效。
默认档回归:立即落库、灌消息分页、复位都照旧。
README
新增第 6 节「场景四:忙时被拒 —— 让『待发气泡还挂着』可复现(--hold-replies)」:写清乐观气泡被
一个往返收走这个机制、开关怎么用、一条可复制的命令序列(起服务 → 发 chat 挂住 → 注入 pending_full →
期望的横幅文案),以及帧上带不带 clientMsgId/messageId 时客户端四种不同的处置。原第 6-10 节顺延为
7-11 节,并在开关表、控制端点表、记录文件节、已知不确定节各补了对应内容。
This commit is contained in:
1 parent
1f6c16c590
commit
c73dd532ff
2 files changed
+291
-8
No files matched your search
@@ -216,6 +216,10 @@ class Store:
|
||||
self.clients = []
|
||||
self.upstream_records = []
|
||||
self.seen_client_msg_ids = {}
|
||||
# 上行落库模式(--hold-replies / POST /__control/hold-replies):挂起的上行消息一条不落库。
|
||||
self.hold_replies = args.hold_replies
|
||||
self.held_messages = []
|
||||
self.hold_seq = 0
|
||||
self.max_upstream_kept = args.keep_records
|
||||
self.out_dir = args.out_dir
|
||||
self.upstream_log = JsonlLog(os.path.join(args.out_dir, "upstream.jsonl"))
|
||||
@@ -234,6 +238,9 @@ class Store:
|
||||
self.state_seq = 0
|
||||
self.seen_client_msg_ids = {}
|
||||
self.upstream_records = []
|
||||
# 挂起的上行消息属于「库里的状态」,一起复位;hold_replies 是**模式**,复位不动它。
|
||||
self.held_messages = []
|
||||
self.hold_seq = 0
|
||||
main = Session(MAIN_SESSION_ID, "main", SEED_MAIN_CREATED_AT)
|
||||
sub = Session(
|
||||
"web:5eed0000000000000000000000000001",
|
||||
@@ -268,17 +275,23 @@ class Store:
|
||||
mood=None,
|
||||
documents=None,
|
||||
created_at=None,
|
||||
message_id=None,
|
||||
) -> dict:
|
||||
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。"""
|
||||
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
|
||||
|
||||
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
|
||||
"""
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError(session_id)
|
||||
self.message_seq += 1
|
||||
if message_id is None:
|
||||
self.message_seq += 1
|
||||
message_id = "msg-{:06d}".format(self.message_seq)
|
||||
previous = session.messages[-1]["createdAt"] if session.messages else 0
|
||||
stamp = created_at if created_at is not None else max(now_ms(), previous + 1, session.created_at + 1)
|
||||
message = {
|
||||
"id": "msg-{:06d}".format(self.message_seq),
|
||||
"id": message_id,
|
||||
"sessionId": session_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
@@ -297,6 +310,57 @@ class Store:
|
||||
session.messages.append(message)
|
||||
return message
|
||||
|
||||
def hold_message(self, session_id: str, role: str, content: str, client_msg_id=None, mode=None) -> dict:
|
||||
"""上行消息**挂起**:不落库、不广播 `message_persisted`(--hold-replies 打开时走这条)。
|
||||
|
||||
为什么要这个模式:默认行为下客户端一发 `chat`,一个往返就收到那条用户消息的
|
||||
`message_persisted`,乐观气泡立刻被收走 ⇒ 「服务端把这条**拒掉**」这类场景根本凑不出来
|
||||
(手里没有还挂着的待发气泡)。真后端忙时也是先排队 / 先拒绝,不会立刻落库。
|
||||
|
||||
id 在这里**先占号**(与真正落库共用同一个 `message_seq`,不会撞号),`createdAt` 留到
|
||||
真正落库那一刻再盖([persist_held_message])。幂等键**不在**这一刻认领 —— 与契约一致:
|
||||
队列满的判定发生在认领之前(WsEvent.kt:398),所以挂起期间重发同一帧会得到第二条挂起
|
||||
记录,而不是 `chat_duplicate`。
|
||||
"""
|
||||
with self.lock:
|
||||
self.message_seq += 1
|
||||
self.hold_seq += 1
|
||||
record = {
|
||||
"holdId": "hold-{:06d}".format(self.hold_seq),
|
||||
"sessionId": session_id,
|
||||
"messageId": "msg-{:06d}".format(self.message_seq),
|
||||
"role": role,
|
||||
"clientMsgId": client_msg_id,
|
||||
"mode": mode,
|
||||
"content": content,
|
||||
"heldAt": iso_now(),
|
||||
}
|
||||
self.held_messages.append(record)
|
||||
return record
|
||||
|
||||
def persist_held_message(self, hold: dict) -> dict:
|
||||
"""把一条挂起的上行消息**显式**落库(控制端点 `POST /__control/persist`)。
|
||||
|
||||
落库动作与默认路径逐字一致:进同一个会话、认领幂等键、由调用方广播 `message_persisted`。
|
||||
"""
|
||||
with self.lock:
|
||||
message = self.add_message(
|
||||
hold["sessionId"],
|
||||
hold["role"],
|
||||
hold["content"],
|
||||
client_msg_id=hold.get("clientMsgId"),
|
||||
mode=hold.get("mode"),
|
||||
message_id=hold["messageId"],
|
||||
)
|
||||
if hold.get("clientMsgId"):
|
||||
self.seen_client_msg_ids[hold["clientMsgId"]] = message["id"]
|
||||
self.held_messages.remove(hold)
|
||||
return message
|
||||
|
||||
def held_for(self, session_id=None) -> list:
|
||||
with self.lock:
|
||||
return [h for h in self.held_messages if session_id is None or h["sessionId"] == session_id]
|
||||
|
||||
def history_replay(self, session_id: str, limit: int) -> list:
|
||||
"""握手 history 帧的回放:**最近 N 条、升序**。N 与库里的条数之差就是那个洞。"""
|
||||
with self.lock:
|
||||
@@ -429,6 +493,8 @@ class Store:
|
||||
"streamChunk": self.args.stream_chunk,
|
||||
"autoReply": self.args.auto_reply,
|
||||
"interruptAck": self.args.interrupt_ack,
|
||||
"holdReplies": self.hold_replies,
|
||||
"heldMessages": [dict(h) for h in self.held_messages],
|
||||
"outDir": self.out_dir,
|
||||
"sessions": sessions,
|
||||
"wsClients": len(self.clients),
|
||||
@@ -643,6 +709,18 @@ def handle_upstream(store: Store, client, frame) -> None:
|
||||
)
|
||||
return
|
||||
try:
|
||||
if store.hold_replies:
|
||||
# 挂起:**不**落库、**不**广播 message_persisted(这一处是默认路径里的第一个广播点)。
|
||||
# 上行帧已经被 record_upstream 记过(out/upstream.jsonl + wsUpstreamByType),
|
||||
# 回复路径照旧不受影响。
|
||||
hold = store.hold_message(session_id, "user", data.get("text", ""), client_msg_id=client_msg_id)
|
||||
log(
|
||||
"hold",
|
||||
"chat 挂起 {} clientMsgId={}(等 /__control/persist)".format(hold["holdId"], client_msg_id),
|
||||
)
|
||||
if store.args.auto_reply:
|
||||
send_reply_round(store, session_id, "收到:{}".format(data.get("text", "")[:20]))
|
||||
return
|
||||
message = store.add_message(
|
||||
session_id,
|
||||
"user",
|
||||
@@ -1224,6 +1302,34 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if session_id not in self.store.sessions:
|
||||
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
|
||||
return
|
||||
if self.store.hold_replies:
|
||||
# 挂起:回执形状逐字不变(`ChatAckDto`),但那条消息**没有**落库、也**没有**广播
|
||||
# message_persisted(这是默认路径里的第二个广播点)。`messageId` 是挂起时先占的号,
|
||||
# 它会在 /__control/persist 那一刻真的进库。
|
||||
hold = self.store.hold_message(
|
||||
session_id,
|
||||
"user",
|
||||
body.get("text", ""),
|
||||
client_msg_id=body.get("clientMsgId"),
|
||||
mode=body.get("mode"),
|
||||
)
|
||||
log(
|
||||
"hold",
|
||||
"rest chat 挂起 {} messageId={}(等 /__control/persist)".format(hold["holdId"], hold["messageId"]),
|
||||
)
|
||||
if self.store.args.auto_reply:
|
||||
send_reply_round(self.store, session_id, "收到:{}".format(body.get("text", "")[:20]))
|
||||
self._ok(
|
||||
{
|
||||
"stream": True,
|
||||
"queued": False,
|
||||
"queueDepth": 0,
|
||||
"messageId": hold["messageId"],
|
||||
"sessionId": session_id,
|
||||
},
|
||||
status=202,
|
||||
)
|
||||
return
|
||||
message = self.store.add_message(
|
||||
session_id,
|
||||
"user",
|
||||
@@ -1401,6 +1507,65 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._ok({"session": session_id, "mode": mode, "dropped": dropped, "delivered": sent})
|
||||
return
|
||||
|
||||
if action == "hold-replies" and method in ("GET", "POST"):
|
||||
# 运行期开关(等价于启动参数 --hold-replies),用来在同一个进程里做「先挂着 → 再落库」两段。
|
||||
on = self._param(query, "on", "1") not in ("0", "false", "no")
|
||||
self.store.hold_replies = on
|
||||
with self.store.lock:
|
||||
held = len(self.store.held_messages)
|
||||
log("ctl ", "hold-replies {} (held={})".format("on" if on else "off", held))
|
||||
self._ok({"holdReplies": on, "held": held})
|
||||
return
|
||||
|
||||
if action == "persist" and method in ("GET", "POST"):
|
||||
# 显式补上挂起那一刻没做的那次落库:进库 + 广播 message_persisted(与默认路径同一形状)。
|
||||
session_id = self._param(query, "session", None)
|
||||
ident = self._param(query, "id", None)
|
||||
index = self._int_param(query, "index", None)
|
||||
candidates = self.store.held_for(session_id)
|
||||
if ident:
|
||||
candidates = [
|
||||
hold
|
||||
for hold in candidates
|
||||
if ident in (hold["holdId"], hold["messageId"], hold.get("clientMsgId"))
|
||||
]
|
||||
elif index is not None:
|
||||
if index < 1 or index > len(candidates):
|
||||
self._fail(404, "not_found", "index {} out of range (1..{})".format(index, len(candidates)))
|
||||
return
|
||||
candidates = [candidates[index - 1]]
|
||||
else:
|
||||
candidates = candidates[:1] # 不给选择器就按 FIFO 落最旧的那条
|
||||
if not candidates:
|
||||
self._fail(
|
||||
404,
|
||||
"no_held_message",
|
||||
"no held message matches (hold-replies={}, held={}); see GET /__control/state".format(
|
||||
self.store.hold_replies, len(self.store.held_messages)
|
||||
),
|
||||
)
|
||||
return
|
||||
hold = candidates[0]
|
||||
message = self.store.persist_held_message(hold)
|
||||
sent = self.store.broadcast(
|
||||
{"type": "message_persisted", "sessionId": message["sessionId"], "data": {"message": message}},
|
||||
"message_persisted",
|
||||
)
|
||||
log("ctl ", "persisted {} as {} (delivered={})".format(hold["holdId"], message["id"], sent))
|
||||
self._ok(
|
||||
{
|
||||
"persisted": True,
|
||||
"session": message["sessionId"],
|
||||
"holdId": hold["holdId"],
|
||||
"messageId": message["id"],
|
||||
"clientMsgId": hold.get("clientMsgId"),
|
||||
"delivered": sent,
|
||||
"remaining": len(self.store.held_messages),
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if action == "pending-full" and method in ("GET", "POST"):
|
||||
session_id = self._param(query, "session", MAIN_SESSION_ID)
|
||||
mode = self._param(query, "mode", "thinking")
|
||||
@@ -1571,6 +1736,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
dest="interrupt_ack",
|
||||
help="收到上行 interrupt 帧后回一帧 session_interrupted{mode:idle,dropped:0}(默认关)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hold-replies",
|
||||
action="store_true",
|
||||
dest="hold_replies",
|
||||
help="上行消息**只记录、不落库**:收到 chat / POST /api/v1/chat 后不广播 message_persisted,"
|
||||
"由 POST /__control/persist 显式补落库(默认关,默认行为逐字不变)。用来复现「忙时被拒 / 排队 / 队列满」",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-dir",
|
||||
default=os.path.join(HERE, "out"),
|
||||
@@ -1600,6 +1772,13 @@ def main(argv=None) -> int:
|
||||
print(" history 握手回放 {} 条(--history-replay)".format(args.history_replay))
|
||||
print(" 回复形状 {}".format("stream_chunk 旧形状" if args.stream_chunk else "message.* 结构化"))
|
||||
print(" 自动回复 {} / interrupt 回执 {}".format(args.auto_reply, args.interrupt_ack))
|
||||
print(
|
||||
" 上行落库 {}".format(
|
||||
"挂起(--hold-replies:等 POST /__control/persist 才落库)"
|
||||
if args.hold_replies
|
||||
else "立即(收到即落库 + message_persisted)"
|
||||
)
|
||||
)
|
||||
print(" 记录目录 {}".format(args.out_dir))
|
||||
print(" 控制台 GET /__control/state")
|
||||
print("=" * 72)
|
||||
|
||||
Reference in new issue
Block a user