Skip to content

fix(cache): stop serving a value after the source reports it gone - #232

Merged
lucataco merged 3 commits into
mainfrom
lucataco/cache-evict-missing
Nov 3, 2026
Merged

lucataco merged 3 commits into
mainfrom
lucataco/cache-evict-missing

Conversation

@lucataco

@lucataco lucataco commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Why

When a fetcher returned ErrDoesNotExist, the cache wrote a negative marker and kept the old value. After the marker expired, Get served the old value again until the stale TTL ran out.

What changed

  • Evict on ErrDoesNotExist. The cache deletes the data and freshness keys, then writes the negative marker if negative caching is on. Before this change, it only wrote the marker.
  • Delete first, on its own. The delete is no longer in the same MULTI as the marker write. Redis aborts a whole transaction when it rejects a queued command, such as a SET at maxmemory, and that would have kept the value.
  • Callers still get ErrDoesNotExist. Eviction errors are recorded and logged but not returned.

Blast radius

  • Every cache built on this package. Caches without negative caching now drop the value when the fetcher reports it gone. Before this change, they kept serving it until the stale TTL.
  • Only the not-found path changes. It now runs DEL then SET instead of a single SET, which adds one round trip. With more than one backend, eviction takes the cross-backend lock that Set already takes.
  • Unchanged: fills, hits, key names, and value format. There are no new keys and no scripts. Old and new code can share Redis during a rolling deploy.

Not in scope

  • A late fill. A fill that started before an eviction can finish after it and write the old value back. That value lasts until the next refresh, at most the fresh TTL. feat(cache): skip a fill that started before an eviction #233 handles this case as a stacked draft PR. It is a separate decision because it changes every fill.
  • A rejected delete. If Redis rejects the delete itself, the old value can still be served.

Verification

  • New tests that failed before their fixes:
    • The cache serving a value after its source reports it gone. This test runs with and without negative caching, with multiple backends, and with a shadow client.
    • A rejected negative-marker write keeping the value.
  • The eviction-failure test now requires its fault hook to fire.
  • Package checks: go test -race -shuffle=on -count=5 ./cache passes, and golangci-lint v2.1.5 reports no issues.
  • End to end against the API service, on a local Compose stack:
    • Built with this branch pinned in go.mod, the API stopped serving a key once the fresh window ended after the source reported it gone, and it did not serve it again.
    • Built with the previous cache code, it served the value again each time the negative marker expired.
    • The API's Go test suite passes with this version pinned.

The cache keeps serving a cached value after the fetcher returns
ErrDoesNotExist for it: only the short negative sentinel is written, so each
time the sentinel expires the next Get is a soft miss served from the old
data. These tests fail on that behavior for a single backend with and without
negative caching, for multiple backends, and for the shadow client, and pin
that the fetcher's error is still returned when Redis rejects the eviction.
…gone

When a fetcher returned ErrDoesNotExist, the cache only wrote the short
negative sentinel and left the data key with its full stale TTL. Each time the
sentinel expired, the next Get was a soft miss and served the old value again,
so a revoked item kept coming back until the stale TTL ran out. fill now also
removes the data and fresh keys from every backend and the shadow client in one
transaction per client, even when negative caching is off, and still returns
the fetcher's error if that eviction fails.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Non-atomic deletion and generation increments allow late fills to restore revoked data, while permanent generation entries cause unbounded Redis storage growth.

4 open issues
What changed in this PR

This PR updates the shared cache used by API authorization to evict values when their source reports ErrDoesNotExist and prevent older fills from restoring them. It does not update the API dependency or deploy the change.

Changes:

  • Delete cached data before optionally writing a negative marker.
  • Add per-key generations and conditional writes for cache fills, including shadow writes.
  • Add regression tests for revocation, negative-marker expiry, multiple backends, late fills, and eviction-error handling.
File Description
cache/​cache.go Adds eviction and generation-checked fill paths; clarifies ErrDoesNotExist behavior.
cache/​cache_test.go Updates command expectations and adds revocation regression tests.

Review effort: Balanced

Comment thread cache/cache.go Outdated
if err := client.Del(ctx, keys.data, keys.fresh).Err(); err != nil {
return err
}
if err := client.Incr(ctx, keys.gen).Err(); err != nil {
Comment thread cache/cache.go Outdated
Comment on lines +405 to +410
if err := client.Del(ctx, keys.data, keys.fresh).Err(); err != nil {
return err
}
if err := client.Incr(ctx, keys.gen).Err(); err != nil {
return err
}
Comment thread cache/cache.go Outdated
errs = append(errs, c.writeIfCurrent(ctx, client, keys, observed[i], data))
}
if c.opts.ShadowWriteClient != nil {
shadowObserved := observed[0]
Comment thread cache/cache_test.go Outdated

func (*failDeletes) DialHook(next redis.DialHook) redis.DialHook { return next }

func (*failDeletes) ProcessHook(next redis.ProcessHook) redis.ProcessHook { return next }
@lucataco

lucataco commented Nov 2, 2026

Copy link
Copy Markdown
Member Author

The four review notes are valid, and the branch now addresses them.

  • Generation keys expire after the fill bound plus the cache lifetime. A fill older than two minutes does not write, so an expired counter cannot make an old fill current again.
  • Delete and generation increment run in one script. The negative marker is still written afterward, so a rejected marker write cannot restore the value.
  • Shadow writes use the shadow generation observed before the fetch, not the primary generation.
  • The eviction-failure hook now rejects the delete command itself and the test requires that rejection to fire.

Eviction ran DEL and the negative-marker SET in one MULTI. Redis aborts the
whole transaction when it rejects a queued command, such as a SET at
maxmemory, so the value survived. The delete now runs first on its own.

The eviction-failure hook now also rejects a DEL sent outside a pipeline, and
the test requires it to fire.
@lucataco

lucataco commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

I reduced this PR to the eviction fix, plus one ordering change that deletes the value before writing the negative marker. The generation compare-and-set work, including the Copilot follow-ups, moved to the stacked draft #233. It changes every fill, so it should be reviewed and measured separately. The earlier review threads about generations apply to #233.

@lucataco
lucataco merged commit 5befd9b into main Nov 3, 2026
2 checks passed
@lucataco
lucataco deleted the lucataco/cache-evict-missing branch November 3, 2026 16:14
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.

3 participants