From 79b34901e4a2f62746f8365885bf29afd2570981 Mon Sep 17 00:00:00 2001 From: Aschenbath Date: Fri, 30 Oct 2026 16:00:24 +0800 Subject: [PATCH] A fast key queued just before the overview deadline should still wait out its own settle window instead of being flushed early with the overview. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Aschenbath --- web/src/useKbEvents.test.ts | 60 ++++++++++++++++++++++++++++++++++++- web/src/useKbEvents.ts | 44 +++++++++++++++++---------- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/web/src/useKbEvents.test.ts b/web/src/useKbEvents.test.ts index 1eac7d45d..be5501ed2 100644 --- a/web/src/useKbEvents.test.ts +++ b/web/src/useKbEvents.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createInvalidationCoalescer } from "./useKbEvents"; +import { + createInvalidationCoalescer, + createKbInvalidations, + OVERVIEW_SETTLE_MS, + SETTLE_MS, +} from "./useKbEvents"; describe("a burst of events buys one refetch", () => { beforeEach(() => vi.useFakeTimers()); @@ -59,3 +64,56 @@ describe("a burst of events buys one refetch", () => { expect(seen).toEqual([["pending", "kb"]]); }); }); + +describe("the overview deadline and the fast keys", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const overview = ["graph", "kb", "overview"]; + const documents = ["documents", "kb"]; + + it("a fast key queued just before the deadline still waits out its own settle window", () => { + const seen: unknown[] = []; + const { push, pushGraph } = createKbInvalidations((key) => seen.push(key), "kb"); + pushGraph(); + vi.advanceTimersByTime(SETTLE_MS); + seen.length = 0; + vi.advanceTimersByTime(OVERVIEW_SETTLE_MS - SETTLE_MS - 10); + push(documents); + vi.advanceTimersByTime(10); + expect(seen).toEqual([overview]); + vi.advanceTimersByTime(SETTLE_MS - 10 - 1); + expect(seen).toEqual([overview]); + vi.advanceTimersByTime(1); + expect(seen).toEqual([overview, documents]); + }); + + it("the overview waits for its own deadline, not the fast window", () => { + const seen: unknown[] = []; + const { pushGraph } = createKbInvalidations((key) => seen.push(key), "kb"); + pushGraph(); + vi.advanceTimersByTime(SETTLE_MS); + expect(seen).toEqual([["graph", "kb", "neighborhood"]]); + vi.advanceTimersByTime(OVERVIEW_SETTLE_MS - SETTLE_MS); + expect(seen).toEqual([["graph", "kb", "neighborhood"], overview]); + }); + + it("a reconnect's broader graph key covers the queued overview", () => { + const seen: unknown[] = []; + const { push, pushGraph, flushAll } = createKbInvalidations((key) => seen.push(key), "kb"); + pushGraph(); + push(["graph", "kb"]); + flushAll(); + expect(seen).toEqual([["graph", "kb"]]); + }); + + it("flushing everything delivers both layers and leaves no timer behind", () => { + const seen: unknown[] = []; + const { pushGraph, flushAll } = createKbInvalidations((key) => seen.push(key), "kb"); + pushGraph(); + flushAll(); + expect(seen).toEqual([["graph", "kb", "neighborhood"], overview]); + vi.advanceTimersByTime(OVERVIEW_SETTLE_MS); + expect(seen).toEqual([["graph", "kb", "neighborhood"], overview]); + }); +}); diff --git a/web/src/useKbEvents.ts b/web/src/useKbEvents.ts index ea7c39fe9..04a9a1742 100644 --- a/web/src/useKbEvents.ts +++ b/web/src/useKbEvents.ts @@ -53,28 +53,42 @@ export function createInvalidationCoalescer( return { push, flush }; } +/** + * 一个库的两层合并:快的键按 `SETTLE_MS`,全图 overview 按 `OVERVIEW_SETTLE_MS`。 + * + * overview 到点只失效它自己(#1463):从前到点是 `push` 再 `flush`,快那层攒着的键 + * 跟着提前失效,没等够自己的静默期。只有 `flushAll` 才经快的那层走:重连补上的 + * `["graph", kbId]` 已覆盖 overview,不再多取一遍。 + */ +export function createKbInvalidations(invalidate: (key: QueryKey) => void, kbId: string) { + const { push, flush } = createInvalidationCoalescer(invalidate, SETTLE_MS); + let flushing = false; + const overview = createInvalidationCoalescer((key) => { + if (flushing) push(key); + else invalidate(key); + }, OVERVIEW_SETTLE_MS); + const pushGraph = () => { + push(["graph", kbId, "neighborhood"]); + overview.push(["graph", kbId, "overview"]); + }; + const flushAll = () => { + flushing = true; + overview.flush(); + flushing = false; + flush(); + }; + return { push, pushGraph, flushAll }; +} + export function useKbEvents(kbId: string | undefined) { const queryClient = useQueryClient(); useEffect(() => { if (!kbId) return; - const { push, flush } = createInvalidationCoalescer( + const { push, pushGraph, flushAll } = createKbInvalidations( (key) => queryClient.invalidateQueries({ queryKey: key }), - SETTLE_MS, + kbId, ); - // overview 到点也经快的那层刷:重连补上的 ["graph", kbId] 已覆盖它,不再多取一遍 - const overview = createInvalidationCoalescer((key) => { - push(key); - flush(); - }, OVERVIEW_SETTLE_MS); - const pushGraph = () => { - push(["graph", kbId, "neighborhood"]); - overview.push(["graph", kbId, "overview"]); - }; - const flushAll = () => { - overview.flush(); - flush(); - }; const unsubscribe = subscribeEvents( `/api/v1/kbs/${kbId}/events`,