From f245c1497ba8ee1a778ba5c56e242d9a95ee9a6e Mon Sep 17 00:00:00 2001 From: Boidushya Date: Tue, 9 Jun 2026 14:41:09 +0530 Subject: [PATCH 1/3] perf(cache): use bbolt page stats for key count --- cache/persistent.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cache/persistent.go b/cache/persistent.go index 76c9ef3..4efa85f 100644 --- a/cache/persistent.go +++ b/cache/persistent.go @@ -218,21 +218,21 @@ func (pc *PersistentCache) Range(fn func(key string, entry CacheEntry) bool) { }) } -// Stats returns cache statistics +// Stats returns cache statistics: the number of keys in the bucket and the +// on-disk size of the database file in KB. Uses bbolt's BucketStats (page-tree +// walk) for the count instead of ForEach so it stays fast on multi-GB DBs. func (pc *PersistentCache) Stats() (numKeys int, sizeInKB int) { pc.db.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(bucketName)) if b == nil { return nil } - - return b.ForEach(func(k, v []byte) error { - numKeys++ - sizeInKB += len(k) + len(v) - return nil - }) + numKeys = b.Stats().KeyN + return nil }) - sizeInKB = sizeInKB / 1024 + if info, err := os.Stat(pc.dbPath); err == nil { + sizeInKB = int(info.Size() / 1024) + } return } From 224a03560940ed63856f28993d6ba34f164b7ab6 Mon Sep 17 00:00:00 2001 From: Boidushya Date: Tue, 9 Jun 2026 14:49:30 +0530 Subject: [PATCH 2/3] cache: log stats errors instead of swallowing them --- cache/persistent.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cache/persistent.go b/cache/persistent.go index 4efa85f..d615a42 100644 --- a/cache/persistent.go +++ b/cache/persistent.go @@ -222,15 +222,19 @@ func (pc *PersistentCache) Range(fn func(key string, entry CacheEntry) bool) { // on-disk size of the database file in KB. Uses bbolt's BucketStats (page-tree // walk) for the count instead of ForEach so it stays fast on multi-GB DBs. func (pc *PersistentCache) Stats() (numKeys int, sizeInKB int) { - pc.db.View(func(tx *bolt.Tx) error { + if err := pc.db.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(bucketName)) if b == nil { return nil } numKeys = b.Stats().KeyN return nil - }) - if info, err := os.Stat(pc.dbPath); err == nil { + }); err != nil { + log.Errorf("%s Failed to read bucket stats: %v", logcolors.LogCache, err) + } + if info, err := os.Stat(pc.dbPath); err != nil { + log.Errorf("%s Failed to stat database file %s: %v", logcolors.LogCache, pc.dbPath, err) + } else { sizeInKB = int(info.Size() / 1024) } return From 5c19c129f2a38bf1e3e51a2d53eecbd62a8af4c2 Mon Sep 17 00:00:00 2001 From: Boidushya Date: Tue, 9 Jun 2026 14:57:26 +0530 Subject: [PATCH 3/3] cache: serve /stats from background snapshot refreshed every 6h --- cache/stats_cache.go | 89 +++++++++++++++++++++++ cache/stats_cache_test.go | 148 ++++++++++++++++++++++++++++++++++++++ handlers.go | 23 +++--- main.go | 6 ++ 4 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 cache/stats_cache.go create mode 100644 cache/stats_cache_test.go diff --git a/cache/stats_cache.go b/cache/stats_cache.go new file mode 100644 index 0000000..19c4c52 --- /dev/null +++ b/cache/stats_cache.go @@ -0,0 +1,89 @@ +package cache + +import ( + "sync" + "sync/atomic" + "time" + + "lyrics-api-go/logcolors" + + log "github.com/sirupsen/logrus" +) + +const ( + StatsStatusComputing = "computing" + StatsStatusReady = "ready" +) + +// CachedStats is an immutable snapshot of cache statistics. +type CachedStats struct { + NumKeys int `json:"num_keys"` + SizeKB int `json:"size_kb"` + ComputedAt time.Time `json:"computed_at"` + DurationMs int64 `json:"duration_ms"` + Status string `json:"status"` +} + +// StatsCache holds the most recent stats snapshot computed in the background. +// Reads are O(1) and lock-free; writes are serialized via a TryLock so concurrent +// refreshes collapse into a single scan. +type StatsCache struct { + value atomic.Pointer[CachedStats] + cache *PersistentCache + refreshMu sync.Mutex +} + +// NewStatsCache returns a StatsCache seeded with a "computing" snapshot. +func NewStatsCache(c *PersistentCache) *StatsCache { + sc := &StatsCache{cache: c} + sc.value.Store(&CachedStats{Status: StatsStatusComputing}) + return sc +} + +// Get returns the most recent snapshot. Always non-nil. +func (sc *StatsCache) Get() *CachedStats { + return sc.value.Load() +} + +// Refresh computes a fresh snapshot and stores it. If a refresh is already in +// flight, the call is a no-op (the in-flight scan's result will be published). +func (sc *StatsCache) Refresh() { + if !sc.refreshMu.TryLock() { + return + } + defer sc.refreshMu.Unlock() + + start := time.Now() + keys, sizeKB := sc.cache.Stats() + sc.value.Store(&CachedStats{ + NumKeys: keys, + SizeKB: sizeKB, + ComputedAt: time.Now(), + DurationMs: time.Since(start).Milliseconds(), + Status: StatsStatusReady, + }) +} + +// StartBackgroundRefresh kicks off an immediate scan in a goroutine and then +// re-scans every interval. Stops when stop is closed. +func (sc *StatsCache) StartBackgroundRefresh(interval time.Duration, stop <-chan struct{}) { + go func() { + log.Infof("%s Computing initial stats snapshot (refresh every %s)", logcolors.LogCache, interval) + sc.Refresh() + snap := sc.Get() + log.Infof("%s Initial stats snapshot ready: %d keys, %d KB (took %dms)", logcolors.LogCache, snap.NumKeys, snap.SizeKB, snap.DurationMs) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + sc.Refresh() + snap := sc.Get() + log.Infof("%s Stats snapshot refreshed: %d keys, %d KB (took %dms)", logcolors.LogCache, snap.NumKeys, snap.SizeKB, snap.DurationMs) + case <-stop: + return + } + } + }() +} diff --git a/cache/stats_cache_test.go b/cache/stats_cache_test.go new file mode 100644 index 0000000..b15afbf --- /dev/null +++ b/cache/stats_cache_test.go @@ -0,0 +1,148 @@ +package cache + +import ( + "sync" + "testing" + "time" +) + +func TestStatsCache_InitialStateIsComputing(t *testing.T) { + pc, _, cleanup := setupTestCache(t, false) + defer cleanup() + + sc := NewStatsCache(pc) + snap := sc.Get() + + if snap == nil { + t.Fatal("expected snapshot, got nil") + } + if snap.Status != StatsStatusComputing { + t.Errorf("expected status %q, got %q", StatsStatusComputing, snap.Status) + } + if snap.NumKeys != 0 { + t.Errorf("expected 0 keys before first refresh, got %d", snap.NumKeys) + } + if !snap.ComputedAt.IsZero() { + t.Errorf("expected zero ComputedAt before first refresh, got %v", snap.ComputedAt) + } +} + +func TestStatsCache_RefreshPopulatesFromUnderlyingCache(t *testing.T) { + pc, _, cleanup := setupTestCache(t, false) + defer cleanup() + + pc.Set("a", "1") + pc.Set("b", "2") + pc.Set("c", "3") + + sc := NewStatsCache(pc) + before := time.Now() + sc.Refresh() + + snap := sc.Get() + if snap.Status != StatsStatusReady { + t.Errorf("expected status %q after refresh, got %q", StatsStatusReady, snap.Status) + } + if snap.NumKeys != 3 { + t.Errorf("expected 3 keys, got %d", snap.NumKeys) + } + if snap.ComputedAt.Before(before) { + t.Errorf("expected ComputedAt >= %v, got %v", before, snap.ComputedAt) + } +} + +func TestStatsCache_RefreshReflectsCacheGrowth(t *testing.T) { + pc, _, cleanup := setupTestCache(t, false) + defer cleanup() + + sc := NewStatsCache(pc) + sc.Refresh() + if got := sc.Get().NumKeys; got != 0 { + t.Fatalf("expected 0 keys initially, got %d", got) + } + + pc.Set("a", "1") + pc.Set("b", "2") + sc.Refresh() + + if got := sc.Get().NumKeys; got != 2 { + t.Errorf("expected 2 keys after adding entries, got %d", got) + } +} + +func TestStatsCache_GetIsConcurrentSafe(t *testing.T) { + pc, _, cleanup := setupTestCache(t, false) + defer cleanup() + + pc.Set("a", "1") + sc := NewStatsCache(pc) + sc.Refresh() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + snap := sc.Get() + if snap.NumKeys != 1 { + t.Errorf("expected 1 key, got %d", snap.NumKeys) + } + }() + } + wg.Wait() +} + +func TestStatsCache_ConcurrentRefreshIsSafe(t *testing.T) { + pc, _, cleanup := setupTestCache(t, false) + defer cleanup() + + pc.Set("a", "1") + sc := NewStatsCache(pc) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + sc.Refresh() + }() + } + wg.Wait() + + snap := sc.Get() + if snap.Status != StatsStatusReady { + t.Errorf("expected status %q, got %q", StatsStatusReady, snap.Status) + } + if snap.NumKeys != 1 { + t.Errorf("expected 1 key, got %d", snap.NumKeys) + } +} + +func TestStatsCache_StartBackgroundRefreshSeedsInitialScan(t *testing.T) { + pc, _, cleanup := setupTestCache(t, false) + defer cleanup() + + pc.Set("a", "1") + pc.Set("b", "2") + + sc := NewStatsCache(pc) + stop := make(chan struct{}) + sc.StartBackgroundRefresh(time.Hour, stop) + defer close(stop) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if sc.Get().Status == StatsStatusReady { + break + } + time.Sleep(10 * time.Millisecond) + } + + snap := sc.Get() + if snap.Status != StatsStatusReady { + t.Fatalf("expected background refresh to complete within deadline; status %q", snap.Status) + } + if snap.NumKeys != 2 { + t.Errorf("expected 2 keys, got %d", snap.NumKeys) + } +} diff --git a/handlers.go b/handlers.go index 2d588f7..688c78e 100644 --- a/handlers.go +++ b/handlers.go @@ -505,12 +505,16 @@ func getStats(w http.ResponseWriter, r *http.Request) { s := stats.Get() snapshot := s.Snapshot() - // Add cache storage info - numKeys, sizeInKB := persistentCache.Stats() + // Add cache storage info. Reads the cached snapshot computed in the background + // every 6h so this endpoint never blocks on a full bucket scan. + cs := cacheStats.Get() snapshot["cache_storage"] = map[string]interface{}{ - "keys": numKeys, - "size_kb": sizeInKB, - "size_mb": float64(sizeInKB) / 1024, + "keys": cs.NumKeys, + "size_kb": cs.SizeKB, + "size_mb": float64(cs.SizeKB) / 1024, + "status": cs.Status, + "computed_at": cs.ComputedAt, + "duration_ms": cs.DurationMs, } // Add circuit breaker status @@ -708,16 +712,17 @@ func restoreCache(w http.ResponseWriter, r *http.Request) { return } - // Get new cache stats after restore - numKeys, sizeKB := persistentCache.Stats() + // Refresh the cached stats snapshot so /stats reflects the restored state. + cacheStats.Refresh() + cs := cacheStats.Get() log.Infof("%s Cache restored from backup: %s", logcolors.LogCacheRestore, backupFileName) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Cache restored successfully", "restored_from": backupFileName, - "keys_restored": numKeys, - "size_kb": sizeKB, + "keys_restored": cs.NumKeys, + "size_kb": cs.SizeKB, }) } diff --git a/main.go b/main.go index 5255219..bcaaf3e 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ var conf = config.Get() var ( persistentCache *cache.PersistentCache + cacheStats *cache.StatsCache statsStore *stats.Store inFlightReqs sync.Map ) @@ -95,6 +96,11 @@ func main() { // Initialize metadata and indexes buckets (separate from cache bucket) initMetadataBuckets() + // Start background stats refresh (6h interval). Reads /stats hit the cached + // snapshot instead of triggering a full scan. + cacheStats = cache.NewStatsCache(persistentCache) + cacheStats.StartBackgroundRefresh(6*time.Hour, nil) + // Start bearer token auto-scraper (proactive refresh based on JWT expiry) ttml.StartBearerTokenMonitor()