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.
412 lines
14 KiB
Go
412 lines
14 KiB
Go
// steamcache/steamcache.go
|
|
package steamcache
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/docker/go-units"
|
|
"golang.org/x/sync/semaphore"
|
|
|
|
"s1d3sw1ped/steamcache2/steamcache/logger"
|
|
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
|
"s1d3sw1ped/steamcache2/vfs"
|
|
"s1d3sw1ped/steamcache2/vfs/cache"
|
|
"s1d3sw1ped/steamcache2/vfs/disk"
|
|
"s1d3sw1ped/steamcache2/vfs/gc"
|
|
"s1d3sw1ped/steamcache2/vfs/memory"
|
|
)
|
|
|
|
type SteamCache struct {
|
|
address string
|
|
upstream string
|
|
|
|
vfs vfs.VFS
|
|
|
|
memory *memory.MemoryFS
|
|
disk *disk.DiskFS
|
|
|
|
memorygc *gc.AsyncGCFS
|
|
diskgc *gc.AsyncGCFS
|
|
|
|
server *http.Server
|
|
client *http.Client
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
|
|
// Shutdown safety (Once hardening per existing patterns)
|
|
shutdownOnce sync.Once
|
|
|
|
// 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 (owned by coalescer wrapper; see coalescing.go)
|
|
coalescer *coalescer
|
|
|
|
// Concurrency control
|
|
maxConcurrentRequests int64
|
|
requestSemaphore *semaphore.Weighted
|
|
|
|
// Per-client rate limiting (owned by clientRateLimiter wrapper; see ratelimit.go)
|
|
clientRateLimiter *clientRateLimiter
|
|
maxRequestsPerClient int64
|
|
|
|
// Hardening config fields (plumbed)
|
|
maxObjectSize int64
|
|
trustedProxies []string
|
|
|
|
// Service management
|
|
serviceManager *ServiceManager
|
|
|
|
// Metrics
|
|
metrics *metrics.Metrics
|
|
|
|
// processor (Phase 3 foundation) + 4 narrow interfaces + ctor injection in New.
|
|
// Bulk logic preserved on SteamCache (intentionally; see handler.go deferral block).
|
|
// SteamCache remains thin coordinator + lifecycle owner. Enables future fakes.
|
|
processor *requestProcessor
|
|
}
|
|
|
|
// New creates a new SteamCache instance.
|
|
// Returns an error (instead of panicking) on invalid memorySize or diskSize strings.
|
|
// Also validates maxObjectSize and accepts trustedProxies for X-Forwarded-For handling.
|
|
// Empty maxObjectSize or nil trustedProxies are normalized to safe defaults before parsing.
|
|
// Callers must check the returned error.
|
|
// The two new positional parameters are a breaking change for direct importers of the simple constructor.
|
|
// Prefer NewWithOptions (or config file) for forward compatibility. See README migration notes.
|
|
func New(address string, memorySize string, diskSize string, diskPath, upstream, memoryGC, diskGC string, maxConcurrentRequests int64, maxRequestsPerClient int64, maxObjectSize string, trustedProxies []string) (*SteamCache, error) {
|
|
memorysize, err := units.FromHumanSize(memorySize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid memory size: %w", err)
|
|
}
|
|
|
|
disksize, err := units.FromHumanSize(diskSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid disk size: %w", err)
|
|
}
|
|
|
|
// Apply safe defaults before parsing user-provided sizes (handles zero-value Options)
|
|
if maxObjectSize == "" {
|
|
maxObjectSize = "0"
|
|
}
|
|
if trustedProxies == nil {
|
|
trustedProxies = []string{}
|
|
}
|
|
|
|
maxObjBytes, err := units.FromHumanSize(maxObjectSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid max object size: %w", err)
|
|
}
|
|
|
|
c := cache.New()
|
|
|
|
var m *memory.MemoryFS
|
|
var mgc *gc.AsyncGCFS
|
|
if memorysize > 0 {
|
|
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
|
|
}
|
|
// Use hybrid async GC with thresholds: 80% async, 95% sync, 100% hard limit
|
|
mgc = gc.NewAsync(m, memoryGCAlgo, true, 0.8, 0.95, 1.0)
|
|
}
|
|
|
|
var d *disk.DiskFS
|
|
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 (delayed attach goroutine hygiene for shutdown races during disk Size() barrier)
|
|
if disksize > 0 {
|
|
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)
|
|
}
|
|
|
|
// 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.)
|
|
|
|
if disksize == 0 && memorysize == 0 {
|
|
return nil, fmt.Errorf("no memory or disk cache configured")
|
|
}
|
|
|
|
transport := newHTTPTransport()
|
|
client := newHTTPClient(transport)
|
|
server := newHTTPServer(address)
|
|
|
|
sc := &SteamCache{
|
|
upstream: upstream,
|
|
address: address,
|
|
vfs: c,
|
|
memory: m,
|
|
disk: d,
|
|
memorygc: mgc,
|
|
diskgc: dgc,
|
|
client: client,
|
|
server: server,
|
|
|
|
// Initialize concurrency control fields (wrappers own maps + internal stop chans for cleanup goroutine)
|
|
coalescer: newCoalescer(),
|
|
maxConcurrentRequests: maxConcurrentRequests,
|
|
requestSemaphore: semaphore.NewWeighted(maxConcurrentRequests),
|
|
clientRateLimiter: newClientRateLimiter(maxRequestsPerClient),
|
|
maxRequestsPerClient: maxRequestsPerClient,
|
|
shutdownCh: make(chan struct{}),
|
|
|
|
// Hardening config plumbed
|
|
maxObjectSize: maxObjBytes,
|
|
trustedProxies: trustedProxies,
|
|
|
|
// Initialize service management
|
|
serviceManager: NewServiceManager(),
|
|
|
|
// Initialize metrics
|
|
metrics: metrics.NewMetrics(),
|
|
}
|
|
|
|
// Wire metrics into TieredCache so promotions are counted.
|
|
c.SetMetrics(sc.metrics)
|
|
|
|
// Wire metrics into the concrete storage tiers (MemoryFS / DiskFS) so per-tier hits and evictions are counted.
|
|
if m != nil {
|
|
m.SetMetrics(sc.metrics)
|
|
}
|
|
if d != nil {
|
|
d.SetMetrics(sc.metrics)
|
|
}
|
|
|
|
// Wire the request processor (constructor injection of interfaces for the owned wrappers + vfs + client + scalars).
|
|
// Done after all fields including wrappers are set; before attach goroutines (no impact on lifecycle paths).
|
|
sc.processor = newRequestProcessor(sc)
|
|
|
|
// 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.)
|
|
|
|
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()
|
|
t0 := time.Now()
|
|
_ = d.Size()
|
|
select {
|
|
case <-sc.shutdownCh:
|
|
return
|
|
default:
|
|
c.SetSlow(dgc)
|
|
logger.Logger.Info().
|
|
Dur("attach_delay", time.Since(t0)).
|
|
Msg("Disk slow tier attached (mixed mode); prior traffic was memory-only")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Log GC algorithm configuration
|
|
if m != nil {
|
|
logger.Logger.Info().Str("memory_gc", memoryGC).Msg("Memory cache GC algorithm configured")
|
|
}
|
|
if d != nil {
|
|
logger.Logger.Info().Str("disk_gc", diskGC).Msg("Disk cache GC algorithm configured")
|
|
}
|
|
|
|
return sc, nil
|
|
}
|
|
|
|
func (sc *SteamCache) Run() error {
|
|
if sc.upstream != "" {
|
|
resp, err := sc.client.Get(sc.upstream)
|
|
if err != nil {
|
|
if resp != nil {
|
|
_ = resp.Body.Close() // best-effort body close on connectivity error path; primary err returned
|
|
}
|
|
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed upstream connectivity check")
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close() // best-effort body close on non-OK status; primary error returned
|
|
logger.Logger.Error().Int("status_code", resp.StatusCode).Str("upstream", sc.upstream).Msg("Upstream connectivity check returned non-OK status")
|
|
return fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
|
}
|
|
_ = resp.Body.Close() // best-effort; success path close before ListenAndServe
|
|
}
|
|
|
|
sc.server.Handler = sc
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sc.cancel = cancel
|
|
|
|
// Start cleanup goroutine for old client limiters
|
|
sc.wg.Add(1)
|
|
go func() {
|
|
defer sc.wg.Done()
|
|
sc.cleanupOldClientLimiters()
|
|
}()
|
|
|
|
sc.wg.Add(1)
|
|
go func() {
|
|
defer sc.wg.Done()
|
|
err := sc.server.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
logger.Logger.Error().Err(err).Msg("Failed to start SteamCache2")
|
|
return // goroutine cannot return err to caller; logged; shutdown path handles
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
_ = sc.server.Shutdown(ctx) // shutdown error secondary (ListenAndServe already terminated or context done); logged elsewhere if fatal
|
|
sc.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (sc *SteamCache) Shutdown() {
|
|
if sc == nil {
|
|
return
|
|
}
|
|
sc.shutdownOnce.Do(func() {
|
|
if sc.cancel != nil {
|
|
sc.cancel()
|
|
}
|
|
// Stop all background managers started in New() (critical for test hygiene + no leaks when using httptest direct ServeHTTP paths that never call Run()).
|
|
if sc.memorygc != nil {
|
|
sc.memorygc.Stop()
|
|
}
|
|
if sc.diskgc != nil {
|
|
sc.diskgc.Stop()
|
|
}
|
|
// Signal cleanup goroutine to exit so wg.Wait below does not hang indefinitely.
|
|
// (stop chan owned by clientRateLimiter wrapper; signaling delegated for full lifecycle encapsulation)
|
|
if sc.clientRateLimiter != nil {
|
|
sc.clientRateLimiter.stop()
|
|
}
|
|
// Signal delayed attach goroutines (attach goroutine shutdown safety) 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)
|
|
})
|
|
}
|
|
|
|
// GetMetrics returns current metrics
|
|
func (sc *SteamCache) GetMetrics() *metrics.Stats {
|
|
// Update cache sizes
|
|
if sc.memory != nil {
|
|
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())
|
|
}
|
|
|
|
return sc.metrics.GetStats()
|
|
}
|
|
|
|
// ResetMetrics resets all metrics to zero
|
|
func (sc *SteamCache) ResetMetrics() {
|
|
sc.metrics.Reset()
|
|
}
|
|
|
|
// newHTTPTransport returns a tuned http.Transport for upstream fetches.
|
|
// Extracted to shrink New (Phase 3).
|
|
func newHTTPTransport() *http.Transport {
|
|
return &http.Transport{
|
|
// Connection pooling optimizations
|
|
MaxIdleConns: 500, // Increased for high concurrency
|
|
MaxIdleConnsPerHost: 100, // Increased for better connection reuse
|
|
MaxConnsPerHost: 200, // Limit connections per host
|
|
IdleConnTimeout: 300 * time.Second, // Longer idle timeout for better reuse
|
|
|
|
// Dial optimizations
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 10 * time.Second, // Faster connection timeout
|
|
KeepAlive: 60 * time.Second, // Longer keep-alive
|
|
DualStack: true, // Enable dual-stack (IPv4/IPv6)
|
|
}).DialContext,
|
|
|
|
// Timeout optimizations
|
|
TLSHandshakeTimeout: 5 * time.Second, // Faster TLS handshake
|
|
ResponseHeaderTimeout: 15 * time.Second, // Faster header timeout
|
|
ExpectContinueTimeout: 1 * time.Second, // Faster expect-continue
|
|
|
|
// Performance optimizations
|
|
DisableCompression: true, // Steam doesn't use compression
|
|
ForceAttemptHTTP2: true, // Enable HTTP/2 if available
|
|
DisableKeepAlives: false, // Enable keep-alives
|
|
|
|
// Buffer optimizations
|
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
|
|
|
// Connection reuse optimizations
|
|
MaxResponseHeaderBytes: 1 << 20, // 1MB max header size
|
|
}
|
|
}
|
|
|
|
// newHTTPClient returns a tuned http.Client wrapping the provided transport.
|
|
// Extracted to shrink New (Phase 3).
|
|
func newHTTPClient(transport *http.Transport) *http.Client {
|
|
return &http.Client{
|
|
Transport: transport,
|
|
Timeout: 60 * time.Second, // Optimized timeout for better responsiveness
|
|
// Add redirect policy for better performance
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
// Limit redirects to prevent infinite loops
|
|
if len(via) >= 10 {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// newHTTPServer returns a tuned http.Server for the given address.
|
|
// Extracted to shrink New (Phase 3).
|
|
func newHTTPServer(address string) *http.Server {
|
|
return &http.Server{
|
|
Addr: address,
|
|
ReadTimeout: 15 * time.Second, // Optimized for faster response
|
|
WriteTimeout: 30 * time.Second, // Optimized for faster response
|
|
IdleTimeout: 300 * time.Second, // Longer idle timeout for better connection reuse
|
|
ReadHeaderTimeout: 5 * time.Second, // Faster header timeout
|
|
MaxHeaderBytes: 1 << 20, // 1MB max header size
|
|
// Connection optimizations will be handled in ServeHTTP method
|
|
}
|
|
}
|