diff --git a/README.md b/README.md index 808d192..9321291 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,10 @@ prevent abuse, and allowlisted source hosts for open access. Multiple source paths may reference the same content blob; the database tracks references rather than using filesystem refcounting. -In-process caching of request-to-output mappings targets 1-5k r/s. +Toward a target of 1-5k r/s, pixa keeps in memory the content types of +the 10,000 transformed images most recently cached or served, so a +cache hit on one of them reads only the image file from disk and not +the metadata file stored beside it. ### Routes diff --git a/TODO.md b/TODO.md index 4674052..1173c47 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,15 @@ P2: security: referer blacklist # Completed Steps +- 2026-09-29 variant content types kept in memory (closes #70): + `Cache.metaCache` holds the content types of up to 10,000 variants in an LRU + (`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by + `GetVariant` after it reads a `.meta` file; for a variant it holds, `Lookup` + skips the check of the disk and `GetVariant` skips the `.meta` read, still + opening the variant file and taking the size from it; eviction removes the + entry before deleting the files, and `GetVariant` removes it when the file + will not open; the cap is a constant, not a setting; the unused `variantMeta` + type is gone; `README.md` describes it. - 2026-09-29 maintenance mode refuses image requests (closes #71): while `maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a `Retry-After` header and the JSON error body, from one middleware in diff --git a/go.mod b/go.mod index f5c4c98..4a13f35 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-chi/httprate v0.16.0 github.com/gorilla/csrf v1.7.3 github.com/gorilla/securecookie v1.1.2 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/prometheus/client_golang v1.23.2 github.com/slok/go-http-metrics v0.13.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index f9a4f3d..c4542f6 100644 --- a/go.sum +++ b/go.sum @@ -228,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= diff --git a/internal/imgcache/cache.go b/internal/imgcache/cache.go index ddac1b6..7a3726c 100644 --- a/internal/imgcache/cache.go +++ b/internal/imgcache/cache.go @@ -14,6 +14,7 @@ import ( "sync" "time" + lru "github.com/hashicorp/golang-lru/v2" "sneak.berlin/go/pixa/internal/httpfetcher" ) @@ -26,6 +27,10 @@ var ( // HTTP status code for successful fetch. const httpStatusOK = 200 +// metaCacheSize is how many variants' content types metaCache holds. A +// variant not among them is served as before, reading its .meta file. +const metaCacheSize = 10000 + // CacheConfig holds cache configuration. type CacheConfig struct { StateDir string @@ -49,12 +54,6 @@ type CacheConfig struct { 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 @@ -76,9 +75,10 @@ type Cache struct { evictionStarted bool evictionStopOnce sync.Once - // In-memory cache of variant metadata (content type, size) to avoid - // reading .meta files - metaCache map[VariantKey]variantMeta + // metaCache holds the content types of the variants most recently + // stored or served, so a hit does not read the variant's .meta file. + // It never stands in for the variant file, which is always opened. + metaCache *lru.Cache[VariantKey, string] // contentLocks serializes StoreSource and evictSourceBlob per // content hash, closing the race window between an eviction's row @@ -101,6 +101,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) { log = slog.Default() } + metaCache, err := lru.New[VariantKey, string](metaCacheSize) + if err != nil { + return nil, fmt.Errorf("failed to create variant content type cache: %w", err) + } + c := &Cache{ db: db, config: config, @@ -109,7 +114,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) { evictionPressure: make(chan struct{}, 1), evictionStop: make(chan struct{}), evictionDone: make(chan struct{}), - metaCache: make(map[VariantKey]variantMeta), + metaCache: metaCache, contentLocks: newContentLock(), } @@ -154,13 +159,15 @@ type LookupResult struct { CacheStatus CacheStatus } -// Lookup checks if a processed variant exists on disk. Hits touch the -// variant's LRU timestamp; a disabled cache always misses. +// Lookup checks if a processed variant exists on disk: a variant held +// in metaCache counts as present without a check of the 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) { + // Check memory, then variant storage - no DB needed for cache hits + if !c.disabled && + (c.metaCache.Contains(cacheKey) || c.variants.Exists(cacheKey)) { c.touchVariant(ctx, cacheKey) return &LookupResult{ @@ -177,13 +184,35 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e }, nil } -// GetVariant returns a reader, size, and content type for a cached variant. +// GetVariant returns a reader, size, and content type for a cached +// variant. The content type comes from metaCache, or else from the +// variant's .meta file and is then kept in metaCache. func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) { if c.disabled { return nil, 0, "", ErrNotFound } - return c.variants.LoadWithMeta(cacheKey) + contentType, known := c.metaCache.Get(cacheKey) + if known { + reader, size, err := c.variants.LoadWithSize(cacheKey) + if err != nil { + // The file is gone, e.g. deleted outside pixa + c.metaCache.Remove(cacheKey) + + return nil, 0, "", err + } + + return reader, size, contentType, nil + } + + reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey) + if err != nil { + return nil, 0, "", err + } + + c.metaCache.Add(cacheKey, contentType) + + return reader, size, contentType, nil } // StoreSource stores fetched source content and metadata. On a @@ -286,6 +315,8 @@ func (c *Cache) StoreVariant( return err } + c.metaCache.Add(cacheKey, contentType) + _, err = c.db.ExecContext(ctx, ` INSERT INTO variant_content (cache_key, size_bytes, content_type) VALUES (?, ?, ?) diff --git a/internal/imgcache/eviction.go b/internal/imgcache/eviction.go index 8bf6edb..9366334 100644 --- a/internal/imgcache/eviction.go +++ b/internal/imgcache/eviction.go @@ -271,7 +271,9 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro // evictVariant removes one variant: accounting row first, then the // content and .meta files, so the database never references a deleted -// file. +// file. The metaCache entry goes before the files; a GetVariant that +// read them just before may put it back, and the next GetVariant then +// fails to open the file and removes it again. func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error { _, err := c.db.ExecContext(ctx, `DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey)) @@ -279,6 +281,8 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error { return fmt.Errorf("failed to delete variant accounting row: %w", err) } + c.metaCache.Remove(cacheKey) + err = c.variants.DeleteWithMeta(cacheKey) if err != nil { return err diff --git a/internal/imgcache/storage.go b/internal/imgcache/storage.go index b9487ce..2ecabca 100644 --- a/internal/imgcache/storage.go +++ b/internal/imgcache/storage.go @@ -506,32 +506,43 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) { return f, nil } -// LoadWithMeta returns a reader, size, and content type for the content at -// the given key. -func (s *VariantStorage) LoadWithMeta( - key VariantKey, -) (io.ReadCloser, int64, string, error) { +// LoadWithSize returns a reader and file size for the content at the +// given key. +func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) { path := s.keyToPath(key) - metaPath := path + ".meta" f, err := os.Open(path) //nolint:gosec // path derived from cache key if err != nil { if os.IsNotExist(err) { - return nil, 0, "", ErrNotFound + return nil, 0, ErrNotFound } - return nil, 0, "", fmt.Errorf("failed to open content: %w", err) + return nil, 0, fmt.Errorf("failed to open content: %w", err) } stat, err := f.Stat() if err != nil { _ = f.Close() - return nil, 0, "", fmt.Errorf("failed to stat content: %w", err) + return nil, 0, fmt.Errorf("failed to stat content: %w", err) + } + + return f, stat.Size(), nil +} + +// LoadWithMeta returns a reader, size, and content type for the content at +// the given key. +func (s *VariantStorage) LoadWithMeta( + key VariantKey, +) (io.ReadCloser, int64, string, error) { + f, size, err := s.LoadWithSize(key) + if err != nil { + return nil, 0, "", err } // Load metadata for content type contentType := "application/octet-stream" // fallback + metaPath := s.keyToPath(key) + ".meta" metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key if err == nil { @@ -541,7 +552,7 @@ func (s *VariantStorage) LoadWithMeta( } } - return f, stat.Size(), contentType, nil + return f, size, contentType, nil } // Exists checks if content exists at the given key.