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`
|
109. `frontend-modern/src/components/shared/summaryInteractionA11y.ts`
|
||||||
110. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`
|
110. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx`
|
||||||
111. `frontend-modern/src/hooks/createNonSuspendingQuery.ts`
|
111. `frontend-modern/src/hooks/createNonSuspendingQuery.ts`
|
||||||
|
111a. `frontend-modern/src/utils/storageSummaryCache.ts`
|
||||||
112. `frontend-modern/src/components/shared/TableCardHeader.tsx`
|
112. `frontend-modern/src/components/shared/TableCardHeader.tsx`
|
||||||
113. `frontend-modern/src/components/shared/UpgradeLink.tsx`
|
113. `frontend-modern/src/components/shared/UpgradeLink.tsx`
|
||||||
114. `frontend-modern/src/components/shared/useUpgradeNavigation.ts`
|
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
|
Feature slices such as recovery and infrastructure drawers may consume that
|
||||||
helper, but they must not fork new suspense-escape helpers once the shared
|
helper, but they must not fork new suspense-escape helpers once the shared
|
||||||
contract exists.
|
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
|
The settings reporting shell now also owns a deliberate split between
|
||||||
historical performance reports and current-state VM inventory export.
|
historical performance reports and current-state VM inventory export.
|
||||||
`frontend-modern/src/components/Settings/ReportingPanel.tsx`,
|
`frontend-modern/src/components/Settings/ReportingPanel.tsx`,
|
||||||
|
|||||||
@@ -4610,6 +4610,7 @@
|
|||||||
"frontend-modern/src/utils/sourcePlatformOptions.ts",
|
"frontend-modern/src/utils/sourcePlatformOptions.ts",
|
||||||
"frontend-modern/src/utils/sourcePlatforms.ts",
|
"frontend-modern/src/utils/sourcePlatforms.ts",
|
||||||
"frontend-modern/src/utils/ssoProviderPresentation.ts",
|
"frontend-modern/src/utils/ssoProviderPresentation.ts",
|
||||||
|
"frontend-modern/src/utils/storageSummaryCache.ts",
|
||||||
"frontend-modern/src/utils/systemLogsPresentation.ts",
|
"frontend-modern/src/utils/systemLogsPresentation.ts",
|
||||||
"frontend-modern/src/utils/systemSettingsPresentation.ts",
|
"frontend-modern/src/utils/systemSettingsPresentation.ts",
|
||||||
"frontend-modern/src/utils/textPresentation.ts",
|
"frontend-modern/src/utils/textPresentation.ts",
|
||||||
@@ -5035,15 +5036,18 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "retained-value-query-helper",
|
"id": "retained-value-query-helper",
|
||||||
"label": "retained-value query helper proof",
|
"label": "retained-value and storage history cache proof",
|
||||||
"match_prefixes": [],
|
"match_prefixes": [],
|
||||||
"match_files": [
|
"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,
|
"allow_same_subsystem_tests": false,
|
||||||
"test_prefixes": [],
|
"test_prefixes": [],
|
||||||
"exact_files": [
|
"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
|
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
|
an explicit summary contract version so long-lived demo sessions do not
|
||||||
rehydrate stale pool or disk sparkline shapes after the storage summary
|
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
|
19. Keep cross-surface workload handoffs on canonical IDs too. Shared workload
|
||||||
chart transport may look up provider-backed VM history through unified
|
chart transport may look up provider-backed VM history through unified
|
||||||
metrics targets, but infrastructure/workloads/storage/recovery navigation
|
metrics targets, but infrastructure/workloads/storage/recovery navigation
|
||||||
@@ -1897,6 +1901,16 @@ that Safe auto-fix or Autopilot remediation is verified.
|
|||||||
|
|
||||||
## Current State
|
## 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
|
### vSphere member composition is inventory, not protection evidence
|
||||||
|
|
||||||
ESXi host members projected under a vCenter connection in the grouped
|
ESXi host members projected under a vCenter connection in the grouped
|
||||||
|
|||||||
@@ -531,7 +531,9 @@ describe('Storage', () => {
|
|||||||
const storageContentSurface = screen.getByTestId('storage-content-surface');
|
const storageContentSurface = screen.getByTestId('storage-content-surface');
|
||||||
expect(await within(storageContentSurface).findByText(/\+40(?:\.0)? GB/)).toBeInTheDocument();
|
expect(await within(storageContentSurface).findByText(/\+40(?:\.0)? GB/)).toBeInTheDocument();
|
||||||
expect(within(storageContentSurface).getByText(/-20(?:\.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();
|
storageSummarySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
|
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
|
||||||
|
import { createSignal } from 'solid-js';
|
||||||
import {
|
import {
|
||||||
createNonSuspendingQuery,
|
createNonSuspendingQuery,
|
||||||
|
getCreateNonSuspendingQueryCacheDiagnosticsForTest,
|
||||||
resetCreateNonSuspendingQueryCacheForTest,
|
resetCreateNonSuspendingQueryCacheForTest,
|
||||||
} from '@/hooks/createNonSuspendingQuery';
|
} from '@/hooks/createNonSuspendingQuery';
|
||||||
|
import { eventBus } from '@/stores/events';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetCreateNonSuspendingQueryCacheForTest();
|
resetCreateNonSuspendingQueryCacheForTest();
|
||||||
cleanup();
|
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>({
|
const state = createNonSuspendingQuery<string, string>({
|
||||||
source: () => 'stable-key',
|
source: () => props.queryKey?.() ?? 'stable-key',
|
||||||
cacheKey: (key) => `${props.cacheNamespace}:${key}`,
|
cacheKey: (key) => `${props.cacheNamespace}:${key}`,
|
||||||
fetcher: props.fetcher,
|
fetcher: props.fetcher,
|
||||||
initialValue: 'initial',
|
initialValue: 'initial',
|
||||||
@@ -51,4 +59,96 @@ describe('createNonSuspendingQuery', () => {
|
|||||||
expect(screen.getByTestId('query-probe').textContent).toContain('loading:false');
|
expect(screen.getByTestId('query-probe').textContent).toContain('loading:false');
|
||||||
expect(screen.getByTestId('query-probe').textContent).not.toContain('initial');
|
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 { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||||
|
import { eventBus } from '@/stores/events';
|
||||||
|
|
||||||
interface CreateNonSuspendingQueryOptions<T, K> {
|
interface CreateNonSuspendingQueryOptions<T, K> {
|
||||||
source: Accessor<K | null>;
|
source: Accessor<K | null>;
|
||||||
@@ -15,14 +16,130 @@ interface QueryRunOptions {
|
|||||||
/**
|
/**
|
||||||
* Keep query-backed surfaces out of the app-level Suspense boundary by
|
* Keep query-backed surfaces out of the app-level Suspense boundary by
|
||||||
* retaining the last fulfilled value while the next request is in flight.
|
* 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<
|
const RETAINED_QUERY_CACHE_MAX_ENTRIES = 64;
|
||||||
string,
|
const RETAINED_QUERY_CACHE_MAX_AGE_MS = 5 * 60_000;
|
||||||
{ error: unknown; resolvedOnce: boolean; value: unknown }
|
|
||||||
>();
|
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() {
|
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>) {
|
export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQueryOptions<T, K>) {
|
||||||
@@ -38,7 +155,7 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
|
|||||||
if (!cacheKey) {
|
if (!cacheKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cached = retainedQueryCache.get(cacheKey);
|
const cached = readRetainedQueryCacheEntry(cacheKey);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -69,9 +186,15 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
|
|||||||
return options.initialValue;
|
return options.initialValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unsubscribeLiveQueryOrgSwitch = eventBus.on('org_switched', () => {
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
onCleanup(unsubscribeLiveQueryOrgSwitch);
|
||||||
|
|
||||||
const run = async (key: K, runOptions: QueryRunOptions = {}): Promise<T> => {
|
const run = async (key: K, runOptions: QueryRunOptions = {}): Promise<T> => {
|
||||||
const requestId = ++latestRequestId;
|
const requestId = ++latestRequestId;
|
||||||
const retainedCacheKey = getRetainedCacheKey(key);
|
const retainedCacheKey = getRetainedCacheKey(key);
|
||||||
|
const cacheGeneration = retainedQueryCacheGeneration;
|
||||||
if (!runOptions.background) {
|
if (!runOptions.background) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
@@ -92,8 +215,8 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
|
|||||||
} finally {
|
} finally {
|
||||||
if (requestId === latestRequestId) {
|
if (requestId === latestRequestId) {
|
||||||
setResolvedOnce(true);
|
setResolvedOnce(true);
|
||||||
if (retainedCacheKey) {
|
if (retainedCacheKey && cacheGeneration === retainedQueryCacheGeneration) {
|
||||||
retainedQueryCache.set(retainedCacheKey, {
|
writeRetainedQueryCacheEntry(retainedCacheKey, {
|
||||||
error: error(),
|
error: error(),
|
||||||
resolvedOnce: true,
|
resolvedOnce: true,
|
||||||
value: value(),
|
value: value(),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('storageSummaryCache', () => {
|
|||||||
|
|
||||||
expect(result).toBe(response);
|
expect(result).toBe(response);
|
||||||
expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(1);
|
expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('1h', undefined, {
|
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('1h', expect.any(AbortSignal), {
|
||||||
nodeId: 'node-1',
|
nodeId: 'node-1',
|
||||||
});
|
});
|
||||||
expect(readStorageSummaryCache('1h', 'node-1')).toBe(response);
|
expect(readStorageSummaryCache('1h', 'node-1')).toBe(response);
|
||||||
@@ -77,7 +77,7 @@ describe('storageSummaryCache', () => {
|
|||||||
|
|
||||||
await fetchStorageSummaryAndCache('24h');
|
await fetchStorageSummaryAndCache('24h');
|
||||||
|
|
||||||
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('24h', undefined, {
|
expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('24h', expect.any(AbortSignal), {
|
||||||
nodeId: undefined,
|
nodeId: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -203,5 +203,43 @@ describe('storageSummaryCache', () => {
|
|||||||
expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2);
|
expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2);
|
||||||
expect(readStorageSummaryCache('1h')).toBe(second);
|
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';
|
import { normalizeOrgScope } from '@/utils/orgScope';
|
||||||
|
|
||||||
const STORAGE_SUMMARY_CACHE_VERSION = 1;
|
const STORAGE_SUMMARY_CACHE_VERSION = 1;
|
||||||
|
const MAX_IN_MEMORY_STORAGE_SUMMARIES = 20;
|
||||||
|
|
||||||
function cacheKeyFor(
|
function cacheKeyFor(
|
||||||
range: TimeRange,
|
range: TimeRange,
|
||||||
@@ -14,13 +15,51 @@ function cacheKeyFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inMemoryCache = new Map<string, StorageSummaryChartsResponse>();
|
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(
|
export function readStorageSummaryCache(
|
||||||
range: TimeRange,
|
range: TimeRange,
|
||||||
nodeId?: string,
|
nodeId?: string,
|
||||||
): StorageSummaryChartsResponse | null {
|
): StorageSummaryChartsResponse | null {
|
||||||
return inMemoryCache.get(cacheKeyFor(range, nodeId)) ?? null;
|
return readInMemoryStorageSummary(cacheKeyFor(range, nodeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchStorageSummaryAndCache(
|
export function fetchStorageSummaryAndCache(
|
||||||
@@ -30,31 +69,38 @@ export function fetchStorageSummaryAndCache(
|
|||||||
const inFlightKey = cacheKeyFor(range, options?.nodeId);
|
const inFlightKey = cacheKeyFor(range, options?.nodeId);
|
||||||
const existing = inFlightFetches.get(inFlightKey);
|
const existing = inFlightFetches.get(inFlightKey);
|
||||||
if (existing) {
|
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,
|
nodeId: options?.nodeId,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
inMemoryCache.set(inFlightKey, response);
|
if (cacheGeneration === storageSummaryCacheGeneration) {
|
||||||
|
writeInMemoryStorageSummary(inFlightKey, response);
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.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;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribeStorageOrgSwitch = eventBus.on('org_switched', () => {
|
const unsubscribeStorageOrgSwitch = eventBus.on('org_switched', () => {
|
||||||
inMemoryCache.clear();
|
clearStorageSummaryCache();
|
||||||
inFlightFetches.clear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
import.meta.hot.dispose(() => {
|
import.meta.hot.dispose(() => {
|
||||||
unsubscribeStorageOrgSwitch();
|
unsubscribeStorageOrgSwitch();
|
||||||
|
clearStorageSummaryCache();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user