Skip to content

FS-997: a cache that reloads when a Redis version key moves - #253

Draft
aaron-wego wants to merge 5 commits into
mainfrom
fs-997-versionedcache
Draft

aaron-wego wants to merge 5 commits into
mainfrom
fs-997-versionedcache

Conversation

@aaron-wego

@aaron-wego aaron-wego commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

A new module, github.com/wego/pkg/versionedcache.

Holds a dataset from Redis in memory and reloads it only when a small version key next to that dataset says the data changed. A cycle that finds the version unchanged costs one GET of a short string instead of reading the whole dataset.

This is the Go equivalent of what FS-987 built for the Java services. The first consumer is flight-cerberus (FS-999), which today re-reads its whole breaker config hash every 5 minutes and cannot check more often without multiplying reads on the shared static-data Redis that FS-980 exists to take load off.

Notes for review

  • Versions are compared as exact text, never as an ordering. Any different value counts as changed — newer, older, or a different shape — so a publisher only has to make the value change, not increase. This also sidesteps the two ordering blind spots FS-986 documents (a restored older dataset, and a deleted marker whose replacement lands below what readers hold).
  • The version is read before the data. A refresh that overlaps a writer is then stamped with the older version and repairs itself on the next cycle. The reverse order can pin a stale value permanently: data read before a writer's update, paired with a version read after it, makes the held version match the published one forever.
  • Get returns an error only when it has never loaded. After a first success, a failed read of either the version or the data keeps the previous value and Get returns it with a nil error; the failure reaches the caller through Options.WhenRefreshed. A Redis outage therefore does not empty a warm cache.
  • One refresher at a time, elected by compare-and-swap. Callers arriving mid-refresh are served the last good value without blocking, rather than queueing behind it.
  • go-redis is pinned at v9.7.3, the version flight-cerberus already uses, so consuming this module does not force an upgrade. Please keep it there.
  • No metrics dependency. Options.WhenRefreshed reports what each cycle did and lets the calling service emit its own metric, which is what keeps observability libraries out of wego/pkg.
  • An Options struct rather than functional options: Go cannot infer the type parameter for a standalone generic option, so WhenRefreshed[T](...) would make every call site spell the type out.

Testing

go test ./... -race -cover in versionedcache/, against miniredis — 12 tests, 95.5% coverage. Covers:

  • no version key reloads every interval, and data is served from memory inside the interval
  • an unchanged version performs no data read
  • a changed version reloads, and any different version counts as changed, including one that moves backwards
  • an absent version key reloads rather than failing
  • a failed version read and a failed data read each keep the previous value, while a first-load failure is returned to the caller
  • the reported outcome tells the three cases apart
  • concurrent callers during a refresh cause one data read, not several (verified stable over 5 repeated -race runs)

Release

Not tagged yet. Once this merges, ./auto_version on main tags versionedcache/v0.1.0, which is what FS-999 pins.

FS-997

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ehc4hP7q1fM6jxKQrkQbUJ

aaron-wego and others added 5 commits September 12, 2026 10:01
Task 1 of the breaker-config version gate plan: module skeleton and the
no-version-key path. Cache[T] holds one dataset in memory and reloads it
every checkEvery when there is no version key to check, or the key is
absent in Redis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehc4hP7q1fM6jxKQrkQbUJ
- add five tests for the version gate: unchanged version avoids reload, changed version reloads, any different version reloads (including backwards), missing version key doesn't fail, and the three refresh outcomes are distinguishable
- add exampleVersionKey constant for use across the new tests

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehc4hP7q1fM6jxKQrkQbUJ
- Add errors import for test error creation
- Append TestFailedVersionReadKeepsTheCachedData to verify warm cache persists through Redis outage
- Append TestFailedDataReadKeepsTheCachedData to verify warm cache persists through data load failure
- Append TestFirstLoadFailureIsReturnedToTheCaller to verify first load failure is reported to caller

Co-Authored-By: Claude <noreply@anthropic.com>
- Add TestConcurrentGetsCauseOneDataRead exercising the compare-and-swap single-flight path: a blocked second load, four concurrent callers served the cached value, then the refresh completing
- Add sync/atomic to the test file's import block, in gofmt order
- Add versionedcache/README.md with the Use example, the empty-version-key note, and the two behaviors that surprise people

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehc4hP7q1fM6jxKQrkQbUJ
- Adds NewWithVersionInHashField beside New. New keeps its GET on a whole key;
the new constructor HGETs one field, so a writer that publishes versions for
many namespaces into one hash needs no adapting on the reader side.

- Naming no field keeps the old GET path, and naming a field but no key is
treated as naming no version at all, so a caller cannot half-configure itself
into reading the wrong place. A failed read now says where it was reading -
the key, and the field when there is one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehc4hP7q1fM6jxKQrkQbUJ
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