Files
Ismo Vuorinen 7f80105ff5 feat: go 1.25.5, dependency updates, renamed internal/errors (#129)
* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
2026-01-01 23:17:29 +02:00

316 lines
7.4 KiB
Go

// Package cache provides XDG-compliant caching functionality for gh-action-readme.
package cache
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/adrg/xdg"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// Entry represents a cached item with TTL support.
type Entry struct {
Value any `json:"value"`
ExpiresAt time.Time `json:"expires_at"`
Size int64 `json:"size"`
}
// Cache provides thread-safe caching with TTL and XDG compliance.
type Cache struct {
path string // XDG cache directory
data map[string]Entry // In-memory cache
mutex sync.RWMutex // Thread safety
ticker *time.Ticker // Cleanup ticker
done chan bool // Cleanup shutdown
defaultTTL time.Duration // Default TTL for entries
saveWG sync.WaitGroup // Wait group for pending save operations
}
// Config represents cache configuration.
type Config struct {
DefaultTTL time.Duration // Default TTL for entries
CleanupInterval time.Duration // How often to clean expired entries
MaxSize int64 // Maximum cache size in bytes (0 = unlimited)
}
// DefaultConfig returns default cache configuration.
func DefaultConfig() *Config {
return &Config{
DefaultTTL: 15 * time.Minute, // 15 minutes for API responses
CleanupInterval: 5 * time.Minute, // Clean up every 5 minutes
MaxSize: 100 * 1024 * 1024, // 100MB max cache size
}
}
// NewCache creates a new XDG-compliant cache instance.
func NewCache(config *Config) (*Cache, error) {
if config == nil {
config = DefaultConfig()
}
// Get XDG cache directory
cacheDir, err := xdg.CacheFile(appconstants.AppName)
if err != nil {
return nil, fmt.Errorf("failed to get XDG cache directory: %w", err)
}
// Ensure cache directory exists
cacheDirParent := filepath.Dir(cacheDir)
// #nosec G301 -- cache directory permissions
if err := os.MkdirAll(cacheDirParent, appconstants.FilePermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
cache := &Cache{
path: filepath.Dir(cacheDir),
data: make(map[string]Entry),
defaultTTL: config.DefaultTTL,
done: make(chan bool),
}
// Load existing cache from disk
_ = cache.loadFromDisk() // Log error but don't fail - we can start with empty cache
// Start cleanup goroutine
cache.ticker = time.NewTicker(config.CleanupInterval)
go cache.cleanupLoop()
return cache, nil
}
// Set stores a value in the cache with default TTL.
func (c *Cache) Set(key string, value any) error {
return c.SetWithTTL(key, value, c.defaultTTL)
}
// SetWithTTL stores a value in the cache with custom TTL.
func (c *Cache) SetWithTTL(key string, value any, ttl time.Duration) error {
c.mutex.Lock()
defer c.mutex.Unlock()
// Calculate size (rough estimate)
size := c.estimateSize(value)
entry := Entry{
Value: value,
ExpiresAt: time.Now().Add(ttl),
Size: size,
}
c.data[key] = entry
// Persist to disk asynchronously
c.saveToDiskAsync()
return nil
}
// Get retrieves a value from the cache.
func (c *Cache) Get(key string) (any, bool) {
c.mutex.RLock()
defer c.mutex.RUnlock()
entry, exists := c.data[key]
if !exists {
return nil, false
}
// Check if expired
if time.Now().After(entry.ExpiresAt) {
// Remove expired entry (will be cleaned up by cleanup goroutine)
return nil, false
}
return entry.Value, true
}
// Delete removes a key from the cache.
func (c *Cache) Delete(key string) {
c.mutex.Lock()
defer c.mutex.Unlock()
delete(c.data, key)
go func() {
_ = c.saveToDisk() // Async operation, error logged internally
}()
}
// Clear removes all entries from the cache.
func (c *Cache) Clear() error {
c.mutex.Lock()
defer c.mutex.Unlock()
c.data = make(map[string]Entry)
// Remove cache file
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove cache file: %w", err)
}
return nil
}
// Stats returns cache statistics.
func (c *Cache) Stats() map[string]any {
c.mutex.RLock()
defer c.mutex.RUnlock()
var totalSize int64
expiredCount := 0
now := time.Now()
for _, entry := range c.data {
totalSize += entry.Size
if now.After(entry.ExpiresAt) {
expiredCount++
}
}
return map[string]any{
"total_entries": len(c.data),
"expired_count": expiredCount,
"total_size": totalSize,
"cache_dir": c.path,
}
}
// Close shuts down the cache and stops background processes.
func (c *Cache) Close() error {
if c.ticker != nil {
c.ticker.Stop()
}
// Signal cleanup goroutine to stop
select {
case c.done <- true:
default:
}
// Wait for any pending async save operations to complete
c.saveWG.Wait()
// Save final state to disk
return c.saveToDisk()
}
// GetOrSet retrieves a value from cache or sets it if not found.
func (c *Cache) GetOrSet(key string, getter func() (any, error)) (any, error) {
// Try to get from cache first
if value, exists := c.Get(key); exists {
return value, nil
}
// Not in cache, get from source
value, err := getter()
if err != nil {
return nil, err
}
// Store in cache
_ = c.Set(key, value) // Log error but don't fail - we have the value
return value, nil
}
// cleanupLoop runs periodically to remove expired entries.
func (c *Cache) cleanupLoop() {
for {
select {
case <-c.ticker.C:
c.cleanup()
case <-c.done:
return
}
}
}
// cleanup removes expired entries.
func (c *Cache) cleanup() {
c.mutex.Lock()
defer c.mutex.Unlock()
now := time.Now()
for key, entry := range c.data {
if now.After(entry.ExpiresAt) {
delete(c.data, key)
}
}
// Save to disk after cleanup
c.saveToDiskAsync()
}
// loadFromDisk loads cache data from disk.
func (c *Cache) loadFromDisk() error {
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
data, err := os.ReadFile(cacheFile) // #nosec G304 -- cache file path constructed internally
if err != nil {
if os.IsNotExist(err) {
return nil // No cache file is fine
}
return fmt.Errorf("failed to read cache file: %w", err)
}
c.mutex.Lock()
defer c.mutex.Unlock()
if err := json.Unmarshal(data, &c.data); err != nil {
return fmt.Errorf("failed to unmarshal cache data: %w", err)
}
return nil
}
// saveToDisk persists cache data to disk.
func (c *Cache) saveToDisk() error {
c.mutex.RLock()
data := make(map[string]Entry)
for k, v := range c.data {
data[k] = v
}
c.mutex.RUnlock()
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal cache data: %w", err)
}
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
// #nosec G306 -- cache file permissions
if err := os.WriteFile(cacheFile, jsonData, appconstants.FilePermDefault); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}
return nil
}
// saveToDiskAsync saves the cache to disk asynchronously.
// Cache save failures are non-critical and silently ignored.
func (c *Cache) saveToDiskAsync() {
c.saveWG.Add(1)
go func() {
defer c.saveWG.Done()
_ = c.saveToDisk() // Ignore errors - cache save failures are non-critical
}()
}
// estimateSize provides a rough estimate of the memory size of a value.
func (c *Cache) estimateSize(value any) int64 {
// This is a simple estimation - could be improved with reflection
jsonData, err := json.Marshal(value)
if err != nil {
return 100 // Default estimate
}
return int64(len(jsonData))
}