perf: faster fresh-client load — WS snapshot replay, map chunk preload, news feed SWR#327
Merged
Merged
Conversation
A late joiner on /api/tracks/live received no backlog — the broadcast consumer reads Kafka from "latest", so the map stayed empty until each poller re-emitted its next full sweep (the orbital sweep alone is a ~15-37s cycle for ~11k satellites). The recent near-instant render made this pre-existing data-load gap glaringly obvious. Add a last-value cache to BroadcastManager: every transformed TAK frame is stored per uid (with a monotonic receive time) and replayed to each newly connected client before live streaming begins. The snapshot is sent directly (not through the bounded 256-slot live queue, which would drop most of a multi-thousand-entity replay), yielding to the event loop every 256 frames so it never starves the consume loop or other clients. Stale entries (not re-emitted within LIVE_SNAPSHOT_TTL_SECONDS, default 300s) are excluded and periodically pruned; LIVE_SNAPSHOT_MAX_ENTITIES (default 20000) caps memory. Wire format is unchanged (one frame per entity), so the frontend needs no changes — the client's lastSourceTime guard harmlessly de-dups any snapshot/live overlap. Fresh-client data latency drops from "next poller sweep" to one connect round-trip, served entirely from memory. Verification (backend/api, host): - ruff check (changed files): passed - pytest tests/test_broadcast_snapshot.py: 9 passed - pytest (full API suite): 167 passed https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
…d with stale-while-revalidate Two more fresh-client load wins, follow-ups to the WS snapshot work. Map asset waterfall: - The default TACTICAL view (TacticalMap) needs the deck-gl (~1MB) and GL engine (~1MB MapLibre / ~1.7MB Mapbox) vendor chunks, but since views are lazy and the entry no longer preloads vendors, a cold client only discovered them after the entry+App parsed and the dynamic import fired — a multi-hop waterfall before first paint. - Add a build-only Vite plugin that injects <link rel="modulepreload"> for deck-gl, the active engine, and the TacticalMap chunk, so they download in parallel with the entry. Engine is chosen at build time to mirror mapStyles.ts (Mapbox when a valid token is set, else MapLibre); the unused engine is not preloaded. Cacheable vendor split is otherwise unchanged. News feed (dashboard text feeds): - GET /api/news/feed fetched 5 RSS feeds sequentially (up to ~50s worst case) and populated the cache lazily, so every 15-min expiry blocked the next caller on the full upstream fetch. - Fetch all feeds concurrently (gather) so latency is bounded by the slowest single feed. Serve stale-while-revalidate: return the cached payload immediately and kick off a background refresh once it's past the freshness window, so callers never block. Refresh is deduped within a worker (task ref) and across workers (Redis SET NX lock); data kept 6h for stale serving. Only a cold cache fetches synchronously. Verification: - frontend: typecheck, lint, test (278 passed); production build injects modulepreload links for deck-gl + maplibre + TacticalMap (mapbox correctly excluded with no token). - backend/api: ruff (changed files) clean; full pytest suite 172 passed (+5). https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
…d never blocks Add news.warm_cache() and call it from the API lifespan after the broadcast service starts. It delegates to the same deduped background refresh used by the stale-while-revalidate path, so it returns immediately and populates the Redis feed cache out-of-band. This removes the one remaining blocking case — the first /api/news/feed request after a backend restart (cold cache) — so a fresh dashboard load gets instant cached text instead of waiting on the upstream RSS fetch. Verification (backend/api, host): ruff clean; pytest 173 passed (+1). https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
Promote the one-shot startup warm into a supervised pre-warm loop (news.prewarm_loop) launched from the API lifespan. It refreshes the feed cache on startup and then every NEWS_PREWARM_INTERVAL (default 600s, inside the 900s freshness window), so the cache stays warm even when no client is polling the feed — a fresh dashboard always hits a warm cache instead of blocking on the upstream RSS fetch. Per-cycle errors are logged and the loop continues; the task is cancelled cleanly on shutdown alongside the historian/RF-cleanup tasks. Reuses the deduped refresh path (within-worker task ref + cross-worker Redis NX lock), so multiple workers still trigger at most one fetch per cycle. Verification (backend/api, host): ruff clean; pytest 175 passed (+2). https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three independent fixes for "a fresh client takes a long time to load data," each addressing a different layer.
1. WebSocket snapshot replay (backend) — the data
A late joiner on
/api/tracks/livereceived no backlog: the broadcast consumer reads Kafka fromlatest, so the map stayed empty until each poller re-emitted its next full sweep (the orbital sweep alone is a ~15–37 s cycle, ~11k satellites). The faster render just made this pre-existing gap obvious.Fix: a last-value cache in
BroadcastManager— every TAK frame is stored peruidand replayed to each newly-connected client before live streaming begins. Sent directly (not via the bounded 256-slot live queue, which would drop most of a multi-thousand-entity replay), yielding every 256 frames. Stale entries pruned via TTL (LIVE_SNAPSHOT_TTL_SECONDS, 300 s) + hard cap (LIVE_SNAPSHOT_MAX_ENTITIES, 20 000). Wire format unchanged → no frontend changes; the client'slastSourceTimeguard de-dups any overlap.Fresh-client data latency: "next poller sweep" → one connect round-trip, served from memory.
2. Critical map-chunk preload (frontend) — the bundle
The default
TACTICALview (TacticalMap) needsdeck-gl(~1 MB) and the GL engine (~1 MB MapLibre / ~1.7 MB Mapbox), but since the views are lazy-loaded and the entry no longer preloads vendors (f32c30d), a cold client only discovered those chunks after the entry + App parsed and the dynamic import fired — a multi-hop waterfall before first paint.Fix: a build-only Vite plugin injects
<link rel="modulepreload">fordeck-gl, the active engine, and theTacticalMapchunk, so they download in parallel with the entry. The engine is picked at build time to mirrormapStyles.ts(Mapbox if a validVITE_MAPBOX_TOKENis set, else MapLibre); the unused engine isn't preloaded. Cacheable vendor split otherwise unchanged. Verified indist/index.html.3. News feed: concurrent fetch + stale-while-revalidate (backend) — the text feeds
GET /api/news/feed(dashboard NewsWidget) fetched 5 RSS feeds sequentially (up to ~50 s worst case) and populated the cache lazily, so every 15-min expiry blocked the next caller on the full upstream fetch.Fix: fetch all feeds concurrently (
asyncio.gather) so latency is bounded by the slowest single feed; serve stale-while-revalidate — return cached data immediately and kick off a background refresh once past the freshness window, so callers never block. Refresh deduped within a worker (task ref) and across workers (RedisSET NX); data kept 6 h for stale serving. Only a truly cold cache fetches synchronously (now concurrent).Changes
backend/api/services/broadcast.py,backend/api/core/config.py— LVC + snapshot replay.frontend/vite.config.ts—mapCriticalPreloadPlugin.backend/api/routers/news.py— concurrent fetch + SWR.tests/test_broadcast_snapshot.py(new),tests/test_news_router.py(extended).agent_docs/tasks/2026-06-13-*.Verification
backend/api):ruffclean; fullpytest→ 172 passed.frontend):typecheck+lintclean;test→ 278 passed; production build injectsmodulepreloadfordeck-gl+maplibre+TacticalMap(mapboxcorrectly excluded with no token).Notes
https://claude.ai/code/session_01Pt8c9eX7dv3oMMRkoudTc7