Skip to content

perf(swr): cache CRDs + /managed in localStorage per MCP, dedup + shrink payload - #657

Draft
maximilianbraun wants to merge 4 commits into
mainfrom
perf/cache-crds
Draft

perf(swr): cache CRDs + /managed in localStorage per MCP, dedup + shrink payload#657
maximilianbraun wants to merge 4 commits into
mainfrom
perf/cache-crds

Conversation

@maximilianbraun

Copy link
Copy Markdown
Member

Summary

Three independent but layered optimisations that compound on MCP page load: server-side jq filters to shrink CRD and /managed responses by ~95%; SWR global dedup tuning to suppress redundant focus-revalidations; and a new per-MCP localStorage cache provider so a cold reload paints from disk and revalidates in the background. Each layer survives the others being rolled back independently — in particular, the in-memory part (jq filter + SWR config tweaks) is still a meaningful win even if the persistence layer is reverted.

What changed

Wire (payload shrink)

  • src/lib/api/types/crossplane/CRDList.ts — add an X-jq filter to CRDRequest that strips OpenAPI schemas down to the handful of fields the client actually reads. Unfiltered responses on a real Crossplane MCP are 1-10 MB; filtered are ~50-500 KB.
  • src/lib/api/types/crossplane/listManagedResources.ts — add X-jq to drop metadata.managedFields server-side. Saves multiple MB per /managed response.

In-session SWR config

  • src/components/SWRConfigWithTokenRefresh.tsx — globally set dedupingInterval: 30s and disable revalidateOnFocus / revalidateOnReconnect. The existing 10s poll already covers staleness; focus-revalidation just double-spends. (Survives rollback of the persistence layer.)
  • src/lib/api/useApiResource.tsuseCRDItemsMapping and useProvidersConfigResource additionally set refreshInterval: 0 + revalidateIfStale: true. CRDs are effectively static within a session.

Cross-session persistence

  • src/lib/swr/persistentProvider.ts (new, 229 lines) — SWR cache provider that persists entries to localStorage under mcp-ui:swr:v1:<project>:<workspace>:<mcpName> (one bucket per MCP, never shared). Persisted JSON is wrapped in { schemaVersion, entries } — a schema sentinel so future format changes bump the version and old buckets are silently dropped on hydrate. Each entry is timestamped with a 30-minute TTL; older entries are dropped on hydrate. Only entries with data and no error are persisted (a transient 403 doesn't sticky into the next page load). Per-bucket cap is 4 MB with oldest-entry-eviction; on QuotaExceededError while writing the active bucket, an LRU cross-bucket eviction finds the other MCP's bucket whose most-recent entry is the oldest and removes it before retrying — the active MCP's bucket is never sacrificed to write itself out. clearPersistedSwrCache() wired into redirectToLogin for a full wipe on logout.
  • src/lib/shared/McpContext.tsx — mount the provider inside RequireDownstreamLogin so each MCP gets its own bucket scoped by (project, workspace, mcpName).
  • src/common/auth/redirectToLogin.ts — call clearPersistedSwrCache() on logout.

Tests

  • src/lib/swr/persistentProvider.spec.ts (new, 14 cases) — hydrate-empty / hydrate-valid / TTL drop / schema mismatch reject / malformed-JSON reject / debounced persist / skip-on-error / skip-when-no-data / QuotaExceededError swallowed / single-bucket clear / all-buckets clear / LRU cross-bucket eviction / two-MCP isolation / late-callback into stale Map writes only to the stale bucket. Spec installs a minimal in-memory localStorage shim per test because the project's jsdom config doesn't expose a working localStorage (same blocker as ViewModeContext.spec.tsx).

Why

On a typical Crossplane MCP, the MCP page kicks off:

  • One /CRDList request: 1-10 MB of OpenAPI schemas the client never reads.
  • One /managed request: hundreds of KB of metadata.managedFields no UI surface touches.
  • Plus focus-triggered revalidations every time the user Cmd-Tabs back.

Wire-layer wins are pure: less data over the wire, less JSON to parse, less RAM. SWR dedup tuning kills wasted requests. The persistence layer turns reload-after-reload from "spinner for 2-5s" into "stale data immediately, revalidate in background" — and importantly, doesn't share state across MCPs, so switching between clusters can't poison one cache with another's data.

Rollback safety: if the persistence layer turns out to be problematic in production, removing persistentProvider.ts + reverting McpContext.tsx leaves the jq filters and SWR config tweaks intact — those are still a meaningful win on their own.

Test plan

  • npm run type-check
  • npm run lint
  • npm run test:vi -- src/lib/swr/persistentProvider.spec.ts — all 14 cases pass.
  • npm run test:vi — full suite.
  • Open an MCP, DevTools Network → confirm /CRDList payload is ≤ ~500 KB (was multi-MB) and /managed no longer contains managedFields.
  • Same MCP: DevTools Application → Local Storage → confirm mcp-ui:swr:v1:<proj>:<ws>:<mcp> bucket appears with { schemaVersion, entries } shape.
  • Hard-reload the page: confirm cards paint from cache (no spinner) then SWR revalidates in the background.
  • Switch to a different MCP: confirm a separate bucket appears; original MCP's bucket is untouched.
  • Log out: confirm all mcp-ui:swr:* buckets are removed.
  • Force quota pressure (artificially fill localStorage to ~9 MB in DevTools), then reload: confirm the active MCP's bucket writes succeed and other MCP buckets get LRU-evicted rather than the active one being dropped.
  • Wait > 30 min, reload: confirm stale entries are dropped on hydrate (revalidation fires fresh).

Three layered changes that compound on MCP page load:

1. Wire: add jq filters to CRDRequest and ManagedResourcesRequest
   (X-jq header). CRD responses drop from 1-10 MB to ~50-500 KB;
   /managed loses the per-item metadata.managedFields bloat.

2. In-session SWR: globally set dedupingInterval=30s and turn off
   revalidateOnFocus/revalidateOnReconnect (the 10s poll already
   covers staleness). useCRDItemsMapping and useProvidersConfigResource
   additionally set refreshInterval=0 + revalidateIfStale=true — CRDs
   are effectively static within a session.

3. Cross-session: new src/lib/swr/persistentProvider.ts. SWR cache
   provider keyed per MCP (mcp-ui:swr:v1:<project>:<workspace>:<mcpName>).
   Mounted inside RequireDownstreamLogin so each MCP gets its own
   bucket. Hydrates on mount so cached entries paint instantly on
   reload, then SWR revalidates in the background. 4 MB cap per
   bucket with oldest-eviction; QuotaExceededError-safe. Only entries
   with `data` and no `error` are persisted (failed 403s stay in
   memory). 30-minute TTL per entry. clearPersistedSwrCache() wired
   into redirectToLogin for full wipe on logout.

Signed-off-by: Maximilian Braun (SAP) <maximilian.braun@sap.com>
- Wrap persisted JSON in { schemaVersion, entries } envelope so future
  format changes (entry layout, jq filters, SWR State shape) can bump
  the version and old buckets are silently dropped on hydrate. Old
  flat-array buckets fail the envelope check and get ignored — same
  effective behaviour as before, with explicit forward-compat.

- New persistentProvider.spec.ts (11 cases): hydrate-empty,
  hydrate-valid, TTL drop, schema mismatch reject, malformed-JSON
  reject, debounced persist, skip-on-error, skip-when-no-data,
  quota-exceeded swallowed, single-bucket clear, all-buckets clear.

  The project's jsdom config doesn't expose a working localStorage
  to specs (same blocker as ViewModeContext.spec.tsx). Installed a
  minimal in-memory shim per test so this suite is self-contained.

Signed-off-by: Maximilian Braun (SAP) <maximilian.braun@sap.com>
Bounds the cross-bucket total: when setItem throws QuotaExceededError
while writing the active MCP's bucket, find the *other* MCP bucket
whose most-recent entry is the oldest and remove it, then retry the
write. Up to 4 evictions per write, then a final last-resort wipe of
all other buckets before giving up.

The active MCP's bucket is never sacrificed to write itself out — the
user-facing cache for the current cluster always wins.

Added a 12th spec case covering the LRU path.

Signed-off-by: Maximilian Braun (SAP) <maximilian.braun@sap.com>
Two cases verifying the per-MCP design holds under common navigation
patterns:

- Two providers with different mcpIds produce distinct Maps and write
  to distinct localStorage buckets — no shared state.
- A late callback resolving into the old MCP's Map (after the user
  has navigated to a new MCP) writes only to the old MCP's bucket
  and never reaches the new bucket. This is correct SWR behaviour
  (each SWRConfig holds its own cache reference) but worth pinning
  with a test.

Signed-off-by: Maximilian Braun (SAP) <maximilian.braun@sap.com>
@maximilianbraun

Copy link
Copy Markdown
Member Author

My naive understanding is, that we not really need the CRDs content at this point in time, especially not the schemas?
If that is true, we shouldnt be breaking anything with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant