fix(chat): track replies by queued batch
This commit is contained in:
@@ -12,7 +12,7 @@ describe("MessageQueue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends the first message immediately", async () => {
|
it("sends the first message immediately", async () => {
|
||||||
const consumer = vi.fn(() => true);
|
const consumer = vi.fn();
|
||||||
const queue = new MessageQueue();
|
const queue = new MessageQueue();
|
||||||
queue.setConsumer(consumer);
|
queue.setConsumer(consumer);
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ describe("MessageQueue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("merges pending messages after the interval", async () => {
|
it("merges pending messages after the interval", async () => {
|
||||||
const consumer = vi.fn(() => true);
|
const consumer = vi.fn();
|
||||||
const queue = new MessageQueue({ intervalMs: 1000 });
|
const queue = new MessageQueue({ intervalMs: 1000 });
|
||||||
queue.setConsumer(consumer);
|
queue.setConsumer(consumer);
|
||||||
|
|
||||||
@@ -48,10 +48,10 @@ describe("MessageQueue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not send a pending batch while a previous send is still running", async () => {
|
it("does not send a pending batch while a previous send is still running", async () => {
|
||||||
let resolveFirstSend: (ok: boolean) => void = () => undefined;
|
let resolveFirstSend: () => void = () => undefined;
|
||||||
const consumer = vi.fn(
|
const consumer = vi.fn(
|
||||||
() =>
|
() =>
|
||||||
new Promise<boolean>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
resolveFirstSend = resolve;
|
resolveFirstSend = resolve;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -66,7 +66,7 @@ describe("MessageQueue", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
expect(consumer).toHaveBeenCalledTimes(1);
|
expect(consumer).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
resolveFirstSend(true);
|
resolveFirstSend();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
@@ -76,12 +76,12 @@ describe("MessageQueue", () => {
|
|||||||
queue.dispose();
|
queue.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requeues a failed batch and merges it with newer pending messages", async () => {
|
it("does not retry a failed batch", async () => {
|
||||||
const consumer = vi
|
const consumer = vi
|
||||||
.fn((content: string) => content.length > 0)
|
.fn()
|
||||||
.mockReturnValueOnce(true)
|
.mockResolvedValueOnce(undefined)
|
||||||
.mockReturnValueOnce(false)
|
.mockRejectedValueOnce(new Error("send failed"))
|
||||||
.mockReturnValue(true);
|
.mockResolvedValue(undefined);
|
||||||
const queue = new MessageQueue({ intervalMs: 1000 });
|
const queue = new MessageQueue({ intervalMs: 1000 });
|
||||||
queue.setConsumer(consumer);
|
queue.setConsumer(consumer);
|
||||||
|
|
||||||
@@ -96,13 +96,14 @@ describe("MessageQueue", () => {
|
|||||||
queue.enqueue("fourth");
|
queue.enqueue("fourth");
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
expect(consumer).toHaveBeenLastCalledWith("second\nthird\nfourth");
|
expect(consumer).toHaveBeenCalledTimes(3);
|
||||||
|
expect(consumer).toHaveBeenLastCalledWith("fourth");
|
||||||
|
|
||||||
queue.dispose();
|
queue.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports a custom joiner", async () => {
|
it("supports a custom joiner", async () => {
|
||||||
const consumer = vi.fn(() => true);
|
const consumer = vi.fn();
|
||||||
const queue = new MessageQueue({ intervalMs: 1000, joiner: " " });
|
const queue = new MessageQueue({ intervalMs: 1000, joiner: " " });
|
||||||
queue.setConsumer(consumer);
|
queue.setConsumer(consumer);
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
* 业务行为:
|
* 业务行为:
|
||||||
* - 队列空闲时,第一条消息立即发送
|
* - 队列空闲时,第一条消息立即发送
|
||||||
* - 后续消息在冷却窗口内暂存,并按 `intervalMs` 合并成一条发送
|
* - 后续消息在冷却窗口内暂存,并按 `intervalMs` 合并成一条发送
|
||||||
* - 消费者可声明每批消息是否成功消费;不成功则重新入队
|
* - 队列只负责发送节奏,不关心每批消息是否成功消费
|
||||||
* - 队列可在任意时刻清空(dispose)
|
* - 队列可在任意时刻清空(dispose)
|
||||||
*/
|
*/
|
||||||
import { Logger } from "@/utils";
|
import { Logger } from "@/utils";
|
||||||
|
|
||||||
export type MessageConsumer = (content: string) => boolean | Promise<boolean>;
|
export type MessageConsumer = (content: string) => void | Promise<void>;
|
||||||
|
|
||||||
export interface MessageQueueOptions {
|
export interface MessageQueueOptions {
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
@@ -38,7 +38,7 @@ export class MessageQueue {
|
|||||||
this.joiner = options.joiner ?? "\n";
|
this.joiner = options.joiner ?? "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册消费者(消费者必须能处理失败/重试)。 */
|
/** 注册消费者。队列只负责调用,不根据结果做重试。 */
|
||||||
setConsumer(consumer: MessageConsumer): void {
|
setConsumer(consumer: MessageConsumer): void {
|
||||||
this.consumer = consumer;
|
this.consumer = consumer;
|
||||||
}
|
}
|
||||||
@@ -69,24 +69,13 @@ export class MessageQueue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ok = false;
|
|
||||||
try {
|
try {
|
||||||
ok = await this.consumer(content);
|
await this.consumer(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error({ err: e }, "consumer error");
|
log.error({ err: e }, "consumer error");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sending = false;
|
this.sending = false;
|
||||||
if (!ok) {
|
|
||||||
// 消费失败,整批放回队首等待下次合并发送。
|
|
||||||
this.pending.unshift(content);
|
|
||||||
log.debug("retry batch", {
|
|
||||||
pendingCount: this.pending.length,
|
|
||||||
contentLength: content.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ function createTestChatMachine(
|
|||||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
if (event.type !== "ChatSendMessage") return;
|
if (event.type !== "ChatSendMessage") return;
|
||||||
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
sendBack({
|
sendBack({
|
||||||
type: "ChatQueuedHttpDone",
|
type: "ChatQueuedHttpDone",
|
||||||
output: {
|
output: {
|
||||||
@@ -266,4 +267,96 @@ describe("chatMachine transitions", () => {
|
|||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks replying state by queued batch instead of user message count", async () => {
|
||||||
|
const machine = chatMachine.provide({
|
||||||
|
actors: {
|
||||||
|
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({
|
||||||
|
messages: [],
|
||||||
|
hasMore: false,
|
||||||
|
newOffset: 0,
|
||||||
|
localOverwritten: true,
|
||||||
|
localCount: 0,
|
||||||
|
networkCount: 0,
|
||||||
|
})),
|
||||||
|
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
||||||
|
async () => ({
|
||||||
|
messages: [],
|
||||||
|
hasMore: false,
|
||||||
|
newOffset: 0,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
sendMessageHttp: fromPromise<
|
||||||
|
SendMessageHttpOutput,
|
||||||
|
{ content: string }
|
||||||
|
>(async () => ({
|
||||||
|
response: makeChatSendResponse(),
|
||||||
|
reply: null,
|
||||||
|
})),
|
||||||
|
sendMessageWs: fromPromise<void, { content: string }>(async () => {}),
|
||||||
|
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||||
|
const pending: string[] = [];
|
||||||
|
receive((event) => {
|
||||||
|
if (event.type !== "ChatSendMessage") return;
|
||||||
|
pending.push(event.content);
|
||||||
|
if (pending.length !== 2) return;
|
||||||
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
|
sendBack({
|
||||||
|
type: "ChatQueuedHttpDone",
|
||||||
|
output: {
|
||||||
|
response: makeChatSendResponse(),
|
||||||
|
reply: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
|
wsPreferredMessageQueue: fromCallback<ChatEvent>(({ receive }) => {
|
||||||
|
receive(() => undefined);
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
|
unlockPrivateMessage: fromPromise<
|
||||||
|
UnlockPrivateOutput,
|
||||||
|
{ messageId: string }
|
||||||
|
>(async ({ input }) => ({
|
||||||
|
messageId: input.messageId,
|
||||||
|
response: UnlockPrivateResponse.from({
|
||||||
|
unlocked: true,
|
||||||
|
content: "unlocked",
|
||||||
|
showUpgrade: false,
|
||||||
|
paywallTriggered: false,
|
||||||
|
privateFreeLimit: 0,
|
||||||
|
privateUsedToday: 0,
|
||||||
|
reason: "ok",
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
chatWebSocket: fromCallback(({ sendBack }) => {
|
||||||
|
sendBack({
|
||||||
|
type: "ChatWebSocketConnected",
|
||||||
|
userId: "user-1",
|
||||||
|
});
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const actor = createActor(machine).start();
|
||||||
|
|
||||||
|
actor.send({ type: "ChatNonVipLogin", token: "token" });
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ nonVipUserSession: "ready" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
actor.send({ type: "ChatSendMessage", content: "first" });
|
||||||
|
actor.send({ type: "ChatSendMessage", content: "second" });
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
actor,
|
||||||
|
(snapshot) => snapshot.context.messages.length === 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context.pendingReplyCount).toBe(0);
|
||||||
|
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
|
||||||
|
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type ChatEvent =
|
|||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||||
| { type: "ChatLoadMoreHistory" }
|
| { type: "ChatLoadMoreHistory" }
|
||||||
|
| { type: "ChatQueuedSendStarted" }
|
||||||
| {
|
| {
|
||||||
type: "ChatQueuedHttpDone";
|
type: "ChatQueuedHttpDone";
|
||||||
output: import("./chat-machine.helpers").HttpSendOutput;
|
output: import("./chat-machine.helpers").HttpSendOutput;
|
||||||
|
|||||||
@@ -242,10 +242,11 @@ function createMessageQueueActor(
|
|||||||
const queue = new MessageQueue();
|
const queue = new MessageQueue();
|
||||||
|
|
||||||
queue.setConsumer(async (content) => {
|
queue.setConsumer(async (content) => {
|
||||||
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
try {
|
try {
|
||||||
if (mode === "wsPreferred") {
|
if (mode === "wsPreferred") {
|
||||||
const ws = getActiveChatWebSocket();
|
const ws = getActiveChatWebSocket();
|
||||||
if (ws?.sendMessage(content)) return true;
|
if (ws?.sendMessage(content)) return;
|
||||||
log.debug("[chat-machine] message queue fallback to HTTP", {
|
log.debug("[chat-machine] message queue fallback to HTTP", {
|
||||||
contentLength: content.length,
|
contentLength: content.length,
|
||||||
});
|
});
|
||||||
@@ -253,7 +254,6 @@ function createMessageQueueActor(
|
|||||||
|
|
||||||
const output = await sendMessageViaHttp(content);
|
const output = await sendMessageViaHttp(content);
|
||||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : "Message send failed";
|
error instanceof Error ? error.message : "Message send failed";
|
||||||
@@ -266,7 +266,6 @@ function createMessageQueueActor(
|
|||||||
content,
|
content,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const chatMachine = setup({
|
|||||||
contentLength: event.content.length,
|
contentLength: event.content.length,
|
||||||
contentPreview: event.content.slice(0, 50),
|
contentPreview: event.content.slice(0, 50),
|
||||||
quotaMode: "server controlled",
|
quotaMode: "server controlled",
|
||||||
isReplyingAI: true,
|
isReplyingAI: context.isReplyingAI,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -123,7 +123,6 @@ export const chatMachine = setup({
|
|||||||
date: today,
|
date: today,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
...beginPendingReply(context),
|
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -139,7 +138,7 @@ export const chatMachine = setup({
|
|||||||
contentLength: event.content.length,
|
contentLength: event.content.length,
|
||||||
contentPreview: event.content.slice(0, 50),
|
contentPreview: event.content.slice(0, 50),
|
||||||
wsConnected: context.wsConnected,
|
wsConnected: context.wsConnected,
|
||||||
isReplyingAI: true,
|
isReplyingAI: context.isReplyingAI,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -151,7 +150,6 @@ export const chatMachine = setup({
|
|||||||
date: today,
|
date: today,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
...beginPendingReply(context),
|
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
upgradeHint: null,
|
upgradeHint: null,
|
||||||
@@ -261,6 +259,8 @@ export const chatMachine = setup({
|
|||||||
return { messages, ...finishPendingReply(context) };
|
return { messages, ...finishPendingReply(context) };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
markQueuedSendStarted: assign(({ context }) => beginPendingReply(context)),
|
||||||
|
|
||||||
applyQueuedHttpOutput: assign(({ context, event }) => {
|
applyQueuedHttpOutput: assign(({ context, event }) => {
|
||||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||||
return applyHttpSendOutput(context, event.output);
|
return applyHttpSendOutput(context, event.output);
|
||||||
@@ -423,6 +423,9 @@ export const chatMachine = setup({
|
|||||||
target: "#chat.vipUserSession",
|
target: "#chat.vipUserSession",
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
|
ChatQueuedSendStarted: {
|
||||||
|
actions: "markQueuedSendStarted",
|
||||||
|
},
|
||||||
ChatQueuedHttpDone: {
|
ChatQueuedHttpDone: {
|
||||||
actions: "applyQueuedHttpOutput",
|
actions: "applyQueuedHttpOutput",
|
||||||
},
|
},
|
||||||
@@ -533,6 +536,9 @@ export const chatMachine = setup({
|
|||||||
target: "#chat.vipUserSession",
|
target: "#chat.vipUserSession",
|
||||||
actions: "startUserSession",
|
actions: "startUserSession",
|
||||||
},
|
},
|
||||||
|
ChatQueuedSendStarted: {
|
||||||
|
actions: "markQueuedSendStarted",
|
||||||
|
},
|
||||||
ChatQueuedHttpDone: {
|
ChatQueuedHttpDone: {
|
||||||
actions: "applyQueuedHttpOutput",
|
actions: "applyQueuedHttpOutput",
|
||||||
},
|
},
|
||||||
@@ -666,6 +672,9 @@ export const chatMachine = setup({
|
|||||||
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
||||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||||
|
ChatQueuedSendStarted: {
|
||||||
|
actions: "markQueuedSendStarted",
|
||||||
|
},
|
||||||
ChatQueuedHttpDone: {
|
ChatQueuedHttpDone: {
|
||||||
actions: "applyQueuedHttpOutput",
|
actions: "applyQueuedHttpOutput",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user