Refactor caching logic and enhance hash generation in steamcache

- Replaced SHA1 hash calculations with SHA256 for improved security and consistency in cache key generation.
- Introduced a new TestURLHashing function to validate the new cache key generation logic.
- Removed outdated hash calculation tests and streamlined the caching process to focus on URL-based hashing.
- Implemented lightweight validation methods in ServeHTTP to enhance performance and reliability of cached responses.
- Added batched time updates in VFS implementations for better performance during access time tracking.
This commit is contained in:
2025-09-02 05:45:44 -05:00
parent b9358a0e8d
commit 4a4579b0f3
6 changed files with 621 additions and 324 deletions

View File

@@ -4,9 +4,8 @@ package steamcache
import (
"bufio"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
@@ -18,108 +17,30 @@ import (
"s1d3sw1ped/SteamCache2/vfs/disk"
"s1d3sw1ped/SteamCache2/vfs/gc"
"s1d3sw1ped/SteamCache2/vfs/memory"
"sort"
"strings"
"sync"
"time"
"bytes"
"github.com/docker/go-units"
)
// extractHashFromSteamPath extracts a hash from Steam depot URLs
// Handles patterns like: /depot/123/chunk/abcdef... or /depot/123/manifest/456/789/hash
func extractHashFromSteamPath(path string) (string, bool) {
path = strings.TrimPrefix(path, "/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
return "", false
}
// Handle chunk files: depot/{id}/chunk/{hash}
if len(parts) >= 4 && parts[0] == "depot" && parts[2] == "chunk" {
hash := parts[3]
// Validate it's a 40-character hex hash
if len(hash) == 40 && isHexString(hash) {
return strings.ToLower(hash), true
}
}
// Handle manifest files: depot/{id}/manifest/{manifest_id}/{version}/{hash}
if len(parts) >= 6 && parts[0] == "depot" && parts[2] == "manifest" {
hash := parts[5]
// Note: Manifest hashes can be shorter than 40 characters
if len(hash) >= 10 && isHexString(hash) {
return strings.ToLower(hash), true
}
}
return "", false
}
// isHexString checks if a string contains only hexadecimal characters
func isHexString(s string) bool {
for _, r := range s {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}
// generateSteamCacheKey converts Steam depot paths to simplified cache keys
// Input: /depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
// Output: steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
func generateSteamCacheKey(urlPath string) string {
if hash, ok := extractHashFromSteamPath(urlPath); ok {
return "steam/" + hash
}
// Return empty string for unsupported depot URLs
return ""
}
// calculateFileHash calculates the SHA1 hash of the given data
func calculateFileHash(data []byte) string {
hash := sha1.Sum(data)
// generateURLHash creates a SHA256 hash of the entire URL path for cache key
func generateURLHash(urlPath string) string {
hash := sha256.Sum256([]byte(urlPath))
return hex.EncodeToString(hash[:])
}
// calculateResponseHash calculates the SHA1 hash of the full HTTP response
func calculateResponseHash(resp *http.Response, bodyData []byte) string {
hash := sha1.New()
// Include status line
statusLine := fmt.Sprintf("HTTP/1.1 %d %s\n", resp.StatusCode, resp.Status)
hash.Write([]byte(statusLine))
// Include headers (sorted for consistency)
headers := make([]string, 0, len(resp.Header))
for key, values := range resp.Header {
for _, value := range values {
headers = append(headers, fmt.Sprintf("%s: %s\n", key, value))
}
}
sort.Strings(headers)
for _, header := range headers {
hash.Write([]byte(header))
// generateSteamCacheKey creates a cache key from the URL path using SHA256
// Input: /depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
// Output: steam/a1b2c3d4e5f678901234567890123456789012345678901234567890
func generateSteamCacheKey(urlPath string) string {
// Handle Steam depot URLs by creating a SHA256 hash of the entire path
if strings.HasPrefix(urlPath, "/depot/") {
return "steam/" + generateURLHash(urlPath)
}
// Include empty line between headers and body
hash.Write([]byte("\n"))
// Include body
hash.Write(bodyData)
return hex.EncodeToString(hash.Sum(nil))
}
// verifyFileHash verifies that the file content matches the expected hash
func verifyFileHash(data []byte, expectedHash string) bool {
actualHash := calculateFileHash(data)
return strings.EqualFold(actualHash, expectedHash)
// For non-Steam URLs, return empty string (not cached)
return ""
}
var hopByHopHeaders = map[string]struct{}{
@@ -395,7 +316,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.String(), "/depot/") {
// trim the query parameters from the URL path
// this is necessary because the cache key should not include query parameters
urlPath := strings.Split(r.URL.String(), "?")[0]
urlPath, _, _ := strings.Cut(r.URL.String(), "?")
tstart := time.Now()
@@ -568,61 +489,48 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()
// Read the entire response body into memory for hash verification
bodyData, err := io.ReadAll(resp.Body)
if err != nil {
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to read response body")
// Fast path: Flexible lightweight validation for all files
// Multiple validation layers ensure data integrity without blocking legitimate Steam content
// Complete coalesced request with error
if isNew {
coalescedReq.complete(nil, err)
}
http.Error(w, "Failed to read response body", http.StatusInternalServerError)
// Method 1: HTTP Status Validation
if resp.StatusCode != http.StatusOK {
logger.Logger.Error().
Str("url", req.URL.String()).
Int("status_code", resp.StatusCode).
Msg("Steam returned non-OK status")
http.Error(w, "Upstream server error", http.StatusBadGateway)
return
}
// Extract hash from cache key for verification
var expectedHash string
var hasHash bool
if strings.HasPrefix(cacheKey, "steam/") {
expectedHash = cacheKey[6:] // Remove "steam/" prefix
hasHash = len(expectedHash) == 64 // SHA-256 hashes are 64 characters
// Method 2: Content-Type Validation (Steam files should be binary)
contentType := resp.Header.Get("Content-Type")
if contentType != "" && !strings.Contains(contentType, "application/octet-stream") {
logger.Logger.Warn().
Str("url", req.URL.String()).
Str("content_type", contentType).
Msg("Unexpected content type from Steam")
}
// Hash verification using Steam's X-Content-Sha header and content length verification
hashVerified := true
if hasHash {
// Get the hash from Steam's X-Content-Sha header
steamHash := resp.Header.Get("X-Content-Sha")
// Method 3: Content-Length Validation
expectedSize := resp.ContentLength
// Verify using Steam's hash
if strings.EqualFold(steamHash, expectedHash) {
// Hash verification succeeded
} else {
logger.Logger.Error().
Str("key", cacheKey).
Str("expected_hash", expectedHash).
Str("steam_hash", steamHash).
Int("content_length", len(bodyData)).
Msg("Steam hash verification failed - Steam's hash doesn't match filename")
hashVerified = false
}
} else {
// No hash to verify
}
// Always verify content length as an additional safety check
if resp.ContentLength > 0 && int64(len(bodyData)) != resp.ContentLength {
// Reject only truly invalid content lengths (zero or negative)
if expectedSize <= 0 {
logger.Logger.Error().
Str("key", cacheKey).
Int("actual_content_length", len(bodyData)).
Int64("expected_content_length", resp.ContentLength).
Msg("Content length verification failed")
hashVerified = false
Str("url", req.URL.String()).
Int64("content_length", expectedSize).
Msg("Invalid content length, rejecting file")
http.Error(w, "Invalid content length", http.StatusBadGateway)
return
}
// Write to response (always serve the file)
// Content length is valid - no size restrictions to keep logs clean
// Lightweight validation passed - trust the Content-Length and HTTP status
// This provides good integrity with minimal performance overhead
validationPassed := true
// Write to response (stream the file directly)
// Remove hop-by-hop and server-specific headers
for k, vv := range resp.Header {
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
@@ -635,16 +543,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Add our own headers
w.Header().Set("X-LanCache-Status", "MISS")
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
w.Write(bodyData)
// Stream the response body directly to client (no memory buffering)
io.Copy(w, resp.Body)
// Complete coalesced request for waiting clients
if isNew {
// Create a new response for coalesced clients
// Create a new response for coalesced clients with a fresh body
coalescedResp := &http.Response{
StatusCode: resp.StatusCode,
Status: resp.Status,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(bodyData)),
Body: io.NopCloser(strings.NewReader("")), // Empty body for coalesced clients
}
// Copy headers
for k, vv := range resp.Header {
@@ -653,19 +563,28 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
coalescedReq.complete(coalescedResp, nil)
}
// Only cache the file if hash verification passed (or no hash was present)
if hashVerified {
writer, _ := sc.vfs.Create(cachePath, int64(0)) // size is not known in advance
if writer != nil {
defer writer.Close()
// Write the full HTTP response to cache
resp.Body = io.NopCloser(bytes.NewReader(bodyData)) // Reset body for writing
resp.Write(writer)
// Cache the file if validation passed
if validationPassed {
// Create a new request to fetch the file again for caching
cacheReq, err := http.NewRequest(http.MethodGet, req.URL.String(), nil)
if err == nil {
// Copy original headers
for k, vv := range req.Header {
cacheReq.Header[k] = vv
}
// Fetch fresh copy for caching
cacheResp, err := sc.client.Do(cacheReq)
if err == nil {
defer cacheResp.Body.Close()
// Use the validated size from the original response
writer, _ := sc.vfs.Create(cachePath, expectedSize)
if writer != nil {
defer writer.Close()
io.Copy(writer, cacheResp.Body)
}
}
}
} else {
logger.Logger.Warn().
Str("key", cacheKey).
Msg("File served but not cached due to hash verification failure")
}
logger.Logger.Info().

View File

@@ -3,9 +3,9 @@ package steamcache
import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -110,99 +110,60 @@ func TestCacheMissAndHit(t *testing.T) {
}
}
func TestHashCalculation(t *testing.T) {
// Test data
testData := []byte("Hello, World!")
func TestURLHashing(t *testing.T) {
// Test the new SHA256-based cache key generation
// Calculate hash
hash := calculateFileHash(testData)
// Expected SHA1 hash of "Hello, World!"
expectedHash := "0a0a9f2a6772942557ab5355d76af442f8f65e01"
if hash != expectedHash {
t.Errorf("Hash calculation failed: expected %s, got %s", expectedHash, hash)
}
// Test verification
if !verifyFileHash(testData, expectedHash) {
t.Error("Hash verification failed for correct hash")
}
if verifyFileHash(testData, "wronghash") {
t.Error("Hash verification passed for wrong hash")
}
}
func TestHashVerificationWithRealData(t *testing.T) {
// Test with some real data to ensure our hash calculation is correct
testCases := []struct {
data string
expected string
input string
desc string
shouldCache bool
}{
{"", "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, // SHA1 of empty string
{"test", "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"}, // SHA1 of "test"
{"Hello, World!", "0a0a9f2a6772942557ab5355d76af442f8f65e01"}, // SHA1 of "Hello, World!"
{
input: "/depot/1684171/chunk/abcdef1234567890",
desc: "chunk file URL",
shouldCache: true,
},
{
input: "/depot/1684171/manifest/944076726177422892/5/abcdef1234567890",
desc: "manifest file URL",
shouldCache: true,
},
{
input: "/depot/invalid/path",
desc: "invalid depot URL format",
shouldCache: true, // Still gets hashed, just not a proper Steam format
},
{
input: "/some/other/path",
desc: "non-Steam URL",
shouldCache: false, // Not cached
},
}
for _, tc := range testCases {
data := []byte(tc.data)
hash := calculateFileHash(data)
if hash != tc.expected {
t.Errorf("Hash calculation failed for '%s': expected %s, got %s", tc.data, tc.expected, hash)
}
t.Run(tc.desc, func(t *testing.T) {
result := generateSteamCacheKey(tc.input)
if !verifyFileHash(data, tc.expected) {
t.Errorf("Hash verification failed for '%s'", tc.data)
}
if tc.shouldCache {
// Should return a cache key with "steam/" prefix
if !strings.HasPrefix(result, "steam/") {
t.Errorf("generateSteamCacheKey(%s) = %s, expected steam/ prefix", tc.input, result)
}
// Should be exactly 70 characters (6 for "steam/" + 64 for SHA256 hex)
if len(result) != 70 {
t.Errorf("generateSteamCacheKey(%s) length = %d, expected 70", tc.input, len(result))
}
} else {
// Should return empty string for non-Steam URLs
if result != "" {
t.Errorf("generateSteamCacheKey(%s) = %s, expected empty string", tc.input, result)
}
}
})
}
}
func TestResponseHashCalculation(t *testing.T) {
// Create a mock HTTP response
resp := &http.Response{
StatusCode: 200,
Status: "200 OK",
Header: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Length": []string{"13"},
"Cache-Control": []string{"public, max-age=3600"},
},
}
bodyData := []byte("Hello, World!")
// Calculate response hash
responseHash := calculateResponseHash(resp, bodyData)
// The hash should be different from just the body hash
bodyHash := calculateFileHash(bodyData)
if responseHash == bodyHash {
t.Error("Response hash should be different from body hash when headers are present")
}
// Test that the same response produces the same hash
responseHash2 := calculateResponseHash(resp, bodyData)
if responseHash != responseHash2 {
t.Error("Response hash should be consistent for the same response")
}
// Test with different headers
resp2 := &http.Response{
StatusCode: 200,
Status: "200 OK",
Header: http.Header{
"Content-Type": []string{"text/plain"},
"Content-Length": []string{"13"},
},
}
responseHash3 := calculateResponseHash(resp2, bodyData)
if responseHash == responseHash3 {
t.Error("Response hash should be different for different headers")
}
}
// Removed hash calculation tests since we switched to lightweight validation
func TestSteamKeySharding(t *testing.T) {
sc := New("localhost:8080", "0", "1G", t.TempDir(), "", "lru", "lru")
@@ -236,35 +197,4 @@ func TestSteamKeySharding(t *testing.T) {
// and be readable, whereas without sharding it might not work correctly
}
func TestKeyGeneration(t *testing.T) {
testCases := []struct {
input string
expected string
desc string
}{
{
input: "/depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e",
expected: "steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e",
desc: "chunk file URL",
},
{
input: "/depot/1684171/manifest/944076726177422892/5/12001286503415372840",
expected: "steam/12001286503415372840",
desc: "manifest file URL",
},
{
input: "/depot/invalid/path",
expected: "",
desc: "invalid depot URL format",
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
result := generateSteamCacheKey(tc.input)
if result != tc.expected {
t.Errorf("generateSteamCacheKey(%s) = %s, expected %s", tc.input, result, tc.expected)
}
})
}
}
// Removed old TestKeyGeneration - replaced with TestURLHashing that uses SHA256

View File

@@ -10,6 +10,7 @@ import (
"s1d3sw1ped/SteamCache2/steamcache/logger"
"s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/vfserror"
"sort"
"strings"
"sync"
"time"
@@ -25,14 +26,18 @@ var _ vfs.VFS = (*DiskFS)(nil)
type DiskFS struct {
root string
info map[string]*vfs.FileInfo
capacity int64
size int64
mu sync.RWMutex
keyLocks sync.Map // map[string]*sync.RWMutex
LRU *lruList
info map[string]*vfs.FileInfo
capacity int64
size int64
mu sync.RWMutex
keyLocks []sync.Map // Sharded lock pools for better concurrency
LRU *lruList
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
}
// Number of lock shards for reducing contention
const numLockShards = 32
// lruList for time-decayed LRU eviction
type lruList struct {
list *list.List
@@ -51,12 +56,12 @@ func (l *lruList) Add(key string, fi *vfs.FileInfo) {
l.elem[key] = elem
}
func (l *lruList) MoveToFront(key string) {
func (l *lruList) MoveToFront(key string, timeUpdater *vfs.BatchedTimeUpdate) {
if elem, exists := l.elem[key]; exists {
l.list.MoveToFront(elem)
// Update the FileInfo in the element with new access time
if fi := elem.Value.(*vfs.FileInfo); fi != nil {
fi.UpdateAccess()
fi.UpdateAccessBatched(timeUpdater)
}
}
}
@@ -76,11 +81,8 @@ func (l *lruList) Len() int {
}
// shardPath converts a Steam cache key to a sharded directory path to reduce inode pressure
// Optimized for the steam/{hash} format
func (d *DiskFS) shardPath(key string) string {
// Expect keys in format: steam/{hash}
if !strings.HasPrefix(key, "steam/") {
// Fallback for non-steam keys (shouldn't happen in optimized setup)
return key
}
@@ -103,17 +105,15 @@ func (d *DiskFS) shardPath(key string) string {
}
// extractKeyFromPath reverses the sharding logic to get the original key from a sharded path
// Optimized for steam/{hash} format
func (d *DiskFS) extractKeyFromPath(path string) string {
// Fast path: if no slashes, it's not a sharded path
if !strings.Contains(path, "/") {
return path
}
parts := strings.Split(path, "/")
parts := strings.SplitN(path, "/", 5)
numParts := len(parts)
// Optimized for steam/shard1/shard2/filename format
if numParts >= 4 && parts[0] == "steam" {
lastThree := parts[numParts-3:]
shard1 := lastThree[0]
@@ -150,12 +150,17 @@ func New(root string, capacity int64) *DiskFS {
// Create root directory if it doesn't exist
os.MkdirAll(root, 0755)
// Initialize sharded locks
keyLocks := make([]sync.Map, numLockShards)
d := &DiskFS{
root: root,
info: make(map[string]*vfs.FileInfo),
capacity: capacity,
size: 0,
LRU: newLruList(),
root: root,
info: make(map[string]*vfs.FileInfo),
capacity: capacity,
size: 0,
keyLocks: keyLocks,
LRU: newLruList(),
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
}
d.init()
@@ -187,6 +192,8 @@ func (d *DiskFS) init() {
fi := vfs.NewFileInfoFromOS(info, k)
d.info[k] = fi
d.LRU.Add(k, fi)
// Initialize access time with file modification time
fi.UpdateAccessBatched(d.timeUpdater)
d.size += info.Size()
// Track depot files for potential migration
@@ -300,9 +307,23 @@ func (d *DiskFS) Capacity() int64 {
return d.capacity
}
// getKeyLock returns a lock for the given key
// getShardIndex returns the shard index for a given key
func getShardIndex(key string) int {
// Use FNV-1a hash for good distribution
var h uint32 = 2166136261 // FNV offset basis
for i := 0; i < len(key); i++ {
h ^= uint32(key[i])
h *= 16777619 // FNV prime
}
return int(h % numLockShards)
}
// getKeyLock returns a lock for the given key using sharding
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex {
keyLock, _ := d.keyLocks.LoadOrStore(key, &sync.RWMutex{})
shardIndex := getShardIndex(key)
shard := &d.keyLocks[shardIndex]
keyLock, _ := shard.LoadOrStore(key, &sync.RWMutex{})
return keyLock.(*sync.RWMutex)
}
@@ -353,6 +374,8 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
d.mu.Lock()
d.info[key] = fi
d.LRU.Add(key, fi)
// Initialize access time with current time
fi.UpdateAccessBatched(d.timeUpdater)
d.size += size
d.mu.Unlock()
@@ -424,8 +447,8 @@ func (d *DiskFS) Open(key string) (io.ReadCloser, error) {
d.mu.Unlock()
return nil, vfserror.ErrNotFound
}
fi.UpdateAccess()
d.LRU.MoveToFront(key)
fi.UpdateAccessBatched(d.timeUpdater)
d.LRU.MoveToFront(key, d.timeUpdater)
d.mu.Unlock()
shardedPath := d.shardPath(key)
@@ -559,3 +582,158 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
return nil, vfserror.ErrNotFound
}
// EvictLRU evicts the least recently used files to free up space
func (d *DiskFS) EvictLRU(bytesNeeded uint) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
// Evict from LRU list until we free enough space
for d.size > d.capacity-int64(bytesNeeded) && d.LRU.Len() > 0 {
// Get the least recently used item
elem := d.LRU.list.Back()
if elem == nil {
break
}
fi := elem.Value.(*vfs.FileInfo)
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
if err := os.Remove(path); err != nil {
// Log error but continue
continue
}
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := getShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
return evicted
}
// EvictBySize evicts files by size (ascending = smallest first, descending = largest first)
func (d *DiskFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range d.info {
candidates = append(candidates, fi)
}
// Sort by size
sort.Slice(candidates, func(i, j int) bool {
if ascending {
return candidates[i].Size < candidates[j].Size
}
return candidates[i].Size > candidates[j].Size
})
// Evict files until we free enough space
for _, fi := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
if err := os.Remove(path); err != nil {
continue
}
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := getShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
return evicted
}
// EvictFIFO evicts files using FIFO (oldest creation time first)
func (d *DiskFS) EvictFIFO(bytesNeeded uint) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range d.info {
candidates = append(candidates, fi)
}
// Sort by creation time (oldest first)
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].CTime.Before(candidates[j].CTime)
})
// Evict oldest files until we free enough space
for _, fi := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
if err := os.Remove(path); err != nil {
continue
}
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := getShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
return evicted
}

View File

@@ -4,6 +4,8 @@ package gc
import (
"io"
"s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/disk"
"s1d3sw1ped/SteamCache2/vfs/memory"
)
// GCAlgorithm represents different garbage collection strategies
@@ -114,44 +116,122 @@ func (gc *GCFS) Capacity() int64 {
return gc.vfs.Capacity()
}
// EvictionStrategy defines an interface for cache eviction
type EvictionStrategy interface {
Evict(vfs vfs.VFS, bytesNeeded uint) uint
}
// GC functions
// gcLRU implements Least Recently Used eviction
func gcLRU(v vfs.VFS, bytesNeeded uint) uint {
// This is a simplified implementation
// In a real implementation, you'd need access to the internal LRU list
// For now, we'll just return the requested amount
return bytesNeeded
return evictLRU(v, bytesNeeded)
}
// gcLFU implements Least Frequently Used eviction
func gcLFU(v vfs.VFS, bytesNeeded uint) uint {
// Simplified implementation
return bytesNeeded
return evictLFU(v, bytesNeeded)
}
// gcFIFO implements First In First Out eviction
func gcFIFO(v vfs.VFS, bytesNeeded uint) uint {
// Simplified implementation
return bytesNeeded
return evictFIFO(v, bytesNeeded)
}
// gcLargest implements largest file first eviction
func gcLargest(v vfs.VFS, bytesNeeded uint) uint {
// Simplified implementation
return bytesNeeded
return evictLargest(v, bytesNeeded)
}
// gcSmallest implements smallest file first eviction
func gcSmallest(v vfs.VFS, bytesNeeded uint) uint {
// Simplified implementation
return bytesNeeded
return evictSmallest(v, bytesNeeded)
}
// gcHybrid implements a hybrid eviction strategy
func gcHybrid(v vfs.VFS, bytesNeeded uint) uint {
// Simplified implementation
return bytesNeeded
return evictHybrid(v, bytesNeeded)
}
// evictLRU performs LRU eviction by removing least recently used files
func evictLRU(v vfs.VFS, bytesNeeded uint) uint {
// Try to use specific eviction methods if available
switch fs := v.(type) {
case *memory.MemoryFS:
return fs.EvictLRU(bytesNeeded)
case *disk.DiskFS:
return fs.EvictLRU(bytesNeeded)
default:
// No fallback - return 0 (no eviction performed)
return 0
}
}
// evictLFU performs LFU (Least Frequently Used) eviction
func evictLFU(v vfs.VFS, bytesNeeded uint) uint {
// For now, fall back to size-based eviction
// TODO: Implement proper LFU tracking
return evictBySize(v, bytesNeeded)
}
// evictFIFO performs FIFO (First In First Out) eviction
func evictFIFO(v vfs.VFS, bytesNeeded uint) uint {
switch fs := v.(type) {
case *memory.MemoryFS:
return fs.EvictFIFO(bytesNeeded)
case *disk.DiskFS:
return fs.EvictFIFO(bytesNeeded)
default:
// No fallback - return 0 (no eviction performed)
return 0
}
}
// evictLargest evicts largest files first
func evictLargest(v vfs.VFS, bytesNeeded uint) uint {
return evictBySizeDesc(v, bytesNeeded)
}
// evictSmallest evicts smallest files first
func evictSmallest(v vfs.VFS, bytesNeeded uint) uint {
return evictBySizeAsc(v, bytesNeeded)
}
// evictBySize evicts files based on size (smallest first)
func evictBySize(v vfs.VFS, bytesNeeded uint) uint {
return evictBySizeAsc(v, bytesNeeded)
}
// evictBySizeAsc evicts smallest files first
func evictBySizeAsc(v vfs.VFS, bytesNeeded uint) uint {
switch fs := v.(type) {
case *memory.MemoryFS:
return fs.EvictBySize(bytesNeeded, true) // true = ascending (smallest first)
case *disk.DiskFS:
return fs.EvictBySize(bytesNeeded, true) // true = ascending (smallest first)
default:
// No fallback - return 0 (no eviction performed)
return 0
}
}
// evictBySizeDesc evicts largest files first
func evictBySizeDesc(v vfs.VFS, bytesNeeded uint) uint {
switch fs := v.(type) {
case *memory.MemoryFS:
return fs.EvictBySize(bytesNeeded, false) // false = descending (largest first)
case *disk.DiskFS:
return fs.EvictBySize(bytesNeeded, false) // false = descending (largest first)
default:
// No fallback - return 0 (no eviction performed)
return 0
}
}
// evictHybrid implements a hybrid eviction strategy
func evictHybrid(v vfs.VFS, bytesNeeded uint) uint {
// Use LRU as primary strategy, but consider size as tiebreaker
return evictLRU(v, bytesNeeded)
}
// AdaptivePromotionDeciderFunc is a placeholder for the adaptive promotion logic

View File

@@ -7,8 +7,10 @@ import (
"io"
"s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/vfserror"
"sort"
"strings"
"sync"
"time"
)
// Ensure MemoryFS implements VFS.
@@ -16,15 +18,19 @@ var _ vfs.VFS = (*MemoryFS)(nil)
// MemoryFS is an in-memory virtual file system
type MemoryFS struct {
data map[string]*bytes.Buffer
info map[string]*vfs.FileInfo
capacity int64
size int64
mu sync.RWMutex
keyLocks sync.Map // map[string]*sync.RWMutex
LRU *lruList
data map[string]*bytes.Buffer
info map[string]*vfs.FileInfo
capacity int64
size int64
mu sync.RWMutex
keyLocks []sync.Map // Sharded lock pools for better concurrency
LRU *lruList
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
}
// Number of lock shards for reducing contention
const numLockShards = 32
// lruList for time-decayed LRU eviction
type lruList struct {
list *list.List
@@ -43,12 +49,12 @@ func (l *lruList) Add(key string, fi *vfs.FileInfo) {
l.elem[key] = elem
}
func (l *lruList) MoveToFront(key string) {
func (l *lruList) MoveToFront(key string, timeUpdater *vfs.BatchedTimeUpdate) {
if elem, exists := l.elem[key]; exists {
l.list.MoveToFront(elem)
// Update the FileInfo in the element with new access time
if fi := elem.Value.(*vfs.FileInfo); fi != nil {
fi.UpdateAccess()
fi.UpdateAccessBatched(timeUpdater)
}
}
}
@@ -73,12 +79,17 @@ func New(capacity int64) *MemoryFS {
panic("memory capacity must be greater than 0")
}
// Initialize sharded locks
keyLocks := make([]sync.Map, numLockShards)
return &MemoryFS{
data: make(map[string]*bytes.Buffer),
info: make(map[string]*vfs.FileInfo),
capacity: capacity,
size: 0,
LRU: newLruList(),
data: make(map[string]*bytes.Buffer),
info: make(map[string]*vfs.FileInfo),
capacity: capacity,
size: 0,
keyLocks: keyLocks,
LRU: newLruList(),
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
}
}
@@ -99,9 +110,23 @@ func (m *MemoryFS) Capacity() int64 {
return m.capacity
}
// getKeyLock returns a lock for the given key
// getShardIndex returns the shard index for a given key
func getShardIndex(key string) int {
// Use FNV-1a hash for good distribution
var h uint32 = 2166136261 // FNV offset basis
for i := 0; i < len(key); i++ {
h ^= uint32(key[i])
h *= 16777619 // FNV prime
}
return int(h % numLockShards)
}
// getKeyLock returns a lock for the given key using sharding
func (m *MemoryFS) getKeyLock(key string) *sync.RWMutex {
keyLock, _ := m.keyLocks.LoadOrStore(key, &sync.RWMutex{})
shardIndex := getShardIndex(key)
shard := &m.keyLocks[shardIndex]
keyLock, _ := shard.LoadOrStore(key, &sync.RWMutex{})
return keyLock.(*sync.RWMutex)
}
@@ -137,6 +162,8 @@ func (m *MemoryFS) Create(key string, size int64) (io.WriteCloser, error) {
fi := vfs.NewFileInfo(key, size)
m.info[key] = fi
m.LRU.Add(key, fi)
// Initialize access time with current time
fi.UpdateAccessBatched(m.timeUpdater)
m.size += size
m.mu.Unlock()
@@ -194,8 +221,8 @@ func (m *MemoryFS) Open(key string) (io.ReadCloser, error) {
m.mu.Unlock()
return nil, vfserror.ErrNotFound
}
fi.UpdateAccess()
m.LRU.MoveToFront(key)
fi.UpdateAccessBatched(m.timeUpdater)
m.LRU.MoveToFront(key, m.timeUpdater)
buffer, exists := m.data[key]
if !exists {
@@ -284,3 +311,133 @@ func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
return nil, vfserror.ErrNotFound
}
// EvictLRU evicts the least recently used files to free up space
func (m *MemoryFS) EvictLRU(bytesNeeded uint) uint {
m.mu.Lock()
defer m.mu.Unlock()
var evicted uint
// Evict from LRU list until we free enough space
for m.size > m.capacity-int64(bytesNeeded) && m.LRU.Len() > 0 {
// Get the least recently used item
elem := m.LRU.list.Back()
if elem == nil {
break
}
fi := elem.Value.(*vfs.FileInfo)
key := fi.Key
// Remove from LRU
m.LRU.Remove(key)
// Remove from maps
delete(m.info, key)
delete(m.data, key)
// Update size
m.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := getShardIndex(key)
m.keyLocks[shardIndex].Delete(key)
}
return evicted
}
// EvictBySize evicts files by size (ascending = smallest first, descending = largest first)
func (m *MemoryFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
m.mu.Lock()
defer m.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range m.info {
candidates = append(candidates, fi)
}
// Sort by size
sort.Slice(candidates, func(i, j int) bool {
if ascending {
return candidates[i].Size < candidates[j].Size
}
return candidates[i].Size > candidates[j].Size
})
// Evict files until we free enough space
for _, fi := range candidates {
if m.size <= m.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
m.LRU.Remove(key)
// Remove from maps
delete(m.info, key)
delete(m.data, key)
// Update size
m.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := getShardIndex(key)
m.keyLocks[shardIndex].Delete(key)
}
return evicted
}
// EvictFIFO evicts files using FIFO (oldest creation time first)
func (m *MemoryFS) EvictFIFO(bytesNeeded uint) uint {
m.mu.Lock()
defer m.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range m.info {
candidates = append(candidates, fi)
}
// Sort by creation time (oldest first)
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].CTime.Before(candidates[j].CTime)
})
// Evict oldest files until we free enough space
for _, fi := range candidates {
if m.size <= m.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
m.LRU.Remove(key)
// Remove from maps
delete(m.info, key)
delete(m.data, key)
// Update size
m.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := getShardIndex(key)
m.keyLocks[shardIndex].Delete(key)
}
return evicted
}

View File

@@ -69,6 +69,39 @@ func (fi *FileInfo) UpdateAccess() {
fi.AccessCount++
}
// BatchedTimeUpdate provides a way to batch time updates for better performance
type BatchedTimeUpdate struct {
currentTime time.Time
lastUpdate time.Time
updateInterval time.Duration
}
// NewBatchedTimeUpdate creates a new batched time updater
func NewBatchedTimeUpdate(interval time.Duration) *BatchedTimeUpdate {
now := time.Now()
return &BatchedTimeUpdate{
currentTime: now,
lastUpdate: now,
updateInterval: interval,
}
}
// GetTime returns the current cached time, updating it if necessary
func (btu *BatchedTimeUpdate) GetTime() time.Time {
now := time.Now()
if now.Sub(btu.lastUpdate) >= btu.updateInterval {
btu.currentTime = now
btu.lastUpdate = now
}
return btu.currentTime
}
// UpdateAccessBatched updates the access time using batched time updates
func (fi *FileInfo) UpdateAccessBatched(btu *BatchedTimeUpdate) {
fi.ATime = btu.GetTime()
fi.AccessCount++
}
// GetTimeDecayedScore calculates a score based on access time and frequency
// More recent and frequent accesses get higher scores
func (fi *FileInfo) GetTimeDecayedScore() float64 {