check / check (push) Waiting to run
Cached images were served until eviction, so upstream changes were missed. After 24 hours a request checks the source with upstream, sending its stored ETag and Last-Modified. A 304 renews it with no download or conversion. New bytes replace it; older variants are converted again. A failed check serves the cached image and puts the URL in the negative cache. Images not checked for 30 days are evicted. Variant rows keep the source's ETag and Last-Modified for checking a variant of an evicted source. A variant file's modification time is its source's check time when it was made or renewed. Hits read it without a database call. The ETag includes it, so new bytes get a new ETag. Model: opus-5-5
199 lines
4.8 KiB
Go
199 lines
4.8 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// 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")
|
|
)
|
|
|
|
// FileStore is a directory of files, each named by a hex key. The file for
|
|
// a key is at <basedir>/<ab>/<cd>/<key>, where <ab> and <cd> are the key's
|
|
// first and second pairs of characters. The cache keeps its source images in
|
|
// one FileStore and its variants in another.
|
|
type FileStore struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewFileStore creates the directory baseDir if it is missing and returns
|
|
// the FileStore of the files in it.
|
|
func NewFileStore(baseDir string) (*FileStore, error) {
|
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
|
}
|
|
|
|
return &FileStore{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// Write writes data with writeFileSynced to the file for key, replacing any
|
|
// file already there.
|
|
func (s *FileStore) Write(key string, data []byte) error {
|
|
path := s.keyToPath(key)
|
|
|
|
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
return writeFileSynced(path, data)
|
|
}
|
|
|
|
// Open opens the file for key and returns it with its information, which
|
|
// gives its size and its modification time. A missing file is ErrNotFound.
|
|
func (s *FileStore) Open(key string) (*os.File, fs.FileInfo, error) {
|
|
f, err := os.Open(s.keyToPath(key))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
|
|
return nil, nil, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
|
|
return nil, nil, fmt.Errorf("failed to stat content: %w", err)
|
|
}
|
|
|
|
return f, info, nil
|
|
}
|
|
|
|
// Size returns the size in bytes of the file for key. A missing file is
|
|
// ErrNotFound.
|
|
func (s *FileStore) Size(key string) (int64, error) {
|
|
stat, err := os.Stat(s.keyToPath(key))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, ErrNotFound
|
|
}
|
|
|
|
return 0, fmt.Errorf("failed to stat content: %w", err)
|
|
}
|
|
|
|
return stat.Size(), nil
|
|
}
|
|
|
|
// SetModTime sets the modification time of the file for key to modTime,
|
|
// leaving its access time as it is.
|
|
func (s *FileStore) SetModTime(key string, modTime time.Time) error {
|
|
err := os.Chtimes(s.keyToPath(key), time.Time{}, modTime)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set modification time: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Delete removes the file for key. A missing file is not an error.
|
|
func (s *FileStore) Delete(key string) error {
|
|
err := os.Remove(s.keyToPath(key))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete content: %w", err)
|
|
}
|
|
|
|
return 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 *FileStore) keyToPath(key string) string {
|
|
if len(key) < MinHashLength {
|
|
return filepath.Join(s.baseDir, key)
|
|
}
|
|
|
|
return filepath.Join(s.baseDir, key[0:2], key[2:4], key)
|
|
}
|