milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import type { EventSourceLike } from "./useLiveQueries";
|
||||
import { EVENT_SOURCE_CLOSED, type EventSourceLike } from "./useLiveQueries";
|
||||
|
||||
const CONNECTING = 0;
|
||||
const OPEN = 1;
|
||||
|
||||
/** Test double for the injected EventSource constructor. */
|
||||
export class FakeEventSource implements EventSourceLike {
|
||||
readonly url: string;
|
||||
closed = false;
|
||||
readyState: number = CONNECTING;
|
||||
private listeners = new Map<string, Array<(event: { data?: unknown }) => void>>();
|
||||
|
||||
constructor(url: string) {
|
||||
@@ -18,9 +22,20 @@ export class FakeEventSource implements EventSourceLike {
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.readyState = EVENT_SOURCE_CLOSED;
|
||||
}
|
||||
|
||||
emit(type: string, event: { data?: unknown } = {}): void {
|
||||
if (type === "open") this.readyState = OPEN;
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-200 response: the browser closes the source, then dispatches one
|
||||
* error event and never retries.
|
||||
*/
|
||||
failFatal(): void {
|
||||
this.readyState = EVENT_SOURCE_CLOSED;
|
||||
this.emit("error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,47 @@ test("repeated errors without open hit the cap state; retry reconnects", () => {
|
||||
expect(hook.result.current.status).toBe("open");
|
||||
});
|
||||
|
||||
test("a fatal rejection caps on the first error event and probes the session", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
await act(async () => {});
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a fatal rejection with an expired session redirects to login", async () => {
|
||||
const assign = stubLocationAssign();
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||
const { sources } = setup(undefined, probeSession);
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a transient error leaves the source open and still takes three to cap", () => {
|
||||
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||
const { sources, hook } = setup(undefined, probeSession);
|
||||
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD - 1; i++) {
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("retrying");
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
expect(probeSession).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
act(() => sources[0]!.emit("error"));
|
||||
expect(hook.result.current.status).toBe("capped");
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a successful open resets the consecutive error count", () => {
|
||||
const { sources, hook } = setup();
|
||||
act(() => sources[0]!.emit("error"));
|
||||
|
||||
@@ -10,8 +10,12 @@ export type StreamStatus = "connecting" | "open" | "retrying" | "capped";
|
||||
export interface EventSourceLike {
|
||||
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void;
|
||||
close(): void;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/** `EventSource.CLOSED`: the browser gave up and will not retry. */
|
||||
export const EVENT_SOURCE_CLOSED = 2;
|
||||
|
||||
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||
|
||||
export interface LiveQueriesOptions {
|
||||
@@ -22,10 +26,11 @@ export interface LiveQueriesOptions {
|
||||
probeSession?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
// The SSE cap rejection (429) is invisible to EventSource beyond a bare
|
||||
// `error` event; this many consecutive errors without an intervening `open`
|
||||
// (the browser retries every 3s per the server's `retry: 3000`) stops the
|
||||
// stream and surfaces a manual-retry state.
|
||||
// A transient drop is invisible to EventSource beyond a bare `error` event;
|
||||
// this many consecutive errors without an intervening `open` (the browser
|
||||
// retries every 3s per the server's `retry: 3000`) stops the stream and
|
||||
// surfaces a manual-retry state. A non-200 response instead fails the source
|
||||
// permanently after one error event, and is handled by readyState below.
|
||||
export const CAP_ERROR_THRESHOLD = 3;
|
||||
|
||||
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
||||
@@ -121,16 +126,27 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||
setRows(bufferRef.current);
|
||||
});
|
||||
|
||||
const giveUp = () => {
|
||||
es.close();
|
||||
setStatus("capped");
|
||||
// EventSource cannot surface a 401; an expired session looks
|
||||
// identical to the cap. Probe once on entering capped so the
|
||||
// user lands on login instead of a misleading capped message.
|
||||
probeSession().catch(handleUnauthorized);
|
||||
};
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
if (esRef.current !== es) return;
|
||||
// A 429 or 401 closes the source outright — no retry follows, so
|
||||
// the consecutive-error counter would never reach its threshold.
|
||||
if (es.readyState === EVENT_SOURCE_CLOSED) {
|
||||
errorsRef.current = CAP_ERROR_THRESHOLD;
|
||||
giveUp();
|
||||
return;
|
||||
}
|
||||
errorsRef.current += 1;
|
||||
if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
|
||||
es.close();
|
||||
setStatus("capped");
|
||||
// EventSource cannot surface a 401; an expired session looks
|
||||
// identical to the cap. Probe once on entering capped so the
|
||||
// user lands on login instead of a misleading capped message.
|
||||
probeSession().catch(handleUnauthorized);
|
||||
giveUp();
|
||||
} else {
|
||||
setStatus("retrying");
|
||||
}
|
||||
|
||||
@@ -63,11 +63,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = createQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryLogPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||
@@ -222,6 +228,47 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
// The newest-100 window moves up while the reader has a second page open.
|
||||
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||
// of the table; the second page must be replayed from the fresh cursor.
|
||||
const before: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(20, "n20.example"), row(19, "n19.example")], next_before: 19 },
|
||||
"/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null },
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": { queries: [row(22, "n22.example"), row(21, "n21.example")], next_before: 21 },
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const payload = live[String(input)];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = renderPage();
|
||||
await screen.findByText("n20.example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("n17.example");
|
||||
|
||||
live = after;
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: ["queries"] });
|
||||
});
|
||||
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/queries", search: "", assign });
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import * as api from "@/lib/api";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesQuery } from "@/lib/queries";
|
||||
import { handleUnauthorized } from "@/lib/queryClient";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { qtypeName } from "./qtype";
|
||||
|
||||
@@ -12,6 +11,10 @@ const inputClass =
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
@@ -77,25 +80,20 @@ export default function QueryLogPage() {
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||
const [extra, setExtra] = useState<QueryRow[]>([]);
|
||||
const [cursorOverride, setCursorOverride] = useState<number | null | undefined>(undefined);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
const base = useQuery({ ...queriesQuery(applied), placeholderData: keepPreviousData });
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||
|
||||
const rows: QueryRow[] = [...(base.data?.queries ?? []), ...extra];
|
||||
const nextBefore = cursorOverride !== undefined ? cursorOverride : (base.data?.next_before ?? null);
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const filterActive = Object.keys(applied).length > 0;
|
||||
|
||||
function resetAccumulation() {
|
||||
generation.current += 1;
|
||||
setExtra([]);
|
||||
setCursorOverride(undefined);
|
||||
setLoadingMore(false);
|
||||
setMoreError(null);
|
||||
}
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -109,7 +107,6 @@ export default function QueryLogPage() {
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
resetAccumulation();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
@@ -119,32 +116,11 @@ export default function QueryLogPage() {
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
resetAccumulation();
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (nextBefore === null || loadingMore || base.isPlaceholderData) return;
|
||||
const startedGeneration = generation.current;
|
||||
setLoadingMore(true);
|
||||
setMoreError(null);
|
||||
api.getQueries({ ...applied, before: nextBefore })
|
||||
.then((page) => {
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setExtra((prev) => [...prev, ...page.queries]);
|
||||
setCursorOverride(page.next_before);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof api.ApiError && error.status === 401) {
|
||||
handleUnauthorized(error);
|
||||
return;
|
||||
}
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setMoreError(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setLoadingMore(false);
|
||||
});
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -236,16 +212,16 @@ export default function QueryLogPage() {
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<p className="text-sm text-zinc-500">
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{nextBefore === null ? " — end of log" : ""}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{nextBefore !== null && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore || base.isPlaceholderData}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
className={buttonClass}
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+15
-4
@@ -1,4 +1,4 @@
|
||||
import { queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||
import { infiniteQueryOptions, keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||
import * as api from "@/lib/api";
|
||||
import type {
|
||||
BlocklistInput,
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PausePost,
|
||||
Period,
|
||||
QueriesFilter,
|
||||
QueriesPage,
|
||||
RuleInput,
|
||||
SettingsPatch,
|
||||
UpstreamInput,
|
||||
@@ -20,7 +21,7 @@ export const queryKeys = {
|
||||
version: ["version"] as const,
|
||||
stats: (period: Period) => ["stats", period] as const,
|
||||
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||
queries: (filter: QueriesFilter) => ["queries", filter] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
upstreamHealth: ["upstream-health"] as const,
|
||||
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||
groups: ["groups"] as const,
|
||||
@@ -54,8 +55,18 @@ export const timeseriesQuery = (period: Period = "24h") =>
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const queriesQuery = (filter: QueriesFilter = {}) =>
|
||||
queryOptions({ queryKey: queryKeys.queries(filter), queryFn: () => api.getQueries(filter) });
|
||||
// Keyset pagination on `next_before` (handlers/queries.zig). A background
|
||||
// refetch replays every page in cursor order, so newly logged rows shift the
|
||||
// whole window instead of opening a gap between page 1 and page 2.
|
||||
export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: queryKeys.queriesInfinite(filter),
|
||||
queryFn: ({ pageParam }: { pageParam: number | undefined }) =>
|
||||
api.getQueries(pageParam === undefined ? filter : { ...filter, before: pageParam }),
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (last: QueriesPage) => last.next_before ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
export const upstreamHealthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import {
|
||||
groupsQuery,
|
||||
healthQuery,
|
||||
localRecordsQuery,
|
||||
queriesQuery,
|
||||
queriesInfiniteQuery,
|
||||
rulesQuery,
|
||||
settingsQuery,
|
||||
statsQuery,
|
||||
@@ -107,7 +107,7 @@ const dashboardRoute = createRoute({
|
||||
const queriesRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/queries",
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(queriesQuery({})),
|
||||
loader: ({ context }) => context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery({})),
|
||||
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user