Files
MeBox/internal/service/cache_cleanup.go
T
2026-09-01 23:16:11 +08:00

141 lines
3.6 KiB
Go

// Package service — generic on-disk cleanup helper used by the
// scheduler. Public so handlers can call it for "purge transcode cache
// now" buttons.
package service
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// walkAndPrune recursively deletes every file under root whose mtime is
// older than cutoff. Empty directories left behind are removed too.
// Best-effort: per-file errors are ignored so a single permission denial
// doesn't abort the cleanup.
func walkAndPrune(root string, cutoff time.Time) error {
if root == "" {
return nil
}
if _, err := os.Stat(root); err != nil {
return nil // nothing to clean
}
dirs := []string{}
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if path != root {
dirs = append(dirs, path)
}
return nil
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(path) // #nosec G122 -- cache pruning is best-effort under the configured cache root.
}
return nil
})
// Remove emptied directories from deepest to shallowest.
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return nil
}
// PruneImageCacheResult holds stats from an image cache prune operation.
type PruneImageCacheResult struct {
TotalFilesBefore int
TotalBytesBefore int64
DeletedFiles int
FreedBytes int64
RemainingBytes int64
}
type imageCacheFileEntry struct {
path string
size int64
modTime time.Time
}
// PruneImageCache scans imagesDir for cached image files. If the total disk usage
// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime)
// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes).
//
// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes.
// Empty subdirectories left behind are best-effort removed.
func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
if imagesDir == "" || maxSizeBytes <= 0 {
return result, nil
}
if _, err := os.Stat(imagesDir); err != nil {
return result, nil
}
var (
dirs []string
entries []imageCacheFileEntry
)
_ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if path != imagesDir {
dirs = append(dirs, path)
}
return nil
}
// Skip temporary files created during image download.
name := info.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") {
return nil
}
size := info.Size()
result.TotalFilesBefore++
result.TotalBytesBefore += size
entries = append(entries, imageCacheFileEntry{
path: path,
size: size,
modTime: info.ModTime(),
})
return nil
})
result.RemainingBytes = result.TotalBytesBefore
if result.TotalBytesBefore <= maxSizeBytes {
return result, nil
}
// High/Low watermark: prune down to 80% of max size to leave headroom
// and prevent disk thrashing on consecutive writes.
targetSizeBytes := maxSizeBytes * 80 / 100
sort.Slice(entries, func(i, j int) bool {
return entries[i].modTime.Before(entries[j].modTime)
})
for _, entry := range entries {
if result.RemainingBytes <= targetSizeBytes {
break
}
if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) {
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
}
}
// Clean up emptied subdirectories from deepest to shallowest.
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return result, nil
}