Files
steamcache2/vfs/disk/disk.go
T
s1d3sw1ped b7e3a0da86
Release Tag / release (push) Successful in 34s
Update metrics tracking and enhance cache eviction strategies
- Added metrics for bytes saved from cache to improve performance insights.
- Updated cache eviction strategies in MemoryFS and DiskFS to include metrics tracking for hits and evictions.
- Enhanced README.md with updated garbage collection algorithm descriptions and recommendations for cache usage.
- Introduced new madviseSequential functionality for improved memory access hints on Unix systems.
- Adjusted validation configuration in examples to better reflect realistic usage scenarios.
2026-05-28 10:31:23 -05:00

932 lines
28 KiB
Go

// vfs/disk/disk.go
package disk
import (
"fmt"
"io"
"os"
"path/filepath"
"s1d3sw1ped/steamcache2/steamcache/logger"
"s1d3sw1ped/steamcache2/steamcache/metrics"
"s1d3sw1ped/steamcache2/vfs"
"s1d3sw1ped/steamcache2/vfs/locks"
"s1d3sw1ped/steamcache2/vfs/lru"
"s1d3sw1ped/steamcache2/vfs/types"
"s1d3sw1ped/steamcache2/vfs/vfserror"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/edsrzf/mmap-go"
)
// maxEvictBatch bounds the candidate snapshot during RLock/Lock collect in Evict* (mirrors memory).
const maxEvictBatch = 4096
// Ensure DiskFS implements VFS.
var _ vfs.VFS = (*DiskFS)(nil)
// DiskFS is a virtual file system that stores files on disk.
type DiskFS struct {
root string
info map[string]*vfs.FileInfo
capacity int64
size int64
mu sync.RWMutex
keyLocks []sync.Map // Sharded lock pools for better concurrency
LRU *lru.LRUList[*vfs.FileInfo]
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
// initDone is closed once background population of size/info/LRU finishes; Size() receives on it for the barrier.
initDone chan struct{}
// initCloseOnce ensures initDone closed exactly once even on panic in bg populator (panic safety for Issue 1).
initCloseOnce sync.Once
startupEvict func(vfs.VFS, uint) uint // passed to New (via gc.GetGCAlgorithm); invoked as last step of bg init if over cap (no post-ctor race)
metrics *metrics.Metrics
}
// shardPath converts a Steam cache key to a sharded directory path to reduce inode pressure
func (d *DiskFS) shardPath(key string) string {
if !strings.HasPrefix(key, "steam/") {
return key
}
// Extract hash part
hashPart := key[6:] // Remove "steam/" prefix
if len(hashPart) < 4 {
// For very short hashes, single level sharding
if len(hashPart) >= 2 {
shard1 := hashPart[:2]
return filepath.Join("steam", shard1, hashPart)
}
return filepath.Join("steam", hashPart)
}
// Optimal 2-level sharding for Steam hashes (typically 40 chars)
shard1 := hashPart[:2] // First 2 chars
shard2 := hashPart[2:4] // Next 2 chars
return filepath.Join("steam", shard1, shard2, hashPart)
}
// pathForKey returns the full on-disk path for a key (sharded + normalized).
// Extracted to reduce duplication in Evict*/Delete/Open paths (still safe to call under lock for evict).
func (d *DiskFS) pathForKey(key string) string {
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
return path
}
// filePathToKey reverses a physical on-disk path (under root) back to logical cache key.
// Used by bg init-time scan (from New) to populate info/LRU for correct Size after barrier.
func (d *DiskFS) filePathToKey(fullPath string) string {
rel, err := filepath.Rel(d.root, fullPath)
if err != nil {
return filepath.Base(fullPath)
}
rel = strings.ReplaceAll(rel, "\\", "/")
if strings.HasPrefix(rel, "steam/") {
if hash := filepath.Base(rel); hash != "" && hash != "." {
return "steam/" + hash
}
}
return rel
}
// New creates a new DiskFS.
// The evict param (from gc.GetGCAlgorithm, or nil) is stored before launching the bg
// population goroutine, eliminating any post-New handoff window/race for the relocated
// startup over-capacity guard (now the last step inside calculateSizeAndPopulateIndex).
// New returns fast even for millions of files (async bg scan + streaming batch inserts).
// Callers (e.g. steamcache.New) that need populated state or post-guard size must call Size()
// (or ops that do) which blocks on the internal init barrier until population + optional guard complete.
// See README "Large Cache Initialization" for migration/observable behavior during the proxy window.
func New(root string, capacity int64, evict func(vfs.VFS, uint) uint) (*DiskFS, error) {
if capacity <= 0 {
return nil, fmt.Errorf("disk capacity must be greater than 0")
}
// Create root directory if it doesn't exist. Propagate error (ctor now returns err for hygiene).
// 0700 (not 0755): cache contents are user data from untrusted CDN responses; least-privilege for LAN appliance.
if err := os.MkdirAll(root, 0700); err != nil {
return nil, fmt.Errorf("failed to create root directory %s: %w", root, err)
}
// Initialize sharded locks
keyLocks := make([]sync.Map, locks.NumLockShards)
d := &DiskFS{
root: root,
info: make(map[string]*vfs.FileInfo),
capacity: capacity,
size: 0,
keyLocks: keyLocks,
LRU: lru.NewLRUList[*vfs.FileInfo](),
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
startupEvict: evict,
}
d.initDone = make(chan struct{})
// Launch heavy population asynchronously so New returns fast (scans millions of files without blocking ctor or using O(N) temp RAM).
// The initDone barrier ensures first Size() and subsequent ops (including late tier attach) see fully populated + post-eviction state.
go d.calculateSizeAndPopulateIndex()
return d, nil
}
// SetMetrics allows the owner (SteamCache) to inject the metrics collector
// so that per-tier hit and eviction counters can be recorded.
func (d *DiskFS) SetMetrics(met *metrics.Metrics) {
d.metrics = met
}
// calculateSizeAndPopulateIndex runs in background from New to avoid blocking startup or O(N) RAM for large caches (millions of Steam files).
// It streams batch inserts (bounded by maxEvictBatch) to keep lock times short and eliminate giant temporary slice.
// Startup over-capacity eviction (if needed) runs as the very last step (using the evict func passed to New, selected via gc.GetGCAlgorithm).
// Only then is initDone closed so Size() and waiters see consistent post-eviction state.
// Panic recovery ensures initDone is always closed (unblocks Size callers) even on scan/IO panic; uses Once for safety.
func (d *DiskFS) calculateSizeAndPopulateIndex() {
defer func() {
if r := recover(); r != nil {
logger.Logger.Error().Interface("recovered_panic", r).Msg("calculateSizeAndPopulateIndex panicked; ensuring initDone closed to unblock Size waiters and prevent hang")
}
d.initCloseOnce.Do(func() { close(d.initDone) })
}()
tstart := time.Now()
// Channel for collecting file information (now includes metadata for info/LRU population)
fileChan := make(chan discoveredFile, 1000)
// Progress tracking
var totalFiles int64
var processedFiles int64
progressTicker := time.NewTicker(2 * time.Second)
defer progressTicker.Stop()
// Wait group for workers
var wg sync.WaitGroup
// Start directory scanner
wg.Add(1)
go func() {
defer wg.Done()
defer close(fileChan)
d.scanFilesForSize(d.root, fileChan, &totalFiles)
}()
// Collect results with progress reporting + streaming batch population (no O(N) discovered slice, bounded locks)
var totalSize int64
const batchSize = maxEvictBatch
var batch []discoveredFile
// Use a separate goroutine to collect results
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case df, ok := <-fileChan:
if !ok {
return
}
totalSize += df.size
processedFiles++
batch = append(batch, df)
if len(batch) >= batchSize {
d.insertBatch(batch)
batch = batch[:0]
}
case <-progressTicker.C:
if totalFiles > 0 {
logger.Logger.Debug().
Int64("processed", processedFiles).
Int64("total", totalFiles).
Int64("size", totalSize).
Float64("progress", float64(processedFiles)/float64(totalFiles)*100).
Msg("Background size calculation progress")
}
}
}
}()
// Wait for scanning to complete
wg.Wait()
<-done
// Final partial batch + set (no size stomp: inserts do the += for discovered; concurrent Creates are additive via their paths)
if len(batch) > 0 {
d.insertBatch(batch)
}
logger.Logger.Info().
Int64("files_scanned", processedFiles).
Int64("total_size", totalSize).
Str("duration", time.Since(tstart).String()).
Msg("Size and index population completed")
// Run over-capacity startup eviction here (LAST step of bg init) using freshly populated index+size.
// The func (passed at New time via gc.GetGCAlgorithm) is guaranteed visible (no post-ctor handoff).
// Snapshot size under RLock to eliminate data race on d.size vs concurrent Create/Evict (fixes -race on guard decision).
d.mu.RLock()
overCapacity := d.size > d.capacity
needed := uint(0)
if overCapacity {
needed = uint(d.size - d.capacity) // #nosec G115 -- diff guaranteed >0 by overCapacity check; eviction API takes uint (bytes); fits in practice for cache sizes
}
d.mu.RUnlock()
if overCapacity && d.startupEvict != nil {
d.startupEvict(d, needed)
}
// Signal readiness: Size() and callers (late tier attach + Evict*) now see correct populated + post-eviction state.
// Use Once (recover path also uses it) to guarantee exactly one close even under panic.
d.initCloseOnce.Do(func() { close(d.initDone) })
}
// insertBatch populates info/LRU under lock for a bounded batch (follows maxEvictBatch pattern for short critical sections).
// Size is incremented here only for files actually added (prevents double-count vs. concurrent Create during window).
func (d *DiskFS) insertBatch(batch []discoveredFile) {
d.mu.Lock()
for _, df := range batch {
if _, exists := d.info[df.key]; !exists {
fi := vfs.NewFileInfoFromOS(df.osInfo, df.key)
d.info[df.key] = fi
d.LRU.Add(df.key, fi)
d.size += df.size
}
}
d.mu.Unlock()
}
// discoveredFile carries metadata for (bg) init-time population of info/LRU.
type discoveredFile struct {
key string
size int64
osInfo os.FileInfo
}
// scanFilesForSize performs recursive file scanning for size + metadata (to populate LRU/info via bg streaming in New).
func (d *DiskFS) scanFilesForSize(dirPath string, fileChan chan<- discoveredFile, totalFiles *int64) {
// Use ReadDir for faster directory listing
entries, err := os.ReadDir(dirPath)
if err != nil {
return
}
// Count files first for progress tracking
fileCount := 0
for _, entry := range entries {
if !entry.IsDir() {
fileCount++
}
}
atomic.AddInt64(totalFiles, int64(fileCount))
// Process entries concurrently with limited workers
semaphore := make(chan struct{}, 16) // More workers for size calculation
var wg sync.WaitGroup
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if entry.IsDir() {
// Recursively scan subdirectories
wg.Add(1)
go func(path string) {
defer wg.Done()
semaphore <- struct{}{} // Acquire semaphore
defer func() { <-semaphore }() // Release semaphore
d.scanFilesForSize(path, fileChan, totalFiles)
}(entryPath)
} else {
// Process file for size + key (for LRU/info population)
wg.Add(1)
go func(entry os.DirEntry) {
defer wg.Done()
semaphore <- struct{}{} // Acquire semaphore
defer func() { <-semaphore }() // Release semaphore
fullPath := filepath.Join(dirPath, entry.Name())
key := d.filePathToKey(fullPath)
// Get file info for size calculation
info, err := entry.Info()
if err != nil {
return
}
// Send discovered file info
fileChan <- discoveredFile{
key: key,
size: info.Size(),
osInfo: info,
}
}(entry)
}
}
wg.Wait()
}
// Name returns the name of this VFS
func (d *DiskFS) Name() string {
return "DiskFS"
}
// Size returns the current size.
// The receive on initDone ensures that after New callers observe the real on-disk total + populated info/LRU
// (barrier unblocks only after bg streaming population + any startup eviction finishes).
// All subsequent calls are non-blocking (closed chan receive is instantaneous).
// During long init for huge caches, this (and callers like GetMetrics, attach logic) will block until ready;
// this is the documented contract enabling "no disk activity until ready" for TieredCache.
func (d *DiskFS) Size() int64 {
<-d.initDone
d.mu.RLock()
defer d.mu.RUnlock()
return d.size
}
// Capacity returns the maximum capacity
func (d *DiskFS) Capacity() int64 {
return d.capacity
}
// getKeyLock returns a lock for the given key using sharding
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex {
return locks.GetKeyLock(d.keyLocks, key)
}
// Create creates a new file
func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
if key == "" {
return nil, vfserror.ErrInvalidKey
}
if key[0] == '/' {
return nil, vfserror.ErrInvalidKey
}
// Sanitize key to prevent path traversal
key = filepath.Clean(key)
key = strings.ReplaceAll(key, "\\", "/")
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
keyMu := d.getKeyLock(key)
keyMu.Lock()
defer keyMu.Unlock()
d.mu.Lock()
// Check if file already exists and handle overwrite
if fi, exists := d.info[key]; exists {
d.size -= fi.Size
d.LRU.Remove(key)
delete(d.info, key)
}
path := d.pathForKey(key)
d.mu.Unlock()
dir := filepath.Dir(path)
// 0700 (not 0755): per-shard cache dirs hold untrusted CDN content; restrict to owner only (G301 addressed).
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
file, err := os.Create(path) // #nosec G304 -- path built by pathForKey from sanitized (Clean, no ..) hash-derived key under trusted disk.root; no untrusted file inclusion
if err != nil {
return nil, err
}
fi := vfs.NewFileInfo(key, size)
d.mu.Lock()
d.info[key] = fi
d.LRU.Add(key, fi)
// Initialize access time with current time
fi.UpdateAccessBatched(d.timeUpdater)
// Add to size for new files (not discovered files)
d.size += size
d.mu.Unlock()
return &diskWriteCloser{
file: file,
disk: d,
key: key,
declaredSize: size,
}, nil
}
// diskWriteCloser implements io.WriteCloser for disk files with size adjustment
type diskWriteCloser struct {
file *os.File
disk *DiskFS
key string
declaredSize int64
}
func (dwc *diskWriteCloser) Write(p []byte) (n int, err error) {
return dwc.file.Write(p)
}
func (dwc *diskWriteCloser) Close() error {
// Get the actual file size
stat, err := dwc.file.Stat()
if err != nil {
_ = dwc.file.Close() // best-effort close on stat error path; primary error is returned
return err
}
actualSize := stat.Size()
// Update the size in FileInfo if it differs from declared size
dwc.disk.mu.Lock()
if fi, exists := dwc.disk.info[dwc.key]; exists {
sizeDiff := actualSize - fi.Size
fi.Size = actualSize
dwc.disk.size += sizeDiff
}
dwc.disk.mu.Unlock()
return dwc.file.Close()
}
// Open opens a file for reading with lazy discovery
func (d *DiskFS) Open(key string) (io.ReadCloser, error) {
if key == "" {
return nil, vfserror.ErrInvalidKey
}
if key[0] == '/' {
return nil, vfserror.ErrInvalidKey
}
// Sanitize key to prevent path traversal
key = filepath.Clean(key)
key = strings.ReplaceAll(key, "\\", "/")
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
// First, try to get the file info
d.mu.RLock()
fi, exists := d.info[key]
d.mu.RUnlock()
if !exists {
// Try lazy discovery
var err error
fi, err = d.Stat(key)
if err != nil {
return nil, err
}
}
// Update access time and LRU (use TryLock to avoid serializing all readers on the global mu despite sharding; approximate LRU under load is acceptable)
if d.mu.TryLock() {
fi.UpdateAccessBatched(d.timeUpdater)
d.LRU.MoveToFront(key, d.timeUpdater)
d.mu.Unlock()
}
path := d.pathForKey(key)
file, err := os.Open(path) // #nosec G304 -- path built by pathForKey from sanitized (Clean, no ..) hash-derived key under trusted disk.root; no untrusted file inclusion
if err != nil {
return nil, err
}
// Use memory mapping for large files to improve performance.
// We use 8 MiB as the threshold because:
// - Most Steam chunks are ~1 MiB (see current disk cache analysis).
// - mmap has non-trivial fixed overhead (page tables, TLB, faults).
// - For files < ~4-8 MiB the overhead often outweighs the zero-copy benefit
// on mostly sequential access patterns.
// - Larger files benefit more from kernel readahead + zero-copy.
const mmapThreshold = 8 * 1024 * 1024 // 8 MiB
if fi.Size > mmapThreshold {
// Close the regular file handle
_ = file.Close() // best-effort; mmap path takes over or falls back
// Try memory mapping
mmapFile, err := os.Open(path)
if err != nil {
return nil, err
}
mapped, err := mmap.Map(mmapFile, mmap.RDONLY, 0)
if err != nil {
_ = mmapFile.Close() // best-effort close before fallback open
// Fallback to regular file reading (intentional 3rd open of same path after mmap failure; pre-existing pattern, no leak)
if d.metrics != nil {
d.metrics.IncrementDiskCacheHits()
}
return os.Open(path)
}
// Hint to the kernel (on supported platforms) that we will access
// this mapping sequentially. This enables better readahead.
if err := madviseSequential(mapped); err != nil {
logger.Logger.Debug().
Err(err).
Str("key", key).
Msg("madvise(MADV_SEQUENTIAL) failed on mmap'd chunk")
}
return &mmapReadCloser{
data: mapped,
file: mmapFile,
offset: 0,
}, nil
}
if d.metrics != nil {
d.metrics.IncrementDiskCacheHits()
}
return file, nil
}
// mmapReadCloser implements io.ReadCloser for memory-mapped files
type mmapReadCloser struct {
data mmap.MMap
file *os.File
offset int
}
func (m *mmapReadCloser) Read(p []byte) (n int, err error) {
if m.offset >= len(m.data) {
return 0, io.EOF
}
n = copy(p, m.data[m.offset:])
m.offset += n
return n, nil
}
func (m *mmapReadCloser) Close() error {
_ = m.data.Unmap() // best-effort; unmap failure non-fatal for read-only mapping
return m.file.Close()
}
// Delete removes a file
func (d *DiskFS) Delete(key string) error {
if key == "" {
return vfserror.ErrInvalidKey
}
if key[0] == '/' {
return vfserror.ErrInvalidKey
}
keyMu := d.getKeyLock(key)
keyMu.Lock()
defer keyMu.Unlock()
d.mu.Lock()
fi, exists := d.info[key]
if !exists {
d.mu.Unlock()
return vfserror.ErrNotFound
}
d.size -= fi.Size
d.LRU.Remove(key)
delete(d.info, key)
d.mu.Unlock()
path := d.pathForKey(key)
err := os.Remove(path)
if err != nil {
return err
}
return nil
}
// Stat returns file information with lazy discovery
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
if key == "" {
return nil, vfserror.ErrInvalidKey
}
if key[0] == '/' {
return nil, vfserror.ErrInvalidKey
}
keyMu := d.getKeyLock(key)
// First, try to get the file info with read lock
keyMu.RLock()
d.mu.RLock()
if fi, ok := d.info[key]; ok {
d.mu.RUnlock()
keyMu.RUnlock()
return fi, nil
}
d.mu.RUnlock()
keyMu.RUnlock()
// Lazy discovery: check if file exists on disk and index it
path := d.pathForKey(key)
info, err := os.Stat(path)
if err != nil {
return nil, vfserror.ErrNotFound
}
// File exists, add it to the index with write lock
keyMu.Lock()
defer keyMu.Unlock()
// Double-check after acquiring write lock
d.mu.Lock()
if fi, ok := d.info[key]; ok {
d.mu.Unlock()
return fi, nil
}
// Re-verify the file still exists on disk under the lock before inserting.
// Concurrent eviction (or Delete) could have removed it between the earlier
// unlocked os.Stat and now. Without this, we can end up with a dangling
// entry in d.info whose backing file is gone (observed under heavy eviction + race).
if _, err := os.Stat(path); err != nil {
d.mu.Unlock()
return nil, vfserror.ErrNotFound
}
// Create and add file info
fi := vfs.NewFileInfoFromOS(info, key)
d.info[key] = fi
d.LRU.Add(key, fi)
fi.UpdateAccessBatched(d.timeUpdater)
// Note: size not updated on lazy discovery (preserves prior behavior; initial on-disk accounted via bg populate at New time,
// subsequent files come via Create which accounts size).
d.mu.Unlock()
return fi, nil
}
// EvictLRU evicts the least recently used files to free up space
// Collect under short exclusive Lock (to serialize concurrent EvictLRU on LRUList), batch under WLock.
func (d *DiskFS) EvictLRU(bytesNeeded uint) uint {
d.mu.Lock()
var toEvict []string
need := int64(bytesNeeded)
cur := d.size
for cur > d.capacity-need && d.LRU.Len() > 0 && len(toEvict) < maxEvictBatch {
elem := d.LRU.Back()
if elem == nil {
break
}
fi := elem.Value.(*vfs.FileInfo)
key := fi.Key
d.LRU.Remove(key) // actually remove during collection so Back() advances to distinct items
toEvict = append(toEvict, key)
cur -= fi.Size
}
d.mu.Unlock()
if len(toEvict) == 0 {
return 0
}
d.mu.Lock()
var evicted uint
for _, key := range toEvict {
if fi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path) // #nosec G304 -- path from sanitized key; best-effort eviction delete under lock. Best effort; performed under WLock to guarantee on-disk deletion is coordinated with metadata removal. This eliminates resurrection via lazy Stat/Open discovery and prevents late unlinks from deleting content of same-key recreates (critical for in-memory metadata safety model + user's explicit non-race requirement on hot eviction path).
d.size -= fi.Size
evicted += uint(fi.Size)
// Intentionally do not Delete from keyLocks here.
// The per-key *RWMutex objects are stable for the lifetime of the DiskFS
// to preserve mutual exclusion across Stat/Create/eviction for the same key.
// Cleanup would allow LoadOrStore to hand out a different mutex later,
// breaking the coordination the two-phase eviction + lazy discovery depends on.
}
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
return evicted
}
// EvictBySize evicts files by size (ascending = smallest first, descending = largest first)
// Scalar snapshot (key+size) under RLock + live re-fetch under WLock for race-free accounting + os.Remove.
type evictCandidate struct {
key string
size int64
}
func (d *DiskFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
d.mu.RLock()
var candidates []evictCandidate
for key, fi := range d.info {
candidates = append(candidates, evictCandidate{key: key, size: fi.Size})
if len(candidates) >= maxEvictBatch {
break
}
}
d.mu.RUnlock()
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
if ascending {
return candidates[i].size < candidates[j].size
}
return candidates[i].size > candidates[j].size
})
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path) // best effort; performed under WLock (reverted from post-unlock) to guarantee on-disk deletion is coordinated with metadata removal. This eliminates resurrection via lazy Stat/Open discovery and prevents late unlinks from deleting content of same-key recreates (critical for in-memory metadata safety model + user's explicit non-race requirement on hot eviction path).
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
// (see EvictLRU for why we no longer Delete per-key locks)
}
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
return evicted
}
// EvictFIFO evicts files using FIFO (oldest creation time first)
// Snapshot ctime under RLock, live re-fetch + remove under WLock.
func (d *DiskFS) EvictFIFO(bytesNeeded uint) uint {
d.mu.RLock()
var candidates []struct {
key string
cTime time.Time
}
for key, fi := range d.info {
candidates = append(candidates, struct {
key string
cTime time.Time
}{key: key, cTime: fi.CTime})
if len(candidates) >= maxEvictBatch {
break
}
}
d.mu.RUnlock()
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].cTime.Before(candidates[j].cTime)
})
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path) // best effort; performed under WLock (reverted from post-unlock) to guarantee on-disk deletion is coordinated with metadata removal. This eliminates resurrection via lazy Stat/Open discovery and prevents late unlinks from deleting content of same-key recreates (critical for in-memory metadata safety model + user's explicit non-race requirement on hot eviction path).
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
// (see EvictLRU for why we no longer Delete per-key locks)
}
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
return evicted
}
// EvictLFU evicts least frequently used files first (by AccessCount ascending).
// Ties broken by ATime (older first). Uses snapshot + live re-fetch under WLock.
func (d *DiskFS) EvictLFU(bytesNeeded uint) uint {
d.mu.RLock()
var candidates []struct {
key string
accessCount int
aTime time.Time
}
for key, fi := range d.info {
candidates = append(candidates, struct {
key string
accessCount int
aTime time.Time
}{key: key, accessCount: fi.AccessCount, aTime: fi.ATime})
if len(candidates) >= maxEvictBatch {
break
}
}
d.mu.RUnlock()
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].accessCount != candidates[j].accessCount {
return candidates[i].accessCount < candidates[j].accessCount
}
return candidates[i].aTime.Before(candidates[j].aTime)
})
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path) // best effort; performed under WLock (reverted from post-unlock) to guarantee on-disk deletion is coordinated with metadata removal. This eliminates resurrection via lazy Stat/Open discovery and prevents late unlinks from deleting content of same-key recreates (critical for in-memory metadata safety model + user's explicit non-race requirement on hot eviction path).
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
// (see EvictLRU for why we no longer Delete per-key locks)
}
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
return evicted
}
// EvictHybrid evicts using time-decayed score (recency + frequency from GetTimeDecayedScore; lower value first).
// This makes "hybrid" a meaningful size + recency + frequency policy.
// Snapshot + decayed score under the appropriate locks.
func (d *DiskFS) EvictHybrid(bytesNeeded uint) uint {
d.mu.RLock()
var candidates []struct {
key string
accessCount int
aTime time.Time
}
for key, fi := range d.info {
candidates = append(candidates, struct {
key string
accessCount int
aTime time.Time
}{key: key, accessCount: fi.AccessCount, aTime: fi.ATime})
if len(candidates) >= maxEvictBatch {
break
}
}
d.mu.RUnlock()
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
// Use shared canonical DecayedScore from types (eliminates dupe with memory + FileInfo method).
scoreI := types.DecayedScore(candidates[i].aTime, candidates[i].accessCount)
scoreJ := types.DecayedScore(candidates[j].aTime, candidates[j].accessCount)
return scoreI < scoreJ
})
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path) // best effort; performed under WLock (reverted from post-unlock) to guarantee on-disk deletion is coordinated with metadata removal. This eliminates resurrection via lazy Stat/Open discovery and prevents late unlinks from deleting content of same-key recreates (critical for in-memory metadata safety model + user's explicit non-race requirement on hot eviction path).
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
// (see EvictLRU for why we no longer Delete per-key locks)
}
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
return evicted
}