Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions secret/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions secret/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading