package imgcache import ( "context" "database/sql" "encoding/json" "errors" "fmt" "io" "log/slog" "path/filepath" "sync" "time" "sneak.berlin/go/pixa/internal/httpfetcher" ) // Cache errors. var ( ErrCacheMiss = errors.New("cache miss") ErrNegativeCache = errors.New("negative cache hit") ) // HTTP status code for successful fetch. const httpStatusOK = 200 // CacheConfig holds cache configuration. type CacheConfig struct { StateDir string CacheTTL time.Duration NegativeTTL time.Duration // MaxBytes is the disk cache size limit in bytes that eviction // enforces. Zero means no limit is enforced (no eviction). The // config layer supplies the computed default when the operator // omits cache_max_bytes. MaxBytes int64 // DisableDiskCache turns the disk cache off entirely: no cache // directories are created, lookups always miss, stores are // no-ops, and no eviction machinery runs. The config layer sets // this when the operator configures cache_max_bytes: 0. DisableDiskCache bool // Logger receives accounting and eviction log output. A nil // Logger means slog.Default(). Logger *slog.Logger } // variantMeta stores content type for fast cache hits without reading .meta file. type variantMeta struct { ContentType string Size int64 } // Cache implements the caching layer for the image proxy. type Cache struct { db *sql.DB srcContent *ContentStorage // source images by content hash variants *VariantStorage // processed variants by cache key srcMetadata *MetadataStorage // source metadata by host/path config CacheConfig log *slog.Logger // disabled means the disk cache is turned off entirely: lookups // always miss, stores are no-ops, and no eviction runs. disabled bool // Eviction machinery. The channels are created in NewCache so // stores can signal write pressure without racing StartEviction. evictionPressure chan struct{} evictionStop chan struct{} evictionDone chan struct{} evictionStarted bool evictionStopOnce sync.Once // In-memory cache of variant metadata (content type, size) to avoid reading .meta files metaCache map[VariantKey]variantMeta } // NewCache creates a new cache instance. func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) { log := config.Logger if log == nil { log = slog.Default() } c := &Cache{ db: db, config: config, log: log, disabled: config.DisableDiskCache, evictionPressure: make(chan struct{}, 1), evictionStop: make(chan struct{}), evictionDone: make(chan struct{}), metaCache: make(map[VariantKey]variantMeta), } if c.disabled { return c, nil } srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources")) if err != nil { return nil, fmt.Errorf("failed to create source content storage: %w", err) } variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants")) if err != nil { return nil, fmt.Errorf("failed to create variant storage: %w", err) } srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata")) if err != nil { return nil, fmt.Errorf("failed to create source metadata storage: %w", err) } c.srcContent = srcContent c.variants = variants c.srcMetadata = srcMetadata return c, nil } // LookupResult contains the result of a cache lookup. type LookupResult struct { Hit bool CacheKey VariantKey ContentType string SizeBytes int64 CacheStatus CacheStatus } // Lookup checks if a processed variant exists on disk. Hits touch the // variant's LRU timestamp; a disabled cache always misses. func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) { cacheKey := CacheKey(req) // Check variant storage directly - no DB needed for cache hits if !c.disabled && c.variants.Exists(cacheKey) { c.touchVariant(ctx, cacheKey) return &LookupResult{ Hit: true, CacheKey: cacheKey, CacheStatus: CacheHit, }, nil } return &LookupResult{ Hit: false, CacheKey: cacheKey, CacheStatus: CacheMiss, }, nil } // touchVariant updates the LRU timestamp of a variant, best-effort: // a failed touch only makes the entry look colder to eviction. func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) { _, err := c.db.ExecContext(ctx, ` UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP WHERE cache_key = ? `, string(cacheKey)) if err != nil { c.log.Debug("failed to touch variant LRU timestamp", "cache_key", cacheKey, "error", err) } } // touchSourceContent updates the LRU timestamp of a source content // blob, best-effort: a failed touch only makes the blob look colder. func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) { _, err := c.db.ExecContext(ctx, ` UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP WHERE content_hash = ? `, string(contentHash)) if err != nil { c.log.Debug("failed to touch source content LRU timestamp", "content_hash", contentHash, "error", err) } } // GetVariant returns a reader, size, and content type for a cached variant. func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) { if c.disabled { return nil, 0, "", ErrNotFound } return c.variants.LoadWithMeta(cacheKey) } // StoreSource stores fetched source content and metadata. On a // disabled cache it is a no-op returning an empty hash. func (c *Cache) StoreSource( ctx context.Context, req *ImageRequest, content io.Reader, result *httpfetcher.FetchResult, ) (ContentHash, error) { if c.disabled { return "", nil } // Store content contentHash, size, err := c.srcContent.Store(content) if err != nil { return "", fmt.Errorf("failed to store source content: %w", err) } // Store in database pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery) headersJSON, _ := json.Marshal(result.Headers) _, err = c.db.ExecContext(ctx, ` INSERT INTO source_content (content_hash, content_type, size_bytes) VALUES (?, ?, ?) ON CONFLICT(content_hash) DO NOTHING `, contentHash, result.ContentType, size) if err != nil { return "", fmt.Errorf("failed to insert source content: %w", err) } _, err = c.db.ExecContext(ctx, ` INSERT INTO source_metadata (source_host, source_path, source_query, path_hash, content_hash, status_code, content_type, response_headers) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET content_hash = excluded.content_hash, status_code = excluded.status_code, content_type = excluded.content_type, response_headers = excluded.response_headers, fetched_at = CURRENT_TIMESTAMP `, req.SourceHost, req.SourcePath, req.SourceQuery, pathHash, contentHash, httpStatusOK, result.ContentType, string(headersJSON)) if err != nil { return "", fmt.Errorf("failed to insert source metadata: %w", err) } // Store metadata JSON file meta := &SourceMetadata{ Host: req.SourceHost, Path: req.SourcePath, Query: req.SourceQuery, ContentHash: string(contentHash), StatusCode: result.StatusCode, ContentType: result.ContentType, ContentLength: result.ContentLength, ResponseHeaders: result.Headers, FetchedAt: time.Now().UTC().Unix(), FetchDurationMs: result.FetchDurationMs, RemoteAddr: result.RemoteAddr, } if err := c.srcMetadata.Store(req.SourceHost, pathHash, meta); err != nil { // Non-fatal, we have it in the database _ = err } c.notifyWritePressure() return contentHash, nil } // StoreVariant stores a processed variant by its cache key and records // it in the size accounting. On a disabled cache it is a no-op. The // accounting insert is best-effort (the startup reconciliation pass // adopts any variant file that misses its accounting row). func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error { if c.disabled { return nil } size, err := c.variants.Store(cacheKey, content, contentType) if err != nil { return err } _, err = c.db.Exec(` INSERT INTO variant_content (cache_key, size_bytes, content_type) VALUES (?, ?, ?) ON CONFLICT(cache_key) DO UPDATE SET size_bytes = excluded.size_bytes, content_type = excluded.content_type, last_accessed_at = CURRENT_TIMESTAMP `, string(cacheKey), size, contentType) if err != nil { c.log.Warn("failed to record variant in size accounting", "cache_key", cacheKey, "error", err) } c.notifyWritePressure() return nil } // LookupSource checks if we have cached source content for a request. // Returns the content hash and content type if found, or empty values // if not. Hits touch the blob's LRU timestamp; a disabled cache always // reports no cached source. func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) { if c.disabled { return "", "", nil } var hashStr, contentType string err := c.db.QueryRowContext(ctx, ` SELECT content_hash, content_type FROM source_metadata WHERE source_host = ? AND source_path = ? AND source_query = ? `, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&hashStr, &contentType) if errors.Is(err, sql.ErrNoRows) { return "", "", nil } if err != nil { return "", "", fmt.Errorf("failed to lookup source: %w", err) } contentHash := ContentHash(hashStr) // Verify the content file exists if !c.srcContent.Exists(contentHash) { return "", "", nil } c.touchSourceContent(ctx, contentHash) return contentHash, contentType, nil } // StoreNegative stores a negative cache entry for a failed fetch. func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error { expiresAt := time.Now().UTC().Add(c.config.NegativeTTL) _, err := c.db.ExecContext(ctx, ` INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET status_code = excluded.status_code, error_message = excluded.error_message, fetched_at = CURRENT_TIMESTAMP, expires_at = excluded.expires_at `, req.SourceHost, req.SourcePath, req.SourceQuery, statusCode, errMsg, expiresAt) if err != nil { return fmt.Errorf("failed to insert negative cache: %w", err) } return nil } // checkNegativeCache checks if a request is in the negative cache. func (c *Cache) checkNegativeCache(ctx context.Context, req *ImageRequest) (bool, error) { var expiresAt time.Time err := c.db.QueryRowContext(ctx, ` SELECT expires_at FROM negative_cache WHERE source_host = ? AND source_path = ? AND source_query = ? `, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("failed to check negative cache: %w", err) } // Check if expired if time.Now().After(expiresAt) { // Clean up expired entry _, _ = c.db.ExecContext(ctx, ` DELETE FROM negative_cache WHERE source_host = ? AND source_path = ? AND source_query = ? `, req.SourceHost, req.SourcePath, req.SourceQuery) return false, nil } return true, nil } // GetSourceMetadataID returns the source metadata ID for a request. func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int64, error) { var id int64 err := c.db.QueryRowContext(ctx, ` SELECT id FROM source_metadata WHERE source_host = ? AND source_path = ? AND source_query = ? `, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id) if err != nil { return 0, fmt.Errorf("failed to get source metadata ID: %w", err) } return id, nil } // GetSourceContent returns a reader for cached source content by its hash. func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) { if c.disabled { return nil, ErrNotFound } return c.srcContent.Load(contentHash) } // CleanExpired removes expired entries from the cache. func (c *Cache) CleanExpired(ctx context.Context) error { // Clean expired negative cache entries _, err := c.db.ExecContext(ctx, ` DELETE FROM negative_cache WHERE expires_at < CURRENT_TIMESTAMP `) if err != nil { return fmt.Errorf("failed to clean negative cache: %w", err) } return nil } // Stats returns cache statistics. func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) { var stats CacheStats // Fetch hit/miss counts from the stats table err := c.db.QueryRowContext(ctx, ` SELECT hit_count, miss_count FROM cache_stats WHERE id = 1 `).Scan(&stats.HitCount, &stats.MissCount) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("failed to get cache stats: %w", err) } // Get actual item count and total size from content tables _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_cache`).Scan(&stats.TotalItems) _ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`).Scan(&stats.TotalSizeBytes) // Compute hit rate as a ratio if stats.HitCount+stats.MissCount > 0 { stats.HitRate = float64(stats.HitCount) / float64(stats.HitCount+stats.MissCount) } return &stats, nil } // IncrementStats increments cache statistics. func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) { if hit { _, _ = c.db.ExecContext(ctx, ` UPDATE cache_stats SET hit_count = hit_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1 `) } else { _, _ = c.db.ExecContext(ctx, ` UPDATE cache_stats SET miss_count = miss_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1 `) } if fetchBytes > 0 { _, _ = c.db.ExecContext(ctx, ` UPDATE cache_stats SET upstream_fetch_count = upstream_fetch_count + 1, upstream_fetch_bytes = upstream_fetch_bytes + ?, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1 `, fetchBytes) } }