Skip to content

Cached polled-feed reponse in gameboard#21

Merged
javuto merged 2 commits into
developfrom
polled-feed-cached
Jun 29, 2026
Merged

Cached polled-feed reponse in gameboard#21
javuto merged 2 commits into
developfrom
polled-feed-cached

Conversation

@javuto

@javuto javuto commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Cache polled gameboard JSON feeds (response cache-aside, TTL + singleflight)

Summary

The gameboard polls four JSON feeds every 15s per connected client — activity, teams, challenges, and chat — and each poll ran several Postgres queries for data that's read-heavy and tolerates a few seconds of staleness. This adds a two-layer response cache-aside so feed responses are served from memory (and Redis) on hit, skipping the DB work entirely. It reuses the Redis connection that was previously used only for sessions.

Why

  • JSONCountriesHandler/JSONWorldDominationHandler/JSONTeamsHandler each issue multiple DB queries (teams, scores, active challenges, categories, members) per poll; with N clients that's a lot of repeated identical queries.
  • The data changes only on gameplay/admin mutations and is polled every 15s, so a few seconds of staleness is imperceptible.
  • Complements the settings cache PR: settings reads are now cached, and the most expensive feed reads are too.

What changes

  • New cmd/map/handlers/respcache.go: a per-feed-key, two-layer cache-aside.
    • L1: in-process sync.Map, 4s TTL — coalesces a burst of simultaneous polls.
    • L2: Redis key mapctf:feed:<name>:<uuid>, 4s TTL — shared across instances.
    • singleflight per key so a cache miss under concurrent polls does one build, and each waiter writes the shared result to its own ResponseWriter.
    • serveCachedJSON(w, key, build): on a cache hit the build closure is never invoked, so the DB queries inside it are skipped.
    • Only 200 responses are cached; error responses are delivered but never cached (a transient DB error won't be stuck for 4s).
    • build returns (status, alreadyMarshaledBody) — keeps per-handler translated error messages intact and lets singleflight safely share the result across separate writers.
    • Backed by the existing RedisCache.Client; if Redis is unavailable (feedCache() nil), feeds build and respond with no caching.
  • cmd/map/handlers/json.go: JSONActivityHandler, JSONTeamsHandler, JSONChallengesHandler, JSONChatHandler wrap their post-validation logic in a serveCachedJSON build closure. validatedJSONUUID still runs first (cheap, no DB) so invalid UUIDs never get a cached response.
  • cmd/map/handlers/handlers.go: feeds *respCache field (lazily initialized from RedisCache.Client).
  • cmd/map/handlers/post.go: targeted invalidation on high-frequency gameplay mutations so captures feel responsive (the 4s TTL already bounds staleness, but invalidation makes it instant):
    • successful score (capture) → invalidate teams + activity
    • successful hint unlock → invalidate teams + activity
    • new chat message → invalidate chat
  • cmd/map/handlers/json_test.go: TestJSONChallengesFeedCacheServesL1AndInvalidates injects an L1-only respCache (network-free), proves a bypassing DB write is not reflected until the cache expires/invalidates, and that invalidateFeed refreshes immediately.

Not cached (intentional)

  • JSONCountriesHandler / JSONWorldDominationHandler — per-user (they depend on the current user's team); they need per-(uuid, teamID) keying with the teamID resolved up front. Follow-up.
  • JSONGameClockHandler — time-based (ServerTime/remaining_ms computed from now); caching would freeze the clock.

Behavior preserved

  • Feed response bodies and status codes are unchanged on cache miss (same marshaling path).
  • With caching disabled (no Redis, e.g. tests), every feed builds and responds directly — no behavior change, no staleness. Existing feed tests pass unchanged.

Performance

  • A feed poll goes from N DB queries per client to one DB load per ~4s per UUID, served from memory thereafter and shared via Redis across instances.
  • Stampede on expiry prevented by singleflight (one build serves all concurrent waiters).
  • Captures invalidate teams/activity immediately so the leaderboard/map reflect a solve on the next poll rather than up to 4s later.

Risks / edge cases

  • Staleness: up to 4s for a feed not covered by explicit invalidation (e.g. admin CRUD of teams/challenges). Admin edits are rare; the TTL bounds it. Wiring admin mutations into invalidateFeed is a small follow-up if instant reflection is wanted.
  • Per-user feeds left uncached: countries/domination still hit the DB per poll until the per-(uuid, teamID) keying follow-up lands.
  • Redis down: feeds degrade transparently to uncached DB reads (cache errors are swallowed and fall back).

Testing

  • New: TestJSONChallengesFeedCacheServesL1AndInvalidates.
  • Existing feed/admin/handler suites pass (they run uncached).
  • Build, go vet, gofmt clean.

Rollout

Rebuild/restart the service. No schema or config change — the feed cache activates automatically when Redis is configured (as it already is for sessions).

@javuto javuto added ✨ enhancement New feature or request cache Cache related issues labels Jun 29, 2026
@javuto javuto merged commit eff2a15 into develop Jun 29, 2026
1 check passed
@javuto javuto deleted the polled-feed-cached branch June 29, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cache Cache related issues ✨ enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant