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>
777 lines
22 KiB
Go
777 lines
22 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultEvictionInterval is how often the background evictor checks
|
|
// cache usage against the configured limit, in addition to the
|
|
// write-pressure wakeups triggered by stores.
|
|
const DefaultEvictionInterval = 5 * time.Minute
|
|
|
|
// evictionBatchSize is how many LRU candidates of each class (variants
|
|
// and source blobs) one eviction pass fetches from the database.
|
|
const evictionBatchSize = 100
|
|
|
|
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
|
// crashed write) must be before reconciliation removes it. Fresh temp
|
|
// files may still belong to an in-flight store.
|
|
const staleTempFileAge = time.Hour
|
|
|
|
// sqliteTimestampLayout matches SQLite's CURRENT_TIMESTAMP format, so
|
|
// timestamps written by reconciliation order correctly against ones
|
|
// written by the hot path.
|
|
const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
|
|
|
// tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache
|
|
// writes (".tmp-*" patterns in the storage layer).
|
|
const tempFilePrefix = ".tmp-"
|
|
|
|
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
|
// to each variant file.
|
|
const variantMetaSuffix = ".meta"
|
|
|
|
// fallbackContentType is recorded when a reconciled variant file has
|
|
// no readable .meta sidecar.
|
|
const fallbackContentType = "application/octet-stream"
|
|
|
|
// UsageBytes returns the total number of bytes of cache content
|
|
// tracked in the database (source content blobs plus processed
|
|
// variants). It never scans the cache directories.
|
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
|
if c.disabled {
|
|
return 0, nil
|
|
}
|
|
|
|
var total int64
|
|
|
|
err := c.db.QueryRowContext(ctx, `
|
|
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
|
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
|
`).Scan(&total)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
|
}
|
|
|
|
return total, nil
|
|
}
|
|
|
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
|
// processed variant (isVariant true, identified by cacheKey) or a
|
|
// source content blob (identified by contentHash).
|
|
type evictionCandidate struct {
|
|
isVariant bool
|
|
cacheKey VariantKey
|
|
contentHash ContentHash
|
|
sizeBytes int64
|
|
lastAccessedAt string
|
|
}
|
|
|
|
// EvictToLimit evicts least-recently-used cache entries until total
|
|
// tracked usage is at or below the configured MaxBytes limit. It is a
|
|
// no-op when the cache is disabled or no limit is configured.
|
|
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
|
if c.disabled || c.config.MaxBytes <= 0 {
|
|
return nil
|
|
}
|
|
|
|
for {
|
|
usage, err := c.UsageBytes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if usage <= c.config.MaxBytes {
|
|
return nil
|
|
}
|
|
|
|
freed, err := c.evictBatch(ctx, usage-c.config.MaxBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if freed == 0 {
|
|
c.log.Warn("cache eviction made no progress",
|
|
"usage_bytes", usage,
|
|
"cache_max_bytes", c.config.MaxBytes,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
c.log.Info("evicted cache content",
|
|
"freed_bytes", freed,
|
|
"usage_bytes", usage-freed,
|
|
"cache_max_bytes", c.config.MaxBytes,
|
|
)
|
|
}
|
|
}
|
|
|
|
// evictBatch fetches one batch of LRU candidates across variants and
|
|
// source blobs and evicts them oldest-first until excessBytes are
|
|
// freed or the batch is exhausted. It returns the bytes freed.
|
|
func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error) {
|
|
candidates, err := c.evictionCandidates(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var freed int64
|
|
|
|
for _, candidate := range candidates {
|
|
if freed >= excessBytes {
|
|
break
|
|
}
|
|
|
|
if err := c.evictCandidate(ctx, candidate); err != nil {
|
|
c.log.Warn("failed to evict cache entry",
|
|
"cache_key", candidate.cacheKey,
|
|
"content_hash", candidate.contentHash,
|
|
"error", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
freed += candidate.sizeBytes
|
|
}
|
|
|
|
return freed, nil
|
|
}
|
|
|
|
// evictCandidate removes a single eviction victim.
|
|
func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate) error {
|
|
if candidate.isVariant {
|
|
return c.evictVariant(ctx, candidate.cacheKey)
|
|
}
|
|
|
|
return c.evictSourceBlob(ctx, candidate.contentHash)
|
|
}
|
|
|
|
// evictionCandidates returns up to evictionBatchSize variants and
|
|
// evictionBatchSize source blobs, merged into a single list ordered by
|
|
// last access time (oldest first).
|
|
func (c *Cache) evictionCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
|
variants, err := c.variantCandidates(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sources, err := c.sourceCandidates(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Merge the two lists, each already sorted oldest-first. SQLite
|
|
// CURRENT_TIMESTAMP strings compare correctly lexicographically.
|
|
merged := make([]evictionCandidate, 0, len(variants)+len(sources))
|
|
|
|
for len(variants) > 0 && len(sources) > 0 {
|
|
if variants[0].lastAccessedAt <= sources[0].lastAccessedAt {
|
|
merged = append(merged, variants[0])
|
|
variants = variants[1:]
|
|
} else {
|
|
merged = append(merged, sources[0])
|
|
sources = sources[1:]
|
|
}
|
|
}
|
|
|
|
merged = append(merged, variants...)
|
|
merged = append(merged, sources...)
|
|
|
|
return merged, nil
|
|
}
|
|
|
|
// variantCandidates returns the least recently used variants.
|
|
func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
|
rows, err := c.db.QueryContext(ctx, `
|
|
SELECT cache_key, size_bytes, last_accessed_at
|
|
FROM variant_content
|
|
ORDER BY last_accessed_at ASC, cache_key ASC
|
|
LIMIT ?
|
|
`, evictionBatchSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query variant eviction candidates: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var candidates []evictionCandidate
|
|
|
|
for rows.Next() {
|
|
candidate := evictionCandidate{isVariant: true}
|
|
|
|
var key string
|
|
if err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt); err != nil {
|
|
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
|
}
|
|
|
|
candidate.cacheKey = VariantKey(key)
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("variant candidate iteration failed: %w", err)
|
|
}
|
|
|
|
return candidates, nil
|
|
}
|
|
|
|
// sourceCandidates returns the least recently used source blobs. Rows
|
|
// written before the LRU column existed fall back to fetched_at.
|
|
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
|
rows, err := c.db.QueryContext(ctx, `
|
|
SELECT content_hash, size_bytes,
|
|
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
|
FROM source_content
|
|
ORDER BY lru ASC, content_hash ASC
|
|
LIMIT ?
|
|
`, evictionBatchSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var candidates []evictionCandidate
|
|
|
|
for rows.Next() {
|
|
var candidate evictionCandidate
|
|
|
|
var hash string
|
|
if err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt); err != nil {
|
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
|
}
|
|
|
|
candidate.contentHash = ContentHash(hash)
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("source candidate iteration failed: %w", err)
|
|
}
|
|
|
|
return candidates, nil
|
|
}
|
|
|
|
// evictVariant removes one variant: accounting row first, then the
|
|
// content and .meta files, so the database never references a deleted
|
|
// file.
|
|
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
|
_, err := c.db.ExecContext(ctx,
|
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
|
}
|
|
|
|
if err := c.variants.DeleteWithMeta(cacheKey); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sourceReference identifies one source_metadata row's JSON sidecar.
|
|
type sourceReference struct {
|
|
host string
|
|
pathHash PathHash
|
|
}
|
|
|
|
// evictSourceBlob removes one source content blob. All source_metadata
|
|
// rows referencing the blob are deleted together with 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 database rows never
|
|
// point at deleted files. The JSON metadata sidecars for the removed
|
|
// rows are deleted afterwards.
|
|
//
|
|
// The whole operation holds the content hash's lock (the same one
|
|
// StoreSource holds for its full store), so a concurrent store of
|
|
// identical content bytes can never observe the file gone but a row
|
|
// still present, or insert a fresh row between this transaction's
|
|
// commit and the file unlink below: it either runs entirely before
|
|
// this eviction starts, or is blocked until this eviction (row
|
|
// deletion and unlink together) has fully completed.
|
|
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
|
unlock := c.contentLocks.Lock(string(contentHash))
|
|
defer unlock()
|
|
|
|
references, err := c.sourceReferences(ctx, contentHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := c.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
|
}
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash)); err != nil {
|
|
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash)); err != nil {
|
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
|
}
|
|
|
|
if c.evictSourceBlobTestHook != nil {
|
|
c.evictSourceBlobTestHook(contentHash)
|
|
}
|
|
|
|
// Only after the rows are gone may the files be removed.
|
|
for _, reference := range references {
|
|
if err := c.srcMetadata.Delete(reference.host, reference.pathHash); err != nil {
|
|
c.log.Warn("failed to delete metadata sidecar",
|
|
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
|
}
|
|
}
|
|
|
|
if err := c.srcContent.Delete(contentHash); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sourceReferences lists the metadata sidecar locations of every
|
|
// source_metadata row referencing the given blob.
|
|
func (c *Cache) sourceReferences(
|
|
ctx context.Context, contentHash ContentHash,
|
|
) ([]sourceReference, error) {
|
|
rows, err := c.db.QueryContext(ctx, `
|
|
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
|
`, string(contentHash))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query source references: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var references []sourceReference
|
|
|
|
for rows.Next() {
|
|
var reference sourceReference
|
|
|
|
var pathHash string
|
|
if err := rows.Scan(&reference.host, &pathHash); err != nil {
|
|
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
|
}
|
|
|
|
reference.pathHash = PathHash(pathHash)
|
|
references = append(references, reference)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
|
}
|
|
|
|
return references, nil
|
|
}
|
|
|
|
// notifyWritePressure wakes the background evictor after a store, so
|
|
// eviction under write pressure happens promptly without blocking the
|
|
// storing request. The notification channel has capacity one and drops
|
|
// when a wakeup is already pending.
|
|
func (c *Cache) notifyWritePressure() {
|
|
if c.disabled || c.config.MaxBytes <= 0 {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case c.evictionPressure <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// StartEviction launches the background eviction goroutine, which
|
|
// reconciles the database accounting with the cache directories at
|
|
// startup and again on every periodic tick thereafter, and evicts to
|
|
// the configured limit on the given periodic interval and on
|
|
// write-pressure notifications. It is a no-op on a disabled cache or
|
|
// when already started.
|
|
func (c *Cache) StartEviction(interval time.Duration) {
|
|
if c.disabled || c.evictionStarted {
|
|
return
|
|
}
|
|
|
|
c.evictionStarted = true
|
|
|
|
go c.evictionLoop(interval)
|
|
}
|
|
|
|
// StopEviction stops the background eviction goroutine and waits for
|
|
// it to exit. It is safe to call when eviction was never started, and
|
|
// safe to call more than once.
|
|
func (c *Cache) StopEviction() {
|
|
if !c.evictionStarted {
|
|
return
|
|
}
|
|
|
|
c.evictionStopOnce.Do(func() {
|
|
close(c.evictionStop)
|
|
<-c.evictionDone
|
|
})
|
|
}
|
|
|
|
// evictionLoop is the body of the background eviction goroutine.
|
|
func (c *Cache) evictionLoop(interval time.Duration) {
|
|
defer close(c.evictionDone)
|
|
|
|
ctx := context.Background()
|
|
|
|
c.runReconciliationPass(ctx)
|
|
c.runEvictionPass(ctx)
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-c.evictionStop:
|
|
return
|
|
case <-ticker.C:
|
|
// Reconciliation walks the cache directories, so it only
|
|
// runs on the periodic ticker rather than on every
|
|
// write-pressure wakeup, keeping it off the per-store hot
|
|
// path. Reusing the eviction interval itself (rather than a
|
|
// separate, longer one) is a deliberate choice: it is the
|
|
// simplest option that still bounds how long a store's
|
|
// best-effort accounting insert can stay silently
|
|
// unaccounted for to one interval, on a process that is
|
|
// already running this loop regardless.
|
|
c.runReconciliationPass(ctx)
|
|
case <-c.evictionPressure:
|
|
}
|
|
|
|
c.runEvictionPass(ctx)
|
|
}
|
|
}
|
|
|
|
// runEvictionPass runs one eviction pass, logging failures instead of
|
|
// propagating them (the loop must keep running).
|
|
func (c *Cache) runEvictionPass(ctx context.Context) {
|
|
if err := c.EvictToLimit(ctx); err != nil {
|
|
c.log.Warn("cache eviction pass failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// runReconciliationPass runs one reconciliation pass, logging failures
|
|
// instead of propagating them (the loop must keep running).
|
|
func (c *Cache) runReconciliationPass(ctx context.Context) {
|
|
if err := c.reconcileAccounting(ctx); err != nil {
|
|
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// reconcileAccounting synchronizes the database size accounting with
|
|
// the actual contents of the cache directories. It runs at startup and
|
|
// again on every periodic eviction tick thereafter, off the request
|
|
// hot path: it adopts variant files that predate the accounting table
|
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
|
// insert under transient DB contention), drops accounting rows whose
|
|
// files are missing, removes source blob files the database does not
|
|
// know (and rows whose files are gone), and sweeps stale temp files
|
|
// left behind by crashed writes. Running it periodically, not just
|
|
// once, bounds how long such drift can accumulate unaccounted for on a
|
|
// long-running process to one eviction interval.
|
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|
if c.disabled {
|
|
return nil
|
|
}
|
|
|
|
if err := c.reconcileVariantFiles(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := c.reconcileVariantRows(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := c.reconcileSourceFiles(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := c.reconcileSourceRows(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// reconcileVariantFiles walks the variant storage directory, adopting
|
|
// files without accounting rows and sweeping stale temp files.
|
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
|
return filepath.WalkDir(c.variants.baseDir, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil || entry.IsDir() {
|
|
return err
|
|
}
|
|
|
|
name := entry.Name()
|
|
|
|
if strings.HasPrefix(name, tempFilePrefix) {
|
|
c.sweepStaleTempFile(path, entry)
|
|
|
|
return nil
|
|
}
|
|
|
|
if strings.HasSuffix(name, variantMetaSuffix) {
|
|
return nil
|
|
}
|
|
|
|
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
|
})
|
|
}
|
|
|
|
// adoptVariantFile inserts an accounting row for a variant file that
|
|
// has none, using the file's size and modification time.
|
|
func (c *Cache) adoptVariantFile(
|
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
|
) error {
|
|
var rowExists int
|
|
|
|
err := c.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
|
).Scan(&rowExists)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
|
}
|
|
|
|
if rowExists > 0 {
|
|
return nil
|
|
}
|
|
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stat variant file: %w", err)
|
|
}
|
|
|
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
|
contentType := c.variantContentTypeFromSidecar(path)
|
|
|
|
_, err = c.db.ExecContext(ctx, `
|
|
INSERT INTO variant_content
|
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
|
}
|
|
|
|
c.log.Info("adopted untracked variant file into size accounting",
|
|
"cache_key", cacheKey, "size_bytes", info.Size())
|
|
|
|
return nil
|
|
}
|
|
|
|
// variantContentTypeFromSidecar reads the content type from a variant
|
|
// .meta sidecar, falling back to application/octet-stream.
|
|
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
|
metaData, err := os.ReadFile(variantPath + variantMetaSuffix) //nolint:gosec // path from cache walk
|
|
if err != nil {
|
|
return fallbackContentType
|
|
}
|
|
|
|
var meta VariantMeta
|
|
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
|
return fallbackContentType
|
|
}
|
|
|
|
return meta.ContentType
|
|
}
|
|
|
|
// reconcileVariantRows drops accounting rows whose variant files are
|
|
// missing, so the database never references deleted content.
|
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
|
keys, err := c.allVariantKeys(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, key := range keys {
|
|
if c.variants.Exists(key) {
|
|
continue
|
|
}
|
|
|
|
if _, err := c.db.ExecContext(ctx,
|
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(key)); err != nil {
|
|
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
|
}
|
|
|
|
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// allVariantKeys returns every tracked variant cache key.
|
|
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
|
|
rows, err := c.db.QueryContext(ctx, `SELECT cache_key FROM variant_content`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query variant keys: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var keys []VariantKey
|
|
|
|
for rows.Next() {
|
|
var key string
|
|
if err := rows.Scan(&key); err != nil {
|
|
return nil, fmt.Errorf("failed to scan variant key: %w", err)
|
|
}
|
|
|
|
keys = append(keys, VariantKey(key))
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("variant key iteration failed: %w", err)
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// reconcileSourceFiles walks the source content directory, removing
|
|
// blob files the database does not track (they are unreachable: source
|
|
// lookups always go through source_metadata) and sweeping stale temp
|
|
// files.
|
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
|
return filepath.WalkDir(c.srcContent.baseDir, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil || entry.IsDir() {
|
|
return err
|
|
}
|
|
|
|
name := entry.Name()
|
|
|
|
if strings.HasPrefix(name, tempFilePrefix) {
|
|
c.sweepStaleTempFile(path, entry)
|
|
|
|
return nil
|
|
}
|
|
|
|
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
|
})
|
|
}
|
|
|
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
|
// source_content row. Any source_metadata rows referencing the hash
|
|
// are removed first so no row ever points at a deleted file.
|
|
func (c *Cache) removeUntrackedSourceFile(
|
|
ctx context.Context, path string, contentHash ContentHash,
|
|
) error {
|
|
var rowExists int
|
|
|
|
err := c.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
|
).Scan(&rowExists)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check source content row: %w", err)
|
|
}
|
|
|
|
if rowExists > 0 {
|
|
return nil
|
|
}
|
|
|
|
if _, err := c.db.ExecContext(ctx,
|
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash)); err != nil {
|
|
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
|
}
|
|
|
|
//nolint:gosec // G703: path comes from walking our own cache directory
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
|
}
|
|
|
|
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
|
|
|
return nil
|
|
}
|
|
|
|
// reconcileSourceRows removes source_content rows (and their metadata
|
|
// references and sidecars) whose blob files are missing on disk.
|
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
|
hashes, err := c.allSourceContentHashes(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, hash := range hashes {
|
|
if c.srcContent.Exists(hash) {
|
|
continue
|
|
}
|
|
|
|
// The blob file is already gone; evictSourceBlob removes the
|
|
// rows and sidecars and tolerates the missing file.
|
|
if err := c.evictSourceBlob(ctx, hash); err != nil {
|
|
return err
|
|
}
|
|
|
|
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// allSourceContentHashes returns every tracked source content hash.
|
|
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
|
rows, err := c.db.QueryContext(ctx, `SELECT content_hash FROM source_content`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query source content hashes: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var hashes []ContentHash
|
|
|
|
for rows.Next() {
|
|
var hash string
|
|
if err := rows.Scan(&hash); err != nil {
|
|
return nil, fmt.Errorf("failed to scan content hash: %w", err)
|
|
}
|
|
|
|
hashes = append(hashes, ContentHash(hash))
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("content hash iteration failed: %w", err)
|
|
}
|
|
|
|
return hashes, nil
|
|
}
|
|
|
|
// sweepStaleTempFile removes a temp file left behind by a crashed
|
|
// write once it is old enough that no in-flight store can own it.
|
|
func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if time.Since(info.ModTime()) < staleTempFileAge {
|
|
return
|
|
}
|
|
|
|
//nolint:gosec // G703: path comes from walking our own cache directory
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
|
|
|
|
return
|
|
}
|
|
|
|
c.log.Info("removed stale temp file", "path", path)
|
|
}
|