Skip to content

fix: persist circuit breaker cache to IndexedDB across page reloads#279

Closed
elzalem wants to merge 6 commits into
koala73:mainfrom
elzalem:fix/circuit-breaker-persistent-cache
Closed

fix: persist circuit breaker cache to IndexedDB across page reloads#279
elzalem wants to merge 6 commits into
koala73:mainfrom
elzalem:fix/circuit-breaker-persistent-cache

Conversation

@elzalem

@elzalem elzalem commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Summary

On page reload, all 28+ circuit breaker in-memory caches are lost, triggering 20-30 simultaneous POST requests to Vercel edge functions. This wires the existing persistent-cache.ts infrastructure (IndexedDB/localStorage/Tauri fallback) into the circuit breaker class so every service automatically persists its cache across reloads.

  • Hydrate: On first execute() call, reads from IndexedDB (~1-5ms). If fresh (within cacheTtlMs), serves immediately — zero network calls
  • Persist: On every recordSuccess(), fire-and-forget write to IndexedDB. No blocking.
  • Fallback: On network failure after reload, serves stale persistent data instead of empty panels
  • Auto-disable: Breakers with cacheTtlMs: 0 (e.g. live market quotes) skip persistence entirely

Impact

Scenario Before After
Page reload within 10min 20-30 POST requests 0 requests (served from IndexedDB)
Page reload after 10min+ 20-30 POST requests 20-30 POST requests (persistent entries expired)
Network failure after reload Empty panels Last-known data from IndexedDB

At 500K monthly users, assuming ~30% of sessions involve at least one reload: ~150K fewer edge function invocations/month.

Changes

  • src/utils/circuit-breaker.ts: Add persistCache option, hydratePersistentCache(), fire-and-forget writes in recordSuccess(), invalidation in clearCache()
  • src/services/persistent-cache.ts: Add deletePersistentCache() for clean cache invalidation

Zero consumer code changes — all 28+ breaker call sites are untouched.

Design

Uses dynamic import('../services/persistent-cache') to avoid a hard dependency from utils to services. The import is resolved once by the module system and cached. Hydration is deduped via a promise guard so concurrent execute() calls share one IndexedDB read.

Persistent entries older than 24 hours are discarded entirely (stale ceiling). Entries between cacheTtlMs and 24h are only used as fallback when the network call fails.

Test plan

  • Open app, wait for all panels to load
  • Open DevTools > Application > IndexedDB > worldmonitor_persistent_cache — verify breaker:* entries appear
  • Reload page — check Network tab shows near-zero /api/ POST requests
  • Verify all panels render immediately with cached data
  • Wait for scheduler refresh — panels update with fresh data (mode switches from cached to live)
  • Disconnect network, reload — panels should show last-known data instead of empty
  • Verify Market Quotes panel still fetches live (no IndexedDB entry for breaker:Market Quotes)

🤖 Generated with Claude Code

On page reload, all 28+ circuit breaker in-memory caches are lost,
triggering 20-30 simultaneous POST requests to Vercel edge functions.

Wire the existing persistent-cache.ts (IndexedDB + localStorage +
Tauri fallback) into CircuitBreaker so every breaker automatically:

- Hydrates from IndexedDB on first execute() call (~1-5ms read)
- Writes to IndexedDB fire-and-forget on every recordSuccess()
- Falls back to stale persistent data on network failure
- Auto-disables for breakers with cacheTtlMs=0 (live pricing)

Zero consumer code changes -- all 28+ breaker call sites untouched.
Reloads within the cache TTL (default 10min) serve instantly from
IndexedDB with zero network calls.

Also adds deletePersistentCache() to persistent-cache.ts for clean
cache invalidation via clearCache().

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@vercel

vercel Bot commented Feb 23, 2026

Copy link
Copy Markdown

@elzalem is attempting to deploy a commit to the Elie Team on Vercel.

A member of the Team first needs to authorize it.

elzalem and others added 2 commits February 23, 2026 23:26
7 tests covering: IndexedDB persistence on success, hydration on new
instance, TTL expiry forcing fresh fetch, 24h stale ceiling rejection,
clearCache cleanup, cacheTtlMs=0 auto-disable, and network failure
fallback to stale persistent data.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
P1: deletePersistentCache sent empty string to write_cache_entry,
which fails Rust's serde_json::from_str (not valid JSON). Add
dedicated delete_cache_entry Tauri command that removes the key
from the in-memory HashMap and flushes to disk.

P2: clearCache() set persistentLoaded=false, allowing a concurrent
execute() to re-hydrate stale data from IndexedDB before the async
delete completed. Remove the reset — after explicit clear there is
no reason to re-hydrate from persistent storage.
@koala73

koala73 commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Fixes for P1 + P2

Pushed to fix/circuit-breaker-persistent-cache with fixes for both issues:

P1: Desktop cache deletion broken (High)

Problem: deletePersistentCache called write_cache_entry with value: '', but the Rust command uses serde_json::from_str(&value) which requires valid JSON — empty string fails silently and the entry is never deleted.

Fix: Added a dedicated delete_cache_entry Tauri command in main.rs that calls data.remove(&key) on the in-memory HashMap and flushes to disk. Updated persistent-cache.ts to call delete_cache_entry instead of write_cache_entry.

P2: clearCache() race condition (Medium)

Problem: clearCache() set persistentLoaded = false and fired an async delete. A concurrent execute() could see persistentLoaded === false, call hydratePersistentCache(), read stale data from IndexedDB before the delete completed, and restore it into the in-memory cache.

Fix: Removed this.persistentLoaded = false from clearCache(). After an explicit clear, there's no reason to re-hydrate from persistent storage — the in-memory cache is null, the fetch function will be called to get fresh data, and recordSuccess() will persist the new result.

Files changed: src-tauri/src/main.rs, src/services/persistent-cache.ts, src/utils/circuit-breaker.ts

P1b: 6 breakers store Date objects (weather, aviation, ACLED,
military-flights, military-vessels, GDACS) which become strings
after JSON round-trip. Callers like MapPopup.getTimeUntil() call
date.getTime() on hydrated strings → TypeError. Change default
to false (opt-in) so persistence requires explicit confirmation
that the payload is JSON-safe.

P2: `if (!entry?.data) return` drops valid falsy payloads (0,
false, empty string). Use explicit null/undefined check instead.
@koala73

koala73 commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Additional fixes: P1b + P2

P1b: Date type loss on hydration (High)

6 breakers store Date objects (weather, aviation, ACLED, military-flights, military-vessels, GDACS). After JSON round-trip via IndexedDB, Date becomes an ISO string. Callers like MapPopup.getTimeUntil() call date.getTime() on the hydrated value → TypeError.

Fix: Changed persistCache default from true to false (opt-in). Persistence now requires explicit persistCache: true, ensuring the caller has confirmed their payload is JSON-safe. E2E tests are unaffected — they all pass persistCache: true explicitly.

P2: Falsy data guard drops valid payloads (Low)

if (!entry?.data) return in hydratePersistentCache treats 0, false, and "" as cache misses, breaking the generic contract for CircuitBreaker<number> etc.

Fix: Changed to if (entry == null || entry.data === undefined || entry.data === null) return — explicit null/undefined check only.

- clearCache() nulls persistentLoadPromise to orphan in-flight hydration
- delete_cache_entry defers disk flush to exit handler (avoids 14MB sync write)
- hydratePersistentCache checks TTL before setting lastDataState to 'cached'
- deletePersistentCache resets cacheDbPromise on IDB error + logs warning
- hydration catch logs warning instead of silently swallowing
- deletePersistentCache respects isStorageQuotaExceeded() for localStorage
@koala73

koala73 commented Feb 23, 2026

Copy link
Copy Markdown
Owner

The 2 commits were added to another PR that closes this

@koala73 koala73 closed this Feb 23, 2026
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.

2 participants