mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 16:20:03 +00:00
Bound long-lived frontend history caches
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user