Skip to content
Open
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
19 changes: 18 additions & 1 deletion internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ func Open(path, lockPath string) (*Store, error) {
}
}

return &Store{db: db, lock: flock.New(lockPath)}, nil
store := &Store{db: db, lock: flock.New(lockPath)}
// Prune expired entries on startup to prevent unbounded growth.
_ = store.Prune()
return store, nil
}

func (s *Store) Close() error {
Expand All @@ -61,6 +64,20 @@ func (s *Store) Close() error {
return s.db.Close()
}

// Prune deletes all cache entries whose TTL has fully expired (age > ttl).
// It is called automatically on Open and can be called manually.
func (s *Store) Prune() error {
if s == nil || s.db == nil {
return nil
}
nowUnix := time.Now().UTC().Unix()
_, err := s.db.Exec("DELETE FROM cache_entries WHERE created_at + ttl_seconds < ?", nowUnix)

Choose a reason for hiding this comment

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

P1 Badge Preserve stale fallback window when pruning cache rows

runCachedCommand intentionally serves expired entries during temporary provider failures when they are within max_stale, but this delete condition removes rows immediately after TTL (created_at + ttl_seconds < now) during every Open() call. Because the CLI is typically a new process per command, entries that should be valid for stale fallback are deleted before the provider fetch path runs, so users lose the documented TTL+max-stale recovery behavior and see hard failures instead of stale fallback.

Useful? React with 👍 / 👎.

if err != nil {
return fmt.Errorf("prune cache: %w", err)
}
return nil
}

func (s *Store) Get(key string, maxStale time.Duration) (Result, error) {
var value []byte
var createdUnix int64
Expand Down
43 changes: 43 additions & 0 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,46 @@ func TestCacheTooStale(t *testing.T) {
t.Fatalf("expected too stale, got %+v", res)
}
}

func TestPruneRemovesExpiredEntries(t *testing.T) {
tmp := t.TempDir()
store, err := Open(filepath.Join(tmp, "cache.db"), filepath.Join(tmp, "cache.lock"))
if err != nil {
t.Fatalf("Open cache failed: %v", err)
}
defer store.Close()

// Insert an entry with a very short TTL.
if err := store.Set("prunable", []byte(`"old"`), 1*time.Second); err != nil {
t.Fatalf("Set failed: %v", err)
}
// Insert a long-lived entry.
if err := store.Set("keeper", []byte(`"keep"`), 1*time.Hour); err != nil {
t.Fatalf("Set failed: %v", err)
}

// Wait for the short entry to expire.
time.Sleep(1200 * time.Millisecond)

if err := store.Prune(); err != nil {
t.Fatalf("Prune failed: %v", err)
}

// The expired entry should be gone (miss).
res, err := store.Get("prunable", 1*time.Hour)
if err != nil {
t.Fatalf("Get prunable failed: %v", err)
}
if res.Hit {
t.Fatalf("expected prunable to be evicted, but got hit")
}

// The long-lived entry should still be there.
res, err = store.Get("keeper", 1*time.Hour)
if err != nil {
t.Fatalf("Get keeper failed: %v", err)
}
if !res.Hit {
t.Fatalf("expected keeper to still be present")
}
}