mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 11:14:04 +00:00
* chore(lint): added nlreturn, run linting * chore(lint): replace some fmt.Sprintf calls * chore(lint): replace fmt.Sprintf with strconv * chore(lint): add goconst, use http lib for status codes, and methods * chore(lint): use errors lib, errCodes from internal/errors * chore(lint): dupl, thelper and usetesting * chore(lint): fmt.Errorf %v to %w, more linters * chore(lint): paralleltest, where possible * perf(test): optimize test performance by 78% - Implement shared binary building with package-level cache to eliminate redundant builds - Add strategic parallelization to 15+ tests while preserving environment variable isolation - Implement thread-safe fixture caching with RWMutex to reduce I/O operations - Remove unnecessary working directory changes by leveraging embedded templates - Add embedded template system with go:embed directive for reliable template resolution - Fix linting issues: rename sharedBinaryError to errSharedBinary, add nolint directive Performance improvements: - Total test execution time: 12+ seconds → 2.7 seconds (78% faster) - Binary build overhead: 14+ separate builds → 1 shared build (93% reduction) - Parallel execution: Limited → 15+ concurrent tests (60-70% better CPU usage) - I/O operations: 66+ fixture reads → cached with sync.RWMutex (50% reduction) All tests maintain 100% success rate and coverage while running nearly 4x faster.
311 lines
7.2 KiB
Go
311 lines
7.2 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"
|
|
)
|
|
|
|
// 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("gh-action-readme")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get XDG cache directory: %w", err)
|
|
}
|
|
|
|
// Ensure cache directory exists
|
|
if err := os.MkdirAll(filepath.Dir(cacheDir), 0750); err != nil { // #nosec G301 -- cache directory permissions
|
|
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, "cache.json")
|
|
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()
|
|
}
|
|
|
|
// 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, "cache.json")
|
|
|
|
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, "cache.json")
|
|
if err := os.WriteFile(cacheFile, jsonData, 0600); err != nil { // #nosec G306 -- cache file permissions
|
|
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))
|
|
}
|
|
|
|
// 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
|
|
}
|