milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+16 -1
View File
@@ -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"));
+26 -10
View File
@@ -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");
}
+48 -1
View File
@@ -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 });
+24 -48
View File
@@ -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
View File
@@ -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
View File
@@ -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")),
});