fix(keyvalue): treat undecodable cached values as a cache miss

A GetWithValue deserialization error in RememberFor was returned as fatal.
On a Redis upgrade the metrics counters live under the same keys as before
but were stored as plain int64, so the first decode into the new envelope
would fail and the metric would break permanently. Treat such errors as a
miss and recompute/overwrite so the cache self-heals.
This commit is contained in:
kolaente 2026-05-30 15:37:38 +02:00 committed by kolaente
parent 9a810f7632
commit e31d73b3df
2 changed files with 30 additions and 5 deletions

View File

@ -155,14 +155,14 @@ type expiringValue[T any] struct {
// older than ttl. On a miss or once expired, it executes fn, caches the result for // older than ttl. On a miss or once expired, it executes fn, caches the result for
// ttl and returns it. If fn returns an error, nothing is cached. // ttl and returns it. If fn returns an error, nothing is cached.
// T must be a concrete (non-pointer) type. // T must be a concrete (non-pointer) type.
//
// A value that cannot be deserialized into the expected type is treated as a cache
// miss and overwritten, so the cache self-heals across upgrades that change what a key
// stores (e.g. a key that previously held a plain int64 in Redis).
func RememberFor[T any](key string, ttl time.Duration, fn func() (T, error)) (T, error) { func RememberFor[T any](key string, ttl time.Duration, fn func() (T, error)) (T, error) {
var cached expiringValue[T] var cached expiringValue[T]
exists, err := GetWithValue(key, &cached) exists, err := GetWithValue(key, &cached)
if err != nil { if err == nil && exists && time.Now().Before(cached.ExpiresAt) {
var zero T
return zero, err
}
if exists && time.Now().Before(cached.ExpiresAt) {
return cached.Value, nil return cached.Value, nil
} }

View File

@ -132,3 +132,28 @@ func TestRememberForErrorDoesNotStore(t *testing.T) {
require.NoError(t, err2) require.NoError(t, err2)
assert.False(t, exists) assert.False(t, exists)
} }
// getWithValueErrorStore simulates a backend that cannot deserialize an existing value
// into the requested type, e.g. a key that held a plain int64 before the cache started
// storing a struct (the pre-refactor metrics counters in Redis).
type getWithValueErrorStore struct {
*memory.Storage
}
func (s *getWithValueErrorStore) GetWithValue(string, interface{}) (bool, error) {
return false, errors.New("decode error")
}
func TestRememberForRecomputesWhenStoredValueCannotBeDeserialized(t *testing.T) {
store = &getWithValueErrorStore{memory.NewStorage()}
called := 0
val, err := RememberFor("foo", time.Hour, func() (int64, error) {
called++
return 42, nil
})
require.NoError(t, err)
assert.Equal(t, int64(42), val)
assert.Equal(t, 1, called)
}