diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 8be4597..1caf7d1 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -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 { @@ -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) + 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 diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 3b537a7..a36abe1 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -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") + } +}