Bound long-lived frontend history caches
Some checks failed
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Canonical Governance / governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Waiting to run
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Helm CI / Lint and Render Chart (push) Has been cancelled

This commit is contained in:
rcourtman 2026-07-23 23:49:31 +01:00
parent 952e593d74
commit c77571685d
8 changed files with 363 additions and 26 deletions

View file

@ -531,7 +531,9 @@ describe('Storage', () => {
const storageContentSurface = screen.getByTestId('storage-content-surface');
expect(await within(storageContentSurface).findByText(/\+40(?:\.0)? GB/)).toBeInTheDocument();
expect(within(storageContentSurface).getByText(/-20(?:\.0)? GB/)).toBeInTheDocument();
expect(storageSummarySpy).toHaveBeenCalledWith('24h', undefined, { nodeId: undefined });
expect(storageSummarySpy).toHaveBeenCalledWith('24h', expect.any(AbortSignal), {
nodeId: undefined,
});
storageSummarySpy.mockRestore();
});

View file

@ -1,18 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import {
createNonSuspendingQuery,
getCreateNonSuspendingQueryCacheDiagnosticsForTest,
resetCreateNonSuspendingQueryCacheForTest,
} from '@/hooks/createNonSuspendingQuery';
import { eventBus } from '@/stores/events';
afterEach(() => {
resetCreateNonSuspendingQueryCacheForTest();
cleanup();
vi.useRealTimers();
});
function QueryProbe(props: { cacheNamespace: string; fetcher: (key: string) => Promise<string> }) {
function QueryProbe(props: {
cacheNamespace: string;
fetcher: (key: string) => Promise<string>;
queryKey?: () => string;
}) {
const state = createNonSuspendingQuery<string, string>({
source: () => 'stable-key',
source: () => props.queryKey?.() ?? 'stable-key',
cacheKey: (key) => `${props.cacheNamespace}:${key}`,
fetcher: props.fetcher,
initialValue: 'initial',
@ -51,4 +59,96 @@ describe('createNonSuspendingQuery', () => {
expect(screen.getByTestId('query-probe').textContent).toContain('loading:false');
expect(screen.getByTestId('query-probe').textContent).not.toContain('initial');
});
it('evicts least-recently-used resource and range entries at the cache limit', async () => {
const cacheNamespace = `bounded-query-cache-${Date.now()}`;
const { maxEntries } = getCreateNonSuspendingQueryCacheDiagnosticsForTest();
const [queryKey, setQueryKey] = createSignal('resource-0:1h');
const fetcher = vi.fn(async (key: string) => `loaded:${key}`);
render(() => (
<QueryProbe cacheNamespace={cacheNamespace} fetcher={fetcher} queryKey={queryKey} />
));
for (let index = 0; index <= maxEntries; index += 1) {
const key = `resource-${index}:1h`;
setQueryKey(key);
await waitFor(() => {
expect(screen.getByTestId('query-probe').textContent).toContain(`loaded:${key}`);
});
}
const diagnostics = getCreateNonSuspendingQueryCacheDiagnosticsForTest();
expect(diagnostics.size).toBe(maxEntries);
expect(diagnostics.keys).not.toContain(`${cacheNamespace}:resource-0:1h`);
expect(diagnostics.keys).toContain(`${cacheNamespace}:resource-${maxEntries}:1h`);
});
it('drops retained values when the organization changes', async () => {
const cacheNamespace = `org-query-cache-${Date.now()}`;
const firstRender = render(() => (
<QueryProbe cacheNamespace={cacheNamespace} fetcher={async () => 'org-a-value'} />
));
await waitFor(() => {
expect(screen.getByTestId('query-probe').textContent).toContain('org-a-value');
});
firstRender.unmount();
expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(1);
eventBus.emit('org_switched', 'org-b');
render(() => (
<QueryProbe cacheNamespace={cacheNamespace} fetcher={() => new Promise<string>(() => {})} />
));
expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(0);
expect(screen.getByTestId('query-probe').textContent).toContain('initial');
expect(screen.getByTestId('query-probe').textContent).not.toContain('org-a-value');
});
it('does not repopulate the cache when an old-org request resolves late', async () => {
let resolveFetch: ((value: string) => void) | undefined;
render(() => (
<QueryProbe
cacheNamespace={`late-query-cache-${Date.now()}`}
fetcher={() =>
new Promise<string>((resolve) => {
resolveFetch = resolve;
})
}
/>
));
await waitFor(() => {
expect(resolveFetch).toBeTypeOf('function');
});
eventBus.emit('org_switched', 'org-b');
resolveFetch?.('late-org-a-value');
await waitFor(() => {
expect(screen.getByTestId('query-probe').textContent).toContain('initial');
});
expect(screen.getByTestId('query-probe').textContent).not.toContain('late-org-a-value');
expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(0);
});
it('expires inactive retained values after the cache age limit', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-23T12:00:00.000Z'));
const diagnostics = getCreateNonSuspendingQueryCacheDiagnosticsForTest();
render(() => (
<QueryProbe cacheNamespace="expiring-query-cache" fetcher={async () => 'short-lived-value'} />
));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(1);
vi.advanceTimersByTime(diagnostics.maxAgeMs);
expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(0);
});
});

View file

@ -1,4 +1,5 @@
import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
import { eventBus } from '@/stores/events';
interface CreateNonSuspendingQueryOptions<T, K> {
source: Accessor<K | null>;
@ -15,14 +16,130 @@ interface QueryRunOptions {
/**
* Keep query-backed surfaces out of the app-level Suspense boundary by
* retaining the last fulfilled value while the next request is in flight.
*
* This cache is shared across route/drawer remounts, so it needs an explicit
* ownership boundary. In particular, history responses can be large and their
* keys contain resource IDs and time ranges that continually change during a
* long-lived browser session.
*/
const retainedQueryCache = new Map<
string,
{ error: unknown; resolvedOnce: boolean; value: unknown }
>();
const RETAINED_QUERY_CACHE_MAX_ENTRIES = 64;
const RETAINED_QUERY_CACHE_MAX_AGE_MS = 5 * 60_000;
type RetainedQueryCacheEntry = {
cachedAt: number;
error: unknown;
resolvedOnce: boolean;
value: unknown;
};
const retainedQueryCache = new Map<string, RetainedQueryCacheEntry>();
let retainedQueryCacheGeneration = 0;
let retainedQueryCacheExpiryTimer: ReturnType<typeof setTimeout> | null = null;
const clearRetainedQueryCacheExpiryTimer = () => {
if (retainedQueryCacheExpiryTimer !== null) {
clearTimeout(retainedQueryCacheExpiryTimer);
retainedQueryCacheExpiryTimer = null;
}
};
const pruneExpiredRetainedQueryEntries = (now = Date.now()) => {
for (const [key, entry] of retainedQueryCache) {
if (now - entry.cachedAt >= RETAINED_QUERY_CACHE_MAX_AGE_MS) {
retainedQueryCache.delete(key);
}
}
};
const scheduleRetainedQueryCacheExpiry = () => {
clearRetainedQueryCacheExpiryTimer();
if (retainedQueryCache.size === 0) {
return;
}
let nextExpiryAt = Number.POSITIVE_INFINITY;
for (const entry of retainedQueryCache.values()) {
nextExpiryAt = Math.min(nextExpiryAt, entry.cachedAt + RETAINED_QUERY_CACHE_MAX_AGE_MS);
}
retainedQueryCacheExpiryTimer = setTimeout(
() => {
retainedQueryCacheExpiryTimer = null;
pruneExpiredRetainedQueryEntries();
scheduleRetainedQueryCacheExpiry();
},
Math.max(0, nextExpiryAt - Date.now()),
);
};
const clearRetainedQueryCache = () => {
retainedQueryCacheGeneration += 1;
retainedQueryCache.clear();
clearRetainedQueryCacheExpiryTimer();
};
const readRetainedQueryCacheEntry = (key: string): RetainedQueryCacheEntry | null => {
const cached = retainedQueryCache.get(key);
if (!cached) {
return null;
}
if (Date.now() - cached.cachedAt >= RETAINED_QUERY_CACHE_MAX_AGE_MS) {
retainedQueryCache.delete(key);
scheduleRetainedQueryCacheExpiry();
return null;
}
// Map insertion order is the LRU order. A cache hit keeps a remounted
// surface's value ahead of older, inactive resource/range combinations.
retainedQueryCache.delete(key);
retainedQueryCache.set(key, cached);
return cached;
};
const writeRetainedQueryCacheEntry = (
key: string,
value: Omit<RetainedQueryCacheEntry, 'cachedAt'>,
) => {
pruneExpiredRetainedQueryEntries();
retainedQueryCache.delete(key);
retainedQueryCache.set(key, {
...value,
cachedAt: Date.now(),
});
while (retainedQueryCache.size > RETAINED_QUERY_CACHE_MAX_ENTRIES) {
const oldestKey = retainedQueryCache.keys().next().value;
if (oldestKey === undefined) {
break;
}
retainedQueryCache.delete(oldestKey);
}
scheduleRetainedQueryCacheExpiry();
};
export function resetCreateNonSuspendingQueryCacheForTest() {
retainedQueryCache.clear();
clearRetainedQueryCache();
}
export function getCreateNonSuspendingQueryCacheDiagnosticsForTest() {
pruneExpiredRetainedQueryEntries();
return {
keys: Array.from(retainedQueryCache.keys()),
maxAgeMs: RETAINED_QUERY_CACHE_MAX_AGE_MS,
maxEntries: RETAINED_QUERY_CACHE_MAX_ENTRIES,
size: retainedQueryCache.size,
};
}
const unsubscribeRetainedQueryOrgSwitch = eventBus.on('org_switched', () => {
clearRetainedQueryCache();
});
if (import.meta.hot) {
import.meta.hot.dispose(() => {
unsubscribeRetainedQueryOrgSwitch();
clearRetainedQueryCache();
});
}
export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQueryOptions<T, K>) {
@ -38,7 +155,7 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
if (!cacheKey) {
return null;
}
const cached = retainedQueryCache.get(cacheKey);
const cached = readRetainedQueryCacheEntry(cacheKey);
if (!cached) {
return null;
}
@ -69,9 +186,15 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
return options.initialValue;
};
const unsubscribeLiveQueryOrgSwitch = eventBus.on('org_switched', () => {
reset();
});
onCleanup(unsubscribeLiveQueryOrgSwitch);
const run = async (key: K, runOptions: QueryRunOptions = {}): Promise<T> => {
const requestId = ++latestRequestId;
const retainedCacheKey = getRetainedCacheKey(key);
const cacheGeneration = retainedQueryCacheGeneration;
if (!runOptions.background) {
setLoading(true);
}
@ -92,8 +215,8 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
} finally {
if (requestId === latestRequestId) {
setResolvedOnce(true);
if (retainedCacheKey) {
retainedQueryCache.set(retainedCacheKey, {
if (retainedCacheKey && cacheGeneration === retainedQueryCacheGeneration) {
writeRetainedQueryCacheEntry(retainedCacheKey, {
error: error(),
resolvedOnce: true,
value: value(),

View file

@ -66,7 +66,7 @@ describe('storageSummaryCache', () => {
expect(result).toBe(response);
expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(1);
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('1h', undefined, {
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('1h', expect.any(AbortSignal), {
nodeId: 'node-1',
});
expect(readStorageSummaryCache('1h', 'node-1')).toBe(response);
@ -77,7 +77,7 @@ describe('storageSummaryCache', () => {
await fetchStorageSummaryAndCache('24h');
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('24h', undefined, {
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('24h', expect.any(AbortSignal), {
nodeId: undefined,
});
});
@ -203,5 +203,43 @@ describe('storageSummaryCache', () => {
expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2);
expect(readStorageSummaryCache('1h')).toBe(second);
});
it('aborts an in-flight summary request when the organization changes', async () => {
let signal: AbortSignal | undefined;
mockGetStorageSummaryCharts.mockImplementationOnce(
(_range: TimeRange, requestSignal: AbortSignal) => {
signal = requestSignal;
return new Promise<StorageSummaryChartsResponse>((_resolve, reject) => {
requestSignal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
});
},
);
const request = fetchStorageSummaryAndCache('1h');
expect(signal?.aborted).toBe(false);
eventBus.emit('org_switched', 'org-b');
expect(signal?.aborted).toBe(true);
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
expect(readStorageSummaryCache('1h')).toBeNull();
});
it('keeps only the 20 most recently used node and range summaries', async () => {
mockGetStorageSummaryCharts.mockImplementation(
(_range: TimeRange, _signal: AbortSignal, options?: { nodeId?: string }) =>
Promise.resolve(makeResponse(Number(options?.nodeId?.split('-').at(-1)) || 1)),
);
for (let index = 0; index <= 20; index += 1) {
await fetchStorageSummaryAndCache('1h', { nodeId: `node-${index}` });
}
expect(readStorageSummaryCache('1h', 'node-0')).toBeNull();
expect(readStorageSummaryCache('1h', 'node-1')).not.toBeNull();
expect(readStorageSummaryCache('1h', 'node-20')).not.toBeNull();
});
});
});

View file

@ -4,6 +4,7 @@ import { getOrgID } from '@/utils/apiClient';
import { normalizeOrgScope } from '@/utils/orgScope';
const STORAGE_SUMMARY_CACHE_VERSION = 1;
const MAX_IN_MEMORY_STORAGE_SUMMARIES = 20;
function cacheKeyFor(
range: TimeRange,
@ -14,13 +15,51 @@ function cacheKeyFor(
}
const inMemoryCache = new Map<string, StorageSummaryChartsResponse>();
const inFlightFetches = new Map<string, Promise<StorageSummaryChartsResponse>>();
const inFlightFetches = new Map<
string,
{
controller: AbortController;
promise: Promise<StorageSummaryChartsResponse>;
}
>();
let storageSummaryCacheGeneration = 0;
const readInMemoryStorageSummary = (key: string): StorageSummaryChartsResponse | null => {
const cached = inMemoryCache.get(key);
if (!cached) {
return null;
}
inMemoryCache.delete(key);
inMemoryCache.set(key, cached);
return cached;
};
const writeInMemoryStorageSummary = (key: string, response: StorageSummaryChartsResponse): void => {
inMemoryCache.delete(key);
inMemoryCache.set(key, response);
while (inMemoryCache.size > MAX_IN_MEMORY_STORAGE_SUMMARIES) {
const oldestKey = inMemoryCache.keys().next().value;
if (oldestKey === undefined) {
break;
}
inMemoryCache.delete(oldestKey);
}
};
const clearStorageSummaryCache = () => {
storageSummaryCacheGeneration += 1;
inMemoryCache.clear();
for (const entry of inFlightFetches.values()) {
entry.controller.abort();
}
inFlightFetches.clear();
};
export function readStorageSummaryCache(
range: TimeRange,
nodeId?: string,
): StorageSummaryChartsResponse | null {
return inMemoryCache.get(cacheKeyFor(range, nodeId)) ?? null;
return readInMemoryStorageSummary(cacheKeyFor(range, nodeId));
}
export function fetchStorageSummaryAndCache(
@ -30,31 +69,38 @@ export function fetchStorageSummaryAndCache(
const inFlightKey = cacheKeyFor(range, options?.nodeId);
const existing = inFlightFetches.get(inFlightKey);
if (existing) {
return existing;
return existing.promise;
}
const request = ChartsAPI.getStorageSummaryCharts(range, undefined, {
const controller = new AbortController();
const cacheGeneration = storageSummaryCacheGeneration;
const request = ChartsAPI.getStorageSummaryCharts(range, controller.signal, {
nodeId: options?.nodeId,
})
.then((response) => {
inMemoryCache.set(inFlightKey, response);
if (cacheGeneration === storageSummaryCacheGeneration) {
writeInMemoryStorageSummary(inFlightKey, response);
}
return response;
})
.finally(() => {
inFlightFetches.delete(inFlightKey);
const inFlight = inFlightFetches.get(inFlightKey);
if (inFlight?.controller === controller) {
inFlightFetches.delete(inFlightKey);
}
});
inFlightFetches.set(inFlightKey, request);
inFlightFetches.set(inFlightKey, { controller, promise: request });
return request;
}
const unsubscribeStorageOrgSwitch = eventBus.on('org_switched', () => {
inMemoryCache.clear();
inFlightFetches.clear();
clearStorageSummaryCache();
});
if (import.meta.hot) {
import.meta.hot.dispose(() => {
unsubscribeStorageOrgSwitch();
clearStorageSummaryCache();
});
}