fix(queue): re-read the server queue after a stream gap
- Add `resync()` to the message queue store: re-reads the server snapshot once a hydration has established server ownership. - Call it from the sync provider's stream-reconnect and transport-switch handlers. - Re-read the server session after a failed take. - Keep a queue a newer broadcast produced when a hydration snapshot predates it, listed in the snapshot or not. - Document the broadcast transports and the resync in the stores and message-queue docs.
This commit is contained in:
@@ -123,6 +123,34 @@ describe("server-owned message queue", () => {
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("hydrate keeps a queue a newer broadcast added even when the snapshot predates it", async () => {
|
||||
applyMessageQueueUpdatedEvent(updated(10, session([serverItem("q1", "queued after the read started")])), "runtime-a")
|
||||
respond = () => json({ revision: 9, sessions: [] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("resync re-reads the server once a hydration established ownership, and drops what it no longer lists", async () => {
|
||||
// Before a hydration the store cannot tell the server's copies from an
|
||||
// older build's local queue, so there is nothing to re-read yet.
|
||||
activeRuntimeKey = "runtime-never-hydrated"
|
||||
await useMessageQueueStore.getState().resync()
|
||||
expect(calls).toHaveLength(0)
|
||||
|
||||
activeRuntimeKey = "runtime-a"
|
||||
respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "queued")], "q1")] })
|
||||
await useMessageQueueStore.getState().hydrate()
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1)
|
||||
|
||||
// The server delivered q1 while this client's stream was down.
|
||||
respond = () => json({ revision: 4, sessions: [] })
|
||||
await useMessageQueueStore.getState().resync()
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue", "GET /api/message-queue"])
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
expect(useMessageQueueStore.getState().sendingIds[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("addToQueue shows the message at once and settles on the server's copy", async () => {
|
||||
respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) })
|
||||
const pending = useMessageQueueStore.getState().addToQueue(target, {
|
||||
@@ -270,6 +298,20 @@ describe("server-owned message queue", () => {
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"])
|
||||
})
|
||||
|
||||
test("a failed take re-reads the server so a stale projection is cleared", async () => {
|
||||
useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "already delivered", text: "already delivered", createdAt: 1 }] } })
|
||||
respond = (call) => (call.path.endsWith("/take")
|
||||
? new Response(JSON.stringify({ error: "queued message not found" }), { status: 404 })
|
||||
: json({ revision: 12, sessions: [] }))
|
||||
await expect(useMessageQueueStore.getState().takeForSend(target, "q1")).rejects.toThrow()
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
||||
"POST /api/message-queue/sessions/session-1/items/q1/take",
|
||||
"GET /api/message-queue",
|
||||
])
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined)
|
||||
})
|
||||
|
||||
test("broadcasts update the projection but never move it backwards", () => {
|
||||
applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a")
|
||||
expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"])
|
||||
|
||||
Reference in New Issue
Block a user