b7e3a0da86
Release Tag / release (push) Successful in 34s
- 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.
932 lines
28 KiB
Go
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
|
|
}
|