From 20dd95147faa36ba471f0de1884c0e5f7cb16eff Mon Sep 17 00:00:00 2001 From: John Pugliesi Date: Thu, 25 Jun 2026 10:33:19 -0700 Subject: [PATCH] feat(secret): add Cache.Invalidate to drop a cached secret on rotation secret.Cache had no way to evict an entry, so a caller that rotates or deletes a secret through the underlying manager would keep reading the stale cached value for the rest of the TTL. Invalidate(name) drops the current-version entry, forcing the next unversioned GetSecretValue to re-fetch. Version-pinned entries are left intact since a specific version is immutable. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018SrxyoWmvc3fjrbdWrHogq --- secret/cache.go | 11 ++++++++ secret/cache_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/secret/cache.go b/secret/cache.go index ae46700..d08a14c 100644 --- a/secret/cache.go +++ b/secret/cache.go @@ -80,6 +80,17 @@ func (c *Cache) GetSecretValue(ctx context.Context, name string, options ...GetO return entry.value, entry.version, nil } +// Invalidate drops the cached current-version entry for name, so the next +// GetSecretValue(name) without an explicit version re-fetches from the +// underlying provider. Call it after rotating or deleting a secret to keep a +// stale value from being served for the rest of the TTL. +// +// Version-pinned entries are left in place: a specific secret version is +// immutable, so its cached bytes never go stale. +func (c *Cache) Invalidate(name string) { + c.cache.Drop(cacheKey{name: name}) +} + func (c *Cache) expireAt() time.Time { if c.ttl > 0 { return time.Now().Add(c.ttl) diff --git a/secret/cache_test.go b/secret/cache_test.go index e80324b..3e9ce65 100644 --- a/secret/cache_test.go +++ b/secret/cache_test.go @@ -203,6 +203,68 @@ func TestCache(t *testing.T) { t.Error("expiration time is too late") } }) + + t.Run("invalidate forces re-fetch of current version", func(t *testing.T) { + var callCount atomic.Int32 + provider := ProviderFunc(func(ctx context.Context, name string, options ...GetOption) (Value, string, error) { + if callCount.Add(1) == 1 { + return Value("stale"), "v1", nil + } + return Value("fresh"), "v2", nil + }) + + cache := NewCache(provider) + + if v, _, _ := cache.GetSecretValue(ctx, "secret"); string(v) != "stale" { + t.Errorf("expected 'stale' on first read, got %q", v) + } + cache.GetSecretValue(ctx, "secret") // cache hit, no new provider call + if callCount.Load() != 1 { + t.Errorf("expected 1 provider call before invalidate, got %d", callCount.Load()) + } + + cache.Invalidate("secret") + + if v, _, _ := cache.GetSecretValue(ctx, "secret"); string(v) != "fresh" { + t.Errorf("expected 'fresh' after invalidate, got %q", v) + } + if callCount.Load() != 2 { + t.Errorf("expected 2 provider calls after invalidate, got %d", callCount.Load()) + } + }) + + t.Run("invalidate leaves version-pinned entries intact", func(t *testing.T) { + var callCount atomic.Int32 + provider := ProviderFunc(func(ctx context.Context, name string, options ...GetOption) (Value, string, error) { + callCount.Add(1) + if v := NewGetOptions(options...).Version(); v != "" { + return Value("pinned-" + v), v, nil + } + return Value("current"), "current", nil + }) + + cache := NewCache(provider) + + cache.GetSecretValue(ctx, "secret") // current + cache.GetSecretValue(ctx, "secret", WithVersion("v1")) // pinned + if callCount.Load() != 2 { + t.Fatalf("expected 2 provider calls priming the cache, got %d", callCount.Load()) + } + + cache.Invalidate("secret") + + // The pinned version is immutable, so it stays cached; only the current + // entry was dropped. + cache.GetSecretValue(ctx, "secret", WithVersion("v1")) + if callCount.Load() != 2 { + t.Errorf("expected version-pinned read to stay cached after invalidate, got %d calls", callCount.Load()) + } + + cache.GetSecretValue(ctx, "secret") + if callCount.Load() != 3 { + t.Errorf("expected current read to re-fetch after invalidate, got %d calls", callCount.Load()) + } + }) } func TestCacheImplementsProvider(t *testing.T) {