Some checks failed
check / check (push) Has been cancelled
Implements #51 per the issue DoD and the owner direction comment (issuecomment-44068). ## Behavior **Config: `cache_max_bytes`** (integrates with the #52/#53 validation framework) - Strict int64 parsing via a new `getInt64`/`int64Val` getter in the existing strict-loader pattern; the key is registered in the known-keys list. A SET but invalid value — negative, float, null, non-numeric string, boolean, list — aborts startup with exit 1 naming the key and the offending value. - Explicit values are used exactly as given, any non-negative amount, no floor. `cache_max_bytes: 0` is a valid value that disables the disk cache entirely. - Omitted: after `state_dir` validation, the default resolves to `max(75% of free bytes on the filesystem containing <state_dir>/cache/, 500 MiB)`. The cache directory is created first and statfs runs on that actual path, so the measurement hits the right filesystem. The probe is injectable (`FreeSpaceProbeFunc`) so tests do not depend on the host disk. The effective limit (and disabled state) is logged at startup. **Size accounting** (no directory scans on the hot path) - Migration `002` adds a `variant_content` table — processed variants were previously untracked anywhere — and a `last_accessed_at` column on `source_content`, both indexed. Total usage is two SUM queries. - Stores record accounting rows; cache hits touch the LRU timestamps (same cost class as the existing per-request stats UPDATEs). The variant accounting insert is best-effort with a warning: the reconciliation pass (below) adopts any file that missed its row, and this keeps the pre-migration inline test schema working. **Eviction policy: global LRU across both content classes** - Candidates are the least-recently-used entries from `variant_content` and `source_content` (batched, 100 per class per pass, merged oldest-first by `COALESCE(last_accessed_at, fetched_at)`), evicted until usage is at or below the limit. - Why global LRU: recency of actual use is the best cheap predictor of future use for a CDN-style cache, and treating both classes in one ordering avoids pathologies of class-priority schemes (e.g. evicting every variant before any cold source blob, which would tank hit rate, or the reverse, which would hoard stale sources). Byte-for-byte, the coldest data goes first regardless of what kind it is. LFU-style schemes need more bookkeeping for marginal gain at this scale. - Reference safety (the multi-reference DoD case): evicting a source blob deletes ALL `source_metadata` rows referencing it plus its `source_content` row in a single transaction BEFORE the file is unlinked. A blob referenced by multiple source paths is only ever removed together with all of its references, and DB rows never point at deleted files (the crash window leaves at worst an orphaned file, which reconciliation sweeps). The JSON metadata sidecars for removed rows are deleted as well. **Triggers, off the request path** - A background goroutine (started in the handlers OnStart hook, stopped in OnStop) runs an eviction pass on a periodic ticker (5 min) and on write pressure: every store sends a non-blocking notification on a capacity-1 channel. Requests never wait on eviction. - On startup the goroutine first reconciles accounting with the disk (off the hot path): adopts untracked variant files (size/mtime from disk, content type from the `.meta` sidecar), drops accounting rows whose files are missing, removes source blob files the DB does not know (unreachable, since lookups go through `source_metadata`), removes rows whose files are gone, and sweeps `.tmp-*` files older than an hour. **`cache_max_bytes: 0` disables the disk cache** - No cache directories are created, lookups always miss, `StoreSource`/`StoreVariant` are no-ops, no evictor runs; every request fetches and processes uncached. Verified end-to-end (below). ## Notes for review - At the `imgcache.CacheConfig` layer, disabling is an explicit `DisableDiskCache` flag rather than `MaxBytes == 0`, because existing test fixtures construct `CacheConfig` without `MaxBytes` and rely on the legacy "no limit" behavior; per repo rules those tests were not touched. The config layer maps `cache_max_bytes: 0` to the flag in `handlers`. `MaxBytes == 0` at that layer means "no limit enforced" and is unreachable from production config (the computed default is always at least 500 MiB). - The negative cache stays active in disabled mode: it is DB-backed (TTL-expired rows in SQLite), not part of the disk cache this issue bounds, and it protects against hammering failing upstreams. Flagging explicitly since the direction said "no cache reads, no cache writes" — I read that as the disk cache; happy to disable it too if intended. - One deviation from pure red/green: after the red commit I extended the new-test fixture helper (`newEvictionTestCache`) to pass `DisableDiskCache: maxBytes == 0`, mirroring the production mapping, when the flag design emerged. Assertions were not touched; no pre-existing tests were modified. - Sidecar files (`.meta`, metadata JSON) are not counted in usage; they are bounded by entry counts and small (tens of bytes to ~1 KiB per entry) while content bytes dominate. Documented here for transparency. - Discovered while working: `Cache.Stats` reads the never-populated `output_content`/`request_cache` tables, so `TotalItems`/`TotalSizeBytes` are always 0. Out of scope here; filing as a separate issue. ## Verification - TDD: commit `3963ec3` adds the failing tests first (18 new tests covering strict parsing, default computation with injected probe including floor and 75% branches, explicit-no-floor, zero-disables, size accounting, dedup accounting, LRU order, multi-reference blob eviction with the no-dangling-references invariant, under-limit no-op, write-pressure trigger, periodic trigger, reconciliation); implementation follows in `8cb09b6`/`bdd86a4` until green. - `make check` green (all tests, lint 0 issues, fmt-check) at HEAD. - Pinned CI lint gate: `docker build --target lint .` green (golangci-lint v2.10.1). - End-to-end with the built binary: - omitted key: startup logs `computed default cache size limit from free space` and `effective cache size limit` (75% of the test host's free space); - `cache_max_bytes: banana`: exit 1 with `config key "cache_max_bytes": value "banana" is not an integer`; - `cache_max_bytes: 0`: `cache_disabled=true` logged, two identical requests both fetch upstream (2 upstream fetches logged), 200 `image/jpeg` responses, no `cache/` directory created, only `state.sqlite3` in the state dir; - enabled: second request served from cache (1 upstream fetch), `variant_content` and `source_content` rows match the on-disk file sizes. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #55 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
548 lines
15 KiB
Go
548 lines
15 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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")
|
|
)
|
|
|
|
// ContentHash is a SHA256 hash of file content (hex-encoded).
|
|
type ContentHash string
|
|
|
|
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
|
|
type VariantKey string
|
|
|
|
// PathHash is a SHA256 hash of a URL path (hex-encoded).
|
|
type PathHash string
|
|
|
|
// ContentStorage handles content-addressable file storage.
|
|
// Files are stored at: <basedir>/<ab>/<cd>/<abcdef...sha256>
|
|
type ContentStorage struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewContentStorage creates a new content storage at the given base directory.
|
|
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
|
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
|
}
|
|
|
|
return &ContentStorage{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// Store writes content to storage and returns its SHA256 hash.
|
|
// The content is read fully into memory to compute the hash before writing.
|
|
func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err error) {
|
|
// Read all content to compute hash
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("failed to read content: %w", err)
|
|
}
|
|
|
|
// Compute hash
|
|
h := sha256.Sum256(data)
|
|
hash = ContentHash(hex.EncodeToString(h[:]))
|
|
size = int64(len(data))
|
|
|
|
if err := s.writeIfAbsent(hash, data); err != nil {
|
|
return "", 0, err
|
|
}
|
|
|
|
return hash, size, nil
|
|
}
|
|
|
|
// StoreHashed writes pre-hashed content to storage at the path derived
|
|
// from hash, without recomputing it. Callers that already know the
|
|
// hash before writing (e.g. because they must hold a hash-keyed lock
|
|
// across the whole store operation) use this instead of Store. Like
|
|
// Store, it is idempotent: content already on disk at that path is
|
|
// left untouched.
|
|
func (s *ContentStorage) StoreHashed(hash ContentHash, data []byte) (size int64, err error) {
|
|
if err := s.writeIfAbsent(hash, data); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return int64(len(data)), nil
|
|
}
|
|
|
|
// writeIfAbsent writes data to the path derived from hash, unless
|
|
// content already exists there, via a temp-file-plus-rename so
|
|
// concurrent readers never observe a partial file.
|
|
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) (err error) {
|
|
// Build path: <basedir>/<ab>/<cd>/<hash>
|
|
path := s.hashToPath(hash)
|
|
|
|
// Check if already exists
|
|
if _, statErr := os.Stat(path); statErr == nil {
|
|
return nil
|
|
}
|
|
|
|
// Create directory structure
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Write to temp file first, then rename for atomicity
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
_ = tmpFile.Close()
|
|
|
|
return fmt.Errorf("failed to write content: %w", err)
|
|
}
|
|
|
|
if err := tmpFile.Close(); err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
//nolint:gosec // G703: paths from internal SHA256 hashes
|
|
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Load returns a reader for the content with the given hash.
|
|
func (s *ContentStorage) Load(hash ContentHash) (io.ReadCloser, error) {
|
|
path := s.hashToPath(hash)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from content hash
|
|
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 hash.
|
|
func (s *ContentStorage) LoadWithSize(hash ContentHash) (io.ReadCloser, int64, error) {
|
|
path := s.hashToPath(hash)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from content hash
|
|
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 hash.
|
|
func (s *ContentStorage) Delete(hash ContentHash) error {
|
|
path := s.hashToPath(hash)
|
|
|
|
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 hash exists.
|
|
func (s *ContentStorage) Exists(hash ContentHash) bool {
|
|
path := s.hashToPath(hash)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// hashToPath converts a hash to a file path: <basedir>/<ab>/<cd>/<hash>
|
|
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
|
h := string(hash)
|
|
if len(h) < MinHashLength {
|
|
return filepath.Clean(filepath.Join(s.baseDir, h))
|
|
}
|
|
|
|
return filepath.Clean(filepath.Join(s.baseDir, h[0:2], h[2:4], h))
|
|
}
|
|
|
|
// MetadataStorage handles JSON metadata file storage.
|
|
// Files are stored at: <basedir>/<hostname>/<path_hash>.json
|
|
type MetadataStorage struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
|
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
|
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
|
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
|
}
|
|
|
|
return &MetadataStorage{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// SourceMetadata represents cached metadata about a source URL.
|
|
type SourceMetadata struct {
|
|
Host string `json:"host"`
|
|
Path string `json:"path"`
|
|
Query string `json:"query,omitempty"`
|
|
ContentHash string `json:"content_hash,omitempty"`
|
|
StatusCode int `json:"status_code"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
ContentLength int64 `json:"content_length,omitempty"`
|
|
ResponseHeaders map[string][]string `json:"response_headers,omitempty"`
|
|
FetchedAt int64 `json:"fetched_at"`
|
|
FetchDurationMs int64 `json:"fetch_duration_ms,omitempty"`
|
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
|
ETag string `json:"etag,omitempty"`
|
|
LastModified string `json:"last_modified,omitempty"`
|
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
}
|
|
|
|
// Store writes metadata to storage.
|
|
func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMetadata) error {
|
|
path := s.metaPath(host, pathHash)
|
|
|
|
// Create directory structure
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Marshal to JSON
|
|
data, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
// Write to temp file first, then rename for atomicity
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*.json")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
_ = tmpFile.Close()
|
|
|
|
return fmt.Errorf("failed to write metadata: %w", err)
|
|
}
|
|
|
|
if err := tmpFile.Close(); err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
//nolint:gosec // G703: paths from internal SHA256 hashes
|
|
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Load reads metadata from storage.
|
|
func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata, error) {
|
|
path := s.metaPath(host, pathHash)
|
|
|
|
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to read metadata: %w", err)
|
|
}
|
|
|
|
var meta SourceMetadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
|
}
|
|
|
|
return &meta, nil
|
|
}
|
|
|
|
// Delete removes metadata for the given host and path hash.
|
|
func (s *MetadataStorage) Delete(host string, pathHash PathHash) error {
|
|
path := s.metaPath(host, pathHash)
|
|
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Exists checks if metadata exists for the given host and path hash.
|
|
func (s *MetadataStorage) Exists(host string, pathHash PathHash) bool {
|
|
path := s.metaPath(host, pathHash)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// metaPath returns the file path for metadata: <basedir>/<host>/<path_hash>.json
|
|
func (s *MetadataStorage) metaPath(host string, pathHash PathHash) string {
|
|
return filepath.Clean(filepath.Join(s.baseDir, host, string(pathHash)+".json"))
|
|
}
|
|
|
|
// HashPath computes the SHA256 hash of a path string.
|
|
func HashPath(path string) PathHash {
|
|
h := sha256.Sum256([]byte(path))
|
|
|
|
return PathHash(hex.EncodeToString(h[:]))
|
|
}
|
|
|
|
// CacheKey generates a unique key for a request variant.
|
|
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
|
|
func CacheKey(req *ImageRequest) VariantKey {
|
|
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
|
|
req.SourceHost,
|
|
req.SourcePath,
|
|
req.SourceQuery,
|
|
req.Size.Width,
|
|
req.Size.Height,
|
|
req.Format,
|
|
req.Quality,
|
|
req.FitMode,
|
|
)
|
|
h := sha256.Sum256([]byte(data))
|
|
|
|
return VariantKey(hex.EncodeToString(h[:]))
|
|
}
|
|
|
|
// 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
|
|
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
|
type VariantStorage struct {
|
|
baseDir string
|
|
}
|
|
|
|
// VariantMeta contains metadata about a cached variant.
|
|
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.
|
|
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
|
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
|
}
|
|
|
|
return &VariantStorage{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// Store writes content and metadata to storage at the given key.
|
|
func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string) (size int64, err 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"
|
|
|
|
// Create directory structure
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Write content to temp file first, then rename for atomicity
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
_ = tmpFile.Close()
|
|
|
|
return 0, fmt.Errorf("failed to write content: %w", err)
|
|
}
|
|
|
|
if err := tmpFile.Close(); err != nil {
|
|
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename content
|
|
//nolint:gosec // G703: paths from internal SHA256 hashes
|
|
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
|
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
// Write metadata
|
|
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)
|
|
}
|
|
|
|
if err := os.WriteFile(metaPath, metaData, StorageFilePerm); err != nil {
|
|
// Non-fatal, content is stored
|
|
_ = err
|
|
}
|
|
|
|
return size, nil
|
|
}
|
|
|
|
// Load returns a reader for the content at the given key.
|
|
func (s *VariantStorage) Load(key 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
|
|
}
|
|
|
|
// 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) {
|
|
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, "", 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)
|
|
}
|
|
|
|
// Load metadata for content type
|
|
contentType := "application/octet-stream" // fallback
|
|
|
|
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, stat.Size(), contentType, nil
|
|
}
|
|
|
|
// Exists checks if content exists at the given key.
|
|
func (s *VariantStorage) Exists(key VariantKey) bool {
|
|
path := s.keyToPath(key)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// Delete removes content at the given key.
|
|
func (s *VariantStorage) Delete(key 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)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteWithMeta removes the content at the given key together with
|
|
// its .meta sidecar file. A missing file is not an error.
|
|
func (s *VariantStorage) DeleteWithMeta(key VariantKey) error {
|
|
if err := s.Delete(key); err != nil {
|
|
return err
|
|
}
|
|
|
|
metaPath := s.keyToPath(key) + ".meta"
|
|
|
|
//nolint:gosec // G703: path derived from cache key
|
|
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 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)
|
|
}
|