Bound long-lived frontend history caches

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
@@ -141,6 +141,7 @@ work extends shared components instead of creating new local variants.
109. `frontend-modern/src/components/shared/summaryInteractionA11y.ts`
110. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`
111. `frontend-modern/src/hooks/createNonSuspendingQuery.ts`
111a. `frontend-modern/src/utils/storageSummaryCache.ts`
112. `frontend-modern/src/components/shared/TableCardHeader.tsx`
113. `frontend-modern/src/components/shared/UpgradeLink.tsx`
114. `frontend-modern/src/components/shared/useUpgradeNavigation.ts`
@@ -2748,6 +2749,15 @@ surface instead of falling through the app-level `Loading view...` fallback.
Feature slices such as recovery and infrastructure drawers may consume that
helper, but they must not fork new suspense-escape helpers once the shared
contract exists.
That retained-value boundary is explicitly bounded for long-lived browser
sessions. Fulfilled resource/range queries use a 64-entry LRU with a five-minute
inactive lifetime, clear on organization changes, and reject late old-scope
completions from the shared cache. Large storage summary history follows the
same ownership rule through `frontend-modern/src/utils/storageSummaryCache.ts`:
it keeps at most 20 recent node/range summaries and aborts in-flight requests
when organization ownership changes. Drawer navigation, chart range churn,
background tabs, reconnects, and server restarts must not turn either cache
into an append-only browser history.
The settings reporting shell now also owns a deliberate split between
historical performance reports and current-state VM inventory export.
`frontend-modern/src/components/Settings/ReportingPanel.tsx`,
@@ -4610,6 +4610,7 @@
"frontend-modern/src/utils/sourcePlatformOptions.ts",
"frontend-modern/src/utils/sourcePlatforms.ts",
"frontend-modern/src/utils/ssoProviderPresentation.ts",
"frontend-modern/src/utils/storageSummaryCache.ts",
"frontend-modern/src/utils/systemLogsPresentation.ts",
"frontend-modern/src/utils/systemSettingsPresentation.ts",
"frontend-modern/src/utils/textPresentation.ts",
@@ -5035,15 +5036,18 @@
},
{
"id": "retained-value-query-helper",
"label": "retained-value query helper proof",
"label": "retained-value and storage history cache proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/hooks/createNonSuspendingQuery.ts"
"frontend-modern/src/hooks/createNonSuspendingQuery.ts",
"frontend-modern/src/utils/storageSummaryCache.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx"
"frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx",
"frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx",
"frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts"
]
},
{
@@ -1803,7 +1803,11 @@ must not treat starter
bounded in-memory cache for same-tab remounts, but its cache key must carry
an explicit summary contract version so long-lived demo sessions do not
rehydrate stale pool or disk sparkline shapes after the storage summary
chart model changes.
chart model changes. The shared storage history owner at
`frontend-modern/src/utils/storageSummaryCache.ts` must likewise remain
bounded to 20 recent node/range summaries, use LRU replacement, and abort
in-flight requests when organization ownership changes so a late response
cannot recreate an old-scope cache entry.
19. Keep cross-surface workload handoffs on canonical IDs too. Shared workload
chart transport may look up provider-backed VM history through unified
metrics targets, but infrastructure/workloads/storage/recovery navigation
@@ -1897,6 +1901,16 @@ that Safe auto-fix or Autopilot remediation is verified.
## Current State
### Storage history remount state is bounded
Storage summary history no longer treats every node/range combination visited
by an always-open tab as permanent browser state. The shared cache keeps 20
recent summaries, refreshes LRU order on reuse, and cancels outstanding chart
requests during an organization switch. This cache is separate from backend
metrics-history retention and recovery-ingest ownership: increasing backup
polling or server-side recovery enumeration must not expand the browser cache
outside the fixed remount budget.
### vSphere member composition is inventory, not protection evidence
ESXi host members projected under a vCenter connection in the grouped
@@ -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();
});
@@ -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);
});
});
@@ -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(),
@@ -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();
});
});
});
@@ -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();
});
}