Files
steamcache2/vfs/disk/disk.go
T
ash d7af699e84
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Failing after 39s
ops: Signal disk-full and eviction capacity pressure
When the disk (or memory) tier is at cap or the volume returns ENOSPC,
ops currently look like random misses with no clear "we are dropping
data." Count those events as capacity_pressure_events on /metrics and
log tier plus reason so operators can tell capacity pressure from a
cold cache, without changing the existing evictions counter.

Link: #36
2026-09-07 19:23:55 +00:00

951 lines
29 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).
// Fail-closed: re-stat each path under d.mu and skip if the file is gone. Create does not wait on
// initDone, so a file the scanner observed can be Evict/Delete'd (info + os.Remove) before this
// insert runs. Inserting without a live-file check would resurrect the key in d.info and make
// Stat succeed while os.Stat fails.
func (d *DiskFS) insertBatch(batch []discoveredFile) {
d.mu.Lock()
for _, df := range batch {
if _, exists := d.info[df.key]; exists {
continue
}
path := d.pathForKey(df.key)
st, err := os.Stat(path)
if err != nil {
continue
}
fi := vfs.NewFileInfoFromOS(st, df.key)
d.info[df.key] = fi
d.LRU.Add(df.key, fi)
d.size += st.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 {
d.recordIfNoSpace(err)
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 {
d.recordIfNoSpace(err)
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) {
n, err = dwc.file.Write(p)
if err != nil {
dwc.disk.recordIfNoSpace(err)
}
return n, err
}
// recordIfNoSpace increments capacity_pressure_events and logs when err is ENOSPC (or Windows disk-full).
func (d *DiskFS) recordIfNoSpace(err error) {
if !isNoSpaceError(err) {
return
}
metrics.NoteNoSpace(d.metrics, err)
}
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 a snapshot of file information with lazy discovery.
// The returned *FileInfo is not the live cache entry; Close may update Size
// on the in-map object under d.mu.
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 {
snap := fi.Clone()
d.mu.RUnlock()
keyMu.RUnlock()
return snap, 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 {
snap := fi.Clone()
d.mu.Unlock()
return snap, 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).
snap := fi.Clone()
d.mu.Unlock()
return snap, 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()
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
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()
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
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()
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
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()
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
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()
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
return evicted
}