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
24 changes: 14 additions & 10 deletions cache/persistent.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,21 +218,25 @@ 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 {
if err := 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
})
})
sizeInKB = sizeInKB / 1024
numKeys = b.Stats().KeyN
return 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
}

Expand Down
89 changes: 89 additions & 0 deletions cache/stats_cache.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}()
}
148 changes: 148 additions & 0 deletions cache/stats_cache_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 14 additions & 9 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand Down
6 changes: 6 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var conf = config.Get()

var (
persistentCache *cache.PersistentCache
cacheStats *cache.StatsCache
statsStore *stats.Store
inFlightReqs sync.Map
)
Expand Down Expand Up @@ -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()

Expand Down
Loading