d7af699e84
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
951 lines
29 KiB
Go
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
|
|
}
|