perf(frontend): coalesce concurrent stack statuses fetches (#1816)

* perf(frontend): coalesce concurrent stack statuses fetches

Sidebar and dashboard both requested GET /stacks/statuses for the same
node; share one in-flight promise keyed by explicit nodeId so boot and
invalidate overlap stop doubling the hot path.

* fix(frontend): clear statuses coalescer by entry id

Avoid Promise identity checks that CodeQL flags as missing await.
Numeric entry ids still protect a newer in-flight fetch after logout clear.
This commit is contained in:
Anso
2026-08-10 14:57:17 -04:00
committed by GitHub
parent 27fe0ae837
commit 017ac57654
8 changed files with 375 additions and 47 deletions
@@ -30,6 +30,7 @@ vi.mock('@/lib/utils', async () => {
});
import { useDashboardData } from '../useDashboardData';
import { __resetStackStatusesFetchForTests } from '@/lib/stackStatusesFetch';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
@@ -91,6 +92,7 @@ async function tickSys(): Promise<void> {
beforeEach(() => {
pollCallbacks = [];
__resetStackStatusesFetchForTests();
apiFetchMock.mockReset();
useNodesMock.mockReset();
useNodesMock.mockReturnValue({
@@ -100,6 +102,7 @@ beforeEach(() => {
});
afterEach(() => {
__resetStackStatusesFetchForTests();
vi.clearAllMocks();
});
@@ -28,6 +28,7 @@ vi.mock('@/lib/utils', async () => {
});
import { useDashboardData } from '../useDashboardData';
import { __resetStackStatusesFetchForTests } from '@/lib/stackStatusesFetch';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
@@ -49,6 +50,7 @@ const SYS_PAYLOAD = {
beforeEach(() => {
vi.useFakeTimers();
__resetStackStatusesFetchForTests();
apiFetchMock.mockReset();
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
@@ -65,6 +67,7 @@ beforeEach(() => {
});
afterEach(() => {
__resetStackStatusesFetchForTests();
vi.useRealTimers();
});
@@ -169,7 +172,7 @@ describe('useDashboardData stackStatuses load states', () => {
expect(result.current.stackStatusesLoadStatus).toBe('success');
});
it('ignores an older soft success after a newer foreground retry', async () => {
it('joins a foreground retry onto an in-flight soft statuses fetch', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
@@ -200,27 +203,20 @@ describe('useDashboardData stackStatuses load states', () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
// Soft and foreground share one in-flight /stacks/statuses.
expect(resolvers).toHaveLength(2);
const softMap = { 'old.yml': { status: 'exited' as const } };
const retryMap = { 'web.yml': { status: 'running' as const } };
const sharedMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[1](okJson(softMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual({});
await act(async () => {
resolvers[2](okJson(retryMap));
resolvers[1](okJson(sharedMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
expect(result.current.stackStatuses).toEqual(sharedMap);
});
it('ignores an older soft failure after a newer foreground retry success', async () => {
it('lets the newer foreground generation commit a shared in-flight failure', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
@@ -237,7 +233,7 @@ describe('useDashboardData stackStatuses load states', () => {
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
resolvers[0](okJson({ 'web.yml': { status: 'running' } }));
await Promise.resolve();
await Promise.resolve();
});
@@ -250,23 +246,16 @@ describe('useDashboardData stackStatuses load states', () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual(retryMap);
expect(resolvers).toHaveLength(2);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
// Soft's older generation is ignored; the foreground generation commits
// the shared failure as an error status (rows are not cleared on failure).
expect(result.current.stackStatusesLoadStatus).toBe('error');
});
it('lets a slow foreground statuses response commit after soft poll and invalidate ticks', async () => {
@@ -382,4 +371,30 @@ describe('useDashboardData stackStatuses load states', () => {
expect(result.current.stackStatusesLoadStatus).toBe('error');
expect(result.current.stackStatuses).toEqual({});
});
it('does not fetch statuses while activeNode is unresolved and stays loading', async () => {
useNodesMock.mockReturnValue({
activeNode: undefined,
nodes: [],
});
const { result, rerender } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(countFetchCalls('/stacks/statuses')).toBe(0);
expect(result.current.stackStatusesLoadStatus).toBe('loading');
expect(result.current.stackStatuses).toEqual({});
useNodesMock.mockReturnValue({
activeNode: { id: 7, name: 'Remote', type: 'remote' },
nodes: [{ id: 7, name: 'Remote', type: 'remote' }],
});
rerender();
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(countFetchCalls('/stacks/statuses')).toBe(1);
expect(apiFetchMock.mock.calls.some(
(call) => call[0] === '/stacks/statuses' && call[1]?.nodeId === 7,
)).toBe(true);
});
});
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNodes } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
import { fetchStackStatusesShared } from '@/lib/stackStatusesFetch';
import { visibilityInterval } from '@/lib/utils';
import type {
Stats,
@@ -270,6 +271,10 @@ export function useDashboardData(): DashboardData {
currentNodeId: number | undefined,
mode: 'foreground' | 'soft',
) => {
// Skip until activeNode resolves. Passing null/"local" here would fetch the
// hub while a remembered remote node is still loading and commit under an
// unresolved guard.
if (currentNodeId === undefined) return;
if (nodeIdRef.current !== currentNodeId) return;
if (mode === 'soft' && stackStatusesInFlightRef.current) return;
const generation = ++stackStatusesFetchGenRef.current;
@@ -279,19 +284,18 @@ export function useDashboardData(): DashboardData {
setStackStatusesLoadError(null);
}
try {
const res = await apiFetch('/stacks/statuses');
const result = await fetchStackStatusesShared(currentNodeId);
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
if (!res.ok) {
if (!result.ok) {
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
`Could not load stack health (${res.status}).`,
`Could not load stack health (${result.status}).`,
);
return;
}
const body: unknown = await res.json();
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
const body = result.body;
if (body && typeof body === 'object' && !Array.isArray(body)) {
// Drop any entry isValidStatusEntry rejects rather than trusting the
// whole map: one bad entry must not crash or misrepresent the rest of
@@ -352,6 +356,9 @@ export function useDashboardData(): DashboardData {
setStackStatusesLoadStatus('loading');
setStackStatusesLoadError(null);
const currentNodeId = nodeId;
// Stay in loading while NodeContext has not resolved activeNode; do not
// fetch against an unknown target (and do not treat that window as empty).
if (currentNodeId === undefined) return;
void fetchStackStatuses(currentNodeId, 'foreground');
const cleanup = visibilityInterval(() => {
void fetchStackStatuses(currentNodeId, 'soft');
@@ -370,6 +377,7 @@ export function useDashboardData(): DashboardData {
// refresh instead of one HTTP request per event.
useEffect(() => {
const currentNodeId = nodeId;
if (currentNodeId === undefined) return;
let active = true;
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
const refresh = async () => {