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
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as api from '@/lib/api';
import {
__resetStackStatusesFetchForTests,
clearStackStatusesFetch,
fetchStackStatusesShared,
} from '@/lib/stackStatusesFetch';
function jsonResponse(body: unknown, init: { status?: number; proxied?: boolean } = {}): Response {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (init.proxied) headers.set('x-sencho-proxy', '1');
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers,
});
}
function deferredResponse(): {
promise: Promise<Response>;
release: (res: Response) => void;
} {
let release!: (res: Response) => void;
const promise = new Promise<Response>((resolve) => {
release = resolve;
});
return { promise, release };
}
describe('fetchStackStatusesShared', () => {
let apiFetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
__resetStackStatusesFetchForTests();
apiFetchSpy = vi.spyOn(api, 'apiFetch');
});
afterEach(() => {
__resetStackStatusesFetchForTests();
apiFetchSpy.mockRestore();
});
it('coalesces concurrent callers for the same nodeId into one apiFetch', async () => {
const gate = deferredResponse();
apiFetchSpy.mockReturnValueOnce(gate.promise);
const a = fetchStackStatusesShared(1);
const b = fetchStackStatusesShared(1);
expect(apiFetchSpy).toHaveBeenCalledTimes(1);
expect(apiFetchSpy).toHaveBeenCalledWith('/stacks/statuses', { nodeId: 1 });
gate.release(jsonResponse({ 'demo.yml': { status: 'running' } }));
const [ra, rb] = await Promise.all([a, b]);
expect(ra.coalesced).toBe(false);
expect(rb.coalesced).toBe(true);
expect(ra.body).toEqual(rb.body);
expect(ra.ok).toBe(true);
});
it('issues a second fetch when the second caller starts after the first resolves', async () => {
apiFetchSpy
.mockResolvedValueOnce(jsonResponse({ a: { status: 'running' } }))
.mockResolvedValueOnce(jsonResponse({ b: { status: 'exited' } }));
const first = await fetchStackStatusesShared(1);
const second = await fetchStackStatusesShared(1);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
expect(first.coalesced).toBe(false);
expect(second.coalesced).toBe(false);
expect(first.body).not.toEqual(second.body);
});
it('does not share across different nodeIds', async () => {
apiFetchSpy
.mockResolvedValueOnce(jsonResponse({ local: { status: 'running' } }))
.mockResolvedValueOnce(jsonResponse({ remote: { status: 'running' } }));
const [a, b] = await Promise.all([
fetchStackStatusesShared(1),
fetchStackStatusesShared(2),
]);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
expect(a.coalesced).toBe(false);
expect(b.coalesced).toBe(false);
});
it('forwards explicit null as the local key and apiFetch nodeId', async () => {
apiFetchSpy.mockResolvedValueOnce(jsonResponse({}));
await fetchStackStatusesShared(null);
expect(apiFetchSpy).toHaveBeenCalledWith('/stacks/statuses', { nodeId: null });
});
it('ignores localStorage divergence when an explicit nodeId is passed', async () => {
localStorage.setItem('sencho-active-node', '99');
const gate = deferredResponse();
apiFetchSpy.mockReturnValueOnce(gate.promise);
const a = fetchStackStatusesShared(3);
const b = fetchStackStatusesShared(3);
expect(apiFetchSpy).toHaveBeenCalledTimes(1);
expect(apiFetchSpy).toHaveBeenCalledWith('/stacks/statuses', { nodeId: 3 });
gate.release(jsonResponse({}));
await Promise.all([a, b]);
localStorage.removeItem('sencho-active-node');
});
it('clears on sencho-unauthorized so the next caller issues a fresh fetch', async () => {
const gate = deferredResponse();
apiFetchSpy
.mockReturnValueOnce(gate.promise)
.mockResolvedValueOnce(jsonResponse({ after: { status: 'exited' } }));
const pending = fetchStackStatusesShared(1);
window.dispatchEvent(new Event('sencho-unauthorized'));
gate.release(jsonResponse({ before: { status: 'running' } }));
await pending;
const next = await fetchStackStatusesShared(1);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
expect(next.coalesced).toBe(false);
expect((next.body as Record<string, unknown>).after).toBeTruthy();
});
it('clearStackStatusesFetch clears without requiring the window event', async () => {
const gate = deferredResponse();
apiFetchSpy
.mockReturnValueOnce(gate.promise)
.mockResolvedValueOnce(jsonResponse({}));
const pending = fetchStackStatusesShared(1);
clearStackStatusesFetch();
gate.release(jsonResponse({ stale: true }));
await pending;
await fetchStackStatusesShared(1);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
});
it('stale settlement after clear does not delete a newer in-flight entry', async () => {
const staleGate = deferredResponse();
const freshGate = deferredResponse();
apiFetchSpy
.mockReturnValueOnce(staleGate.promise)
.mockReturnValueOnce(freshGate.promise);
const stale = fetchStackStatusesShared(1);
clearStackStatusesFetch();
const fresh = fetchStackStatusesShared(1);
const joined = fetchStackStatusesShared(1);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
staleGate.release(jsonResponse({ stale: true }));
await stale;
// Fresh slot must still be joinable after the stale owner settles.
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
freshGate.release(jsonResponse({ fresh: { status: 'running' } }));
const [freshResult, joinedResult] = await Promise.all([fresh, joined]);
expect(freshResult.coalesced).toBe(false);
expect(joinedResult.coalesced).toBe(true);
expect(freshResult.body).toEqual(joinedResult.body);
});
it('does not retain a rejected promise for later callers', async () => {
apiFetchSpy
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(jsonResponse({ ok: { status: 'running' } }));
await expect(fetchStackStatusesShared(1)).rejects.toThrow('network down');
const recovered = await fetchStackStatusesShared(1);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
expect(recovered.ok).toBe(true);
});
it('propagates JSON decode failures to both waiters and clears the slot', async () => {
const bad = new Response('not-json', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
apiFetchSpy
.mockResolvedValueOnce(bad)
.mockResolvedValueOnce(jsonResponse({}));
const a = fetchStackStatusesShared(1);
const b = fetchStackStatusesShared(1);
await expect(a).rejects.toThrow();
await expect(b).rejects.toThrow();
const recovered = await fetchStackStatusesShared(1);
expect(recovered.ok).toBe(true);
expect(apiFetchSpy).toHaveBeenCalledTimes(2);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { apiFetch } from '@/lib/api';
/**
* Raw transport result for GET /stacks/statuses. Callers keep all
* interpretation (sanitization, legacy-format fallback, error copy).
*/
export type StackStatusesFetchResult = {
ok: boolean;
status: number;
proxied: boolean;
body: unknown;
/** True when this waiter joined an in-flight promise owned by another caller. */
coalesced: boolean;
};
type NodeKey = string;
type InflightEntry = {
/** Monotonic id so settled owners clear only their own map slot. */
id: number;
promise: Promise<StackStatusesFetchResult>;
};
const inflight = new Map<NodeKey, InflightEntry>();
let nextInflightId = 0;
function nodeKey(nodeId: number | null): NodeKey {
return nodeId === null ? 'local' : String(nodeId);
}
async function requestStackStatuses(
nodeId: number | null,
): Promise<StackStatusesFetchResult> {
const res = await apiFetch('/stacks/statuses', { nodeId });
const proxied = res.headers.get('x-sencho-proxy') === '1';
let body: unknown = null;
if (res.ok) {
body = await res.json();
}
return {
ok: res.ok,
status: res.status,
proxied,
body,
coalesced: false,
};
}
/** Drop every in-flight join so a prior auth session cannot share results. */
export function clearStackStatusesFetch(): void {
inflight.clear();
}
if (typeof window !== 'undefined') {
window.addEventListener('sencho-unauthorized', clearStackStatusesFetch);
}
/**
* Coalesce concurrent GET /stacks/statuses for the same node/auth context.
* Always forwards an explicit nodeId to apiFetch so the key and request target
* cannot diverge from localStorage during a node switch.
*/
export async function fetchStackStatusesShared(
nodeId: number | null,
): Promise<StackStatusesFetchResult> {
const key = nodeKey(nodeId);
const existing = inflight.get(key);
if (existing) {
const shared = await existing.promise;
return { ...shared, coalesced: true };
}
const id = ++nextInflightId;
const promise = requestStackStatuses(nodeId);
inflight.set(key, { id, promise });
try {
return await promise;
} finally {
// Clear only if this owner still holds the slot. A logout clear followed by
// a new fetch must not be deleted by a stale settlement.
if (inflight.get(key)?.id === id) {
inflight.delete(key);
}
}
}
/** Test-only: reset module state between vitest cases. */
export function __resetStackStatusesFetchForTests(): void {
clearStackStatusesFetch();
}