diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8b37705180..462d3492ab 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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`, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index a7c55a7364..5a98a72927 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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" ] }, { diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 09e42426c9..e7e1c2e820 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index 22b86beac4..2db164f9ca 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -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(); }); diff --git a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx index 3fa8d233e8..76c01a6da5 100644 --- a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx +++ b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx @@ -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 }) { +function QueryProbe(props: { + cacheNamespace: string; + fetcher: (key: string) => Promise; + queryKey?: () => string; +}) { const state = createNonSuspendingQuery({ - 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(() => ( + + )); + + 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(() => ( + '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(() => ( + new Promise(() => {})} /> + )); + + 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(() => ( + + new Promise((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(() => ( + '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); + }); }); diff --git a/frontend-modern/src/hooks/createNonSuspendingQuery.ts b/frontend-modern/src/hooks/createNonSuspendingQuery.ts index c34f8791bc..b85b4e418b 100644 --- a/frontend-modern/src/hooks/createNonSuspendingQuery.ts +++ b/frontend-modern/src/hooks/createNonSuspendingQuery.ts @@ -1,4 +1,5 @@ import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; +import { eventBus } from '@/stores/events'; interface CreateNonSuspendingQueryOptions { source: Accessor; @@ -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(); +let retainedQueryCacheGeneration = 0; +let retainedQueryCacheExpiryTimer: ReturnType | 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, +) => { + 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(options: CreateNonSuspendingQueryOptions) { @@ -38,7 +155,7 @@ export function createNonSuspendingQuery(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(options: CreateNonSuspendingQuery return options.initialValue; }; + const unsubscribeLiveQueryOrgSwitch = eventBus.on('org_switched', () => { + reset(); + }); + onCleanup(unsubscribeLiveQueryOrgSwitch); + const run = async (key: K, runOptions: QueryRunOptions = {}): Promise => { const requestId = ++latestRequestId; const retainedCacheKey = getRetainedCacheKey(key); + const cacheGeneration = retainedQueryCacheGeneration; if (!runOptions.background) { setLoading(true); } @@ -92,8 +215,8 @@ export function createNonSuspendingQuery(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(), diff --git a/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts b/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts index a1beffa986..06bbdcae5b 100644 --- a/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts +++ b/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts @@ -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((_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(); + }); }); }); diff --git a/frontend-modern/src/utils/storageSummaryCache.ts b/frontend-modern/src/utils/storageSummaryCache.ts index e8dd1ace8e..ff587515f1 100644 --- a/frontend-modern/src/utils/storageSummaryCache.ts +++ b/frontend-modern/src/utils/storageSummaryCache.ts @@ -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(); -const inFlightFetches = new Map>(); +const inFlightFetches = new Map< + string, + { + controller: AbortController; + promise: Promise; + } +>(); +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(); }); }