From adad6e060a508c0a3f32ddc6ce8dc2ce9978c69b Mon Sep 17 00:00:00 2001 From: Gustavo Mac Mini Date: Sun, 22 Feb 2026 13:01:17 -0400 Subject: [PATCH] feat(cache): prune expired entries on startup Add a Prune() method to cache.Store that deletes rows where created_at + ttl_seconds < now. Called automatically during Open() to prevent unbounded cache growth. Closes #5 --- internal/cache/cache.go | 19 +++++++++++++++- internal/cache/cache_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) 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") + } +}