Keep cached sources and variants in one FileStore type (closes #318)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
imgcache.FileStore is a directory of files named by a hex key, at <ab>/<cd>/<key>. It writes a file under a temp name, syncs it and renames it into place. It opens, reports the size of and deletes the file for a key. The cache keeps its sources in one FileStore and its variants in another, so ContentStorage and VariantStorage are gone. FileStore has no Exists: the reconciliation pass checks a variant's file with Size, and the tests look for the file on disk. Cache.Variant reads the content type from the file's first bytes itself. storage.go is now filestore.go, and the unused testdata/red.avif is gone. Model: opus-5-5
This commit was merged in pull request #335.
This commit is contained in:
+55
-11
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/magic"
|
||||
)
|
||||
|
||||
// maxPendingAccessTimes is how many access times of variants served from
|
||||
@@ -57,8 +58,8 @@ type Config struct {
|
||||
// Cache implements the caching layer for the image proxy.
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
srcContent *ContentStorage // source images by source key
|
||||
variants *VariantStorage // processed variants by cache key
|
||||
srcContent *FileStore // source images by source key
|
||||
variants *FileStore // processed variants by cache key
|
||||
config Config
|
||||
log *slog.Logger
|
||||
|
||||
@@ -135,14 +136,14 @@ func NewCache(
|
||||
return c, nil
|
||||
}
|
||||
|
||||
srcContent, err := NewContentStorage(
|
||||
srcContent, err := NewFileStore(
|
||||
filepath.Join(config.StateDir, "cache", "sources"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||
}
|
||||
|
||||
variants, err := NewVariantStorage(
|
||||
variants, err := NewFileStore(
|
||||
filepath.Join(config.StateDir, "cache", "variants"),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -169,14 +170,21 @@ func (c *Cache) Variant(
|
||||
return nil, 0, "", ErrNotFound
|
||||
}
|
||||
|
||||
reader, size, contentType, err := c.variants.LoadWithContentType(cacheKey)
|
||||
file, size, err := c.variants.Open(string(cacheKey))
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
contentType, err := outputContentType(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
c.touchVariant(cacheKey)
|
||||
|
||||
return reader, size, contentType, nil
|
||||
return file, size, contentType, nil
|
||||
}
|
||||
|
||||
// PutVariant stores a processed variant by its cache key and records
|
||||
@@ -190,11 +198,18 @@ func (c *Cache) PutVariant(
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := c.variants.Store(cacheKey, content)
|
||||
data, err := io.ReadAll(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read content: %w", err)
|
||||
}
|
||||
|
||||
err = c.variants.Write(string(cacheKey), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
size := int64(len(data))
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO variant_content (cache_key, size_bytes)
|
||||
VALUES (?, ?)
|
||||
@@ -238,14 +253,14 @@ func (c *Cache) Source(
|
||||
return nil, 0, ErrNotFound
|
||||
}
|
||||
|
||||
reader, size, err := c.srcContent.LoadWithSize(key)
|
||||
file, size, err := c.srcContent.Open(string(key))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
c.touchSourceContent(key)
|
||||
|
||||
return reader, size, nil
|
||||
return file, size, nil
|
||||
}
|
||||
|
||||
// PutSource stores fetched source content: it writes the file named by
|
||||
@@ -270,11 +285,13 @@ func (c *Cache) PutSource(
|
||||
|
||||
key := src.Key()
|
||||
|
||||
size, err := c.srcContent.Store(key, data)
|
||||
err = c.srcContent.Write(string(key), data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store source content: %w", err)
|
||||
}
|
||||
|
||||
size := int64(len(data))
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO source_content (source_key, size_bytes)
|
||||
VALUES (?, ?)
|
||||
@@ -283,7 +300,7 @@ func (c *Cache) PutSource(
|
||||
last_accessed_at = CURRENT_TIMESTAMP
|
||||
`, string(key), size)
|
||||
if err != nil {
|
||||
deleteErr := c.srcContent.Delete(key)
|
||||
deleteErr := c.srcContent.Delete(string(key))
|
||||
if deleteErr != nil {
|
||||
c.log.Warn("failed to remove source file whose row was not written",
|
||||
"source_key", key, "error", deleteErr)
|
||||
@@ -422,3 +439,30 @@ func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// errNotAnOutputFormat is returned for a variant file whose first bytes are
|
||||
// those of a format pixa reads but never writes, such as SVG.
|
||||
var errNotAnOutputFormat = errors.New("variant file is not in an output format")
|
||||
|
||||
// outputContentType reads the first bytes of content, without moving its
|
||||
// offset, and returns the content type of the output format they show.
|
||||
func outputContentType(content io.ReaderAt) (string, error) {
|
||||
firstBytes := make([]byte, magic.MinMagicBytes)
|
||||
|
||||
_, err := content.ReadAt(firstBytes, 0)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read the first bytes: %w", err)
|
||||
}
|
||||
|
||||
detected, err := magic.DetectFormat(firstBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, isOutputFormat := magic.MIMEToImageFormat(string(detected))
|
||||
if !isOutputFormat {
|
||||
return "", fmt.Errorf("%w: %s", errNotAnOutputFormat, detected)
|
||||
}
|
||||
|
||||
return string(detected), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user