Skip to content

Cache platform settings in Redis#20

Merged
javuto merged 1 commit into
developfrom
settings-cached
Jun 29, 2026
Merged

Cache platform settings in Redis#20
javuto merged 1 commit into
developfrom
settings-cached

Conversation

@javuto

@javuto javuto commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Cache platform settings in Redis (read-heavy, write-invalidate)

Summary

Platform settings are read on essentially every UUID-scoped request (the game-language fallback, the gameboard page's ~8 setting reads, the polled JSON feeds), and each read was a live SELECT … WHERE name=? AND uuid=? with no caching. Settings change only via admin writes. This adds a two-layer cache-aside to SettingsManager so reads are served from memory and writes invalidate, finally putting the already-wired-but-unused Redis connection to work.

Why

  • SettingsManager.Get ran a DB query per call; the gameboard render alone issued ~8 of them, and the JSON feeds several more — all for data that rarely changes.
  • HandlersMap.RedisCache was connected but never used for data (only the SCS session store used Redis).
  • Settings are the highest-impact, lowest-risk cache candidate: read on most requests, written through a small number of centralized paths, and tolerant of bounded staleness.

What changes

  • New pkg/settings/cache.go: a per-UUID, two-layer cache-aside.
    • L1: in-process sync.Map holding the full settings set per UUID, 2s TTL — coalesces the burst of reads within a single request.
    • L2: Redis key mapctf:settings:<uuid>, 60s TTL — shared across instances, survives request bursts.
    • singleflight per UUID key so a cache miss under concurrent requests does one DB load, not a thundering herd.
    • SetCache(client): enables caching; nil enables L1-only (network-free). Cache errors never fail a read — any miss/error falls back to the DB.
  • pkg/settings/settings.go:
    • Get/GetAll are now served from the cache; getAllFromDB/loadFromDB are the uncached DB reads used to populate it.
    • Get preserves gorm.ErrRecordNotFound semantics, so existing callers (including upsertSetting's existence check) are unaffected.
    • Save, Create, and Change call invalidate(uuid) (drops L1 + Redis). Every settings write path (admin SetX, reset-to-defaults, import) goes through upsertSettingSave/Create, so a changed setting is visible on the next read.
  • cmd/map/main.go: settingsMgr.SetCache(redis.Client) after manager creation (and before Initialization, which seeds via Create → invalidate, a no-op on the empty cache).
  • go.mod: golang.org/x/sync promoted to a direct require (for singleflight).
  • pkg/settings/settings_test.go: TestSettingsCacheServesReadsAndInvalidatesOnWrite — proves a cached read does not reflect a DB write that bypasses the manager, that explicit invalidate refreshes, and that a manager write invalidates so the new value is visible immediately. Uses L1-only to stay network-free; the Redis L2 path uses the same load/invalidate logic.

How it works (resolution)

Get(name, uuid)loadCached(uuid):

  1. L1 hit (fresh) → return.
  2. L1 miss → singleflight → L2 Redis GET → on hit, populate L1 and return.
  3. L2 miss/error → getAllFromDB → populate L2 (if enabled) + L1 → return.

Save/Create/Changeinvalidate(uuid) → delete L1 entry + DEL Redis key.

Behavior preserved

  • Get still returns gorm.ErrRecordNotFound for missing settings.
  • GetAll still returns the ordered slice.
  • With caching disabled (SetCache never called, e.g. existing tests), every read goes straight to the DB — no behavior change, no staleness.
  • Mutating a Get result is safe: map access returns a copy, so upsertSetting's in-place mutation of the returned setting cannot corrupt the cache.

Performance

  • Per-request settings DB cost collapses to one DB load per ~2s per UUID, served from memory thereafter (and shared via Redis across instances).
  • 8 sequential Get calls in one request → 1 DB/Redis load + 7 in-memory hits.
  • Stampede on expiry is prevented by singleflight.

Risks / edge cases

  • Staleness window: a bypassing DB write (not through the manager) is not reflected until the 2s L1 TTL expires or invalidate is called. All real write paths go through the manager, so this only matters for direct-DB maintenance.
  • Redis down: reads degrade transparently to the DB (cache errors are swallowed and fall back).
  • Serialization: the full []PlatformSetting (including gorm.Model fields) is JSON-marshaled to Redis; callers only read value fields, so cached IDs/timestamps are not relied upon beyond Save's use of the ID (which comes from the returned copy).

Testing

  • New: TestSettingsCacheServesReadsAndInvalidatesOnWrite.
  • Existing pkg/settings and cmd/map/handlers suites still pass (handlers run with caching disabled in tests, so behavior is unchanged).
  • Build, go vet, gofmt clean.

Rollout

Rebuild/restart the service so SetCache(redis.Client) takes effect. No schema change, no config change. The cache is enabled 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 181bf3c into develop Jun 29, 2026
1 check passed
@javuto javuto deleted the settings-cached branch June 29, 2026 15:35
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