Keep variant content types in memory for cache hits (closes #70) #157
@@ -86,7 +86,10 @@ prevent abuse, and allowlisted source hosts for open access.
|
|||||||
|
|
||||||
Multiple source paths may reference the same content blob; the
|
Multiple source paths may reference the same content blob; the
|
||||||
database tracks references rather than using filesystem refcounting.
|
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
|
### Routes
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ P2: security: referer blacklist
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
||||||
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
`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
|
`Retry-After` header and the JSON error body, from one middleware in
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/go-chi/httprate v0.16.0
|
github.com/go-chi/httprate v0.16.0
|
||||||
github.com/gorilla/csrf v1.7.3
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/securecookie v1.1.2
|
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/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -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.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 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
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 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
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=
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
|||||||
+47
-16
@@ -14,6 +14,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
lru "github.com/hashicorp/golang-lru/v2"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ var (
|
|||||||
// HTTP status code for successful fetch.
|
// HTTP status code for successful fetch.
|
||||||
const httpStatusOK = 200
|
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.
|
// CacheConfig holds cache configuration.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
StateDir string
|
StateDir string
|
||||||
@@ -49,12 +54,6 @@ type CacheConfig struct {
|
|||||||
Logger *slog.Logger
|
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.
|
// Cache implements the caching layer for the image proxy.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -76,9 +75,10 @@ type Cache struct {
|
|||||||
evictionStarted bool
|
evictionStarted bool
|
||||||
evictionStopOnce sync.Once
|
evictionStopOnce sync.Once
|
||||||
|
|
||||||
// In-memory cache of variant metadata (content type, size) to avoid
|
// metaCache holds the content types of the variants most recently
|
||||||
// reading .meta files
|
// stored or served, so a hit does not read the variant's .meta file.
|
||||||
metaCache map[VariantKey]variantMeta
|
// It never stands in for the variant file, which is always opened.
|
||||||
|
metaCache *lru.Cache[VariantKey, string]
|
||||||
|
|
||||||
// contentLocks serializes StoreSource and evictSourceBlob per
|
// contentLocks serializes StoreSource and evictSourceBlob per
|
||||||
// content hash, closing the race window between an eviction's row
|
// 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()
|
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{
|
c := &Cache{
|
||||||
db: db,
|
db: db,
|
||||||
config: config,
|
config: config,
|
||||||
@@ -109,7 +114,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
evictionPressure: make(chan struct{}, 1),
|
evictionPressure: make(chan struct{}, 1),
|
||||||
evictionStop: make(chan struct{}),
|
evictionStop: make(chan struct{}),
|
||||||
evictionDone: make(chan struct{}),
|
evictionDone: make(chan struct{}),
|
||||||
metaCache: make(map[VariantKey]variantMeta),
|
metaCache: metaCache,
|
||||||
contentLocks: newContentLock(),
|
contentLocks: newContentLock(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +159,15 @@ type LookupResult struct {
|
|||||||
CacheStatus CacheStatus
|
CacheStatus CacheStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk. Hits touch the
|
// Lookup checks if a processed variant exists on disk: a variant held
|
||||||
// variant's LRU timestamp; a disabled cache always misses.
|
// 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) {
|
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
// Check variant storage directly - no DB needed for cache hits
|
// Check memory, then variant storage - no DB needed for cache hits
|
||||||
if !c.disabled && c.variants.Exists(cacheKey) {
|
if !c.disabled &&
|
||||||
|
(c.metaCache.Contains(cacheKey) || c.variants.Exists(cacheKey)) {
|
||||||
c.touchVariant(ctx, cacheKey)
|
c.touchVariant(ctx, cacheKey)
|
||||||
|
|
||||||
return &LookupResult{
|
return &LookupResult{
|
||||||
@@ -177,13 +184,35 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
|
|||||||
}, nil
|
}, 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) {
|
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||||
if c.disabled {
|
if c.disabled {
|
||||||
return nil, 0, "", ErrNotFound
|
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
|
// StoreSource stores fetched source content and metadata. On a
|
||||||
@@ -286,6 +315,8 @@ func (c *Cache) StoreVariant(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.metaCache.Add(cacheKey, contentType)
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
|
|||||||
@@ -271,7 +271,9 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
|||||||
|
|
||||||
// evictVariant removes one variant: accounting row first, then the
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
// content and .meta files, so the database never references a deleted
|
// 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 {
|
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||||
_, err := c.db.ExecContext(ctx,
|
_, err := c.db.ExecContext(ctx,
|
||||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
`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)
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.metaCache.Remove(cacheKey)
|
||||||
|
|
||||||
err = c.variants.DeleteWithMeta(cacheKey)
|
err = c.variants.DeleteWithMeta(cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -506,32 +506,43 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
// LoadWithSize returns a reader and file size for the content at the
|
||||||
// the given key.
|
// given key.
|
||||||
func (s *VariantStorage) LoadWithMeta(
|
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
|
||||||
key VariantKey,
|
|
||||||
) (io.ReadCloser, int64, string, error) {
|
|
||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
|
||||||
|
|
||||||
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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()
|
stat, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = f.Close()
|
_ = 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
|
// Load metadata for content type
|
||||||
contentType := "application/octet-stream" // fallback
|
contentType := "application/octet-stream" // fallback
|
||||||
|
metaPath := s.keyToPath(key) + ".meta"
|
||||||
|
|
||||||
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
||||||
if err == nil {
|
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.
|
// Exists checks if content exists at the given key.
|
||||||
|
|||||||
Reference in New Issue
Block a user