check / check (push) Canceled after 0s
A source is now one source_content row and one file named by its SourceKey, the SHA-256 of its host, path and query, made as the variant key is. A store writes the file, then upserts the row, and removes the file when the row cannot be written; an eviction deletes the row, then the file, with no lock. source_metadata, the JSON files under cache/metadata/, MetadataStorage, the content lock, the eviction transaction and its test hook are gone, as is the reconciliation pass that dropped a source row whose file is missing. Two source URLs serving the same bytes store them twice. Model: opus-5-5
392 lines
9.7 KiB
Go
392 lines
9.7 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
)
|
|
|
|
// Storage constants.
|
|
const (
|
|
// StorageDirPerm is the permission mode for storage directories.
|
|
StorageDirPerm = 0750
|
|
// StorageFilePerm is the permission mode for storage files.
|
|
StorageFilePerm = 0600
|
|
// MinHashLength is the minimum hash length for path splitting.
|
|
MinHashLength = 4
|
|
)
|
|
|
|
// Storage errors.
|
|
var (
|
|
ErrNotFound = errors.New("content not found")
|
|
)
|
|
|
|
// ContentStorage handles source image file storage, one file per source
|
|
// URL. Files are stored at: <basedir>/<ab>/<cd>/<source key>
|
|
type ContentStorage struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewContentStorage creates a new content storage at the given base directory.
|
|
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
|
}
|
|
|
|
return &ContentStorage{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// Store writes data with writeFileSynced to the path derived from key,
|
|
// replacing any file already there, and returns its size.
|
|
func (s *ContentStorage) Store(
|
|
key imagerequest.SourceKey, data []byte,
|
|
) (int64, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
err = writeFileSynced(path, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return int64(len(data)), nil
|
|
}
|
|
|
|
// Load returns a reader for the content with the given key.
|
|
func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from source key
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// LoadWithSize returns a reader and file size for the content with the given key.
|
|
func (s *ContentStorage) LoadWithSize(
|
|
key imagerequest.SourceKey,
|
|
) (io.ReadCloser, int64, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from source key
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, 0, ErrNotFound
|
|
}
|
|
|
|
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 f, stat.Size(), nil
|
|
}
|
|
|
|
// Delete removes content with the given key.
|
|
func (s *ContentStorage) Delete(key imagerequest.SourceKey) error {
|
|
path := s.keyToPath(key)
|
|
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete content: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Exists checks if content with the given key exists.
|
|
func (s *ContentStorage) Exists(key imagerequest.SourceKey) bool {
|
|
path := s.keyToPath(key)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// writeFileSynced writes data to path through a temp file in the same
|
|
// directory: the temp file is synced to disk, renamed to path, and the
|
|
// directory synced after, so that neither a concurrent reader nor a
|
|
// crash finds a partial file under path. When the directory sync fails,
|
|
// the file is removed from path, so that a failed write leaves no file
|
|
// there.
|
|
func writeFileSynced(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
tmpPath := tmpFile.Name()
|
|
|
|
_, err = tmpFile.Write(data)
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to write temp file: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Sync()
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
err = syncDirectory(dir)
|
|
if err != nil {
|
|
_ = os.Remove(path)
|
|
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// syncDirectory syncs the directory dir to disk, so that a file just
|
|
// renamed into it is still there after a crash.
|
|
func syncDirectory(dir string) error {
|
|
d, err := os.Open(filepath.Clean(dir))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open directory: %w", err)
|
|
}
|
|
|
|
err = d.Sync()
|
|
_ = d.Close()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
|
func (s *ContentStorage) keyToPath(key imagerequest.SourceKey) string {
|
|
k := string(key)
|
|
if len(k) < MinHashLength {
|
|
return filepath.Clean(filepath.Join(s.baseDir, k))
|
|
}
|
|
|
|
return filepath.Clean(filepath.Join(s.baseDir, k[0:2], k[2:4], k))
|
|
}
|
|
|
|
// VariantStorage handles key-based file storage for processed image variants.
|
|
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
|
|
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
|
|
type VariantStorage struct {
|
|
baseDir string
|
|
log *slog.Logger
|
|
}
|
|
|
|
// VariantMeta contains metadata about a cached variant.
|
|
//
|
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
|
type VariantMeta struct {
|
|
ContentType string `json:"content_type"`
|
|
Size int64 `json:"size"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
}
|
|
|
|
// NewVariantStorage creates a new variant storage at the given base directory.
|
|
// A failed .meta write is logged to log.
|
|
func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) {
|
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
|
}
|
|
|
|
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
|
}
|
|
|
|
// Store writes content and metadata to storage at the given key, each
|
|
// with writeFileSynced. The .meta file is written first, so that a crash
|
|
// cannot leave the content in place without it.
|
|
func (s *VariantStorage) Store(
|
|
key imagerequest.VariantKey, r io.Reader, contentType string,
|
|
) (int64, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read content: %w", err)
|
|
}
|
|
|
|
size := int64(len(data))
|
|
path := s.keyToPath(key)
|
|
metaPath := path + ".meta"
|
|
|
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
meta := VariantMeta{
|
|
ContentType: contentType,
|
|
Size: size,
|
|
CreatedAt: time.Now().UTC().Unix(),
|
|
}
|
|
|
|
metaData, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
// Metadata write failure is non-fatal: the content is then served as
|
|
// application/octet-stream.
|
|
err = writeFileSynced(metaPath, metaData)
|
|
if err != nil {
|
|
s.log.Warn("failed to write variant metadata sidecar",
|
|
"path", metaPath, "error", err)
|
|
}
|
|
|
|
err = writeFileSynced(path, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return size, nil
|
|
}
|
|
|
|
// Load returns a reader for the content at the given key.
|
|
func (s *VariantStorage) Load(key imagerequest.VariantKey) (io.ReadCloser, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// LoadWithSize returns a reader and file size for the content at the
|
|
// given key.
|
|
func (s *VariantStorage) LoadWithSize(
|
|
key imagerequest.VariantKey,
|
|
) (io.ReadCloser, int64, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
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, 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 f, stat.Size(), nil
|
|
}
|
|
|
|
// LoadWithMeta returns a reader, size, and content type for the content at
|
|
// the given key. The content type is read from the .meta file, and is
|
|
// empty when that file is missing or unreadable.
|
|
func (s *VariantStorage) LoadWithMeta(
|
|
key imagerequest.VariantKey,
|
|
) (io.ReadCloser, int64, string, error) {
|
|
f, size, err := s.LoadWithSize(key)
|
|
if err != nil {
|
|
return nil, 0, "", err
|
|
}
|
|
|
|
var contentType string
|
|
|
|
metaPath := s.keyToPath(key) + ".meta"
|
|
|
|
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
|
if err == nil {
|
|
var meta VariantMeta
|
|
if json.Unmarshal(metaData, &meta) == nil && meta.ContentType != "" {
|
|
contentType = meta.ContentType
|
|
}
|
|
}
|
|
|
|
return f, size, contentType, nil
|
|
}
|
|
|
|
// Exists checks if content exists at the given key.
|
|
func (s *VariantStorage) Exists(key imagerequest.VariantKey) bool {
|
|
path := s.keyToPath(key)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// Delete removes the content at the given key together with its .meta
|
|
// sidecar file. A missing file is not an error.
|
|
func (s *VariantStorage) Delete(key imagerequest.VariantKey) error {
|
|
path := s.keyToPath(key)
|
|
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete content: %w", err)
|
|
}
|
|
|
|
metaPath := path + ".meta"
|
|
|
|
err = os.Remove(metaPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete variant metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
|
func (s *VariantStorage) keyToPath(key imagerequest.VariantKey) string {
|
|
k := string(key)
|
|
if len(k) < MinHashLength {
|
|
return filepath.Join(s.baseDir, k)
|
|
}
|
|
|
|
return filepath.Join(s.baseDir, k[0:2], k[2:4], k)
|
|
}
|