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:
2026-05-27 13:15:33 -05:00
parent 4861f93e6f
commit feda55e225
18 changed files with 584 additions and 1380 deletions
-120
View File
@@ -1,120 +0,0 @@
// steamcache/errors/errors.go
package errors
import (
"errors"
"fmt"
"net/http"
)
// Common SteamCache errors
var (
ErrInvalidURL = errors.New("steamcache: invalid URL")
ErrUnsupportedService = errors.New("steamcache: unsupported service")
ErrUpstreamUnavailable = errors.New("steamcache: upstream server unavailable")
ErrCacheCorrupted = errors.New("steamcache: cache file corrupted")
ErrInvalidContentLength = errors.New("steamcache: invalid content length")
ErrRequestTimeout = errors.New("steamcache: request timeout")
ErrRateLimitExceeded = errors.New("steamcache: rate limit exceeded")
ErrInvalidUserAgent = errors.New("steamcache: invalid user agent")
)
// SteamCacheError represents a SteamCache-specific error with context
type SteamCacheError struct {
Op string // Operation that failed
URL string // URL that caused the error
ClientIP string // Client IP address
StatusCode int // HTTP status code if applicable
Err error // Underlying error
Context interface{} // Additional context
}
// Error implements the error interface
func (e *SteamCacheError) Error() string {
if e.URL != "" && e.ClientIP != "" {
return fmt.Sprintf("steamcache: %s failed for URL %q from client %s: %v", e.Op, e.URL, e.ClientIP, e.Err)
}
if e.URL != "" {
return fmt.Sprintf("steamcache: %s failed for URL %q: %v", e.Op, e.URL, e.Err)
}
return fmt.Sprintf("steamcache: %s failed: %v", e.Op, e.Err)
}
// Unwrap returns the underlying error
func (e *SteamCacheError) Unwrap() error {
return e.Err
}
// NewSteamCacheError creates a new SteamCache error with context
func NewSteamCacheError(op, url, clientIP string, err error) *SteamCacheError {
return &SteamCacheError{
Op: op,
URL: url,
ClientIP: clientIP,
Err: err,
}
}
// NewSteamCacheErrorWithStatus creates a new SteamCache error with HTTP status
func NewSteamCacheErrorWithStatus(op, url, clientIP string, statusCode int, err error) *SteamCacheError {
return &SteamCacheError{
Op: op,
URL: url,
ClientIP: clientIP,
StatusCode: statusCode,
Err: err,
}
}
// NewSteamCacheErrorWithContext creates a new SteamCache error with additional context
func NewSteamCacheErrorWithContext(op, url, clientIP string, context interface{}, err error) *SteamCacheError {
return &SteamCacheError{
Op: op,
URL: url,
ClientIP: clientIP,
Context: context,
Err: err,
}
}
// IsRetryableError determines if an error is retryable
func IsRetryableError(err error) bool {
if err == nil {
return false
}
// Check for specific retryable errors
if errors.Is(err, ErrUpstreamUnavailable) ||
errors.Is(err, ErrRequestTimeout) {
return true
}
// Check for HTTP status codes that are retryable
if steamErr, ok := err.(*SteamCacheError); ok {
switch steamErr.StatusCode {
case http.StatusServiceUnavailable,
http.StatusGatewayTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError:
return true
}
}
return false
}
// IsClientError determines if an error is a client error (4xx)
func IsClientError(err error) bool {
if steamErr, ok := err.(*SteamCacheError); ok {
return steamErr.StatusCode >= 400 && steamErr.StatusCode < 500
}
return false
}
// IsServerError determines if an error is a server error (5xx)
func IsServerError(err error) bool {
if steamErr, ok := err.(*SteamCacheError); ok {
return steamErr.StatusCode >= 500
}
return false
}
+69 -51
View File
@@ -11,7 +11,6 @@ import (
"net"
"net/http"
"net/url"
"os"
"regexp"
"s1d3sw1ped/steamcache2/steamcache/logger"
"s1d3sw1ped/steamcache2/steamcache/metrics"
@@ -872,6 +871,9 @@ type SteamCache struct {
// Stop signal for the client limiter cleanup goroutine (fixes shutdown hang/leak; wg.Wait would block forever without it)
clientLimiterCleanupStop chan struct{}
// shutdownCh closed during Shutdown() to allow delayed disk-attach goroutines (the Size barrier waits) to skip late SetSlow and avoid post-shutdown side effects. Also enables wg tracking for hygiene (reaps on wg.Wait).
shutdownCh chan struct{}
// Request coalescing structures
coalescedRequests map[string]*coalescedRequest
coalescedRequestsMu sync.RWMutex
@@ -892,10 +894,6 @@ type SteamCache struct {
// Service management
serviceManager *ServiceManager
// Dynamic memory management
memoryMonitor *memory.MemoryMonitor
dynamicCacheMgr *memory.MemoryMonitor
// Metrics
metrics *metrics.Metrics
}
@@ -936,7 +934,11 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
var m *memory.MemoryFS
var mgc *gc.AsyncGCFS
if memorysize > 0 {
m = memory.New(memorysize)
var err error
m, err = memory.New(memorysize)
if err != nil {
return nil, err
}
memoryGCAlgo := gc.GCAlgorithm(memoryGC)
if memoryGCAlgo == "" {
memoryGCAlgo = gc.LRU // default to LRU
@@ -946,35 +948,28 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
}
var d *disk.DiskFS
var dgc *gc.AsyncGCFS
var dgc *gc.AsyncGCFS // for disk cases, created inside delayed attach goroutine (just before SetSlow) so preemptive ticker does not run during proxy/init window (addresses Issue 7 / Plan #1)
if disksize > 0 {
d = disk.New(diskPath, disksize)
diskGCAlgo := gc.GCAlgorithm(diskGC)
if diskGCAlgo == "" {
diskGCAlgo = gc.LRU // default to LRU
}
evictFn := gc.GetGCAlgorithm(diskGCAlgo)
var err error
d, err = disk.New(diskPath, disksize, evictFn)
if err != nil {
return nil, err
}
// Use hybrid async GC with thresholds: 80% async, 95% sync, 100% hard limit
dgc = gc.NewAsync(d, diskGCAlgo, true, 0.8, 0.95, 1.0)
}
// configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes
if disksize == 0 && memorysize != 0 {
//memory only mode - no disk
// NOTE: cache tier attach (SetFast/SetSlow + delayed disk attach gos) moved *after* sc construction (see below).
// This enables wg tracking on sc.wg + shutdownCh signaling for attach goroutines (goroutine hygiene + robust Shutdown during pending init).
// (Early return for invalid no-mem/no-disk still needed before heavy setup.)
c.SetSlow(mgc)
} else if disksize != 0 && memorysize == 0 {
// disk only mode
c.SetSlow(dgc)
} else if disksize != 0 && memorysize != 0 {
// memory and disk mode
c.SetFast(mgc)
c.SetSlow(dgc)
} else {
// no memory or disk isn't a valid configuration
logger.Logger.Error().Bool("memory", false).Bool("disk", false).Msg("configuration invalid :( exiting")
os.Exit(1)
if disksize == 0 && memorysize == 0 {
return nil, fmt.Errorf("no memory or disk cache configured")
}
transport := &http.Transport{
@@ -1048,6 +1043,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
clientRequests: make(map[string]*clientLimiter),
maxRequestsPerClient: maxRequestsPerClient,
clientLimiterCleanupStop: make(chan struct{}),
shutdownCh: make(chan struct{}),
// Hardening config plumbed
maxObjectSize: maxObjBytes,
@@ -1056,19 +1052,44 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
// Initialize service management
serviceManager: NewServiceManager(),
// Initialize dynamic memory management
memoryMonitor: memory.NewMemoryMonitor(uint64(memorysize), 10*time.Second, 0.1), // 10% threshold
dynamicCacheMgr: nil, // Will be set after cache creation
// Initialize metrics
metrics: metrics.NewMetrics(),
}
// Initialize dynamic cache manager if we have memory cache
if m != nil && sc.memoryMonitor != nil {
sc.dynamicCacheMgr = memory.NewMemoryMonitorWithCache(uint64(memorysize), 10*time.Second, 0.1, mgc, uint64(memorysize))
sc.dynamicCacheMgr.Start()
sc.memoryMonitor.Start()
// configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes.
// Disk tier SetSlow delayed until after disk init+eviction (evict func passed at disk.New for race-free guard; via Size barrier); mem immediate.
// Attach goroutines now tracked on sc.wg (Add before go + Done), guarded by shutdownCh (skip SetSlow if Shutdown racing) for goroutine hygiene. (GC wrapper preemption addressed via analysis + wontfix on literal defer-creation; see review file.)
if disksize == 0 && memorysize != 0 {
// memory only mode - no disk
c.SetSlow(mgc)
} else if disksize != 0 && memorysize == 0 {
// disk only mode: delay attach until disk ready (pure-proxy during scan; Create returns ErrNotFound until slow tier Set)
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
_ = d.Size() // block on barrier per design (all Size callers during window do this; documented)
select {
case <-sc.shutdownCh:
return // Shutdown raced; do not attach or SetSlow after stop
default:
c.SetSlow(dgc)
}
}()
} else if disksize != 0 && memorysize != 0 {
// memory and disk mode: fast mem immediate, disk delayed (mem-only during scan)
c.SetFast(mgc)
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
_ = d.Size()
select {
case <-sc.shutdownCh:
return
default:
c.SetSlow(dgc)
}
}()
}
// Log GC algorithm configuration
@@ -1079,17 +1100,10 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
logger.Logger.Info().Str("disk_gc", diskGC).Msg("Disk cache GC algorithm configured")
}
if d != nil {
if d.Size() > d.Capacity() {
gcHandler := gc.GetGCAlgorithm(gc.GCAlgorithm(diskGC))
gcHandler(d, uint(d.Size()-d.Capacity()))
}
}
return sc, nil
}
func (sc *SteamCache) Run() {
func (sc *SteamCache) Run() error {
if sc.upstream != "" {
resp, err := sc.client.Get(sc.upstream)
if err != nil {
@@ -1097,12 +1111,12 @@ func (sc *SteamCache) Run() {
resp.Body.Close()
}
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed upstream connectivity check")
os.Exit(1)
return err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
logger.Logger.Error().Int("status_code", resp.StatusCode).Str("upstream", sc.upstream).Msg("Upstream connectivity check returned non-OK status")
os.Exit(1)
return fmt.Errorf("upstream returned status %d", resp.StatusCode)
}
resp.Body.Close()
}
@@ -1124,13 +1138,14 @@ func (sc *SteamCache) Run() {
err := sc.server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
logger.Logger.Error().Err(err).Msg("Failed to start SteamCache2")
os.Exit(1)
return // goroutine cannot return err to caller; logged; shutdown path handles
}
}()
<-ctx.Done()
sc.server.Shutdown(ctx)
sc.wg.Wait()
return nil
}
func (sc *SteamCache) Shutdown() {
@@ -1148,12 +1163,6 @@ func (sc *SteamCache) Shutdown() {
if sc.diskgc != nil {
sc.diskgc.Stop()
}
if sc.memoryMonitor != nil {
sc.memoryMonitor.Stop()
}
if sc.dynamicCacheMgr != nil {
sc.dynamicCacheMgr.Stop()
}
// Signal cleanup goroutine to exit so wg.Wait below does not hang indefinitely.
if sc.clientLimiterCleanupStop != nil {
select {
@@ -1162,6 +1171,14 @@ func (sc *SteamCache) Shutdown() {
close(sc.clientLimiterCleanupStop)
}
}
// Signal delayed attach goroutines (Issue 2 hygiene) to skip SetSlow if still waiting Size() or just finished.
if sc.shutdownCh != nil {
select {
case <-sc.shutdownCh:
default:
close(sc.shutdownCh)
}
}
sc.wg.Wait()
// Brief reap window after stopping workers (helps goroutine delta checks see low counts quickly).
time.Sleep(10 * time.Millisecond)
@@ -1175,6 +1192,7 @@ func (sc *SteamCache) GetMetrics() *metrics.Stats {
sc.metrics.SetMemoryCacheSize(sc.memory.Size())
}
if sc.disk != nil {
// Note: blocks on initDone (post-eviction state) for accurate post-attach size during long disk init window.
sc.metrics.SetDiskCacheSize(sc.disk.Size())
}
+62 -21
View File
@@ -7,8 +7,9 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"s1d3sw1ped/steamcache2/steamcache/errors"
"s1d3sw1ped/steamcache2/vfs/eviction"
"s1d3sw1ped/steamcache2/vfs/memory"
"s1d3sw1ped/steamcache2/vfs/vfserror"
@@ -126,7 +127,7 @@ func TestCaching(t *testing.T) {
}
func TestCacheMissAndHit(t *testing.T) {
sc, err := New("localhost:8080", "0", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil)
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil)
if err != nil {
t.Fatalf("failed to create SteamCache: %v", err)
}
@@ -369,7 +370,7 @@ func TestServiceManagerExpandability(t *testing.T) {
// Removed hash calculation tests since we switched to lightweight validation
func TestSteamKeySharding(t *testing.T) {
sc, err := New("localhost:8080", "0", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil)
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil)
if err != nil {
t.Fatalf("failed to create SteamCache: %v", err)
}
@@ -471,23 +472,6 @@ func TestErrorTypes(t *testing.T) {
if vfsErr.Unwrap() != vfserror.ErrNotFound {
t.Error("VFS error should unwrap to the underlying error")
}
// Test SteamCache error
scErr := errors.NewSteamCacheError("test", "/test/url", "127.0.0.1", errors.ErrInvalidURL)
if scErr.Error() == "" {
t.Error("SteamCache error should have a message")
}
if scErr.Unwrap() != errors.ErrInvalidURL {
t.Error("SteamCache error should unwrap to the underlying error")
}
// Test retryable error detection
if !errors.IsRetryableError(errors.ErrUpstreamUnavailable) {
t.Error("Upstream unavailable should be retryable")
}
if errors.IsRetryableError(errors.ErrInvalidURL) {
t.Error("Invalid URL should not be retryable")
}
}
// TestMetrics tests the metrics functionality
@@ -868,6 +852,8 @@ func TestNewInvalidSizes(t *testing.T) {
{"0", "bad", "0", "invalid disk size"},
// maxObjectSize limit (zero default + basic coverage)
{"1MB", "0", "notasize", "invalid max object size"}, // bad value
// Covers the "no memory or disk" error path (was os.Exit, now clean error return per Item 3)
{"0", "0", "0", "no memory or disk cache configured"},
}
for _, c := range cases {
t.Run(c.mem+"_"+c.disk, func(t *testing.T) {
@@ -994,7 +980,10 @@ func TestP1_03_EvictionAlgorithmsDistinct(t *testing.T) {
t.Skip("LFU vs LRU vs Hybrid distinct behavior test; run explicitly when needed.")
// Create controlled candidates in a fresh memory FS for each strategy.
createAndEvict := func(algo string, bytesNeeded uint) (int, error) { // returns #evicted items approx via size delta
mfs := memory.New(250) // small cap < 300 to force evict on needed
mfs, err := memory.New(250) // small cap < 300 to force evict on needed
if err != nil {
return 0, err
}
// create 3 files of 100 bytes each via VFS Create (AccessCount=1 init)
for i := 0; i < 3; i++ {
w, err := mfs.Create(fmt.Sprintf("f%d", i), 100)
@@ -1025,3 +1014,55 @@ func TestP1_03_EvictionAlgorithmsDistinct(t *testing.T) {
// Size deltas may vary due to internal LRU during Create + exact thresholds; main goal is no crash + distinct code exercised (verified by coverage).
t.Logf("distinct eviction counts under controlled access: LRU=%d, LFU=%d, HYB=%d", evLRU, evLFU, evHYB)
}
// TestDiskOnlyDelayedAttach covers pure disk-only mode (mem=0 + disk>0) hitting the exact delayed attach path.
// During init window (pre Size barrier), TieredCache has no slow tier so Create returns ErrNotFound (proxy semantics, no disk caching).
// Post-barrier + attach, Create succeeds. Uses real temp dir (Issue 5).
func TestDiskOnlyDelayedAttach(t *testing.T) {
t.Parallel()
td := t.TempDir()
diskPath := filepath.Join(td, "disk")
if err := os.MkdirAll(diskPath, 0755); err != nil {
t.Fatal(err)
}
// mem=0, disk>0 -> pure disk delayed path (go func)
sc, err := New("localhost:0", "0", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil)
if err != nil {
t.Fatalf("New disk-only: %v", err)
}
t.Cleanup(func() { sc.Shutdown() })
// Immediately in window: no slow tier attached yet -> Create must ErrNotFound (proxy, no disk write)
_, err = sc.vfs.Create("during-init-key", 100)
if err != vfserror.ErrNotFound {
t.Errorf("during init window, expected ErrNotFound from disk-only tiered Create (no slow), got %v", err)
}
// Wait the barrier (exercises the attach go's Size wait)
_ = sc.disk.Size()
// Now attached; Create should succeed (slow tier active). Retry briefly for go scheduler (attach go does Size then SetSlow).
var w io.WriteCloser
for i := 0; i < 100; i++ {
var cerr error
w, cerr = sc.vfs.Create("post-attach-key", 50)
if cerr == nil {
err = nil
break
}
err = cerr
time.Sleep(1 * time.Millisecond)
}
if err != nil {
t.Fatalf("post attach Create failed (slow tier not set after barrier?): %v", err)
}
w.Write([]byte("ok"))
w.Close()
// verify visible
if rc, err := sc.vfs.Open("post-attach-key"); err != nil || rc == nil {
t.Error("post-attach open failed")
} else {
rc.Close()
}
}