Enhance DiskFS initialization and error handling
- Updated `disk.New` to support asynchronous initialization for large caches, improving responsiveness during startup. - Introduced an eviction function parameter to `disk.New`, ensuring proper handling of over-capacity scenarios. - Enhanced error handling in various components, including memory and disk tests, to ensure robustness and clarity. - Refactored tests to validate new behaviors, including checks for delayed attachment and proper error propagation. - Removed obsolete error handling code and tests related to the now-deleted errors package, streamlining the codebase.
This commit is contained in:
+141
-72
@@ -18,7 +18,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/edsrzf/mmap-go"
|
||||
)
|
||||
|
||||
@@ -39,6 +38,11 @@ type DiskFS struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// shardPath converts a Steam cache key to a sharded directory path to reduce inode pressure
|
||||
@@ -74,67 +78,78 @@ func (d *DiskFS) pathForKey(key string) string {
|
||||
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.
|
||||
func New(root string, capacity int64) *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 {
|
||||
panic("disk capacity must be greater than 0")
|
||||
return nil, fmt.Errorf("disk capacity must be greater than 0")
|
||||
}
|
||||
|
||||
// Create root directory if it doesn't exist
|
||||
os.MkdirAll(root, 0755)
|
||||
// Create root directory if it doesn't exist. Propagate error (ctor now returns err for hygiene).
|
||||
if err := os.MkdirAll(root, 0755); 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
|
||||
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.init()
|
||||
return d
|
||||
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
|
||||
}
|
||||
|
||||
// init loads existing files from disk with ultra-fast lazy initialization
|
||||
func (d *DiskFS) init() {
|
||||
// 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()
|
||||
|
||||
// Ultra-fast initialization: only scan directory structure, defer file stats
|
||||
d.scanDirectoriesOnly()
|
||||
|
||||
// Start background size calculation in a separate goroutine
|
||||
go d.calculateSizeInBackground()
|
||||
|
||||
logger.Logger.Info().
|
||||
Str("name", d.Name()).
|
||||
Str("root", d.root).
|
||||
Str("capacity", units.HumanSize(float64(d.capacity))).
|
||||
Str("size", units.HumanSize(float64(d.Size()))).
|
||||
Str("files", fmt.Sprint(len(d.info))).
|
||||
Str("duration", time.Since(tstart).String()).
|
||||
Msg("init")
|
||||
}
|
||||
|
||||
// scanDirectoriesOnly performs ultra-fast directory structure scanning without file stats
|
||||
func (d *DiskFS) scanDirectoriesOnly() {
|
||||
// Just ensure the root directory exists and is accessible
|
||||
// No file scanning during init - files will be discovered on-demand
|
||||
logger.Logger.Debug().
|
||||
Str("root", d.root).
|
||||
Msg("Directory structure scan completed (lazy file discovery enabled)")
|
||||
}
|
||||
|
||||
// calculateSizeInBackground calculates the total size of all files in the background
|
||||
func (d *DiskFS) calculateSizeInBackground() {
|
||||
tstart := time.Now()
|
||||
|
||||
// Channel for collecting file information
|
||||
fileChan := make(chan fileSizeInfo, 1000)
|
||||
// Channel for collecting file information (now includes metadata for info/LRU population)
|
||||
fileChan := make(chan discoveredFile, 1000)
|
||||
|
||||
// Progress tracking
|
||||
var totalFiles int64
|
||||
@@ -153,8 +168,10 @@ func (d *DiskFS) calculateSizeInBackground() {
|
||||
d.scanFilesForSize(d.root, fileChan, &totalFiles)
|
||||
}()
|
||||
|
||||
// Collect results with progress reporting
|
||||
// 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{})
|
||||
@@ -162,12 +179,17 @@ func (d *DiskFS) calculateSizeInBackground() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case fi, ok := <-fileChan:
|
||||
case df, ok := <-fileChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
totalSize += fi.size
|
||||
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().
|
||||
@@ -185,25 +207,60 @@ func (d *DiskFS) calculateSizeInBackground() {
|
||||
wg.Wait()
|
||||
<-done
|
||||
|
||||
// Update the total size
|
||||
d.mu.Lock()
|
||||
d.size = totalSize
|
||||
d.mu.Unlock()
|
||||
// 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("Background size calculation completed")
|
||||
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)
|
||||
}
|
||||
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) })
|
||||
}
|
||||
|
||||
// fileSizeInfo represents a file found during size calculation
|
||||
type fileSizeInfo struct {
|
||||
size int64
|
||||
// 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()
|
||||
}
|
||||
|
||||
// scanFilesForSize performs recursive file scanning for size calculation only
|
||||
func (d *DiskFS) scanFilesForSize(dirPath string, fileChan chan<- fileSizeInfo, totalFiles *int64) {
|
||||
// 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 {
|
||||
@@ -236,22 +293,27 @@ func (d *DiskFS) scanFilesForSize(dirPath string, fileChan chan<- fileSizeInfo,
|
||||
d.scanFilesForSize(path, fileChan, totalFiles)
|
||||
}(entryPath)
|
||||
} else {
|
||||
// Process file for size only
|
||||
// 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 file size info
|
||||
fileChan <- fileSizeInfo{
|
||||
size: info.Size(),
|
||||
// Send discovered file info
|
||||
fileChan <- discoveredFile{
|
||||
key: key,
|
||||
size: info.Size(),
|
||||
osInfo: info,
|
||||
}
|
||||
}(entry)
|
||||
}
|
||||
@@ -265,8 +327,14 @@ func (d *DiskFS) Name() string {
|
||||
return "DiskFS"
|
||||
}
|
||||
|
||||
// Size returns the current size
|
||||
// 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
|
||||
@@ -405,11 +473,12 @@ func (d *DiskFS) Open(key string) (io.ReadCloser, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update access time and LRU
|
||||
d.mu.Lock()
|
||||
fi.UpdateAccessBatched(d.timeUpdater)
|
||||
d.LRU.MoveToFront(key, d.timeUpdater)
|
||||
d.mu.Unlock()
|
||||
// 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)
|
||||
|
||||
@@ -548,8 +617,8 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
d.info[key] = fi
|
||||
d.LRU.Add(key, fi)
|
||||
fi.UpdateAccessBatched(d.timeUpdater)
|
||||
// Note: Don't add to d.size here as it's being calculated in background
|
||||
// The background calculation will handle the total size
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user