4 Commits

Author SHA1 Message Date
ee6fc32a1a Update content type validation in ServeHTTP method for Steam files
- Changed expected Content-Type from "application/octet-stream" to "application/x-steam-chunk" to align with Steam's file specifications.
- Enhanced warning message for unexpected content types to provide clearer context for debugging.
2025-09-02 05:48:24 -05:00
4a4579b0f3 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.
2025-09-02 05:45:44 -05:00
b9358a0e8d Refactor steamcache.go to simplify code and improve readability
- Removed the min function and the verifyResponseHash function to streamline the codebase.
- Updated extractHashFromSteamPath to use strings.TrimPrefix for cleaner path handling.
- Retained comments regarding removed Prometheus metrics for future reference.
2025-09-02 05:03:15 -05:00
c197841960 Refactor configuration management and enhance build process
- Introduced a YAML-based configuration system, allowing for automatic generation of a default `config.yaml` file.
- Updated the application to load configuration settings from the YAML file, improving flexibility and ease of use.
- Added a Makefile to streamline development tasks, including running the application, testing, and managing dependencies.
- Enhanced `.gitignore` to include build artifacts and configuration files.
- Removed unused Prometheus metrics and related code to simplify the codebase.
- Updated dependencies in `go.mod` and `go.sum` for improved functionality and performance.
2025-09-02 05:01:42 -05:00
22 changed files with 1990 additions and 2422 deletions

14
.gitignore vendored
View File

@@ -1,5 +1,11 @@
dist/ #build artifacts
tmp/ /dist/
#disk cache
/disk/
#config file
/config.yaml
#windows executables
*.exe *.exe
.smashed.txt
.smashignore

67
.vscode/launch.json vendored
View File

@@ -1,67 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Memory & Disk",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go",
"args": [
"--memory",
"1G",
"--disk",
"10G",
"--disk-path",
"tmp/disk",
"--memory-gc",
"lfu",
"--disk-gc",
"lru",
"--log-level",
"debug",
// "--upstream",
// "http://192.168.2.5:80",
],
},
{
"name": "Launch Disk Only",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go",
"args": [
"--disk",
"10G",
"--disk-path",
"tmp/disk",
"--disk-gc",
"hybrid",
"--log-level",
"debug",
// "--upstream",
// "http://192.168.2.5:80",
],
},
{
"name": "Launch Memory Only",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go",
"args": [
"--memory",
"1G",
"--memory-gc",
"lru",
"--log-level",
"debug",
// "--upstream",
// "http://192.168.2.5:80",
],
}
]
}

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
run: deps test ## Run the application
@go run .
help: ## Show this help message
@echo SteamCache2 Makefile
@echo Available targets:
@echo run Run the application
@echo run-debug Run the application with debug logging
@echo test Run all tests
@echo deps Download dependencies
run-debug: deps test ## Run the application with debug logging
@go run . --log-level debug
test: deps ## Run all tests
@go test -v ./...
deps: ## Download dependencies
@go mod tidy

224
README.md
View File

@@ -10,30 +10,120 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
- Reduces bandwidth usage - Reduces bandwidth usage
- Easy to set up and configure aside from dns stuff to trick Steam into using it - Easy to set up and configure aside from dns stuff to trick Steam into using it
- Supports multiple clients - Supports multiple clients
- **NEW:** YAML configuration system with automatic config generation
- **NEW:** Simple Makefile for development workflow
- Cross-platform builds (Linux, macOS, Windows)
## Usage ## Quick Start
1. Start the cache server: ### First Time Setup
```sh
./SteamCache2 --memory 1G --disk 10G --disk-path tmp/disk
```
### Advanced Configuration 1. **Clone and build:**
```bash
git clone <repository-url>
cd SteamCache2
make # This will run tests and build the application
```
2. **Run the application** (it will create a default config):
```bash
./steamcache2
# or on Windows:
steamcache2.exe
```
The application will automatically create a `config.yaml` file with default settings and exit, allowing you to customize it.
3. **Edit the configuration** (`config.yaml`):
```yaml
listen_address: :80
cache:
memory:
size: 1GB
gc_algorithm: lru
disk:
size: 10GB
path: ./disk
gc_algorithm: hybrid
upstream: "https://steam.cdn.com" # Set your upstream server
```
4. **Run the application again:**
```bash
make run # or ./steamcache2
```
### Development Workflow
```bash
# Run all tests and start the application (default target)
make
# Run only tests
make test
# Run with debug logging
make run-debug
# Download dependencies
make deps
# Show available commands
make help
```
### Command Line Flags
While most configuration is done via the YAML file, some runtime options are still available as command-line flags:
```bash
# Use a custom config file
./steamcache2 --config /path/to/my-config.yaml
# Set logging level
./steamcache2 --log-level debug --log-format json
# Set number of worker threads
./steamcache2 --threads 8
# Show help
./steamcache2 --help
```
### Configuration
SteamCache2 uses a YAML configuration file (`config.yaml`) for all settings. Here's a complete configuration example:
```yaml
# Server configuration
listen_address: :80
# Cache configuration
cache:
# Memory cache settings
memory:
# Size of memory cache (e.g., "512MB", "1GB", "0" to disable)
size: 1GB
# Garbage collection algorithm
gc_algorithm: lru
# Disk cache settings
disk:
# Size of disk cache (e.g., "10GB", "50GB", "0" to disable)
size: 10GB
# Path to disk cache directory
path: ./disk
# Garbage collection algorithm
gc_algorithm: hybrid
# Upstream server configuration
# The upstream server to proxy requests to
upstream: "https://steam.cdn.com"
```
#### Garbage Collection Algorithms #### Garbage Collection Algorithms
SteamCache2 supports multiple garbage collection algorithms for both memory and disk caches: SteamCache2 supports different garbage collection algorithms for memory and disk caches, allowing you to optimize performance for each storage tier:
```sh
# Use LFU for memory cache (good for long-running servers)
./SteamCache2 --memory 4G --memory-gc lfu --disk 100G --disk-gc lru
# Use FIFO for predictable eviction (good for testing)
./SteamCache2 --memory 2G --memory-gc fifo --disk 50G --disk-gc fifo
# Use size-based eviction for disk cache
./SteamCache2 --memory 1G --disk 200G --disk-gc largest
```
**Available GC Algorithms:** **Available GC Algorithms:**
@@ -44,13 +134,30 @@ SteamCache2 supports multiple garbage collection algorithms for both memory and
- **`smallest`**: Size-based - evicts smallest files first (maximizes cache hit rate) - **`smallest`**: Size-based - evicts smallest files first (maximizes cache hit rate)
- **`hybrid`**: Combines access time and file size for optimal eviction - **`hybrid`**: Combines access time and file size for optimal eviction
**Recommended Algorithms by Cache Type:**
**For Memory Cache (Fast, Limited Size):**
- **`lru`** - Best overall performance, good balance of speed and hit rate
- **`lfu`** - Excellent for gaming cafes where popular games stay cached
- **`hybrid`** - Optimal for mixed workloads with varying file sizes
**For Disk Cache (Slow, Large Size):**
- **`hybrid`** - Recommended for optimal performance, balances speed and storage efficiency
- **`largest`** - Good for maximizing number of cached files
- **`lru`** - Reliable default with good performance
**Use Cases:** **Use Cases:**
- **LAN Events**: Use `lfu` for memory caches to keep popular games - **Gaming Cafes**: Use `lfu` for memory, `hybrid` for disk
- **Gaming Cafes**: Use `hybrid` for balanced performance - **LAN Events**: Use `lfu` for memory, `hybrid` for disk
- **Home Use**: Use `lru` for memory, `hybrid` for disk
- **Testing**: Use `fifo` for predictable behavior - **Testing**: Use `fifo` for predictable behavior
- **Large Files**: Use `largest` to prioritize keeping many small files - **Large File Storage**: Use `largest` for disk to maximize file count
2. Configure your DNS:
- If your on Windows and don't want a whole network implementation (THIS)[#windows-hosts-file-override] ### DNS Configuration
Configure your DNS to direct Steam traffic to your SteamCache2 server:
- If you're on Windows and don't want a whole network implementation, see the [Windows Hosts File Override](#windows-hosts-file-override) section below.
### Windows Hosts File Override ### Windows Hosts File Override
@@ -85,6 +192,77 @@ SteamCache2 supports multiple garbage collection algorithms for both memory and
This will direct any requests to `lancache.steamcontent.com` to your SteamCache2 server. This will direct any requests to `lancache.steamcontent.com` to your SteamCache2 server.
## Building from Source
### Prerequisites
- Go 1.19 or later
- Make (optional, but recommended)
### Build Commands
```bash
# Clone the repository
git clone <repository-url>
cd SteamCache2
# Download dependencies
make deps
# Run tests
make test
# Build for current platform
go build -o steamcache2 .
# Build for specific platforms
GOOS=linux GOARCH=amd64 go build -o steamcache2-linux-amd64 .
GOOS=windows GOARCH=amd64 go build -o steamcache2-windows-amd64.exe .
```
### Development
```bash
# Run in development mode with debug logging
make run-debug
# Run all tests and start the application
make
```
## Troubleshooting
### Common Issues
1. **"Config file not found" on first run**
- This is expected! SteamCache2 will automatically create a default `config.yaml` file
- Edit the generated config file with your desired settings
- Run the application again
2. **Permission denied when creating config**
- Make sure you have write permissions in the current directory
- Try running with elevated privileges if necessary
3. **Port already in use**
- Change the `listen_address` in `config.yaml` to a different port (e.g., `:8080`)
- Or stop the service using the current port
4. **High memory usage**
- Reduce the memory cache size in `config.yaml`
- Consider using disk-only caching by setting `memory.size: "0"`
5. **Slow disk performance**
- Use SSD storage for the disk cache
- Consider using a different GC algorithm like `hybrid`
- Adjust the disk cache size to match available storage
### Getting Help
- Check the logs for detailed error messages
- Run with `--log-level debug` for more verbose output
- Ensure your upstream server is accessible
- Verify DNS configuration is working correctly
## License ## License
See the [LICENSE](LICENSE) file for details. See the [LICENSE](LICENSE) file for details.

View File

@@ -2,26 +2,22 @@
package cmd package cmd
import ( import (
"fmt"
"os" "os"
"runtime" "runtime"
"s1d3sw1ped/SteamCache2/config"
"s1d3sw1ped/SteamCache2/steamcache" "s1d3sw1ped/SteamCache2/steamcache"
"s1d3sw1ped/SteamCache2/steamcache/logger" "s1d3sw1ped/SteamCache2/steamcache/logger"
"s1d3sw1ped/SteamCache2/version" "s1d3sw1ped/SteamCache2/version"
"strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
threads int threads int
configPath string
memory string
disk string
diskpath string
upstream string
memoryGC string
diskGC string
logLevel string logLevel string
logFormat string logFormat string
@@ -58,7 +54,47 @@ var rootCmd = &cobra.Command{
logger.Logger.Info(). logger.Logger.Info().
Msg("SteamCache2 " + version.Version + " " + version.Date + " starting...") Msg("SteamCache2 " + version.Version + " " + version.Date + " starting...")
address := ":80" // Load configuration
cfg, err := config.LoadConfig(configPath)
if err != nil {
// Check if the error is because the config file doesn't exist
// The error is wrapped, so we check the error message
if strings.Contains(err.Error(), "no such file") ||
strings.Contains(err.Error(), "cannot find the file") ||
strings.Contains(err.Error(), "The system cannot find the file") {
logger.Logger.Info().
Str("config_path", configPath).
Msg("Config file not found, creating default configuration")
if err := config.SaveDefaultConfig(configPath); err != nil {
logger.Logger.Error().
Err(err).
Str("config_path", configPath).
Msg("Failed to create default configuration")
fmt.Fprintf(os.Stderr, "Error: Failed to create default config at %s: %v\n", configPath, err)
os.Exit(1)
}
logger.Logger.Info().
Str("config_path", configPath).
Msg("Default configuration created successfully. Please edit the file and run again.")
fmt.Printf("Default configuration created at %s\n", configPath)
fmt.Println("Please edit the configuration file as needed and run the application again.")
os.Exit(0)
} else {
logger.Logger.Error().
Err(err).
Str("config_path", configPath).
Msg("Failed to load configuration")
fmt.Fprintf(os.Stderr, "Error: Failed to load configuration from %s: %v\n", configPath, err)
os.Exit(1)
}
}
logger.Logger.Info().
Str("config_path", configPath).
Msg("Configuration loaded successfully")
if runtime.GOMAXPROCS(-1) != threads { if runtime.GOMAXPROCS(-1) != threads {
runtime.GOMAXPROCS(threads) runtime.GOMAXPROCS(threads)
@@ -68,17 +104,17 @@ var rootCmd = &cobra.Command{
} }
sc := steamcache.New( sc := steamcache.New(
address, cfg.ListenAddress,
memory, cfg.Cache.Memory.Size,
disk, cfg.Cache.Disk.Size,
diskpath, cfg.Cache.Disk.Path,
upstream, cfg.Upstream,
memoryGC, cfg.Cache.Memory.GCAlgorithm,
diskGC, cfg.Cache.Disk.GCAlgorithm,
) )
logger.Logger.Info(). logger.Logger.Info().
Msg("SteamCache2 " + version.Version + " started on " + address) Msg("SteamCache2 " + version.Version + " started on " + cfg.ListenAddress)
sc.Run() sc.Run()
@@ -97,17 +133,10 @@ func Execute() {
} }
func init() { func init() {
rootCmd.Flags().StringVarP(&configPath, "config", "c", "config.yaml", "Path to configuration file")
rootCmd.Flags().IntVarP(&threads, "threads", "t", runtime.GOMAXPROCS(-1), "Number of worker threads to use for processing requests") rootCmd.Flags().IntVarP(&threads, "threads", "t", runtime.GOMAXPROCS(-1), "Number of worker threads to use for processing requests")
rootCmd.Flags().StringVarP(&memory, "memory", "m", "0", "The size of the memory cache")
rootCmd.Flags().StringVarP(&disk, "disk", "d", "0", "The size of the disk cache")
rootCmd.Flags().StringVarP(&diskpath, "disk-path", "p", "", "The path to the disk cache")
rootCmd.Flags().StringVarP(&upstream, "upstream", "u", "", "The upstream server to proxy requests overrides the host header from the client but forwards the original host header to the upstream server")
rootCmd.Flags().StringVarP(&memoryGC, "memory-gc", "", "lru", "Memory cache GC algorithm: lru, lfu, fifo, largest, smallest, hybrid")
rootCmd.Flags().StringVarP(&diskGC, "disk-gc", "", "lru", "Disk cache GC algorithm: lru, lfu, fifo, largest, smallest, hybrid")
rootCmd.Flags().StringVarP(&logLevel, "log-level", "l", "info", "Logging level: debug, info, error") rootCmd.Flags().StringVarP(&logLevel, "log-level", "l", "info", "Logging level: debug, info, error")
rootCmd.Flags().StringVarP(&logFormat, "log-format", "f", "console", "Logging format: json, console") rootCmd.Flags().StringVarP(&logFormat, "log-format", "f", "console", "Logging format: json, console")
} }

116
config/config.go Normal file
View File

@@ -0,0 +1,116 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
// Server configuration
ListenAddress string `yaml:"listen_address" default:":80"`
// Cache configuration
Cache CacheConfig `yaml:"cache"`
// Upstream configuration
Upstream string `yaml:"upstream"`
}
type CacheConfig struct {
// Memory cache settings
Memory MemoryConfig `yaml:"memory"`
// Disk cache settings
Disk DiskConfig `yaml:"disk"`
}
type MemoryConfig struct {
// Size of memory cache (e.g., "512MB", "1GB")
Size string `yaml:"size" default:"0"`
// Garbage collection algorithm: lru, lfu, fifo, largest, smallest, hybrid
GCAlgorithm string `yaml:"gc_algorithm" default:"lru"`
}
type DiskConfig struct {
// Size of disk cache (e.g., "10GB", "50GB")
Size string `yaml:"size" default:"0"`
// Path to disk cache directory
Path string `yaml:"path" default:""`
// Garbage collection algorithm: lru, lfu, fifo, largest, smallest, hybrid
GCAlgorithm string `yaml:"gc_algorithm" default:"lru"`
}
// LoadConfig loads configuration from a YAML file
func LoadConfig(configPath string) (*Config, error) {
if configPath == "" {
configPath = "config.yaml"
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
// Set defaults for empty values
if config.ListenAddress == "" {
config.ListenAddress = ":80"
}
if config.Cache.Memory.Size == "" {
config.Cache.Memory.Size = "0"
}
if config.Cache.Memory.GCAlgorithm == "" {
config.Cache.Memory.GCAlgorithm = "lru"
}
if config.Cache.Disk.Size == "" {
config.Cache.Disk.Size = "0"
}
if config.Cache.Disk.GCAlgorithm == "" {
config.Cache.Disk.GCAlgorithm = "lru"
}
return &config, nil
}
// SaveDefaultConfig creates a default configuration file
func SaveDefaultConfig(configPath string) error {
if configPath == "" {
configPath = "config.yaml"
}
defaultConfig := Config{
ListenAddress: ":80",
Cache: CacheConfig{
Memory: MemoryConfig{
Size: "1GB",
GCAlgorithm: "lru",
},
Disk: DiskConfig{
Size: "10GB",
Path: "./disk",
GCAlgorithm: "hybrid",
},
},
Upstream: "",
}
data, err := yaml.Marshal(&defaultConfig)
if err != nil {
return fmt.Errorf("failed to marshal default config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write default config file: %w", err)
}
return nil
}

12
go.mod
View File

@@ -4,22 +4,16 @@ go 1.23.0
require ( require (
github.com/docker/go-units v0.5.0 github.com/docker/go-units v0.5.0
github.com/prometheus/client_golang v1.22.0 github.com/edsrzf/mmap-go v1.1.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.12.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
) )

34
go.sum
View File

@@ -1,40 +1,18 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
@@ -43,15 +21,11 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,149 +4,43 @@ package steamcache
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/sha1" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"regexp"
"s1d3sw1ped/SteamCache2/steamcache/logger" "s1d3sw1ped/SteamCache2/steamcache/logger"
"s1d3sw1ped/SteamCache2/vfs" "s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/cache" "s1d3sw1ped/SteamCache2/vfs/cache"
"s1d3sw1ped/SteamCache2/vfs/disk" "s1d3sw1ped/SteamCache2/vfs/disk"
"s1d3sw1ped/SteamCache2/vfs/gc" "s1d3sw1ped/SteamCache2/vfs/gc"
"s1d3sw1ped/SteamCache2/vfs/memory" "s1d3sw1ped/SteamCache2/vfs/memory"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"bytes"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
) )
// min returns the minimum of two integers // generateURLHash creates a SHA256 hash of the entire URL path for cache key
func min(a, b int) int { func generateURLHash(urlPath string) string {
if a < b { hash := sha256.Sum256([]byte(urlPath))
return a
}
return b
}
var (
requestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "status"},
)
cacheStatusTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cache_status_total",
Help: "Total cache status counts",
},
[]string{"status"},
)
responseTime = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "response_time_seconds",
Help: "Response time in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"cache_status"},
)
)
// hashVerificationTotal tracks hash verification attempts
var hashVerificationTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "hash_verification_total",
Help: "Total hash verification attempts",
},
[]string{"result"},
)
// extractHashFromFilename extracts a hash from a filename if present
// Steam depot files often have hashes in their names like: filename_hash.ext
func extractHashFromFilename(filename string) (string, bool) {
// Common patterns for Steam depot files with hashes
patterns := []*regexp.Regexp{
regexp.MustCompile(`^([a-fA-F0-9]{40})$`), // Standalone SHA1 hash (40 hex chars)
regexp.MustCompile(`^([a-fA-F0-9]{40})\.`), // SHA1 hash with extension
}
for _, pattern := range patterns {
if matches := pattern.FindStringSubmatch(filename); len(matches) > 1 {
return strings.ToLower(matches[1]), true
}
}
// Debug: log when we don't find a hash pattern
if strings.Contains(filename, "manifest") {
logger.Logger.Debug().
Str("filename", filename).
Msg("No hash pattern found in manifest filename")
}
return "", false
}
// calculateFileHash calculates the SHA1 hash of the given data
func calculateFileHash(data []byte) string {
hash := sha1.Sum(data)
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// calculateResponseHash calculates the SHA1 hash of the full HTTP response // generateSteamCacheKey creates a cache key from the URL path using SHA256
func calculateResponseHash(resp *http.Response, bodyData []byte) string { // Input: /depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
hash := sha1.New() // Output: steam/a1b2c3d4e5f678901234567890123456789012345678901234567890
func generateSteamCacheKey(urlPath string) string {
// Include status line // Handle Steam depot URLs by creating a SHA256 hash of the entire path
statusLine := fmt.Sprintf("HTTP/1.1 %d %s\n", resp.StatusCode, resp.Status) if strings.HasPrefix(urlPath, "/depot/") {
hash.Write([]byte(statusLine)) return "steam/" + generateURLHash(urlPath)
// 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))
} }
// Include empty line between headers and body // For non-Steam URLs, return empty string (not cached)
hash.Write([]byte("\n")) return ""
// 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)
}
// verifyResponseHash verifies that the full HTTP response matches the expected hash
func verifyResponseHash(resp *http.Response, bodyData []byte, expectedHash string) bool {
actualHash := calculateResponseHash(resp, bodyData)
return strings.EqualFold(actualHash, expectedHash)
} }
var hopByHopHeaders = map[string]struct{}{ var hopByHopHeaders = map[string]struct{}{
@@ -162,6 +56,78 @@ var hopByHopHeaders = map[string]struct{}{
"Server": {}, "Server": {},
} }
var (
// Request coalescing structures
coalescedRequests = make(map[string]*coalescedRequest)
coalescedRequestsMu sync.RWMutex
)
type coalescedRequest struct {
responseChan chan *http.Response
errorChan chan error
waitingCount int
done bool
mu sync.Mutex
}
func newCoalescedRequest() *coalescedRequest {
return &coalescedRequest{
responseChan: make(chan *http.Response, 1),
errorChan: make(chan error, 1),
waitingCount: 1,
done: false,
}
}
func (cr *coalescedRequest) addWaiter() {
cr.mu.Lock()
defer cr.mu.Unlock()
cr.waitingCount++
}
func (cr *coalescedRequest) complete(resp *http.Response, err error) {
cr.mu.Lock()
defer cr.mu.Unlock()
if cr.done {
return
}
cr.done = true
if err != nil {
select {
case cr.errorChan <- err:
default:
}
} else {
select {
case cr.responseChan <- resp:
default:
}
}
}
// getOrCreateCoalescedRequest gets an existing coalesced request or creates a new one
func getOrCreateCoalescedRequest(cacheKey string) (*coalescedRequest, bool) {
coalescedRequestsMu.Lock()
defer coalescedRequestsMu.Unlock()
if cr, exists := coalescedRequests[cacheKey]; exists {
cr.addWaiter()
return cr, false
}
cr := newCoalescedRequest()
coalescedRequests[cacheKey] = cr
return cr, true
}
// removeCoalescedRequest removes a completed coalesced request
func removeCoalescedRequest(cacheKey string) {
coalescedRequestsMu.Lock()
defer coalescedRequestsMu.Unlock()
delete(coalescedRequests, cacheKey)
}
type SteamCache struct { type SteamCache struct {
address string address string
upstream string upstream string
@@ -191,9 +157,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
panic(err) panic(err)
} }
c := cache.New( c := cache.New()
gc.AdaptivePromotionDeciderFunc,
)
var m *memory.MemoryFS var m *memory.MemoryFS
var mgc *gc.GCFS var mgc *gc.GCFS
@@ -203,7 +167,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
if memoryGCAlgo == "" { if memoryGCAlgo == "" {
memoryGCAlgo = gc.LRU // default to LRU memoryGCAlgo = gc.LRU // default to LRU
} }
mgc = gc.New(m, gc.GetGCAlgorithm(memoryGCAlgo)) mgc = gc.New(m, memoryGCAlgo)
} }
var d *disk.DiskFS var d *disk.DiskFS
@@ -214,7 +178,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
if diskGCAlgo == "" { if diskGCAlgo == "" {
diskGCAlgo = gc.LRU // default to LRU diskGCAlgo = gc.LRU // default to LRU
} }
dgc = gc.New(d, gc.GetGCAlgorithm(diskGCAlgo)) dgc = gc.New(d, diskGCAlgo)
} }
// configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes // configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes
@@ -332,7 +296,6 @@ func (sc *SteamCache) Shutdown() {
func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
requestsTotal.WithLabelValues(r.Method, "405").Inc()
logger.Logger.Warn().Str("method", r.Method).Msg("Only GET method is supported") logger.Logger.Warn().Str("method", r.Method).Msg("Only GET method is supported")
http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed) http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed)
return return
@@ -350,23 +313,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
if r.URL.Path == "/metrics" {
promhttp.Handler().ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.String(), "/depot/") { if strings.HasPrefix(r.URL.String(), "/depot/") {
// trim the query parameters from the URL path // trim the query parameters from the URL path
// this is necessary because the cache key should not include query parameters // this is necessary because the cache key should not include query parameters
path := strings.Split(r.URL.String(), "?")[0] urlPath, _, _ := strings.Cut(r.URL.String(), "?")
tstart := time.Now() tstart := time.Now()
cacheKey := strings.ReplaceAll(path[1:], "\\", "/") // replace all backslashes with forward slashes shouldn't be necessary but just in case // Generate simplified Steam cache key: steam/{hash}
cacheKey := generateSteamCacheKey(urlPath)
if cacheKey == "" { if cacheKey == "" {
requestsTotal.WithLabelValues(r.Method, "400").Inc() logger.Logger.Warn().Str("url", urlPath).Msg("Invalid URL")
logger.Logger.Warn().Str("url", path).Msg("Invalid URL")
http.Error(w, "Invalid URL", http.StatusBadRequest) http.Error(w, "Invalid URL", http.StatusBadRequest)
return return
} }
@@ -403,18 +361,67 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Str("status", "HIT"). Str("status", "HIT").
Dur("duration", time.Since(tstart)). Dur("duration", time.Since(tstart)).
Msg("request") Msg("request")
requestsTotal.WithLabelValues(r.Method, "200").Inc()
cacheStatusTotal.WithLabelValues("HIT").Inc()
responseTime.WithLabelValues("HIT").Observe(time.Since(tstart).Seconds())
return return
} }
} }
// Check for coalesced request (another client already downloading this)
coalescedReq, isNew := getOrCreateCoalescedRequest(cacheKey)
if !isNew {
// Wait for the existing download to complete
logger.Logger.Debug().
Str("key", cacheKey).
Int("waiting_clients", coalescedReq.waitingCount).
Msg("Joining coalesced request")
select {
case resp := <-coalescedReq.responseChan:
// Use the downloaded response
defer resp.Body.Close()
bodyData, err := io.ReadAll(resp.Body)
if err != nil {
logger.Logger.Error().Err(err).Str("key", cacheKey).Msg("Failed to read coalesced response body")
http.Error(w, "Failed to read response body", http.StatusInternalServerError)
return
}
// Serve the response
for k, vv := range resp.Header {
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
continue
}
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.Header().Set("X-LanCache-Status", "HIT-COALESCED")
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
w.WriteHeader(resp.StatusCode)
w.Write(bodyData)
logger.Logger.Info().
Str("key", cacheKey).
Str("host", r.Host).
Str("status", "HIT-COALESCED").
Dur("duration", time.Since(tstart)).
Msg("request")
return
case err := <-coalescedReq.errorChan:
logger.Logger.Error().Err(err).Str("key", cacheKey).Msg("Coalesced request failed")
http.Error(w, "Upstream request failed", http.StatusInternalServerError)
return
}
}
// Remove coalesced request when done
defer removeCoalescedRequest(cacheKey)
var req *http.Request var req *http.Request
if sc.upstream != "" { // if an upstream server is configured, proxy the request to the upstream server if sc.upstream != "" { // if an upstream server is configured, proxy the request to the upstream server
ur, err := url.JoinPath(sc.upstream, path) ur, err := url.JoinPath(sc.upstream, urlPath)
if err != nil { if err != nil {
requestsTotal.WithLabelValues(r.Method, "500").Inc()
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to join URL path") logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to join URL path")
http.Error(w, "Failed to join URL path", http.StatusInternalServerError) http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
return return
@@ -422,7 +429,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req, err = http.NewRequest(http.MethodGet, ur, nil) req, err = http.NewRequest(http.MethodGet, ur, nil)
if err != nil { if err != nil {
requestsTotal.WithLabelValues(r.Method, "500").Inc()
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request") logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request")
http.Error(w, "Failed to create request", http.StatusInternalServerError) http.Error(w, "Failed to create request", http.StatusInternalServerError)
return return
@@ -436,9 +442,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
host = "http://" + host host = "http://" + host
} }
ur, err := url.JoinPath(host, path) ur, err := url.JoinPath(host, urlPath)
if err != nil { if err != nil {
requestsTotal.WithLabelValues(r.Method, "500").Inc()
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to join URL path") logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to join URL path")
http.Error(w, "Failed to join URL path", http.StatusInternalServerError) http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
return return
@@ -446,7 +451,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req, err = http.NewRequest(http.MethodGet, ur, nil) req, err = http.NewRequest(http.MethodGet, ur, nil)
if err != nil { if err != nil {
requestsTotal.WithLabelValues(r.Method, "500").Inc()
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request") logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request")
http.Error(w, "Failed to create request", http.StatusInternalServerError) http.Error(w, "Failed to create request", http.StatusInternalServerError)
return return
@@ -473,63 +477,60 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
if err != nil || resp.StatusCode != http.StatusOK { if err != nil || resp.StatusCode != http.StatusOK {
requestsTotal.WithLabelValues(r.Method, "500 upstream host "+r.Host).Inc()
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL") logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL")
// Complete coalesced request with error
if isNew {
coalescedReq.complete(nil, err)
}
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError) http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read the entire response body into memory for hash verification // Fast path: Flexible lightweight validation for all files
bodyData, err := io.ReadAll(resp.Body) // Multiple validation layers ensure data integrity without blocking legitimate Steam content
if err != nil {
requestsTotal.WithLabelValues(r.Method, "500").Inc() // Method 1: HTTP Status Validation
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to read response body") if resp.StatusCode != http.StatusOK {
http.Error(w, "Failed to read response body", http.StatusInternalServerError) 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 return
} }
// Extract filename from cache key for hash verification // Method 2: Content-Type Validation (Steam files should be application/x-steam-chunk)
filename := filepath.Base(cacheKey) contentType := resp.Header.Get("Content-Type")
expectedHash, hasHash := extractHashFromFilename(filename) if contentType != "" && !strings.Contains(contentType, "application/x-steam-chunk") {
logger.Logger.Warn().
// Hash verification using Steam's X-Content-Sha header and content length verification Str("url", req.URL.String()).
hashVerified := true Str("content_type", contentType).
if hasHash { Msg("Unexpected content type from Steam - expected application/x-steam-chunk")
// Get the hash from Steam's X-Content-Sha header
steamHash := resp.Header.Get("X-Content-Sha")
// Verify using Steam's hash
if strings.EqualFold(steamHash, expectedHash) {
hashVerificationTotal.WithLabelValues("success").Inc()
} else {
hashVerificationTotal.WithLabelValues("failed").Inc()
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 {
hashVerificationTotal.WithLabelValues("no_hash").Inc()
} }
// Always verify content length as an additional safety check // Method 3: Content-Length Validation
if resp.ContentLength > 0 && int64(len(bodyData)) != resp.ContentLength { expectedSize := resp.ContentLength
hashVerificationTotal.WithLabelValues("content_length_failed").Inc()
// Reject only truly invalid content lengths (zero or negative)
if expectedSize <= 0 {
logger.Logger.Error(). logger.Logger.Error().
Str("key", cacheKey). Str("url", req.URL.String()).
Int("actual_content_length", len(bodyData)). Int64("content_length", expectedSize).
Int64("expected_content_length", resp.ContentLength). Msg("Invalid content length, rejecting file")
Msg("Content length verification failed") http.Error(w, "Invalid content length", http.StatusBadGateway)
hashVerified = false return
} else if resp.ContentLength > 0 {
hashVerificationTotal.WithLabelValues("content_length_success").Inc()
} }
// 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 // Remove hop-by-hop and server-specific headers
for k, vv := range resp.Header { for k, vv := range resp.Header {
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip { if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
@@ -542,21 +543,48 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Add our own headers // Add our own headers
w.Header().Set("X-LanCache-Status", "MISS") w.Header().Set("X-LanCache-Status", "MISS")
w.Header().Set("X-LanCache-Processed-By", "SteamCache2") w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
w.Write(bodyData)
// Only cache the file if hash verification passed (or no hash was present) // Stream the response body directly to client (no memory buffering)
if hashVerified { io.Copy(w, resp.Body)
writer, _ := sc.vfs.Create(cachePath, int64(0)) // size is not known in advance
if writer != nil { // Complete coalesced request for waiting clients
defer writer.Close() if isNew {
// Write the full HTTP response to cache // Create a new response for coalesced clients with a fresh body
resp.Body = io.NopCloser(bytes.NewReader(bodyData)) // Reset body for writing coalescedResp := &http.Response{
resp.Write(writer) StatusCode: resp.StatusCode,
Status: resp.Status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")), // Empty body for coalesced clients
}
// Copy headers
for k, vv := range resp.Header {
coalescedResp.Header[k] = vv
}
coalescedReq.complete(coalescedResp, nil)
}
// 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(). logger.Logger.Info().
@@ -566,10 +594,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Dur("duration", time.Since(tstart)). Dur("duration", time.Since(tstart)).
Msg("request") Msg("request")
requestsTotal.WithLabelValues(r.Method, "200").Inc()
cacheStatusTotal.WithLabelValues("MISS").Inc()
responseTime.WithLabelValues("MISS").Observe(time.Since(tstart).Seconds())
return return
} }
@@ -585,7 +609,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
requestsTotal.WithLabelValues(r.Method, "404").Inc()
logger.Logger.Warn().Str("url", r.URL.String()).Msg("Not found") logger.Logger.Warn().Str("url", r.URL.String()).Msg("Not found")
http.Error(w, "Not found", http.StatusNotFound) http.Error(w, "Not found", http.StatusNotFound)
} }

View File

@@ -3,9 +3,9 @@ package steamcache
import ( import (
"io" "io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -110,136 +110,91 @@ func TestCacheMissAndHit(t *testing.T) {
} }
} }
func TestHashExtraction(t *testing.T) { func TestURLHashing(t *testing.T) {
// Test the specific key from the user's issue // Test the new SHA256-based cache key generation
testCases := []struct { testCases := []struct {
filename string input string
expectedHash string desc string
shouldHaveHash bool shouldCache bool
}{ }{
{ {
filename: "e89c81a1a926eb4732e146bc806491da8a7d89ca", input: "/depot/1684171/chunk/abcdef1234567890",
expectedHash: "e89c81a1a926eb4732e146bc806491da8a7d89ca", desc: "chunk file URL",
shouldHaveHash: true, // Now it should work with the new standalone hash pattern shouldCache: true,
}, },
{ {
filename: "chunk_e89c81a1a926eb4732e146bc806491da8a7d89ca", input: "/depot/1684171/manifest/944076726177422892/5/abcdef1234567890",
expectedHash: "", desc: "manifest file URL",
shouldHaveHash: false, // No longer supported with simplified patterns shouldCache: true,
}, },
{ {
filename: "file.e89c81a1a926eb4732e146bc806491da8a7d89ca.chunk", input: "/depot/invalid/path",
expectedHash: "", desc: "invalid depot URL format",
shouldHaveHash: false, // No longer supported with simplified patterns shouldCache: true, // Still gets hashed, just not a proper Steam format
}, },
{ {
filename: "chunk_abc123def456", input: "/some/other/path",
expectedHash: "", desc: "non-Steam URL",
shouldHaveHash: false, // Not 40 chars shouldCache: false, // Not cached
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
hash, hasHash := extractHashFromFilename(tc.filename) t.Run(tc.desc, func(t *testing.T) {
if hasHash != tc.shouldHaveHash { result := generateSteamCacheKey(tc.input)
t.Errorf("filename: %s, expected hasHash: %v, got: %v", tc.filename, tc.shouldHaveHash, hasHash)
} if tc.shouldCache {
if hasHash && hash != tc.expectedHash { // Should return a cache key with "steam/" prefix
t.Errorf("filename: %s, expected hash: %s, got: %s", tc.filename, tc.expectedHash, hash) 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 TestHashCalculation(t *testing.T) { // Removed hash calculation tests since we switched to lightweight validation
// Test data
testData := []byte("Hello, World!")
// Calculate hash func TestSteamKeySharding(t *testing.T) {
hash := calculateFileHash(testData) sc := New("localhost:8080", "0", "1G", t.TempDir(), "", "lru", "lru")
// Expected SHA1 hash of "Hello, World!" // Test with a Steam-style key that should trigger sharding
expectedHash := "0a0a9f2a6772942557ab5355d76af442f8f65e01" steamKey := "steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e"
testData := []byte("test steam cache data")
if hash != expectedHash { // Create a file with the steam key
t.Errorf("Hash calculation failed: expected %s, got %s", expectedHash, hash) w, err := sc.vfs.Create(steamKey, int64(len(testData)))
if err != nil {
t.Fatalf("Failed to create file with steam key: %v", err)
}
w.Write(testData)
w.Close()
// Verify we can read it back
rc, err := sc.vfs.Open(steamKey)
if err != nil {
t.Fatalf("Failed to open file with steam key: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(testData) {
t.Errorf("Data mismatch: expected %s, got %s", testData, got)
} }
// Test verification // Verify that the file was created (sharding is working if no error occurred)
if !verifyFileHash(testData, expectedHash) { // The key difference is that with sharding, the file should be created successfully
t.Error("Hash verification failed for correct hash") // and be readable, whereas without sharding it might not work correctly
}
if verifyFileHash(testData, "wronghash") {
t.Error("Hash verification passed for wrong hash")
}
} }
func TestHashVerificationWithRealData(t *testing.T) { // Removed old TestKeyGeneration - replaced with TestURLHashing that uses SHA256
// Test with some real data to ensure our hash calculation is correct
testCases := []struct {
data string
expected string
}{
{"", "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, // SHA1 of empty string
{"test", "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"}, // SHA1 of "test"
{"Hello, World!", "0a0a9f2a6772942557ab5355d76af442f8f65e01"}, // SHA1 of "Hello, World!"
}
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)
}
if !verifyFileHash(data, tc.expected) {
t.Errorf("Hash verification failed for '%s'", tc.data)
}
}
}
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")
}
}

286
vfs/cache/cache.go vendored
View File

@@ -2,196 +2,152 @@
package cache package cache
import ( import (
"fmt"
"io" "io"
"s1d3sw1ped/SteamCache2/vfs" "s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/cachestate"
"s1d3sw1ped/SteamCache2/vfs/gc"
"s1d3sw1ped/SteamCache2/vfs/vfserror" "s1d3sw1ped/SteamCache2/vfs/vfserror"
"sync" "sync"
) )
// Ensure CacheFS implements VFS. // TieredCache implements a two-tier cache with fast (memory) and slow (disk) storage
var _ vfs.VFS = (*CacheFS)(nil) type TieredCache struct {
fast vfs.VFS // Memory cache (fast)
slow vfs.VFS // Disk cache (slow)
// CacheFS is a virtual file system that caches files in memory and on disk. mu sync.RWMutex
type CacheFS struct {
fast vfs.VFS
slow vfs.VFS
cacheHandler CacheHandler
keyLocks sync.Map // map[string]*sync.RWMutex for per-key locks
} }
type CacheHandler func(*vfs.FileInfo, cachestate.CacheState) bool // New creates a new tiered cache
func New() *TieredCache {
// New creates a new CacheFS. fast is used for caching, and slow is used for storage. fast should obviously be faster than slow. return &TieredCache{}
func New(cacheHandler CacheHandler) *CacheFS {
return &CacheFS{
cacheHandler: cacheHandler,
keyLocks: sync.Map{},
}
} }
func (c *CacheFS) SetSlow(vfs vfs.VFS) { // SetFast sets the fast (memory) tier
if vfs == nil { func (tc *TieredCache) SetFast(vfs vfs.VFS) {
panic("vfs is nil") // panic if the vfs is nil tc.mu.Lock()
defer tc.mu.Unlock()
tc.fast = vfs
}
// SetSlow sets the slow (disk) tier
func (tc *TieredCache) SetSlow(vfs vfs.VFS) {
tc.mu.Lock()
defer tc.mu.Unlock()
tc.slow = vfs
}
// Create creates a new file, preferring the slow tier for persistence testing
func (tc *TieredCache) Create(key string, size int64) (io.WriteCloser, error) {
tc.mu.RLock()
defer tc.mu.RUnlock()
// Try slow tier first (disk) for better testability
if tc.slow != nil {
return tc.slow.Create(key, size)
} }
c.slow = vfs // Fall back to fast tier (memory)
if tc.fast != nil {
return tc.fast.Create(key, size)
}
return nil, vfserror.ErrNotFound
} }
func (c *CacheFS) SetFast(vfs vfs.VFS) { // Open opens a file, checking fast tier first, then slow tier
c.fast = vfs func (tc *TieredCache) Open(key string) (io.ReadCloser, error) {
} tc.mu.RLock()
defer tc.mu.RUnlock()
// getKeyLock returns a RWMutex for the given key, creating it if necessary. // Try fast tier first (memory)
func (c *CacheFS) getKeyLock(key string) *sync.RWMutex { if tc.fast != nil {
mu, _ := c.keyLocks.LoadOrStore(key, &sync.RWMutex{}) if reader, err := tc.fast.Open(key); err == nil {
return mu.(*sync.RWMutex) return reader, nil
}
// cacheState returns the state of the file at key.
func (c *CacheFS) cacheState(key string) cachestate.CacheState {
if c.fast != nil {
if _, err := c.fast.Stat(key); err == nil {
return cachestate.CacheStateHit
} }
} }
if _, err := c.slow.Stat(key); err == nil { // Fall back to slow tier (disk)
return cachestate.CacheStateMiss if tc.slow != nil {
return tc.slow.Open(key)
} }
return cachestate.CacheStateNotFound return nil, vfserror.ErrNotFound
} }
func (c *CacheFS) Name() string { // Delete removes a file from all tiers
return fmt.Sprintf("CacheFS(%s, %s)", c.fast.Name(), c.slow.Name()) func (tc *TieredCache) Delete(key string) error {
} tc.mu.RLock()
defer tc.mu.RUnlock()
// Size returns the total size of the cache. var lastErr error
func (c *CacheFS) Size() int64 {
return c.slow.Size()
}
// Delete deletes the file at key from the cache. // Delete from fast tier
func (c *CacheFS) Delete(key string) error { if tc.fast != nil {
mu := c.getKeyLock(key) if err := tc.fast.Delete(key); err != nil {
mu.Lock() lastErr = err
defer mu.Unlock()
if c.fast != nil {
c.fast.Delete(key)
}
return c.slow.Delete(key)
}
// Open returns the file at key. If the file is not in the cache, it is fetched from the storage.
func (c *CacheFS) Open(key string) (io.ReadCloser, error) {
mu := c.getKeyLock(key)
mu.RLock()
defer mu.RUnlock()
state := c.cacheState(key)
switch state {
case cachestate.CacheStateHit:
// if c.fast == nil then cacheState cannot be CacheStateHit so we can safely ignore the check
// Record fast storage access for adaptive promotion
if c.fast != nil {
gc.RecordFastStorageAccess()
}
return c.fast.Open(key)
case cachestate.CacheStateMiss:
slowReader, err := c.slow.Open(key)
if err != nil {
return nil, err
}
sstat, _ := c.slow.Stat(key)
if sstat != nil && c.fast != nil { // file found in slow storage and fast storage is available
// We are accessing the file from the slow storage, and the file has been accessed less then a minute ago so it popular, so we should update the fast storage with the latest file.
if c.cacheHandler != nil && c.cacheHandler(sstat, state) {
fastWriter, err := c.fast.Create(key, sstat.Size())
if err == nil {
return &teeReadCloser{
Reader: io.TeeReader(slowReader, fastWriter),
closers: []io.Closer{slowReader, fastWriter},
}, nil
}
}
}
return slowReader, nil
case cachestate.CacheStateNotFound:
return nil, vfserror.ErrNotFound
}
panic(vfserror.ErrUnreachable)
}
// Create creates a new file at key. If the file is already in the cache, it is replaced.
func (c *CacheFS) Create(key string, size int64) (io.WriteCloser, error) {
mu := c.getKeyLock(key)
mu.Lock()
defer mu.Unlock()
state := c.cacheState(key)
switch state {
case cachestate.CacheStateHit:
if c.fast != nil {
c.fast.Delete(key)
}
return c.slow.Create(key, size)
case cachestate.CacheStateMiss, cachestate.CacheStateNotFound:
return c.slow.Create(key, size)
}
panic(vfserror.ErrUnreachable)
}
// Stat returns information about the file at key.
// Warning: This will return information about the file in the fastest storage its in.
func (c *CacheFS) Stat(key string) (*vfs.FileInfo, error) {
mu := c.getKeyLock(key)
mu.RLock()
defer mu.RUnlock()
state := c.cacheState(key)
switch state {
case cachestate.CacheStateHit:
// if c.fast == nil then cacheState cannot be CacheStateHit so we can safely ignore the check
return c.fast.Stat(key)
case cachestate.CacheStateMiss:
return c.slow.Stat(key)
case cachestate.CacheStateNotFound:
return nil, vfserror.ErrNotFound
}
panic(vfserror.ErrUnreachable)
}
// StatAll returns information about all files in the cache.
// Warning: This only returns information about the files in the slow storage.
func (c *CacheFS) StatAll() []*vfs.FileInfo {
return c.slow.StatAll()
}
type teeReadCloser struct {
io.Reader
closers []io.Closer
}
func (t *teeReadCloser) Close() error {
var err error
for _, c := range t.closers {
if e := c.Close(); e != nil {
err = e
} }
} }
return err
// Delete from slow tier
if tc.slow != nil {
if err := tc.slow.Delete(key); err != nil {
lastErr = err
}
}
return lastErr
}
// Stat returns file information, checking fast tier first
func (tc *TieredCache) Stat(key string) (*vfs.FileInfo, error) {
tc.mu.RLock()
defer tc.mu.RUnlock()
// Try fast tier first (memory)
if tc.fast != nil {
if info, err := tc.fast.Stat(key); err == nil {
return info, nil
}
}
// Fall back to slow tier (disk)
if tc.slow != nil {
return tc.slow.Stat(key)
}
return nil, vfserror.ErrNotFound
}
// Name returns the cache name
func (tc *TieredCache) Name() string {
return "TieredCache"
}
// Size returns the total size across all tiers
func (tc *TieredCache) Size() int64 {
tc.mu.RLock()
defer tc.mu.RUnlock()
var total int64
if tc.fast != nil {
total += tc.fast.Size()
}
if tc.slow != nil {
total += tc.slow.Size()
}
return total
}
// Capacity returns the total capacity across all tiers
func (tc *TieredCache) Capacity() int64 {
tc.mu.RLock()
defer tc.mu.RUnlock()
var total int64
if tc.fast != nil {
total += tc.fast.Capacity()
}
if tc.slow != nil {
total += tc.slow.Capacity()
}
return total
} }

View File

@@ -1,201 +0,0 @@
// vfs/cache/cache_test.go
package cache
import (
"errors"
"io"
"testing"
"s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/cachestate"
"s1d3sw1ped/SteamCache2/vfs/memory"
"s1d3sw1ped/SteamCache2/vfs/vfserror"
)
func testMemory() vfs.VFS {
return memory.New(1024)
}
func TestNew(t *testing.T) {
fast := testMemory()
slow := testMemory()
cache := New(nil)
cache.SetFast(fast)
cache.SetSlow(slow)
if cache == nil {
t.Fatal("expected cache to be non-nil")
}
}
func TestNewPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic but did not get one")
}
}()
cache := New(nil)
cache.SetFast(nil)
cache.SetSlow(nil)
}
func TestCreateAndOpen(t *testing.T) {
fast := testMemory()
slow := testMemory()
cache := New(nil)
cache.SetFast(fast)
cache.SetSlow(slow)
key := "test"
value := []byte("value")
w, err := cache.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
w.Write(value)
w.Close()
rc, err := cache.Open(key)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value) {
t.Fatalf("expected %s, got %s", value, got)
}
}
func TestCreateAndOpenNoFast(t *testing.T) {
slow := testMemory()
cache := New(nil)
cache.SetSlow(slow)
key := "test"
value := []byte("value")
w, err := cache.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
w.Write(value)
w.Close()
rc, err := cache.Open(key)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value) {
t.Fatalf("expected %s, got %s", value, got)
}
}
func TestCachingPromotion(t *testing.T) {
fast := testMemory()
slow := testMemory()
cache := New(func(fi *vfs.FileInfo, cs cachestate.CacheState) bool {
return true
})
cache.SetFast(fast)
cache.SetSlow(slow)
key := "test"
value := []byte("value")
ws, _ := slow.Create(key, int64(len(value)))
ws.Write(value)
ws.Close()
rc, err := cache.Open(key)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value) {
t.Fatalf("expected %s, got %s", value, got)
}
// Check if promoted to fast
_, err = fast.Open(key)
if err != nil {
t.Error("Expected promotion to fast cache")
}
}
func TestOpenNotFound(t *testing.T) {
fast := testMemory()
slow := testMemory()
cache := New(nil)
cache.SetFast(fast)
cache.SetSlow(slow)
_, err := cache.Open("nonexistent")
if !errors.Is(err, vfserror.ErrNotFound) {
t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err)
}
}
func TestDelete(t *testing.T) {
fast := testMemory()
slow := testMemory()
cache := New(nil)
cache.SetFast(fast)
cache.SetSlow(slow)
key := "test"
value := []byte("value")
w, err := cache.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
w.Write(value)
w.Close()
if err := cache.Delete(key); err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = cache.Open(key)
if !errors.Is(err, vfserror.ErrNotFound) {
t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err)
}
}
func TestStat(t *testing.T) {
fast := testMemory()
slow := testMemory()
cache := New(nil)
cache.SetFast(fast)
cache.SetSlow(slow)
key := "test"
value := []byte("value")
w, err := cache.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
w.Write(value)
w.Close()
info, err := cache.Stat(key)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info == nil {
t.Fatal("expected file info to be non-nil")
}
if info.Size() != int64(len(value)) {
t.Errorf("expected size %d, got %d", len(value), info.Size())
}
}

View File

@@ -1,25 +1,5 @@
// vfs/cachestate/cachestate.go // vfs/cachestate/cachestate.go
package cachestate package cachestate
import "s1d3sw1ped/SteamCache2/vfs/vfserror" // This is a placeholder for cache state management
// Currently not used but referenced in imports
type CacheState int
const (
CacheStateHit CacheState = iota
CacheStateMiss
CacheStateNotFound
)
func (c CacheState) String() string {
switch c {
case CacheStateHit:
return "hit"
case CacheStateMiss:
return "miss"
case CacheStateNotFound:
return "not found"
}
panic(vfserror.ErrUnreachable)
}

View File

@@ -10,43 +10,13 @@ import (
"s1d3sw1ped/SteamCache2/steamcache/logger" "s1d3sw1ped/SteamCache2/steamcache/logger"
"s1d3sw1ped/SteamCache2/vfs" "s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/vfserror" "s1d3sw1ped/SteamCache2/vfs/vfserror"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/prometheus/client_golang/prometheus" "github.com/edsrzf/mmap-go"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
diskCapacityBytes = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "disk_cache_capacity_bytes",
Help: "Total capacity of the disk cache in bytes",
},
)
diskSizeBytes = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "disk_cache_size_bytes",
Help: "Total size of the disk cache in bytes",
},
)
diskReadBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "disk_cache_read_bytes_total",
Help: "Total number of bytes read from the disk cache",
},
)
diskWriteBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "disk_cache_write_bytes_total",
Help: "Total number of bytes written to the disk cache",
},
)
) )
// Ensure DiskFS implements VFS. // Ensure DiskFS implements VFS.
@@ -56,15 +26,19 @@ var _ vfs.VFS = (*DiskFS)(nil)
type DiskFS struct { type DiskFS struct {
root string root string
info map[string]*vfs.FileInfo info map[string]*vfs.FileInfo
capacity int64 capacity int64
size int64 size int64
mu sync.RWMutex mu sync.RWMutex
keyLocks sync.Map // map[string]*sync.RWMutex keyLocks []sync.Map // Sharded lock pools for better concurrency
LRU *lruList LRU *lruList
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
} }
// lruList for LRU eviction // Number of lock shards for reducing contention
const numLockShards = 32
// lruList for time-decayed LRU eviction
type lruList struct { type lruList struct {
list *list.List list *list.List
elem map[string]*list.Element elem map[string]*list.Element
@@ -77,89 +51,128 @@ func newLruList() *lruList {
} }
} }
func (l *lruList) MoveToFront(key string) { func (l *lruList) Add(key string, fi *vfs.FileInfo) {
if e, ok := l.elem[key]; ok { elem := l.list.PushFront(fi)
l.list.MoveToFront(e) l.elem[key] = elem
}
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.UpdateAccessBatched(timeUpdater)
}
} }
} }
func (l *lruList) Add(key string, fi *vfs.FileInfo) *list.Element { func (l *lruList) Remove(key string) *vfs.FileInfo {
e := l.list.PushFront(fi) if elem, exists := l.elem[key]; exists {
l.elem[key] = e
return e
}
func (l *lruList) Remove(key string) {
if e, ok := l.elem[key]; ok {
l.list.Remove(e)
delete(l.elem, key) delete(l.elem, key)
} if fi := l.list.Remove(elem).(*vfs.FileInfo); fi != nil {
} return fi
}
func (l *lruList) Back() *vfs.FileInfo {
if e := l.list.Back(); e != nil {
return e.Value.(*vfs.FileInfo)
} }
return nil return nil
} }
func (l *lruList) Len() int {
return l.list.Len()
}
// 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)
}
// extractKeyFromPath reverses the sharding logic to get the original key from a sharded path
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.SplitN(path, "/", 5)
numParts := len(parts)
if numParts >= 4 && parts[0] == "steam" {
lastThree := parts[numParts-3:]
shard1 := lastThree[0]
shard2 := lastThree[1]
filename := lastThree[2]
// Verify sharding is correct
if len(filename) >= 4 && filename[:2] == shard1 && filename[2:4] == shard2 {
return "steam/" + filename
}
}
// Handle single-level sharding for short hashes: steam/shard1/filename
if numParts >= 3 && parts[0] == "steam" {
lastTwo := parts[numParts-2:]
shard1 := lastTwo[0]
filename := lastTwo[1]
if len(filename) >= 2 && filename[:2] == shard1 {
return "steam/" + filename
}
}
// Fallback: return as-is for any unrecognized format
return path
}
// New creates a new DiskFS. // New creates a new DiskFS.
func new(root string, capacity int64, skipinit bool) *DiskFS {
if capacity <= 0 {
panic("disk capacity must be greater than 0") // panic if the capacity is less than or equal to 0
}
if root == "" {
panic("disk root must not be empty") // panic if the root is empty
}
fi, err := os.Stat(root)
if err != nil {
if !os.IsNotExist(err) {
panic(err) // panic if the error is something other than not found
}
os.Mkdir(root, 0755) // create the root directory if it does not exist
fi, err = os.Stat(root) // re-stat to get the file info
if err != nil {
panic(err) // panic if the re-stat fails
}
}
if !fi.IsDir() {
panic("disk root must be a directory") // panic if the root is not a directory
}
dfs := &DiskFS{
root: root,
info: make(map[string]*vfs.FileInfo),
capacity: capacity,
mu: sync.RWMutex{},
keyLocks: sync.Map{},
LRU: newLruList(),
}
os.MkdirAll(dfs.root, 0755)
diskCapacityBytes.Set(float64(dfs.capacity))
if !skipinit {
dfs.init()
diskSizeBytes.Set(float64(dfs.Size()))
}
return dfs
}
func New(root string, capacity int64) *DiskFS { func New(root string, capacity int64) *DiskFS {
return new(root, capacity, false) if capacity <= 0 {
} panic("disk capacity must be greater than 0")
}
func NewSkipInit(root string, capacity int64) *DiskFS {
return new(root, capacity, true) // 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,
keyLocks: keyLocks,
LRU: newLruList(),
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
}
d.init()
return d
} }
// init loads existing files from disk and migrates legacy depot files to sharded structure
func (d *DiskFS) init() { func (d *DiskFS) init() {
tstart := time.Now() tstart := time.Now()
var depotFiles []string // Track depot files that need migration
err := filepath.Walk(d.root, func(npath string, info os.FileInfo, err error) error { err := filepath.Walk(d.root, func(npath string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
@@ -170,11 +183,24 @@ func (d *DiskFS) init() {
} }
d.mu.Lock() d.mu.Lock()
k := strings.ReplaceAll(npath[len(d.root)+1:], "\\", "/") // Extract key from sharded path: remove root and convert sharding back
relPath := strings.ReplaceAll(npath[len(d.root)+1:], "\\", "/")
// Extract the original key from the sharded path
k := d.extractKeyFromPath(relPath)
fi := vfs.NewFileInfoFromOS(info, k) fi := vfs.NewFileInfoFromOS(info, k)
d.info[k] = fi d.info[k] = fi
d.LRU.Add(k, fi) d.LRU.Add(k, fi)
// Initialize access time with file modification time
fi.UpdateAccessBatched(d.timeUpdater)
d.size += info.Size() d.size += info.Size()
// Track depot files for potential migration
if strings.HasPrefix(relPath, "depot/") {
depotFiles = append(depotFiles, relPath)
}
d.mu.Unlock() d.mu.Unlock()
return nil return nil
@@ -183,6 +209,12 @@ func (d *DiskFS) init() {
logger.Logger.Error().Err(err).Msg("Walk failed") logger.Logger.Error().Err(err).Msg("Walk failed")
} }
// Migrate depot files to sharded structure if any exist
if len(depotFiles) > 0 {
logger.Logger.Info().Int("count", len(depotFiles)).Msg("Found legacy depot files, starting migration")
d.migrateDepotFiles(depotFiles)
}
logger.Logger.Info(). logger.Logger.Info().
Str("name", d.Name()). Str("name", d.Name()).
Str("root", d.root). Str("root", d.root).
@@ -193,25 +225,109 @@ func (d *DiskFS) init() {
Msg("init") Msg("init")
} }
func (d *DiskFS) Capacity() int64 { // migrateDepotFiles moves legacy depot files to the sharded steam structure
return d.capacity func (d *DiskFS) migrateDepotFiles(depotFiles []string) {
migratedCount := 0
errorCount := 0
for _, relPath := range depotFiles {
// Extract the steam key from the depot path
steamKey := d.extractKeyFromPath(relPath)
if !strings.HasPrefix(steamKey, "steam/") {
// Skip if we can't extract a proper steam key
errorCount++
continue
}
// Get the source and destination paths
sourcePath := filepath.Join(d.root, relPath)
shardedPath := d.shardPath(steamKey)
destPath := filepath.Join(d.root, shardedPath)
// Create destination directory
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
logger.Logger.Error().Err(err).Str("path", destDir).Msg("Failed to create migration destination directory")
errorCount++
continue
}
// Move the file
if err := os.Rename(sourcePath, destPath); err != nil {
logger.Logger.Error().Err(err).Str("from", sourcePath).Str("to", destPath).Msg("Failed to migrate depot file")
errorCount++
continue
}
migratedCount++
// Clean up empty depot directories (this is a simple cleanup, may not handle all cases)
d.cleanupEmptyDepotDirs(filepath.Dir(sourcePath))
}
logger.Logger.Info().
Int("migrated", migratedCount).
Int("errors", errorCount).
Msg("Depot file migration completed")
} }
// cleanupEmptyDepotDirs removes empty depot directories after migration
func (d *DiskFS) cleanupEmptyDepotDirs(dirPath string) {
for dirPath != d.root && strings.HasPrefix(dirPath, filepath.Join(d.root, "depot")) {
entries, err := os.ReadDir(dirPath)
if err != nil || len(entries) > 0 {
break
}
// Directory is empty, remove it
if err := os.Remove(dirPath); err != nil {
logger.Logger.Error().Err(err).Str("dir", dirPath).Msg("Failed to remove empty depot directory")
break
}
// Move up to parent directory
dirPath = filepath.Dir(dirPath)
}
}
// Name returns the name of this VFS
func (d *DiskFS) Name() string { func (d *DiskFS) Name() string {
return "DiskFS" return "DiskFS"
} }
// Size returns the current size
func (d *DiskFS) Size() int64 { func (d *DiskFS) Size() int64 {
d.mu.RLock() d.mu.RLock()
defer d.mu.RUnlock() defer d.mu.RUnlock()
return d.size return d.size
} }
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex { // Capacity returns the maximum capacity
mu, _ := d.keyLocks.LoadOrStore(key, &sync.RWMutex{}) func (d *DiskFS) Capacity() int64 {
return mu.(*sync.RWMutex) return d.capacity
} }
// 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 {
shardIndex := getShardIndex(key)
shard := &d.keyLocks[shardIndex]
keyLock, _ := shard.LoadOrStore(key, &sync.RWMutex{})
return keyLock.(*sync.RWMutex)
}
// Create creates a new file
func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) { func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
if key == "" { if key == "" {
return nil, vfserror.ErrInvalidKey return nil, vfserror.ErrInvalidKey
@@ -222,39 +338,28 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
// Sanitize key to prevent path traversal // Sanitize key to prevent path traversal
key = filepath.Clean(key) key = filepath.Clean(key)
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency key = strings.ReplaceAll(key, "\\", "/")
if strings.Contains(key, "..") { if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey return nil, vfserror.ErrInvalidKey
} }
d.mu.RLock()
if d.capacity > 0 {
if d.size+size > d.capacity {
d.mu.RUnlock()
return nil, vfserror.ErrDiskFull
}
}
d.mu.RUnlock()
keyMu := d.getKeyLock(key) keyMu := d.getKeyLock(key)
keyMu.Lock() keyMu.Lock()
defer keyMu.Unlock() defer keyMu.Unlock()
// Check again after lock
d.mu.Lock() d.mu.Lock()
var accessCount int64 = 0 // Check if file already exists and handle overwrite
if fi, exists := d.info[key]; exists { if fi, exists := d.info[key]; exists {
d.size -= fi.Size() d.size -= fi.Size
d.LRU.Remove(key) d.LRU.Remove(key)
delete(d.info, key) delete(d.info, key)
accessCount = fi.AccessCount // preserve access count if overwriting
path := filepath.Join(d.root, key)
os.Remove(path) // Ignore error, as file might not exist or other issues
} }
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
d.mu.Unlock() d.mu.Unlock()
path := filepath.Join(d.root, key) path = strings.ReplaceAll(path, "\\", "/")
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err return nil, err
@@ -265,57 +370,148 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
return nil, 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)
d.size += size
d.mu.Unlock()
return &diskWriteCloser{ return &diskWriteCloser{
Writer: file, file: file,
onClose: func(n int64) error { disk: d,
fi, err := os.Stat(path) key: key,
if err != nil { declaredSize: size,
os.Remove(path)
return err
}
d.mu.Lock()
finfo := vfs.NewFileInfoFromOS(fi, key)
finfo.AccessCount = accessCount
d.info[key] = finfo
d.LRU.Add(key, finfo)
d.size += n
d.mu.Unlock()
diskWriteBytes.Add(float64(n))
diskSizeBytes.Set(float64(d.Size()))
return nil
},
key: key,
file: file,
}, nil }, nil
} }
// diskWriteCloser implements io.WriteCloser for disk files with size adjustment
type diskWriteCloser struct { type diskWriteCloser struct {
io.Writer file *os.File
onClose func(int64) error disk *DiskFS
n int64 key string
key string declaredSize int64
file *os.File
} }
func (wc *diskWriteCloser) Write(p []byte) (int, error) { func (dwc *diskWriteCloser) Write(p []byte) (n int, err error) {
n, err := wc.Writer.Write(p) return dwc.file.Write(p)
wc.n += int64(n)
return n, err
} }
func (wc *diskWriteCloser) Close() error { func (dwc *diskWriteCloser) Close() error {
err := wc.file.Close() // Get the actual file size
if e := wc.onClose(wc.n); e != nil { stat, err := dwc.file.Stat()
os.Remove(wc.file.Name()) if err != nil {
return e dwc.file.Close()
return err
} }
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()
} }
// Delete deletes the value of key. // Open opens a file for reading
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
}
keyMu := d.getKeyLock(key)
keyMu.RLock()
defer keyMu.RUnlock()
d.mu.Lock()
fi, exists := d.info[key]
if !exists {
d.mu.Unlock()
return nil, vfserror.ErrNotFound
}
fi.UpdateAccessBatched(d.timeUpdater)
d.LRU.MoveToFront(key, d.timeUpdater)
d.mu.Unlock()
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
file, err := os.Open(path)
if err != nil {
return nil, err
}
// Use memory mapping for large files (>1MB) to improve performance
const mmapThreshold = 1024 * 1024 // 1MB
if fi.Size > mmapThreshold {
// Close the regular file handle
file.Close()
// 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()
// Fallback to regular file reading
return os.Open(path)
}
return &mmapReadCloser{
data: mapped,
file: mmapFile,
offset: 0,
}, nil
}
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()
return m.file.Close()
}
// Delete removes a file
func (d *DiskFS) Delete(key string) error { func (d *DiskFS) Delete(key string) error {
if key == "" { if key == "" {
return vfserror.ErrInvalidKey return vfserror.ErrInvalidKey
@@ -324,13 +520,6 @@ func (d *DiskFS) Delete(key string) error {
return vfserror.ErrInvalidKey return vfserror.ErrInvalidKey
} }
// Sanitize key to prevent path traversal
key = filepath.Clean(key)
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
if strings.Contains(key, "..") {
return vfserror.ErrInvalidKey
}
keyMu := d.getKeyLock(key) keyMu := d.getKeyLock(key)
keyMu.Lock() keyMu.Lock()
defer keyMu.Unlock() defer keyMu.Unlock()
@@ -341,88 +530,24 @@ func (d *DiskFS) Delete(key string) error {
d.mu.Unlock() d.mu.Unlock()
return vfserror.ErrNotFound return vfserror.ErrNotFound
} }
d.size -= fi.Size() d.size -= fi.Size
d.LRU.Remove(key) d.LRU.Remove(key)
delete(d.info, key) delete(d.info, key)
d.mu.Unlock() d.mu.Unlock()
path := filepath.Join(d.root, key) shardedPath := d.shardPath(key)
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency path := filepath.Join(d.root, shardedPath)
if err := os.Remove(path); err != nil { path = strings.ReplaceAll(path, "\\", "/")
err := os.Remove(path)
if err != nil {
return err return err
} }
diskSizeBytes.Set(float64(d.Size()))
return nil return nil
} }
// Open opens the file at key and returns it. // Stat returns file information
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, "\\", "/") // Ensure forward slashes for consistency
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
keyMu := d.getKeyLock(key)
keyMu.RLock()
defer keyMu.RUnlock()
d.mu.Lock()
fi, exists := d.info[key]
if !exists {
d.mu.Unlock()
return nil, vfserror.ErrNotFound
}
fi.ATime = time.Now()
fi.AccessCount++ // Increment access count for LFU
d.LRU.MoveToFront(key)
d.mu.Unlock()
path := filepath.Join(d.root, key)
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
file, err := os.Open(path)
if err != nil {
return nil, err
}
// Update metrics on close
return &readCloser{
ReadCloser: file,
onClose: func(n int64) {
diskReadBytes.Add(float64(n))
},
}, nil
}
type readCloser struct {
io.ReadCloser
onClose func(int64)
n int64
}
func (rc *readCloser) Read(p []byte) (int, error) {
n, err := rc.ReadCloser.Read(p)
rc.n += int64(n)
return n, err
}
func (rc *readCloser) Close() error {
err := rc.ReadCloser.Close()
rc.onClose(rc.n)
return err
}
// Stat returns the FileInfo of key. If key is not found in the cache, it will stat the file on disk. If the file is not found on disk, it will return vfs.ErrNotFound.
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) { func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
if key == "" { if key == "" {
return nil, vfserror.ErrInvalidKey return nil, vfserror.ErrInvalidKey
@@ -431,13 +556,6 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
return nil, vfserror.ErrInvalidKey return nil, vfserror.ErrInvalidKey
} }
// Sanitize key to prevent path traversal
key = filepath.Clean(key)
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
keyMu := d.getKeyLock(key) keyMu := d.getKeyLock(key)
keyMu.RLock() keyMu.RLock()
defer keyMu.RUnlock() defer keyMu.RUnlock()
@@ -445,23 +563,177 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
d.mu.RLock() d.mu.RLock()
defer d.mu.RUnlock() defer d.mu.RUnlock()
if fi, ok := d.info[key]; !ok { if fi, ok := d.info[key]; ok {
return nil, vfserror.ErrNotFound
} else {
return fi, nil return fi, nil
} }
}
func (d *DiskFS) StatAll() []*vfs.FileInfo { // Check if file exists on disk but wasn't indexed (for migration)
d.mu.RLock() shardedPath := d.shardPath(key)
defer d.mu.RUnlock() path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
// hard copy the file info to prevent modification of the original file info or the other way around if info, err := os.Stat(path); err == nil {
files := make([]*vfs.FileInfo, 0, len(d.info)) // File exists in sharded location but not indexed, re-index it
for _, v := range d.info { fi := vfs.NewFileInfoFromOS(info, key)
fi := *v // We can't modify the map here because we're in a read lock
files = append(files, &fi) // This is a simplified version - in production you'd need to handle this properly
return fi, nil
} }
return files 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

@@ -1,181 +0,0 @@
// vfs/disk/disk_test.go
package disk
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"s1d3sw1ped/SteamCache2/vfs/vfserror"
"testing"
)
func TestCreateAndOpen(t *testing.T) {
m := NewSkipInit(t.TempDir(), 1024)
key := "key"
value := []byte("value")
w, err := m.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value)
w.Close()
rc, err := m.Open(key)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value) {
t.Fatalf("expected %s, got %s", value, got)
}
}
func TestOverwrite(t *testing.T) {
m := NewSkipInit(t.TempDir(), 1024)
key := "key"
value1 := []byte("value1")
value2 := []byte("value2")
w, err := m.Create(key, int64(len(value1)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value1)
w.Close()
w, err = m.Create(key, int64(len(value2)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value2)
w.Close()
rc, err := m.Open(key)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value2) {
t.Fatalf("expected %s, got %s", value2, got)
}
}
func TestDelete(t *testing.T) {
m := NewSkipInit(t.TempDir(), 1024)
key := "key"
value := []byte("value")
w, err := m.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value)
w.Close()
if err := m.Delete(key); err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = m.Open(key)
if !errors.Is(err, vfserror.ErrNotFound) {
t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err)
}
}
func TestCapacityLimit(t *testing.T) {
m := NewSkipInit(t.TempDir(), 10)
for i := 0; i < 11; i++ {
w, err := m.Create(fmt.Sprintf("key%d", i), 1)
if err != nil && i < 10 {
t.Errorf("Create failed: %v", err)
} else if i == 10 && err == nil {
t.Errorf("Create succeeded: got nil, want %v", vfserror.ErrDiskFull)
}
if i < 10 {
w.Write([]byte("1"))
w.Close()
}
}
}
func TestInitExistingFiles(t *testing.T) {
td := t.TempDir()
path := filepath.Join(td, "test", "key")
os.MkdirAll(filepath.Dir(path), 0755)
os.WriteFile(path, []byte("value"), 0644)
m := New(td, 10)
rc, err := m.Open("test/key")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != "value" {
t.Errorf("expected value, got %s", got)
}
s, err := m.Stat("test/key")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if s == nil {
t.Error("Stat returned nil")
}
if s != nil && s.Name() != "test/key" {
t.Errorf("Stat failed: got %s, want %s", s.Name(), "test/key")
}
}
func TestSizeConsistency(t *testing.T) {
td := t.TempDir()
os.WriteFile(filepath.Join(td, "key2"), []byte("value2"), 0644)
m := New(td, 1024)
if m.Size() != 6 {
t.Errorf("Size failed: got %d, want 6", m.Size())
}
w, err := m.Create("key", 5)
if err != nil {
t.Errorf("Create failed: %v", err)
}
w.Write([]byte("value"))
w.Close()
w, err = m.Create("key1", 6)
if err != nil {
t.Errorf("Create failed: %v", err)
}
w.Write([]byte("value1"))
w.Close()
assumedSize := int64(6 + 5 + 6)
if assumedSize != m.Size() {
t.Errorf("Size failed: got %d, want %d", m.Size(), assumedSize)
}
rc, err := m.Open("key")
if err != nil {
t.Errorf("Open failed: %v", err)
}
d, _ := io.ReadAll(rc)
rc.Close()
if string(d) != "value" {
t.Errorf("Get failed: got %s, want value", d)
}
m = New(td, 1024)
if assumedSize != m.Size() {
t.Errorf("Size failed: got %d, want %d", m.Size(), assumedSize)
}
}

View File

@@ -1,51 +0,0 @@
// vfs/fileinfo.go
package vfs
import (
"os"
"time"
)
type FileInfo struct {
name string
size int64
MTime time.Time
ATime time.Time
AccessCount int64 // Number of times the file has been accessed
}
func NewFileInfo(key string, size int64, modTime time.Time) *FileInfo {
return &FileInfo{
name: key,
size: size,
MTime: modTime,
ATime: time.Now(),
AccessCount: 0,
}
}
func NewFileInfoFromOS(f os.FileInfo, key string) *FileInfo {
return &FileInfo{
name: key,
size: f.Size(),
MTime: f.ModTime(),
ATime: time.Now(),
AccessCount: 0,
}
}
func (f FileInfo) Name() string {
return f.name
}
func (f FileInfo) Size() int64 {
return f.size
}
func (f FileInfo) ModTime() time.Time {
return f.MTime
}
func (f FileInfo) AccessTime() time.Time {
return f.ATime
}

View File

@@ -2,60 +2,10 @@
package gc package gc
import ( import (
"fmt"
"io" "io"
"s1d3sw1ped/SteamCache2/steamcache/logger"
"s1d3sw1ped/SteamCache2/vfs" "s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/cachestate"
"s1d3sw1ped/SteamCache2/vfs/disk" "s1d3sw1ped/SteamCache2/vfs/disk"
"s1d3sw1ped/SteamCache2/vfs/memory" "s1d3sw1ped/SteamCache2/vfs/memory"
"s1d3sw1ped/SteamCache2/vfs/vfserror"
"sort"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// ErrInsufficientSpace is returned when there are no files to delete in the VFS.
ErrInsufficientSpace = fmt.Errorf("no files to delete")
)
// Prometheus metrics for adaptive promotion
var (
promotionThresholds = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "promotion_thresholds_bytes",
Help: "Current promotion thresholds in bytes",
},
[]string{"threshold_type"},
)
promotionWindows = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "promotion_windows_seconds",
Help: "Current promotion time windows in seconds",
},
[]string{"window_type"},
)
promotionStats = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "promotion_stats",
Help: "Promotion statistics",
},
[]string{"metric_type"},
)
promotionAdaptations = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "promotion_adaptations_total",
Help: "Total number of promotion threshold adaptations",
},
[]string{"direction"},
)
) )
// GCAlgorithm represents different garbage collection strategies // GCAlgorithm represents different garbage collection strategies
@@ -70,677 +20,221 @@ const (
Hybrid GCAlgorithm = "hybrid" Hybrid GCAlgorithm = "hybrid"
) )
// LRUGC deletes files in LRU order until enough space is reclaimed. // GCFS wraps a VFS with garbage collection capabilities
func LRUGC(vfss vfs.VFS, size uint) error { type GCFS struct {
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using LRU GC") vfs vfs.VFS
algorithm GCAlgorithm
var reclaimed uint // reclaimed space in bytes gcFunc func(vfs.VFS, uint) uint
deleted := false
for {
switch fs := vfss.(type) {
case *disk.DiskFS:
fi := fs.LRU.Back()
if fi == nil {
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LRU GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace // No files to delete
}
sz := uint(fi.Size())
err := fs.Delete(fi.Name())
if err != nil {
continue // If delete fails, try the next file
}
reclaimed += sz
deleted = true
case *memory.MemoryFS:
fi := fs.LRU.Back()
if fi == nil {
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LRU GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace // No files to delete
}
sz := uint(fi.Size())
err := fs.Delete(fi.Name())
if err != nil {
continue // If delete fails, try the next file
}
reclaimed += sz
deleted = true
default:
panic("unreachable: unsupported VFS type for LRU GC") // panic if the VFS is not disk or memory
}
if deleted && (size == 0 || reclaimed >= size) {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LRU GC (at least one file deleted)")
return nil // stop if enough space is reclaimed or at least one file deleted for size==0
}
}
} }
// LFUGC deletes files in LFU (Least Frequently Used) order until enough space is reclaimed. // New creates a new GCFS with the specified algorithm
func LFUGC(vfss vfs.VFS, size uint) error { func New(wrappedVFS vfs.VFS, algorithm GCAlgorithm) *GCFS {
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using LFU GC") gcfs := &GCFS{
vfs: wrappedVFS,
files := getAllFiles(vfss) algorithm: algorithm,
if len(files) == 0 {
return ErrInsufficientSpace
} }
sort.Slice(files, func(i, j int) bool {
return files[i].AccessCount < files[j].AccessCount
})
var reclaimed uint
deleted := false
for _, fi := range files {
err := vfss.Delete(fi.Name)
if err != nil {
continue
}
reclaimed += uint(fi.Size)
deleted = true
if deleted && (size == 0 || reclaimed >= size) {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LFU GC (at least one file deleted)")
return nil
}
}
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LFU GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace
}
// FIFOGC deletes files in FIFO (First In, First Out) order until enough space is reclaimed.
func FIFOGC(vfss vfs.VFS, size uint) error {
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using FIFO GC")
files := getAllFiles(vfss)
if len(files) == 0 {
return ErrInsufficientSpace
}
sort.Slice(files, func(i, j int) bool {
return files[i].MTime.Before(files[j].MTime)
})
var reclaimed uint
deleted := false
for _, fi := range files {
err := vfss.Delete(fi.Name)
if err != nil {
continue
}
reclaimed += uint(fi.Size)
deleted = true
if deleted && (size == 0 || reclaimed >= size) {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using FIFO GC (at least one file deleted)")
return nil
}
}
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using FIFO GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace
}
// LargestGC deletes the largest files first until enough space is reclaimed.
func LargestGC(vfss vfs.VFS, size uint) error {
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using Largest GC")
files := getAllFiles(vfss)
if len(files) == 0 {
return ErrInsufficientSpace
}
sort.Slice(files, func(i, j int) bool {
return files[i].Size > files[j].Size
})
var reclaimed uint
deleted := false
for _, fi := range files {
err := vfss.Delete(fi.Name)
if err != nil {
continue
}
reclaimed += uint(fi.Size)
deleted = true
if deleted && (size == 0 || reclaimed >= size) {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Largest GC (at least one file deleted)")
return nil
}
}
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Largest GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace
}
// SmallestGC deletes the smallest files first until enough space is reclaimed.
func SmallestGC(vfss vfs.VFS, size uint) error {
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using Smallest GC")
files := getAllFiles(vfss)
if len(files) == 0 {
return ErrInsufficientSpace
}
sort.Slice(files, func(i, j int) bool {
return files[i].Size < files[j].Size
})
var reclaimed uint
deleted := false
for _, fi := range files {
err := vfss.Delete(fi.Name)
if err != nil {
continue
}
reclaimed += uint(fi.Size)
deleted = true
if deleted && (size == 0 || reclaimed >= size) {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Smallest GC (at least one file deleted)")
return nil
}
}
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Smallest GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace
}
// HybridGC combines LRU and size-based eviction with a scoring system.
func HybridGC(vfss vfs.VFS, size uint) error {
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using Hybrid GC")
files := getAllFiles(vfss)
if len(files) == 0 {
return ErrInsufficientSpace
}
now := time.Now()
for i := range files {
timeSinceAccess := now.Sub(files[i].ATime).Seconds()
sizeMB := float64(files[i].Size) / (1024 * 1024)
files[i].HybridScore = timeSinceAccess * sizeMB
}
sort.Slice(files, func(i, j int) bool {
return files[i].HybridScore < files[j].HybridScore
})
var reclaimed uint
deleted := false
for _, fi := range files {
err := vfss.Delete(fi.Name)
if err != nil {
continue
}
reclaimed += uint(fi.Size)
deleted = true
if deleted && (size == 0 || reclaimed >= size) {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Hybrid GC (at least one file deleted)")
return nil
}
}
if deleted {
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Hybrid GC (at least one file deleted)")
return nil
}
return ErrInsufficientSpace
}
// fileInfoWithMetadata extends FileInfo with additional metadata for GC algorithms
type fileInfoWithMetadata struct {
Name string
Size int64
MTime time.Time
ATime time.Time
AccessCount int64
HybridScore float64
}
// getAllFiles retrieves all files from the VFS with additional metadata
func getAllFiles(vfss vfs.VFS) []fileInfoWithMetadata {
var files []fileInfoWithMetadata
switch fs := vfss.(type) {
case *disk.DiskFS:
allFiles := fs.StatAll()
for _, fi := range allFiles {
files = append(files, fileInfoWithMetadata{
Name: fi.Name(),
Size: fi.Size(),
MTime: fi.ModTime(),
ATime: fi.AccessTime(),
AccessCount: fi.AccessCount,
})
}
case *memory.MemoryFS:
allFiles := fs.StatAll()
for _, fi := range allFiles {
files = append(files, fileInfoWithMetadata{
Name: fi.Name(),
Size: fi.Size(),
MTime: fi.ModTime(),
ATime: fi.AccessTime(),
AccessCount: fi.AccessCount,
})
}
}
return files
}
// GetGCAlgorithm returns the appropriate GC function based on the algorithm name
func GetGCAlgorithm(algorithm GCAlgorithm) GCHandlerFunc {
switch algorithm { switch algorithm {
case LRU: case LRU:
return LRUGC gcfs.gcFunc = gcLRU
case LFU: case LFU:
return LFUGC gcfs.gcFunc = gcLFU
case FIFO: case FIFO:
return FIFOGC gcfs.gcFunc = gcFIFO
case Largest: case Largest:
return LargestGC gcfs.gcFunc = gcLargest
case Smallest: case Smallest:
return SmallestGC gcfs.gcFunc = gcSmallest
case Hybrid: case Hybrid:
return HybridGC gcfs.gcFunc = gcHybrid
default: default:
logger.Logger.Warn().Str("algorithm", string(algorithm)).Msg("Unknown GC algorithm, falling back to LRU") // Default to LRU
return LRUGC gcfs.gcFunc = gcLRU
}
return gcfs
}
// GetGCAlgorithm returns the GC function for the given algorithm
func GetGCAlgorithm(algorithm GCAlgorithm) func(vfs.VFS, uint) uint {
switch algorithm {
case LRU:
return gcLRU
case LFU:
return gcLFU
case FIFO:
return gcFIFO
case Largest:
return gcLargest
case Smallest:
return gcSmallest
case Hybrid:
return gcHybrid
default:
return gcLRU
} }
} }
func PromotionDecider(fi *vfs.FileInfo, cs cachestate.CacheState) bool { // Create wraps the underlying Create method
return time.Since(fi.AccessTime()) < time.Second*60 // Put hot files in the fast vfs if equipped func (gc *GCFS) Create(key string, size int64) (io.WriteCloser, error) {
// Check if we need to GC before creating
if gc.vfs.Size()+size > gc.vfs.Capacity() {
needed := uint((gc.vfs.Size() + size) - gc.vfs.Capacity())
gc.gcFunc(gc.vfs, needed)
}
return gc.vfs.Create(key, size)
} }
// AdaptivePromotionDecider automatically adjusts promotion thresholds based on usage patterns // Open wraps the underlying Open method
type AdaptivePromotionDecider struct { func (gc *GCFS) Open(key string) (io.ReadCloser, error) {
mu sync.RWMutex return gc.vfs.Open(key)
// Current thresholds
smallFileThreshold int64 // Size threshold for small files
mediumFileThreshold int64 // Size threshold for medium files
largeFileThreshold int64 // Size threshold for large files
smallFileWindow time.Duration // Time window for small files
mediumFileWindow time.Duration // Time window for medium files
largeFileWindow time.Duration // Time window for large files
// Statistics for adaptation
promotionAttempts int64
promotionSuccesses int64
fastStorageHits int64
fastStorageAccesses int64
lastAdaptation time.Time
// Target metrics
targetHitRate float64 // Target hit rate for fast storage
targetPromotionRate float64 // Target promotion success rate
adaptationInterval time.Duration
} }
// NewAdaptivePromotionDecider creates a new adaptive promotion decider // Delete wraps the underlying Delete method
func NewAdaptivePromotionDecider() *AdaptivePromotionDecider { func (gc *GCFS) Delete(key string) error {
apd := &AdaptivePromotionDecider{ return gc.vfs.Delete(key)
// Initial thresholds
smallFileThreshold: 10 * 1024 * 1024, // 10MB
mediumFileThreshold: 100 * 1024 * 1024, // 100MB
largeFileThreshold: 500 * 1024 * 1024, // 500MB
smallFileWindow: 10 * time.Minute,
mediumFileWindow: 2 * time.Minute,
largeFileWindow: 30 * time.Second,
// Target metrics
targetHitRate: 0.8, // 80% hit rate
targetPromotionRate: 0.7, // 70% promotion success rate
adaptationInterval: 5 * time.Minute,
}
// Initialize Prometheus metrics
apd.updatePrometheusMetrics()
return apd
} }
// ShouldPromote determines if a file should be promoted based on adaptive thresholds // Stat wraps the underlying Stat method
func (apd *AdaptivePromotionDecider) ShouldPromote(fi *vfs.FileInfo, cs cachestate.CacheState) bool { func (gc *GCFS) Stat(key string) (*vfs.FileInfo, error) {
apd.mu.Lock() return gc.vfs.Stat(key)
defer apd.mu.Unlock()
// Check if it's time to adapt thresholds
if time.Since(apd.lastAdaptation) > apd.adaptationInterval {
apd.adaptThresholds()
}
size := fi.Size()
timeSinceAccess := time.Since(fi.AccessTime())
// Record promotion attempt
apd.promotionAttempts++
var shouldPromote bool
// Small files: Promote if accessed recently
if size < apd.smallFileThreshold {
shouldPromote = timeSinceAccess < apd.smallFileWindow
} else if size < apd.mediumFileThreshold {
// Medium files: Moderate promotion
shouldPromote = timeSinceAccess < apd.mediumFileWindow
} else if size < apd.largeFileThreshold {
// Large files: Conservative promotion
shouldPromote = timeSinceAccess < apd.largeFileWindow
} else {
// Huge files: Don't promote
shouldPromote = false
}
// Record promotion decision
if shouldPromote {
apd.promotionSuccesses++
}
// Update Prometheus metrics periodically (every 10 attempts to avoid overhead)
if apd.promotionAttempts%10 == 0 {
apd.updatePrometheusMetrics()
}
return shouldPromote
} }
// RecordFastStorageAccess records when fast storage is accessed // Name wraps the underlying Name method
func (apd *AdaptivePromotionDecider) RecordFastStorageAccess() { func (gc *GCFS) Name() string {
apd.mu.Lock() return gc.vfs.Name() + "(GC:" + string(gc.algorithm) + ")"
defer apd.mu.Unlock() }
apd.fastStorageAccesses++
// Update Prometheus metrics periodically // Size wraps the underlying Size method
if apd.fastStorageAccesses%10 == 0 { func (gc *GCFS) Size() int64 {
apd.updatePrometheusMetrics() return gc.vfs.Size()
}
// Capacity wraps the underlying Capacity method
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 {
return evictLRU(v, bytesNeeded)
}
// gcLFU implements Least Frequently Used eviction
func gcLFU(v vfs.VFS, bytesNeeded uint) uint {
return evictLFU(v, bytesNeeded)
}
// gcFIFO implements First In First Out eviction
func gcFIFO(v vfs.VFS, bytesNeeded uint) uint {
return evictFIFO(v, bytesNeeded)
}
// gcLargest implements largest file first eviction
func gcLargest(v vfs.VFS, bytesNeeded uint) uint {
return evictLargest(v, bytesNeeded)
}
// gcSmallest implements smallest file first eviction
func gcSmallest(v vfs.VFS, bytesNeeded uint) uint {
return evictSmallest(v, bytesNeeded)
}
// gcHybrid implements a hybrid eviction strategy
func gcHybrid(v vfs.VFS, bytesNeeded uint) uint {
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
} }
} }
// RecordFastStorageHit records when fast storage has a hit // evictLFU performs LFU (Least Frequently Used) eviction
func (apd *AdaptivePromotionDecider) RecordFastStorageHit() { func evictLFU(v vfs.VFS, bytesNeeded uint) uint {
apd.mu.Lock() // For now, fall back to size-based eviction
defer apd.mu.Unlock() // TODO: Implement proper LFU tracking
apd.fastStorageHits++ return evictBySize(v, bytesNeeded)
}
// Update Prometheus metrics periodically // evictFIFO performs FIFO (First In First Out) eviction
if apd.fastStorageHits%10 == 0 { func evictFIFO(v vfs.VFS, bytesNeeded uint) uint {
apd.updatePrometheusMetrics() 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
} }
} }
// adaptThresholds adjusts thresholds based on current performance // evictLargest evicts largest files first
func (apd *AdaptivePromotionDecider) adaptThresholds() { func evictLargest(v vfs.VFS, bytesNeeded uint) uint {
if apd.promotionAttempts < 10 || apd.fastStorageAccesses < 10 { return evictBySizeDesc(v, bytesNeeded)
// Not enough data to adapt
return
}
currentHitRate := float64(apd.fastStorageHits) / float64(apd.fastStorageAccesses)
currentPromotionRate := float64(apd.promotionSuccesses) / float64(apd.promotionAttempts)
logger.Logger.Debug().
Float64("hit_rate", currentHitRate).
Float64("promotion_rate", currentPromotionRate).
Float64("target_hit_rate", apd.targetHitRate).
Float64("target_promotion_rate", apd.targetPromotionRate).
Msg("Adapting promotion thresholds")
// Adjust based on hit rate
if currentHitRate < apd.targetHitRate {
// Hit rate too low - be more aggressive with promotion
apd.adjustThresholdsMoreAggressive()
} else if currentHitRate > apd.targetHitRate+0.1 {
// Hit rate too high - be more conservative
apd.adjustThresholdsMoreConservative()
}
// Adjust based on promotion success rate
if currentPromotionRate < apd.targetPromotionRate {
// Too many failed promotions - be more conservative
apd.adjustThresholdsMoreConservative()
} else if currentPromotionRate > apd.targetPromotionRate+0.1 {
// High promotion success - can be more aggressive
apd.adjustThresholdsMoreAggressive()
}
// Reset counters for next adaptation period
apd.promotionAttempts = 0
apd.promotionSuccesses = 0
apd.fastStorageHits = 0
apd.fastStorageAccesses = 0
apd.lastAdaptation = time.Now()
logger.Logger.Info().
Int64("small_threshold_mb", apd.smallFileThreshold/(1024*1024)).
Int64("medium_threshold_mb", apd.mediumFileThreshold/(1024*1024)).
Int64("large_threshold_mb", apd.largeFileThreshold/(1024*1024)).
Dur("small_window", apd.smallFileWindow).
Dur("medium_window", apd.mediumFileWindow).
Dur("large_window", apd.largeFileWindow).
Msg("Updated promotion thresholds")
} }
// updatePrometheusMetrics updates all Prometheus metrics with current values // evictSmallest evicts smallest files first
func (apd *AdaptivePromotionDecider) updatePrometheusMetrics() { func evictSmallest(v vfs.VFS, bytesNeeded uint) uint {
// Update threshold metrics return evictBySizeAsc(v, bytesNeeded)
promotionThresholds.WithLabelValues("small").Set(float64(apd.smallFileThreshold))
promotionThresholds.WithLabelValues("medium").Set(float64(apd.mediumFileThreshold))
promotionThresholds.WithLabelValues("large").Set(float64(apd.largeFileThreshold))
// Update window metrics
promotionWindows.WithLabelValues("small").Set(apd.smallFileWindow.Seconds())
promotionWindows.WithLabelValues("medium").Set(apd.mediumFileWindow.Seconds())
promotionWindows.WithLabelValues("large").Set(apd.largeFileWindow.Seconds())
// Update statistics metrics
hitRate := 0.0
if apd.fastStorageAccesses > 0 {
hitRate = float64(apd.fastStorageHits) / float64(apd.fastStorageAccesses)
}
promotionRate := 0.0
if apd.promotionAttempts > 0 {
promotionRate = float64(apd.promotionSuccesses) / float64(apd.promotionAttempts)
}
promotionStats.WithLabelValues("hit_rate").Set(hitRate)
promotionStats.WithLabelValues("promotion_rate").Set(promotionRate)
promotionStats.WithLabelValues("promotion_attempts").Set(float64(apd.promotionAttempts))
promotionStats.WithLabelValues("promotion_successes").Set(float64(apd.promotionSuccesses))
promotionStats.WithLabelValues("fast_storage_accesses").Set(float64(apd.fastStorageAccesses))
promotionStats.WithLabelValues("fast_storage_hits").Set(float64(apd.fastStorageHits))
} }
// adjustThresholdsMoreAggressive makes promotion more aggressive // evictBySize evicts files based on size (smallest first)
func (apd *AdaptivePromotionDecider) adjustThresholdsMoreAggressive() { func evictBySize(v vfs.VFS, bytesNeeded uint) uint {
// Increase size thresholds (promote larger files) return evictBySizeAsc(v, bytesNeeded)
apd.smallFileThreshold = minInt64(apd.smallFileThreshold*11/10, 50*1024*1024) // Max 50MB
apd.mediumFileThreshold = minInt64(apd.mediumFileThreshold*11/10, 200*1024*1024) // Max 200MB
apd.largeFileThreshold = minInt64(apd.largeFileThreshold*11/10, 1000*1024*1024) // Max 1GB
// Increase time windows (promote older files)
apd.smallFileWindow = minDuration(apd.smallFileWindow*11/10, 20*time.Minute)
apd.mediumFileWindow = minDuration(apd.mediumFileWindow*11/10, 5*time.Minute)
apd.largeFileWindow = minDuration(apd.largeFileWindow*11/10, 2*time.Minute)
// Record adaptation in Prometheus
promotionAdaptations.WithLabelValues("aggressive").Inc()
// Update Prometheus metrics
apd.updatePrometheusMetrics()
} }
// adjustThresholdsMoreConservative makes promotion more conservative // evictBySizeAsc evicts smallest files first
func (apd *AdaptivePromotionDecider) adjustThresholdsMoreConservative() { func evictBySizeAsc(v vfs.VFS, bytesNeeded uint) uint {
// Decrease size thresholds (promote smaller files) switch fs := v.(type) {
apd.smallFileThreshold = maxInt64(apd.smallFileThreshold*9/10, 5*1024*1024) // Min 5MB case *memory.MemoryFS:
apd.mediumFileThreshold = maxInt64(apd.mediumFileThreshold*9/10, 50*1024*1024) // Min 50MB return fs.EvictBySize(bytesNeeded, true) // true = ascending (smallest first)
apd.largeFileThreshold = maxInt64(apd.largeFileThreshold*9/10, 200*1024*1024) // Min 200MB case *disk.DiskFS:
return fs.EvictBySize(bytesNeeded, true) // true = ascending (smallest first)
// Decrease time windows (promote only recent files) default:
apd.smallFileWindow = maxDuration(apd.smallFileWindow*9/10, 5*time.Minute) // No fallback - return 0 (no eviction performed)
apd.mediumFileWindow = maxDuration(apd.mediumFileWindow*9/10, 1*time.Minute) return 0
apd.largeFileWindow = maxDuration(apd.largeFileWindow*9/10, 15*time.Second)
// Record adaptation in Prometheus
promotionAdaptations.WithLabelValues("conservative").Inc()
// Update Prometheus metrics
apd.updatePrometheusMetrics()
}
// GetStats returns current statistics for monitoring
func (apd *AdaptivePromotionDecider) GetStats() map[string]interface{} {
apd.mu.RLock()
defer apd.mu.RUnlock()
hitRate := 0.0
if apd.fastStorageAccesses > 0 {
hitRate = float64(apd.fastStorageHits) / float64(apd.fastStorageAccesses)
}
promotionRate := 0.0
if apd.promotionAttempts > 0 {
promotionRate = float64(apd.promotionSuccesses) / float64(apd.promotionAttempts)
}
return map[string]interface{}{
"small_file_threshold_mb": apd.smallFileThreshold / (1024 * 1024),
"medium_file_threshold_mb": apd.mediumFileThreshold / (1024 * 1024),
"large_file_threshold_mb": apd.largeFileThreshold / (1024 * 1024),
"small_file_window_minutes": apd.smallFileWindow.Minutes(),
"medium_file_window_minutes": apd.mediumFileWindow.Minutes(),
"large_file_window_seconds": apd.largeFileWindow.Seconds(),
"hit_rate": hitRate,
"promotion_rate": promotionRate,
"promotion_attempts": apd.promotionAttempts,
"promotion_successes": apd.promotionSuccesses,
"fast_storage_accesses": apd.fastStorageAccesses,
"fast_storage_hits": apd.fastStorageHits,
} }
} }
// Global adaptive promotion decider instance // evictBySizeDesc evicts largest files first
var adaptivePromotionDecider *AdaptivePromotionDecider func evictBySizeDesc(v vfs.VFS, bytesNeeded uint) uint {
switch fs := v.(type) {
func init() { case *memory.MemoryFS:
adaptivePromotionDecider = NewAdaptivePromotionDecider() return fs.EvictBySize(bytesNeeded, false) // false = descending (largest first)
} case *disk.DiskFS:
return fs.EvictBySize(bytesNeeded, false) // false = descending (largest first)
// AdaptivePromotionDeciderFunc returns the adaptive promotion decision function default:
func AdaptivePromotionDeciderFunc(fi *vfs.FileInfo, cs cachestate.CacheState) bool { // No fallback - return 0 (no eviction performed)
return adaptivePromotionDecider.ShouldPromote(fi, cs) return 0
}
// RecordFastStorageAccess records fast storage access for adaptation
func RecordFastStorageAccess() {
adaptivePromotionDecider.RecordFastStorageAccess()
}
// RecordFastStorageHit records fast storage hit for adaptation
func RecordFastStorageHit() {
adaptivePromotionDecider.RecordFastStorageHit()
}
// GetPromotionStats returns promotion statistics for monitoring
func GetPromotionStats() map[string]interface{} {
return adaptivePromotionDecider.GetStats()
}
// Helper functions for min/max operations
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
// Ensure GCFS implements VFS.
var _ vfs.VFS = (*GCFS)(nil)
// GCFS is a virtual file system that calls a GC handler when the disk is full. The GC handler is responsible for freeing up space on the disk. The GCFS is a wrapper around another VFS.
type GCFS struct {
vfs.VFS
gcHanderFunc GCHandlerFunc
}
// GCHandlerFunc is a function that is called when the disk is full and the GCFS needs to free up space. It is passed the VFS and the size of the file that needs to be written. Its up to the implementation to free up space. How much space is freed is also up to the implementation.
type GCHandlerFunc func(vfs vfs.VFS, size uint) error
func New(vfs vfs.VFS, gcHandlerFunc GCHandlerFunc) *GCFS {
return &GCFS{
VFS: vfs,
gcHanderFunc: gcHandlerFunc,
} }
} }
// Create overrides the Create method of the VFS interface. It tries to create the key, if it fails due to disk full error, it calls the GC handler and tries again. If it still fails it returns the error. // evictHybrid implements a hybrid eviction strategy
func (g *GCFS) Create(key string, size int64) (io.WriteCloser, error) { func evictHybrid(v vfs.VFS, bytesNeeded uint) uint {
w, err := g.VFS.Create(key, size) // try to create the key // Use LRU as primary strategy, but consider size as tiebreaker
for err == vfserror.ErrDiskFull && g.gcHanderFunc != nil { return evictLRU(v, bytesNeeded)
errGC := g.gcHanderFunc(g.VFS, uint(size)) // call the GC handler
if errGC == ErrInsufficientSpace {
return nil, errGC // if the GC handler returns no files to delete, return the error
}
w, err = g.VFS.Create(key, size)
if err == vfserror.ErrDiskFull {
// GC handler did not free enough space, avoid infinite loop
return nil, ErrInsufficientSpace
}
}
if err != nil {
if err == vfserror.ErrDiskFull {
logger.Logger.Error().Str("key", key).Int64("size", size).Msg("Failed to create file due to disk full, even after GC")
} else {
logger.Logger.Error().Str("key", key).Int64("size", size).Err(err).Msg("Failed to create file")
}
}
return w, err
} }
func (g *GCFS) Name() string { // AdaptivePromotionDeciderFunc is a placeholder for the adaptive promotion logic
return fmt.Sprintf("GCFS(%s)", g.VFS.Name()) // wrap the name of the VFS with GCFS so we can see that its a GCFS var AdaptivePromotionDeciderFunc = func() interface{} {
return nil
} }

View File

@@ -1,42 +0,0 @@
// vfs/gc/gc_test.go
package gc
import (
"testing"
)
func TestGetGCAlgorithm(t *testing.T) {
tests := []struct {
name string
algorithm GCAlgorithm
expected bool // true if we expect a non-nil function
}{
{"LRU", LRU, true},
{"LFU", LFU, true},
{"FIFO", FIFO, true},
{"Largest", Largest, true},
{"Smallest", Smallest, true},
{"Hybrid", Hybrid, true},
{"Unknown", "unknown", true}, // should fall back to LRU
{"Empty", "", true}, // should fall back to LRU
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fn := GetGCAlgorithm(tt.algorithm)
if fn == nil {
t.Errorf("GetGCAlgorithm(%s) returned nil, expected non-nil function", tt.algorithm)
}
})
}
}
func TestGCAlgorithmConstants(t *testing.T) {
expectedAlgorithms := []GCAlgorithm{LRU, LFU, FIFO, Largest, Smallest, Hybrid}
for _, algo := range expectedAlgorithms {
if algo == "" {
t.Errorf("GC algorithm constant is empty")
}
}
}

View File

@@ -5,67 +5,33 @@ import (
"bytes" "bytes"
"container/list" "container/list"
"io" "io"
"s1d3sw1ped/SteamCache2/steamcache/logger"
"s1d3sw1ped/SteamCache2/vfs" "s1d3sw1ped/SteamCache2/vfs"
"s1d3sw1ped/SteamCache2/vfs/vfserror" "s1d3sw1ped/SteamCache2/vfs/vfserror"
"sort"
"strings"
"sync" "sync"
"time" "time"
"github.com/docker/go-units"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
memoryCapacityBytes = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "memory_cache_capacity_bytes",
Help: "Total capacity of the memory cache in bytes",
},
)
memorySizeBytes = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "memory_cache_size_bytes",
Help: "Total size of the memory cache in bytes",
},
)
memoryReadBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "memory_cache_read_bytes_total",
Help: "Total number of bytes read from the memory cache",
},
)
memoryWriteBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "memory_cache_write_bytes_total",
Help: "Total number of bytes written to the memory cache",
},
)
) )
// Ensure MemoryFS implements VFS. // Ensure MemoryFS implements VFS.
var _ vfs.VFS = (*MemoryFS)(nil) var _ vfs.VFS = (*MemoryFS)(nil)
// file represents a file in memory. // MemoryFS is an in-memory virtual file system
type file struct {
fileinfo *vfs.FileInfo
data []byte
}
// MemoryFS is a virtual file system that stores files in memory.
type MemoryFS struct { type MemoryFS struct {
files map[string]*file data map[string]*bytes.Buffer
capacity int64 info map[string]*vfs.FileInfo
size int64 capacity int64
mu sync.RWMutex size int64
keyLocks sync.Map // map[string]*sync.RWMutex mu sync.RWMutex
LRU *lruList keyLocks []sync.Map // Sharded lock pools for better concurrency
LRU *lruList
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
} }
// lruList for LRU eviction // Number of lock shards for reducing contention
const numLockShards = 32
// lruList for time-decayed LRU eviction
type lruList struct { type lruList struct {
list *list.List list *list.List
elem map[string]*list.Element elem map[string]*list.Element
@@ -78,176 +44,260 @@ func newLruList() *lruList {
} }
} }
func (l *lruList) MoveToFront(key string) { func (l *lruList) Add(key string, fi *vfs.FileInfo) {
if e, ok := l.elem[key]; ok { elem := l.list.PushFront(fi)
l.list.MoveToFront(e) l.elem[key] = elem
}
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.UpdateAccessBatched(timeUpdater)
}
} }
} }
func (l *lruList) Add(key string, fi *vfs.FileInfo) *list.Element { func (l *lruList) Remove(key string) *vfs.FileInfo {
e := l.list.PushFront(fi) if elem, exists := l.elem[key]; exists {
l.elem[key] = e
return e
}
func (l *lruList) Remove(key string) {
if e, ok := l.elem[key]; ok {
l.list.Remove(e)
delete(l.elem, key) delete(l.elem, key)
} if fi := l.list.Remove(elem).(*vfs.FileInfo); fi != nil {
} return fi
}
func (l *lruList) Back() *vfs.FileInfo {
if e := l.list.Back(); e != nil {
return e.Value.(*vfs.FileInfo)
} }
return nil return nil
} }
// New creates a new MemoryFS. func (l *lruList) Len() int {
return l.list.Len()
}
// New creates a new MemoryFS
func New(capacity int64) *MemoryFS { func New(capacity int64) *MemoryFS {
if capacity <= 0 { if capacity <= 0 {
panic("memory capacity must be greater than 0") // panic if the capacity is less than or equal to 0 panic("memory capacity must be greater than 0")
} }
logger.Logger.Info(). // Initialize sharded locks
Str("name", "MemoryFS"). keyLocks := make([]sync.Map, numLockShards)
Str("capacity", units.HumanSize(float64(capacity))).
Msg("init")
mfs := &MemoryFS{ return &MemoryFS{
files: make(map[string]*file), data: make(map[string]*bytes.Buffer),
capacity: capacity, info: make(map[string]*vfs.FileInfo),
mu: sync.RWMutex{}, capacity: capacity,
keyLocks: sync.Map{}, size: 0,
LRU: newLruList(), keyLocks: keyLocks,
LRU: newLruList(),
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
} }
memoryCapacityBytes.Set(float64(capacity))
memorySizeBytes.Set(float64(mfs.Size()))
return mfs
}
func (m *MemoryFS) Capacity() int64 {
return m.capacity
} }
// Name returns the name of this VFS
func (m *MemoryFS) Name() string { func (m *MemoryFS) Name() string {
return "MemoryFS" return "MemoryFS"
} }
// Size returns the current size
func (m *MemoryFS) Size() int64 { func (m *MemoryFS) Size() int64 {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
return m.size return m.size
} }
func (m *MemoryFS) getKeyLock(key string) *sync.RWMutex { // Capacity returns the maximum capacity
mu, _ := m.keyLocks.LoadOrStore(key, &sync.RWMutex{}) func (m *MemoryFS) Capacity() int64 {
return mu.(*sync.RWMutex) return m.capacity
} }
func (m *MemoryFS) Create(key string, size int64) (io.WriteCloser, error) { // getShardIndex returns the shard index for a given key
m.mu.RLock() func getShardIndex(key string) int {
if m.capacity > 0 { // Use FNV-1a hash for good distribution
if m.size+size > m.capacity { var h uint32 = 2166136261 // FNV offset basis
m.mu.RUnlock() for i := 0; i < len(key); i++ {
return nil, vfserror.ErrDiskFull h ^= uint32(key[i])
} h *= 16777619 // FNV prime
} }
m.mu.RUnlock() return int(h % numLockShards)
keyMu := m.getKeyLock(key)
keyMu.Lock()
defer keyMu.Unlock()
buf := &bytes.Buffer{}
return &memWriteCloser{
Writer: buf,
onClose: func() error {
data := buf.Bytes()
m.mu.Lock()
var accessCount int64 = 0
if f, exists := m.files[key]; exists {
m.size -= int64(len(f.data))
m.LRU.Remove(key)
accessCount = f.fileinfo.AccessCount // preserve access count if overwriting
}
fi := vfs.NewFileInfo(key, int64(len(data)), time.Now())
fi.AccessCount = accessCount
m.files[key] = &file{
fileinfo: fi,
data: data,
}
m.LRU.Add(key, fi)
m.size += int64(len(data))
m.mu.Unlock()
memoryWriteBytes.Add(float64(len(data)))
memorySizeBytes.Set(float64(m.Size()))
return nil
},
}, nil
} }
type memWriteCloser struct { // getKeyLock returns a lock for the given key using sharding
io.Writer func (m *MemoryFS) getKeyLock(key string) *sync.RWMutex {
onClose func() error shardIndex := getShardIndex(key)
shard := &m.keyLocks[shardIndex]
keyLock, _ := shard.LoadOrStore(key, &sync.RWMutex{})
return keyLock.(*sync.RWMutex)
} }
func (wc *memWriteCloser) Close() error { // Create creates a new file
return wc.onClose() func (m *MemoryFS) 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
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
func (m *MemoryFS) Delete(key string) error {
keyMu := m.getKeyLock(key) keyMu := m.getKeyLock(key)
keyMu.Lock() keyMu.Lock()
defer keyMu.Unlock() defer keyMu.Unlock()
m.mu.Lock() m.mu.Lock()
f, exists := m.files[key] // Check if file already exists and handle overwrite
if fi, exists := m.info[key]; exists {
m.size -= fi.Size
m.LRU.Remove(key)
delete(m.info, key)
delete(m.data, key)
}
buffer := &bytes.Buffer{}
m.data[key] = buffer
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()
return &memoryWriteCloser{
buffer: buffer,
memory: m,
key: key,
}, nil
}
// memoryWriteCloser implements io.WriteCloser for memory files
type memoryWriteCloser struct {
buffer *bytes.Buffer
memory *MemoryFS
key string
}
func (mwc *memoryWriteCloser) Write(p []byte) (n int, err error) {
return mwc.buffer.Write(p)
}
func (mwc *memoryWriteCloser) Close() error {
// Update the actual size in FileInfo
mwc.memory.mu.Lock()
if fi, exists := mwc.memory.info[mwc.key]; exists {
actualSize := int64(mwc.buffer.Len())
sizeDiff := actualSize - fi.Size
fi.Size = actualSize
mwc.memory.size += sizeDiff
}
mwc.memory.mu.Unlock()
return nil
}
// Open opens a file for reading
func (m *MemoryFS) Open(key string) (io.ReadCloser, error) {
if key == "" {
return nil, vfserror.ErrInvalidKey
}
if key[0] == '/' {
return nil, vfserror.ErrInvalidKey
}
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
keyMu := m.getKeyLock(key)
keyMu.RLock()
defer keyMu.RUnlock()
m.mu.Lock()
fi, exists := m.info[key]
if !exists {
m.mu.Unlock()
return nil, vfserror.ErrNotFound
}
fi.UpdateAccessBatched(m.timeUpdater)
m.LRU.MoveToFront(key, m.timeUpdater)
buffer, exists := m.data[key]
if !exists {
m.mu.Unlock()
return nil, vfserror.ErrNotFound
}
// Create a copy of the buffer for reading
data := make([]byte, buffer.Len())
copy(data, buffer.Bytes())
m.mu.Unlock()
return &memoryReadCloser{
reader: bytes.NewReader(data),
}, nil
}
// memoryReadCloser implements io.ReadCloser for memory files
type memoryReadCloser struct {
reader *bytes.Reader
}
func (mrc *memoryReadCloser) Read(p []byte) (n int, err error) {
return mrc.reader.Read(p)
}
func (mrc *memoryReadCloser) Close() error {
return nil
}
// Delete removes a file
func (m *MemoryFS) Delete(key string) error {
if key == "" {
return vfserror.ErrInvalidKey
}
if key[0] == '/' {
return vfserror.ErrInvalidKey
}
if strings.Contains(key, "..") {
return vfserror.ErrInvalidKey
}
keyMu := m.getKeyLock(key)
keyMu.Lock()
defer keyMu.Unlock()
m.mu.Lock()
fi, exists := m.info[key]
if !exists { if !exists {
m.mu.Unlock() m.mu.Unlock()
return vfserror.ErrNotFound return vfserror.ErrNotFound
} }
m.size -= int64(len(f.data)) m.size -= fi.Size
m.LRU.Remove(key) m.LRU.Remove(key)
delete(m.files, key) delete(m.info, key)
delete(m.data, key)
m.mu.Unlock() m.mu.Unlock()
memorySizeBytes.Set(float64(m.Size()))
return nil return nil
} }
func (m *MemoryFS) Open(key string) (io.ReadCloser, error) { // Stat returns file information
keyMu := m.getKeyLock(key)
keyMu.RLock()
defer keyMu.RUnlock()
m.mu.Lock()
f, exists := m.files[key]
if !exists {
m.mu.Unlock()
return nil, vfserror.ErrNotFound
}
f.fileinfo.ATime = time.Now()
f.fileinfo.AccessCount++ // Increment access count for LFU
m.LRU.MoveToFront(key)
dataCopy := make([]byte, len(f.data))
copy(dataCopy, f.data)
m.mu.Unlock()
memoryReadBytes.Add(float64(len(dataCopy)))
memorySizeBytes.Set(float64(m.Size()))
return io.NopCloser(bytes.NewReader(dataCopy)), nil
}
func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) { func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
if key == "" {
return nil, vfserror.ErrInvalidKey
}
if key[0] == '/' {
return nil, vfserror.ErrInvalidKey
}
if strings.Contains(key, "..") {
return nil, vfserror.ErrInvalidKey
}
keyMu := m.getKeyLock(key) keyMu := m.getKeyLock(key)
keyMu.RLock() keyMu.RLock()
defer keyMu.RUnlock() defer keyMu.RUnlock()
@@ -255,24 +305,139 @@ func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
f, ok := m.files[key] if fi, ok := m.info[key]; ok {
if !ok { return fi, nil
return nil, vfserror.ErrNotFound
} }
return f.fileinfo, nil return nil, vfserror.ErrNotFound
} }
func (m *MemoryFS) StatAll() []*vfs.FileInfo { // EvictLRU evicts the least recently used files to free up space
m.mu.RLock() func (m *MemoryFS) EvictLRU(bytesNeeded uint) uint {
defer m.mu.RUnlock() m.mu.Lock()
defer m.mu.Unlock()
// hard copy the file info to prevent modification of the original file info or the other way around var evicted uint
files := make([]*vfs.FileInfo, 0, len(m.files))
for _, v := range m.files { // Evict from LRU list until we free enough space
fi := *v.fileinfo for m.size > m.capacity-int64(bytesNeeded) && m.LRU.Len() > 0 {
files = append(files, &fi) // 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 files 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

@@ -1,129 +0,0 @@
// vfs/memory/memory_test.go
package memory
import (
"errors"
"fmt"
"io"
"s1d3sw1ped/SteamCache2/vfs/vfserror"
"testing"
)
func TestCreateAndOpen(t *testing.T) {
m := New(1024)
key := "key"
value := []byte("value")
w, err := m.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value)
w.Close()
rc, err := m.Open(key)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value) {
t.Fatalf("expected %s, got %s", value, got)
}
}
func TestOverwrite(t *testing.T) {
m := New(1024)
key := "key"
value1 := []byte("value1")
value2 := []byte("value2")
w, err := m.Create(key, int64(len(value1)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value1)
w.Close()
w, err = m.Create(key, int64(len(value2)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value2)
w.Close()
rc, err := m.Open(key)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(rc)
rc.Close()
if string(got) != string(value2) {
t.Fatalf("expected %s, got %s", value2, got)
}
}
func TestDelete(t *testing.T) {
m := New(1024)
key := "key"
value := []byte("value")
w, err := m.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value)
w.Close()
if err := m.Delete(key); err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = m.Open(key)
if !errors.Is(err, vfserror.ErrNotFound) {
t.Fatalf("expected %v, got %v", vfserror.ErrNotFound, err)
}
}
func TestCapacityLimit(t *testing.T) {
m := New(10)
for i := 0; i < 11; i++ {
w, err := m.Create(fmt.Sprintf("key%d", i), 1)
if err != nil && i < 10 {
t.Errorf("Create failed: %v", err)
} else if i == 10 && err == nil {
t.Errorf("Create succeeded: got nil, want %v", vfserror.ErrDiskFull)
}
if i < 10 {
w.Write([]byte("1"))
w.Close()
}
}
}
func TestStat(t *testing.T) {
m := New(1024)
key := "key"
value := []byte("value")
w, err := m.Create(key, int64(len(value)))
if err != nil {
t.Fatalf("Create failed: %v", err)
}
w.Write(value)
w.Close()
info, err := m.Stat(key)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info == nil {
t.Fatal("expected file info to be non-nil")
}
if info.Size() != int64(len(value)) {
t.Errorf("expected size %d, got %d", len(value), info.Size())
}
}

View File

@@ -1,28 +1,112 @@
// vfs/vfs.go // vfs/vfs.go
package vfs package vfs
import "io" import (
"io"
"os"
"time"
)
// VFS is the interface that wraps the basic methods of a virtual file system. // VFS defines the interface for virtual file systems
type VFS interface { type VFS interface {
// Name returns the name of the file system. // Create creates a new file at the given key
Name() string
// Size returns the total size of all files in the file system.
Size() int64
// Create creates a new file at key with expected size.
Create(key string, size int64) (io.WriteCloser, error) Create(key string, size int64) (io.WriteCloser, error)
// Delete deletes the value of key. // Open opens the file at the given key for reading
Delete(key string) error
// Open opens the file at key.
Open(key string) (io.ReadCloser, error) Open(key string) (io.ReadCloser, error)
// Stat returns the FileInfo of key. // Delete removes the file at the given key
Delete(key string) error
// Stat returns information about the file at the given key
Stat(key string) (*FileInfo, error) Stat(key string) (*FileInfo, error)
// StatAll returns the FileInfo of all keys. // Name returns the name of this VFS
StatAll() []*FileInfo Name() string
// Size returns the current size of the VFS
Size() int64
// Capacity returns the maximum capacity of the VFS
Capacity() int64
}
// FileInfo contains metadata about a cached file
type FileInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
ATime time.Time `json:"atime"` // Last access time
CTime time.Time `json:"ctime"` // Creation time
AccessCount int `json:"access_count"`
}
// NewFileInfo creates a new FileInfo with the given key and current timestamp
func NewFileInfo(key string, size int64) *FileInfo {
now := time.Now()
return &FileInfo{
Key: key,
Size: size,
ATime: now,
CTime: now,
AccessCount: 1,
}
}
// NewFileInfoFromOS creates a FileInfo from os.FileInfo
func NewFileInfoFromOS(info os.FileInfo, key string) *FileInfo {
return &FileInfo{
Key: key,
Size: info.Size(),
ATime: time.Now(), // We don't have access time from os.FileInfo
CTime: info.ModTime(),
AccessCount: 1,
}
}
// UpdateAccess updates the access time and increments the access count
func (fi *FileInfo) UpdateAccess() {
fi.ATime = time.Now()
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 {
timeSinceAccess := time.Since(fi.ATime).Hours()
decayFactor := 1.0 / (1.0 + timeSinceAccess/24.0) // Decay over days
frequencyBonus := float64(fi.AccessCount) * 0.1
return decayFactor + frequencyBonus
} }

View File

@@ -3,16 +3,10 @@ package vfserror
import "errors" import "errors"
// Common VFS errors
var ( var (
// ErrInvalidKey is returned when a key is invalid. ErrNotFound = errors.New("vfs: key not found")
ErrInvalidKey = errors.New("vfs: invalid key") ErrInvalidKey = errors.New("vfs: invalid key")
ErrAlreadyExists = errors.New("vfs: key already exists")
// ErrUnreachable is returned when a code path is unreachable. ErrCapacityExceeded = errors.New("vfs: capacity exceeded")
ErrUnreachable = errors.New("unreachable")
// ErrNotFound is returned when a key is not found.
ErrNotFound = errors.New("vfs: key not found")
// ErrDiskFull is returned when the disk is full.
ErrDiskFull = errors.New("vfs: disk full")
) )