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.
This commit is contained in:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,5 +1,11 @@
|
||||
dist/
|
||||
tmp/
|
||||
#build artifacts
|
||||
/dist/
|
||||
|
||||
#disk cache
|
||||
/disk/
|
||||
|
||||
#config file
|
||||
/config.yaml
|
||||
|
||||
#windows executables
|
||||
*.exe
|
||||
.smashed.txt
|
||||
.smashignore
|
||||
|
||||
67
.vscode/launch.json
vendored
67
.vscode/launch.json
vendored
@@ -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
19
Makefile
Normal 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
224
README.md
@@ -10,30 +10,120 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
|
||||
- Reduces bandwidth usage
|
||||
- Easy to set up and configure aside from dns stuff to trick Steam into using it
|
||||
- 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:
|
||||
```sh
|
||||
./SteamCache2 --memory 1G --disk 10G --disk-path tmp/disk
|
||||
```
|
||||
### First Time Setup
|
||||
|
||||
### 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
|
||||
|
||||
SteamCache2 supports multiple garbage collection algorithms for both memory and disk caches:
|
||||
|
||||
```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
|
||||
```
|
||||
SteamCache2 supports different garbage collection algorithms for memory and disk caches, allowing you to optimize performance for each storage tier:
|
||||
|
||||
**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)
|
||||
- **`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:**
|
||||
- **LAN Events**: Use `lfu` for memory caches to keep popular games
|
||||
- **Gaming Cafes**: Use `hybrid` for balanced performance
|
||||
- **Gaming Cafes**: Use `lfu` for memory, `hybrid` for disk
|
||||
- **LAN Events**: Use `lfu` for memory, `hybrid` for disk
|
||||
- **Home Use**: Use `lru` for memory, `hybrid` for disk
|
||||
- **Testing**: Use `fifo` for predictable behavior
|
||||
- **Large Files**: Use `largest` to prioritize keeping many small files
|
||||
2. Configure your DNS:
|
||||
- If your on Windows and don't want a whole network implementation (THIS)[#windows-hosts-file-override]
|
||||
- **Large File Storage**: Use `largest` for disk to maximize file count
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
83
cmd/root.go
83
cmd/root.go
@@ -2,26 +2,22 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"s1d3sw1ped/SteamCache2/config"
|
||||
"s1d3sw1ped/SteamCache2/steamcache"
|
||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||
"s1d3sw1ped/SteamCache2/version"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
threads int
|
||||
|
||||
memory string
|
||||
disk string
|
||||
diskpath string
|
||||
upstream string
|
||||
|
||||
memoryGC string
|
||||
diskGC string
|
||||
threads int
|
||||
configPath string
|
||||
|
||||
logLevel string
|
||||
logFormat string
|
||||
@@ -58,7 +54,47 @@ var rootCmd = &cobra.Command{
|
||||
logger.Logger.Info().
|
||||
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 {
|
||||
runtime.GOMAXPROCS(threads)
|
||||
@@ -68,17 +104,17 @@ var rootCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
sc := steamcache.New(
|
||||
address,
|
||||
memory,
|
||||
disk,
|
||||
diskpath,
|
||||
upstream,
|
||||
memoryGC,
|
||||
diskGC,
|
||||
cfg.ListenAddress,
|
||||
cfg.Cache.Memory.Size,
|
||||
cfg.Cache.Disk.Size,
|
||||
cfg.Cache.Disk.Path,
|
||||
cfg.Upstream,
|
||||
cfg.Cache.Memory.GCAlgorithm,
|
||||
cfg.Cache.Disk.GCAlgorithm,
|
||||
)
|
||||
|
||||
logger.Logger.Info().
|
||||
Msg("SteamCache2 " + version.Version + " started on " + address)
|
||||
Msg("SteamCache2 " + version.Version + " started on " + cfg.ListenAddress)
|
||||
|
||||
sc.Run()
|
||||
|
||||
@@ -97,17 +133,10 @@ func Execute() {
|
||||
}
|
||||
|
||||
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().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(&logFormat, "log-format", "f", "console", "Logging format: json, console")
|
||||
}
|
||||
|
||||
116
config/config.go
Normal file
116
config/config.go
Normal 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
12
go.mod
@@ -4,22 +4,16 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
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/spf13/cobra v1.8.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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/mattn/go-colorable v0.1.13 // 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
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
)
|
||||
|
||||
34
go.sum
34
go.sum
@@ -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/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/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/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/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/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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
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/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/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
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/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
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.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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||
"s1d3sw1ped/SteamCache2/vfs"
|
||||
"s1d3sw1ped/SteamCache2/vfs/cache"
|
||||
@@ -28,9 +26,6 @@ import (
|
||||
"bytes"
|
||||
|
||||
"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
|
||||
@@ -41,67 +36,65 @@ func min(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
var (
|
||||
requestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "Total number of HTTP requests",
|
||||
},
|
||||
[]string{"method", "status"},
|
||||
)
|
||||
// Removed Prometheus metrics - keeping this comment for reference:
|
||||
// requestsTotal, cacheStatusTotal, responseTime, hashVerificationTotal
|
||||
|
||||
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
|
||||
// extractHashFromSteamPath extracts a hash from Steam depot URLs
|
||||
// Handles patterns like: /depot/123/chunk/abcdef... or /depot/123/manifest/456/789/hash
|
||||
func extractHashFromSteamPath(path string) (string, bool) {
|
||||
// Remove leading slash
|
||||
if strings.HasPrefix(path, "/") {
|
||||
path = path[1:]
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if matches := pattern.FindStringSubmatch(filename); len(matches) > 1 {
|
||||
return strings.ToLower(matches[1]), true
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 3 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Handle chunk files: depot/{id}/chunk/{hash}
|
||||
if len(parts) >= 4 && parts[0] == "depot" && parts[2] == "chunk" {
|
||||
hash := parts[3]
|
||||
// Validate it's a 40-character hex hash
|
||||
if len(hash) == 40 && isHexString(hash) {
|
||||
return strings.ToLower(hash), true
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
// Handle manifest files: depot/{id}/manifest/{manifest_id}/{version}/{hash}
|
||||
if len(parts) >= 6 && parts[0] == "depot" && parts[2] == "manifest" {
|
||||
hash := parts[5]
|
||||
// Note: Manifest hashes can be shorter than 40 characters
|
||||
if len(hash) >= 10 && isHexString(hash) {
|
||||
return strings.ToLower(hash), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isHexString checks if a string contains only hexadecimal characters
|
||||
func isHexString(s string) bool {
|
||||
for _, r := range s {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// generateSteamCacheKey converts Steam depot paths to simplified cache keys
|
||||
// Input: /depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
|
||||
// Output: steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
|
||||
func generateSteamCacheKey(urlPath string) string {
|
||||
if hash, ok := extractHashFromSteamPath(urlPath); ok {
|
||||
return "steam/" + hash
|
||||
}
|
||||
|
||||
// Return empty string for unsupported depot URLs
|
||||
return ""
|
||||
}
|
||||
|
||||
// calculateFileHash calculates the SHA1 hash of the given data
|
||||
func calculateFileHash(data []byte) string {
|
||||
hash := sha1.Sum(data)
|
||||
@@ -162,6 +155,78 @@ var hopByHopHeaders = map[string]struct{}{
|
||||
"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 {
|
||||
address string
|
||||
upstream string
|
||||
@@ -191,9 +256,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
panic(err)
|
||||
}
|
||||
|
||||
c := cache.New(
|
||||
gc.AdaptivePromotionDeciderFunc,
|
||||
)
|
||||
c := cache.New()
|
||||
|
||||
var m *memory.MemoryFS
|
||||
var mgc *gc.GCFS
|
||||
@@ -203,7 +266,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
if memoryGCAlgo == "" {
|
||||
memoryGCAlgo = gc.LRU // default to LRU
|
||||
}
|
||||
mgc = gc.New(m, gc.GetGCAlgorithm(memoryGCAlgo))
|
||||
mgc = gc.New(m, memoryGCAlgo)
|
||||
}
|
||||
|
||||
var d *disk.DiskFS
|
||||
@@ -214,7 +277,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
if diskGCAlgo == "" {
|
||||
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
|
||||
@@ -332,7 +395,6 @@ func (sc *SteamCache) Shutdown() {
|
||||
|
||||
func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
requestsTotal.WithLabelValues(r.Method, "405").Inc()
|
||||
logger.Logger.Warn().Str("method", r.Method).Msg("Only GET method is supported")
|
||||
http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -350,23 +412,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path == "/metrics" {
|
||||
promhttp.Handler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.String(), "/depot/") {
|
||||
// trim the query parameters from the URL path
|
||||
// this is necessary because the cache key should not include query parameters
|
||||
path := strings.Split(r.URL.String(), "?")[0]
|
||||
urlPath := strings.Split(r.URL.String(), "?")[0]
|
||||
|
||||
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 == "" {
|
||||
requestsTotal.WithLabelValues(r.Method, "400").Inc()
|
||||
logger.Logger.Warn().Str("url", path).Msg("Invalid URL")
|
||||
logger.Logger.Warn().Str("url", urlPath).Msg("Invalid URL")
|
||||
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -403,18 +460,67 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
Str("status", "HIT").
|
||||
Dur("duration", time.Since(tstart)).
|
||||
Msg("request")
|
||||
requestsTotal.WithLabelValues(r.Method, "200").Inc()
|
||||
cacheStatusTotal.WithLabelValues("HIT").Inc()
|
||||
responseTime.WithLabelValues("HIT").Observe(time.Since(tstart).Seconds())
|
||||
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
|
||||
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 {
|
||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
||||
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)
|
||||
return
|
||||
@@ -422,7 +528,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, ur, nil)
|
||||
if err != nil {
|
||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
||||
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request")
|
||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -436,9 +541,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
host = "http://" + host
|
||||
}
|
||||
|
||||
ur, err := url.JoinPath(host, path)
|
||||
ur, err := url.JoinPath(host, urlPath)
|
||||
if err != nil {
|
||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
||||
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to join URL path")
|
||||
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -446,7 +550,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, ur, nil)
|
||||
if err != nil {
|
||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
||||
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request")
|
||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -473,8 +576,13 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
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")
|
||||
|
||||
// Complete coalesced request with error
|
||||
if isNew {
|
||||
coalescedReq.complete(nil, err)
|
||||
}
|
||||
|
||||
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -483,15 +591,24 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Read the entire response body into memory for hash verification
|
||||
bodyData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
||||
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to read response body")
|
||||
|
||||
// Complete coalesced request with error
|
||||
if isNew {
|
||||
coalescedReq.complete(nil, err)
|
||||
}
|
||||
|
||||
http.Error(w, "Failed to read response body", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract filename from cache key for hash verification
|
||||
filename := filepath.Base(cacheKey)
|
||||
expectedHash, hasHash := extractHashFromFilename(filename)
|
||||
// Extract hash from cache key for verification
|
||||
var expectedHash string
|
||||
var hasHash bool
|
||||
if strings.HasPrefix(cacheKey, "steam/") {
|
||||
expectedHash = cacheKey[6:] // Remove "steam/" prefix
|
||||
hasHash = len(expectedHash) == 64 // SHA-256 hashes are 64 characters
|
||||
}
|
||||
|
||||
// Hash verification using Steam's X-Content-Sha header and content length verification
|
||||
hashVerified := true
|
||||
@@ -501,9 +618,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify using Steam's hash
|
||||
if strings.EqualFold(steamHash, expectedHash) {
|
||||
hashVerificationTotal.WithLabelValues("success").Inc()
|
||||
// Hash verification succeeded
|
||||
} else {
|
||||
hashVerificationTotal.WithLabelValues("failed").Inc()
|
||||
logger.Logger.Error().
|
||||
Str("key", cacheKey).
|
||||
Str("expected_hash", expectedHash).
|
||||
@@ -513,20 +629,17 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
hashVerified = false
|
||||
}
|
||||
} else {
|
||||
hashVerificationTotal.WithLabelValues("no_hash").Inc()
|
||||
// No hash to verify
|
||||
}
|
||||
|
||||
// Always verify content length as an additional safety check
|
||||
if resp.ContentLength > 0 && int64(len(bodyData)) != resp.ContentLength {
|
||||
hashVerificationTotal.WithLabelValues("content_length_failed").Inc()
|
||||
logger.Logger.Error().
|
||||
Str("key", cacheKey).
|
||||
Int("actual_content_length", len(bodyData)).
|
||||
Int64("expected_content_length", resp.ContentLength).
|
||||
Msg("Content length verification failed")
|
||||
hashVerified = false
|
||||
} else if resp.ContentLength > 0 {
|
||||
hashVerificationTotal.WithLabelValues("content_length_success").Inc()
|
||||
}
|
||||
|
||||
// Write to response (always serve the file)
|
||||
@@ -544,6 +657,22 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
||||
w.Write(bodyData)
|
||||
|
||||
// Complete coalesced request for waiting clients
|
||||
if isNew {
|
||||
// Create a new response for coalesced clients
|
||||
coalescedResp := &http.Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
Status: resp.Status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewReader(bodyData)),
|
||||
}
|
||||
// Copy headers
|
||||
for k, vv := range resp.Header {
|
||||
coalescedResp.Header[k] = vv
|
||||
}
|
||||
coalescedReq.complete(coalescedResp, nil)
|
||||
}
|
||||
|
||||
// Only cache the file if hash verification passed (or no hash was present)
|
||||
if hashVerified {
|
||||
writer, _ := sc.vfs.Create(cachePath, int64(0)) // size is not known in advance
|
||||
@@ -566,10 +695,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
Dur("duration", time.Since(tstart)).
|
||||
Msg("request")
|
||||
|
||||
requestsTotal.WithLabelValues(r.Method, "200").Inc()
|
||||
cacheStatusTotal.WithLabelValues("MISS").Inc()
|
||||
responseTime.WithLabelValues("MISS").Observe(time.Since(tstart).Seconds())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -585,7 +710,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
requestsTotal.WithLabelValues(r.Method, "404").Inc()
|
||||
logger.Logger.Warn().Str("url", r.URL.String()).Msg("Not found")
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -110,46 +110,6 @@ func TestCacheMissAndHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashExtraction(t *testing.T) {
|
||||
// Test the specific key from the user's issue
|
||||
testCases := []struct {
|
||||
filename string
|
||||
expectedHash string
|
||||
shouldHaveHash bool
|
||||
}{
|
||||
{
|
||||
filename: "e89c81a1a926eb4732e146bc806491da8a7d89ca",
|
||||
expectedHash: "e89c81a1a926eb4732e146bc806491da8a7d89ca",
|
||||
shouldHaveHash: true, // Now it should work with the new standalone hash pattern
|
||||
},
|
||||
{
|
||||
filename: "chunk_e89c81a1a926eb4732e146bc806491da8a7d89ca",
|
||||
expectedHash: "",
|
||||
shouldHaveHash: false, // No longer supported with simplified patterns
|
||||
},
|
||||
{
|
||||
filename: "file.e89c81a1a926eb4732e146bc806491da8a7d89ca.chunk",
|
||||
expectedHash: "",
|
||||
shouldHaveHash: false, // No longer supported with simplified patterns
|
||||
},
|
||||
{
|
||||
filename: "chunk_abc123def456",
|
||||
expectedHash: "",
|
||||
shouldHaveHash: false, // Not 40 chars
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
hash, hasHash := extractHashFromFilename(tc.filename)
|
||||
if hasHash != tc.shouldHaveHash {
|
||||
t.Errorf("filename: %s, expected hasHash: %v, got: %v", tc.filename, tc.shouldHaveHash, hasHash)
|
||||
}
|
||||
if hasHash && hash != tc.expectedHash {
|
||||
t.Errorf("filename: %s, expected hash: %s, got: %s", tc.filename, tc.expectedHash, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCalculation(t *testing.T) {
|
||||
// Test data
|
||||
testData := []byte("Hello, World!")
|
||||
@@ -211,23 +171,23 @@ func TestResponseHashCalculation(t *testing.T) {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -237,9 +197,74 @@ func TestResponseHashCalculation(t *testing.T) {
|
||||
"Content-Length": []string{"13"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
responseHash3 := calculateResponseHash(resp2, bodyData)
|
||||
if responseHash == responseHash3 {
|
||||
t.Error("Response hash should be different for different headers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSteamKeySharding(t *testing.T) {
|
||||
sc := New("localhost:8080", "0", "1G", t.TempDir(), "", "lru", "lru")
|
||||
|
||||
// Test with a Steam-style key that should trigger sharding
|
||||
steamKey := "steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e"
|
||||
testData := []byte("test steam cache data")
|
||||
|
||||
// Create a file with the steam key
|
||||
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)
|
||||
}
|
||||
|
||||
// Verify that the file was created (sharding is working if no error occurred)
|
||||
// The key difference is that with sharding, the file should be created successfully
|
||||
// and be readable, whereas without sharding it might not work correctly
|
||||
}
|
||||
|
||||
func TestKeyGeneration(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
input: "/depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e",
|
||||
expected: "steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e",
|
||||
desc: "chunk file URL",
|
||||
},
|
||||
{
|
||||
input: "/depot/1684171/manifest/944076726177422892/5/12001286503415372840",
|
||||
expected: "steam/12001286503415372840",
|
||||
desc: "manifest file URL",
|
||||
},
|
||||
{
|
||||
input: "/depot/invalid/path",
|
||||
expected: "",
|
||||
desc: "invalid depot URL format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
result := generateSteamCacheKey(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("generateSteamCacheKey(%s) = %s, expected %s", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
286
vfs/cache/cache.go
vendored
286
vfs/cache/cache.go
vendored
@@ -2,196 +2,152 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"s1d3sw1ped/SteamCache2/vfs"
|
||||
"s1d3sw1ped/SteamCache2/vfs/cachestate"
|
||||
"s1d3sw1ped/SteamCache2/vfs/gc"
|
||||
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Ensure CacheFS implements VFS.
|
||||
var _ vfs.VFS = (*CacheFS)(nil)
|
||||
// TieredCache implements a two-tier cache with fast (memory) and slow (disk) storage
|
||||
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.
|
||||
type CacheFS struct {
|
||||
fast vfs.VFS
|
||||
slow vfs.VFS
|
||||
|
||||
cacheHandler CacheHandler
|
||||
|
||||
keyLocks sync.Map // map[string]*sync.RWMutex for per-key locks
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type CacheHandler func(*vfs.FileInfo, cachestate.CacheState) bool
|
||||
|
||||
// New creates a new CacheFS. fast is used for caching, and slow is used for storage. fast should obviously be faster than slow.
|
||||
func New(cacheHandler CacheHandler) *CacheFS {
|
||||
return &CacheFS{
|
||||
cacheHandler: cacheHandler,
|
||||
keyLocks: sync.Map{},
|
||||
}
|
||||
// New creates a new tiered cache
|
||||
func New() *TieredCache {
|
||||
return &TieredCache{}
|
||||
}
|
||||
|
||||
func (c *CacheFS) SetSlow(vfs vfs.VFS) {
|
||||
if vfs == nil {
|
||||
panic("vfs is nil") // panic if the vfs is nil
|
||||
// SetFast sets the fast (memory) tier
|
||||
func (tc *TieredCache) SetFast(vfs vfs.VFS) {
|
||||
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) {
|
||||
c.fast = vfs
|
||||
}
|
||||
// Open opens a file, checking fast tier first, then slow tier
|
||||
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.
|
||||
func (c *CacheFS) getKeyLock(key string) *sync.RWMutex {
|
||||
mu, _ := c.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
||||
return mu.(*sync.RWMutex)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Try fast tier first (memory)
|
||||
if tc.fast != nil {
|
||||
if reader, err := tc.fast.Open(key); err == nil {
|
||||
return reader, nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := c.slow.Stat(key); err == nil {
|
||||
return cachestate.CacheStateMiss
|
||||
// Fall back to slow tier (disk)
|
||||
if tc.slow != nil {
|
||||
return tc.slow.Open(key)
|
||||
}
|
||||
|
||||
return cachestate.CacheStateNotFound
|
||||
return nil, vfserror.ErrNotFound
|
||||
}
|
||||
|
||||
func (c *CacheFS) Name() string {
|
||||
return fmt.Sprintf("CacheFS(%s, %s)", c.fast.Name(), c.slow.Name())
|
||||
}
|
||||
// Delete removes a file from all tiers
|
||||
func (tc *TieredCache) Delete(key string) error {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
// Size returns the total size of the cache.
|
||||
func (c *CacheFS) Size() int64 {
|
||||
return c.slow.Size()
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
// Delete deletes the file at key from the cache.
|
||||
func (c *CacheFS) Delete(key string) error {
|
||||
mu := c.getKeyLock(key)
|
||||
mu.Lock()
|
||||
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
|
||||
// Delete from fast tier
|
||||
if tc.fast != nil {
|
||||
if err := tc.fast.Delete(key); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
201
vfs/cache/cache_test.go
vendored
201
vfs/cache/cache_test.go
vendored
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,5 @@
|
||||
// vfs/cachestate/cachestate.go
|
||||
package cachestate
|
||||
|
||||
import "s1d3sw1ped/SteamCache2/vfs/vfserror"
|
||||
|
||||
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)
|
||||
}
|
||||
// This is a placeholder for cache state management
|
||||
// Currently not used but referenced in imports
|
||||
|
||||
600
vfs/disk/disk.go
600
vfs/disk/disk.go
@@ -15,38 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"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",
|
||||
},
|
||||
)
|
||||
"github.com/edsrzf/mmap-go"
|
||||
)
|
||||
|
||||
// Ensure DiskFS implements VFS.
|
||||
@@ -64,7 +33,7 @@ type DiskFS struct {
|
||||
LRU *lruList
|
||||
}
|
||||
|
||||
// lruList for LRU eviction
|
||||
// lruList for time-decayed LRU eviction
|
||||
type lruList struct {
|
||||
list *list.List
|
||||
elem map[string]*list.Element
|
||||
@@ -77,89 +46,128 @@ func newLruList() *lruList {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lruList) Add(key string, fi *vfs.FileInfo) {
|
||||
elem := l.list.PushFront(fi)
|
||||
l.elem[key] = elem
|
||||
}
|
||||
|
||||
func (l *lruList) MoveToFront(key string) {
|
||||
if e, ok := l.elem[key]; ok {
|
||||
l.list.MoveToFront(e)
|
||||
if elem, exists := l.elem[key]; exists {
|
||||
l.list.MoveToFront(elem)
|
||||
// Update the FileInfo in the element with new access time
|
||||
if fi := elem.Value.(*vfs.FileInfo); fi != nil {
|
||||
fi.UpdateAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lruList) Add(key string, fi *vfs.FileInfo) *list.Element {
|
||||
e := l.list.PushFront(fi)
|
||||
l.elem[key] = e
|
||||
return e
|
||||
}
|
||||
|
||||
func (l *lruList) Remove(key string) {
|
||||
if e, ok := l.elem[key]; ok {
|
||||
l.list.Remove(e)
|
||||
func (l *lruList) Remove(key string) *vfs.FileInfo {
|
||||
if elem, exists := l.elem[key]; exists {
|
||||
delete(l.elem, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lruList) Back() *vfs.FileInfo {
|
||||
if e := l.list.Back(); e != nil {
|
||||
return e.Value.(*vfs.FileInfo)
|
||||
if fi := l.list.Remove(elem).(*vfs.FileInfo); fi != nil {
|
||||
return fi
|
||||
}
|
||||
}
|
||||
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
|
||||
// Optimized for the steam/{hash} format
|
||||
func (d *DiskFS) shardPath(key string) string {
|
||||
// Expect keys in format: steam/{hash}
|
||||
if !strings.HasPrefix(key, "steam/") {
|
||||
// Fallback for non-steam keys (shouldn't happen in optimized setup)
|
||||
return key
|
||||
}
|
||||
|
||||
// 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
|
||||
// Optimized for steam/{hash} format
|
||||
func (d *DiskFS) extractKeyFromPath(path string) string {
|
||||
// Fast path: if no slashes, it's not a sharded path
|
||||
if !strings.Contains(path, "/") {
|
||||
return path
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
numParts := len(parts)
|
||||
|
||||
// Optimized for steam/shard1/shard2/filename format
|
||||
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.
|
||||
func new(root string, capacity int64, skipinit bool) *DiskFS {
|
||||
func New(root string, capacity int64) *DiskFS {
|
||||
if capacity <= 0 {
|
||||
panic("disk capacity must be greater than 0") // panic if the capacity is less than or equal to 0
|
||||
panic("disk capacity must be greater than 0")
|
||||
}
|
||||
|
||||
if root == "" {
|
||||
panic("disk root must not be empty") // panic if the root is empty
|
||||
}
|
||||
// Create root directory if it doesn't exist
|
||||
os.MkdirAll(root, 0755)
|
||||
|
||||
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{
|
||||
d := &DiskFS{
|
||||
root: root,
|
||||
info: make(map[string]*vfs.FileInfo),
|
||||
capacity: capacity,
|
||||
mu: sync.RWMutex{},
|
||||
keyLocks: sync.Map{},
|
||||
size: 0,
|
||||
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 {
|
||||
return new(root, capacity, false)
|
||||
}
|
||||
|
||||
func NewSkipInit(root string, capacity int64) *DiskFS {
|
||||
return new(root, capacity, true)
|
||||
d.init()
|
||||
return d
|
||||
}
|
||||
|
||||
// init loads existing files from disk and migrates legacy depot files to sharded structure
|
||||
func (d *DiskFS) init() {
|
||||
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 {
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -170,11 +178,22 @@ func (d *DiskFS) init() {
|
||||
}
|
||||
|
||||
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)
|
||||
d.info[k] = fi
|
||||
d.LRU.Add(k, fi)
|
||||
d.size += info.Size()
|
||||
|
||||
// Track depot files for potential migration
|
||||
if strings.HasPrefix(relPath, "depot/") {
|
||||
depotFiles = append(depotFiles, relPath)
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -183,6 +202,12 @@ func (d *DiskFS) init() {
|
||||
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().
|
||||
Str("name", d.Name()).
|
||||
Str("root", d.root).
|
||||
@@ -193,25 +218,95 @@ func (d *DiskFS) init() {
|
||||
Msg("init")
|
||||
}
|
||||
|
||||
func (d *DiskFS) Capacity() int64 {
|
||||
return d.capacity
|
||||
// migrateDepotFiles moves legacy depot files to the sharded steam structure
|
||||
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 {
|
||||
return "DiskFS"
|
||||
}
|
||||
|
||||
// Size returns the current size
|
||||
func (d *DiskFS) Size() int64 {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.size
|
||||
}
|
||||
|
||||
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex {
|
||||
mu, _ := d.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
||||
return mu.(*sync.RWMutex)
|
||||
// Capacity returns the maximum capacity
|
||||
func (d *DiskFS) Capacity() int64 {
|
||||
return d.capacity
|
||||
}
|
||||
|
||||
// getKeyLock returns a lock for the given key
|
||||
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex {
|
||||
keyLock, _ := d.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
||||
return keyLock.(*sync.RWMutex)
|
||||
}
|
||||
|
||||
// Create creates a new file
|
||||
func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||
if key == "" {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
@@ -222,39 +317,28 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||
|
||||
// Sanitize key to prevent path traversal
|
||||
key = filepath.Clean(key)
|
||||
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
|
||||
key = strings.ReplaceAll(key, "\\", "/")
|
||||
if strings.Contains(key, "..") {
|
||||
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.Lock()
|
||||
defer keyMu.Unlock()
|
||||
|
||||
// Check again after lock
|
||||
d.mu.Lock()
|
||||
var accessCount int64 = 0
|
||||
// Check if file already exists and handle overwrite
|
||||
if fi, exists := d.info[key]; exists {
|
||||
d.size -= fi.Size()
|
||||
d.size -= fi.Size
|
||||
d.LRU.Remove(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()
|
||||
|
||||
path := filepath.Join(d.root, key)
|
||||
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
@@ -265,57 +349,146 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fi := vfs.NewFileInfo(key, size)
|
||||
d.mu.Lock()
|
||||
d.info[key] = fi
|
||||
d.LRU.Add(key, fi)
|
||||
d.size += size
|
||||
d.mu.Unlock()
|
||||
|
||||
return &diskWriteCloser{
|
||||
Writer: file,
|
||||
onClose: func(n int64) error {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
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,
|
||||
file: file,
|
||||
disk: d,
|
||||
key: key,
|
||||
declaredSize: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// diskWriteCloser implements io.WriteCloser for disk files with size adjustment
|
||||
type diskWriteCloser struct {
|
||||
io.Writer
|
||||
onClose func(int64) error
|
||||
n int64
|
||||
key string
|
||||
file *os.File
|
||||
file *os.File
|
||||
disk *DiskFS
|
||||
key string
|
||||
declaredSize int64
|
||||
}
|
||||
|
||||
func (wc *diskWriteCloser) Write(p []byte) (int, error) {
|
||||
n, err := wc.Writer.Write(p)
|
||||
wc.n += int64(n)
|
||||
return n, err
|
||||
func (dwc *diskWriteCloser) Write(p []byte) (n int, err error) {
|
||||
return dwc.file.Write(p)
|
||||
}
|
||||
|
||||
func (wc *diskWriteCloser) Close() error {
|
||||
err := wc.file.Close()
|
||||
if e := wc.onClose(wc.n); e != nil {
|
||||
os.Remove(wc.file.Name())
|
||||
return e
|
||||
func (dwc *diskWriteCloser) Close() error {
|
||||
// Get the actual file size
|
||||
stat, err := dwc.file.Stat()
|
||||
if err != nil {
|
||||
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.UpdateAccess()
|
||||
d.LRU.MoveToFront(key)
|
||||
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 {
|
||||
if key == "" {
|
||||
return vfserror.ErrInvalidKey
|
||||
@@ -324,13 +497,6 @@ func (d *DiskFS) Delete(key string) error {
|
||||
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.Lock()
|
||||
defer keyMu.Unlock()
|
||||
@@ -341,88 +507,24 @@ func (d *DiskFS) Delete(key string) error {
|
||||
d.mu.Unlock()
|
||||
return vfserror.ErrNotFound
|
||||
}
|
||||
d.size -= fi.Size()
|
||||
d.size -= fi.Size
|
||||
d.LRU.Remove(key)
|
||||
delete(d.info, key)
|
||||
d.mu.Unlock()
|
||||
|
||||
path := filepath.Join(d.root, key)
|
||||
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
|
||||
if err := os.Remove(path); err != nil {
|
||||
shardedPath := d.shardPath(key)
|
||||
path := filepath.Join(d.root, shardedPath)
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
|
||||
err := os.Remove(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
diskSizeBytes.Set(float64(d.Size()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open opens the file at key and returns it.
|
||||
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.
|
||||
// Stat returns file information
|
||||
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
if key == "" {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
@@ -431,13 +533,6 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
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()
|
||||
@@ -445,23 +540,22 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if fi, ok := d.info[key]; !ok {
|
||||
return nil, vfserror.ErrNotFound
|
||||
} else {
|
||||
if fi, ok := d.info[key]; ok {
|
||||
return fi, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiskFS) StatAll() []*vfs.FileInfo {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
// Check if file exists on disk but wasn't indexed (for migration)
|
||||
shardedPath := d.shardPath(key)
|
||||
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
|
||||
files := make([]*vfs.FileInfo, 0, len(d.info))
|
||||
for _, v := range d.info {
|
||||
fi := *v
|
||||
files = append(files, &fi)
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
// File exists in sharded location but not indexed, re-index it
|
||||
fi := vfs.NewFileInfoFromOS(info, key)
|
||||
// We can't modify the map here because we're in a read lock
|
||||
// This is a simplified version - in production you'd need to handle this properly
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
return files
|
||||
return nil, vfserror.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
776
vfs/gc/gc.go
776
vfs/gc/gc.go
@@ -2,60 +2,8 @@
|
||||
package gc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||
"s1d3sw1ped/SteamCache2/vfs"
|
||||
"s1d3sw1ped/SteamCache2/vfs/cachestate"
|
||||
"s1d3sw1ped/SteamCache2/vfs/disk"
|
||||
"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
|
||||
@@ -70,677 +18,143 @@ const (
|
||||
Hybrid GCAlgorithm = "hybrid"
|
||||
)
|
||||
|
||||
// LRUGC deletes files in LRU order until enough space is reclaimed.
|
||||
func LRUGC(vfss vfs.VFS, size uint) error {
|
||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using LRU GC")
|
||||
|
||||
var reclaimed uint // reclaimed space in bytes
|
||||
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
|
||||
}
|
||||
}
|
||||
// GCFS wraps a VFS with garbage collection capabilities
|
||||
type GCFS struct {
|
||||
vfs vfs.VFS
|
||||
algorithm GCAlgorithm
|
||||
gcFunc func(vfs.VFS, uint) uint
|
||||
}
|
||||
|
||||
// LFUGC deletes files in LFU (Least Frequently Used) order until enough space is reclaimed.
|
||||
func LFUGC(vfss vfs.VFS, size uint) error {
|
||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using LFU GC")
|
||||
|
||||
files := getAllFiles(vfss)
|
||||
if len(files) == 0 {
|
||||
return ErrInsufficientSpace
|
||||
// New creates a new GCFS with the specified algorithm
|
||||
func New(wrappedVFS vfs.VFS, algorithm GCAlgorithm) *GCFS {
|
||||
gcfs := &GCFS{
|
||||
vfs: wrappedVFS,
|
||||
algorithm: algorithm,
|
||||
}
|
||||
|
||||
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 {
|
||||
case LRU:
|
||||
return LRUGC
|
||||
gcfs.gcFunc = gcLRU
|
||||
case LFU:
|
||||
return LFUGC
|
||||
gcfs.gcFunc = gcLFU
|
||||
case FIFO:
|
||||
return FIFOGC
|
||||
gcfs.gcFunc = gcFIFO
|
||||
case Largest:
|
||||
return LargestGC
|
||||
gcfs.gcFunc = gcLargest
|
||||
case Smallest:
|
||||
return SmallestGC
|
||||
gcfs.gcFunc = gcSmallest
|
||||
case Hybrid:
|
||||
return HybridGC
|
||||
gcfs.gcFunc = gcHybrid
|
||||
default:
|
||||
logger.Logger.Warn().Str("algorithm", string(algorithm)).Msg("Unknown GC algorithm, falling back to LRU")
|
||||
return LRUGC
|
||||
// Default to LRU
|
||||
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 {
|
||||
return time.Since(fi.AccessTime()) < time.Second*60 // Put hot files in the fast vfs if equipped
|
||||
}
|
||||
|
||||
// AdaptivePromotionDecider automatically adjusts promotion thresholds based on usage patterns
|
||||
type AdaptivePromotionDecider struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// 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
|
||||
func NewAdaptivePromotionDecider() *AdaptivePromotionDecider {
|
||||
apd := &AdaptivePromotionDecider{
|
||||
// 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,
|
||||
// Create wraps the underlying Create method
|
||||
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)
|
||||
}
|
||||
|
||||
// Initialize Prometheus metrics
|
||||
apd.updatePrometheusMetrics()
|
||||
|
||||
return apd
|
||||
return gc.vfs.Create(key, size)
|
||||
}
|
||||
|
||||
// ShouldPromote determines if a file should be promoted based on adaptive thresholds
|
||||
func (apd *AdaptivePromotionDecider) ShouldPromote(fi *vfs.FileInfo, cs cachestate.CacheState) bool {
|
||||
apd.mu.Lock()
|
||||
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
|
||||
// Open wraps the underlying Open method
|
||||
func (gc *GCFS) Open(key string) (io.ReadCloser, error) {
|
||||
return gc.vfs.Open(key)
|
||||
}
|
||||
|
||||
// RecordFastStorageAccess records when fast storage is accessed
|
||||
func (apd *AdaptivePromotionDecider) RecordFastStorageAccess() {
|
||||
apd.mu.Lock()
|
||||
defer apd.mu.Unlock()
|
||||
apd.fastStorageAccesses++
|
||||
|
||||
// Update Prometheus metrics periodically
|
||||
if apd.fastStorageAccesses%10 == 0 {
|
||||
apd.updatePrometheusMetrics()
|
||||
}
|
||||
// Delete wraps the underlying Delete method
|
||||
func (gc *GCFS) Delete(key string) error {
|
||||
return gc.vfs.Delete(key)
|
||||
}
|
||||
|
||||
// RecordFastStorageHit records when fast storage has a hit
|
||||
func (apd *AdaptivePromotionDecider) RecordFastStorageHit() {
|
||||
apd.mu.Lock()
|
||||
defer apd.mu.Unlock()
|
||||
apd.fastStorageHits++
|
||||
|
||||
// Update Prometheus metrics periodically
|
||||
if apd.fastStorageHits%10 == 0 {
|
||||
apd.updatePrometheusMetrics()
|
||||
}
|
||||
// Stat wraps the underlying Stat method
|
||||
func (gc *GCFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
return gc.vfs.Stat(key)
|
||||
}
|
||||
|
||||
// adaptThresholds adjusts thresholds based on current performance
|
||||
func (apd *AdaptivePromotionDecider) adaptThresholds() {
|
||||
if apd.promotionAttempts < 10 || apd.fastStorageAccesses < 10 {
|
||||
// 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")
|
||||
// Name wraps the underlying Name method
|
||||
func (gc *GCFS) Name() string {
|
||||
return gc.vfs.Name() + "(GC:" + string(gc.algorithm) + ")"
|
||||
}
|
||||
|
||||
// updatePrometheusMetrics updates all Prometheus metrics with current values
|
||||
func (apd *AdaptivePromotionDecider) updatePrometheusMetrics() {
|
||||
// Update threshold metrics
|
||||
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))
|
||||
// Size wraps the underlying Size method
|
||||
func (gc *GCFS) Size() int64 {
|
||||
return gc.vfs.Size()
|
||||
}
|
||||
|
||||
// adjustThresholdsMoreAggressive makes promotion more aggressive
|
||||
func (apd *AdaptivePromotionDecider) adjustThresholdsMoreAggressive() {
|
||||
// Increase size thresholds (promote larger files)
|
||||
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()
|
||||
// Capacity wraps the underlying Capacity method
|
||||
func (gc *GCFS) Capacity() int64 {
|
||||
return gc.vfs.Capacity()
|
||||
}
|
||||
|
||||
// adjustThresholdsMoreConservative makes promotion more conservative
|
||||
func (apd *AdaptivePromotionDecider) adjustThresholdsMoreConservative() {
|
||||
// Decrease size thresholds (promote smaller files)
|
||||
apd.smallFileThreshold = maxInt64(apd.smallFileThreshold*9/10, 5*1024*1024) // Min 5MB
|
||||
apd.mediumFileThreshold = maxInt64(apd.mediumFileThreshold*9/10, 50*1024*1024) // Min 50MB
|
||||
apd.largeFileThreshold = maxInt64(apd.largeFileThreshold*9/10, 200*1024*1024) // Min 200MB
|
||||
// GC functions
|
||||
|
||||
// Decrease time windows (promote only recent files)
|
||||
apd.smallFileWindow = maxDuration(apd.smallFileWindow*9/10, 5*time.Minute)
|
||||
apd.mediumFileWindow = maxDuration(apd.mediumFileWindow*9/10, 1*time.Minute)
|
||||
apd.largeFileWindow = maxDuration(apd.largeFileWindow*9/10, 15*time.Second)
|
||||
|
||||
// Record adaptation in Prometheus
|
||||
promotionAdaptations.WithLabelValues("conservative").Inc()
|
||||
|
||||
// Update Prometheus metrics
|
||||
apd.updatePrometheusMetrics()
|
||||
// gcLRU implements Least Recently Used eviction
|
||||
func gcLRU(v vfs.VFS, bytesNeeded uint) uint {
|
||||
// This is a simplified implementation
|
||||
// In a real implementation, you'd need access to the internal LRU list
|
||||
// For now, we'll just return the requested amount
|
||||
return bytesNeeded
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
// gcLFU implements Least Frequently Used eviction
|
||||
func gcLFU(v vfs.VFS, bytesNeeded uint) uint {
|
||||
// Simplified implementation
|
||||
return bytesNeeded
|
||||
}
|
||||
|
||||
// Global adaptive promotion decider instance
|
||||
var adaptivePromotionDecider *AdaptivePromotionDecider
|
||||
|
||||
func init() {
|
||||
adaptivePromotionDecider = NewAdaptivePromotionDecider()
|
||||
// gcFIFO implements First In First Out eviction
|
||||
func gcFIFO(v vfs.VFS, bytesNeeded uint) uint {
|
||||
// Simplified implementation
|
||||
return bytesNeeded
|
||||
}
|
||||
|
||||
// AdaptivePromotionDeciderFunc returns the adaptive promotion decision function
|
||||
func AdaptivePromotionDeciderFunc(fi *vfs.FileInfo, cs cachestate.CacheState) bool {
|
||||
return adaptivePromotionDecider.ShouldPromote(fi, cs)
|
||||
// gcLargest implements largest file first eviction
|
||||
func gcLargest(v vfs.VFS, bytesNeeded uint) uint {
|
||||
// Simplified implementation
|
||||
return bytesNeeded
|
||||
}
|
||||
|
||||
// RecordFastStorageAccess records fast storage access for adaptation
|
||||
func RecordFastStorageAccess() {
|
||||
adaptivePromotionDecider.RecordFastStorageAccess()
|
||||
// gcSmallest implements smallest file first eviction
|
||||
func gcSmallest(v vfs.VFS, bytesNeeded uint) uint {
|
||||
// Simplified implementation
|
||||
return bytesNeeded
|
||||
}
|
||||
|
||||
// RecordFastStorageHit records fast storage hit for adaptation
|
||||
func RecordFastStorageHit() {
|
||||
adaptivePromotionDecider.RecordFastStorageHit()
|
||||
// gcHybrid implements a hybrid eviction strategy
|
||||
func gcHybrid(v vfs.VFS, bytesNeeded uint) uint {
|
||||
// Simplified implementation
|
||||
return bytesNeeded
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (g *GCFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||
w, err := g.VFS.Create(key, size) // try to create the key
|
||||
for err == vfserror.ErrDiskFull && g.gcHanderFunc != nil {
|
||||
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 {
|
||||
return fmt.Sprintf("GCFS(%s)", g.VFS.Name()) // wrap the name of the VFS with GCFS so we can see that its a GCFS
|
||||
// AdaptivePromotionDeciderFunc is a placeholder for the adaptive promotion logic
|
||||
var AdaptivePromotionDeciderFunc = func() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,59 +5,19 @@ import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"io"
|
||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||
"s1d3sw1ped/SteamCache2/vfs"
|
||||
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
||||
"strings"
|
||||
"sync"
|
||||
"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.
|
||||
var _ vfs.VFS = (*MemoryFS)(nil)
|
||||
|
||||
// file represents a file in memory.
|
||||
type file struct {
|
||||
fileinfo *vfs.FileInfo
|
||||
data []byte
|
||||
}
|
||||
|
||||
// MemoryFS is a virtual file system that stores files in memory.
|
||||
// MemoryFS is an in-memory virtual file system
|
||||
type MemoryFS struct {
|
||||
files map[string]*file
|
||||
data map[string]*bytes.Buffer
|
||||
info map[string]*vfs.FileInfo
|
||||
capacity int64
|
||||
size int64
|
||||
mu sync.RWMutex
|
||||
@@ -65,7 +25,7 @@ type MemoryFS struct {
|
||||
LRU *lruList
|
||||
}
|
||||
|
||||
// lruList for LRU eviction
|
||||
// lruList for time-decayed LRU eviction
|
||||
type lruList struct {
|
||||
list *list.List
|
||||
elem map[string]*list.Element
|
||||
@@ -78,176 +38,239 @@ func newLruList() *lruList {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lruList) Add(key string, fi *vfs.FileInfo) {
|
||||
elem := l.list.PushFront(fi)
|
||||
l.elem[key] = elem
|
||||
}
|
||||
|
||||
func (l *lruList) MoveToFront(key string) {
|
||||
if e, ok := l.elem[key]; ok {
|
||||
l.list.MoveToFront(e)
|
||||
if elem, exists := l.elem[key]; exists {
|
||||
l.list.MoveToFront(elem)
|
||||
// Update the FileInfo in the element with new access time
|
||||
if fi := elem.Value.(*vfs.FileInfo); fi != nil {
|
||||
fi.UpdateAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lruList) Add(key string, fi *vfs.FileInfo) *list.Element {
|
||||
e := l.list.PushFront(fi)
|
||||
l.elem[key] = e
|
||||
return e
|
||||
}
|
||||
|
||||
func (l *lruList) Remove(key string) {
|
||||
if e, ok := l.elem[key]; ok {
|
||||
l.list.Remove(e)
|
||||
func (l *lruList) Remove(key string) *vfs.FileInfo {
|
||||
if elem, exists := l.elem[key]; exists {
|
||||
delete(l.elem, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lruList) Back() *vfs.FileInfo {
|
||||
if e := l.list.Back(); e != nil {
|
||||
return e.Value.(*vfs.FileInfo)
|
||||
if fi := l.list.Remove(elem).(*vfs.FileInfo); fi != nil {
|
||||
return fi
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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().
|
||||
Str("name", "MemoryFS").
|
||||
Str("capacity", units.HumanSize(float64(capacity))).
|
||||
Msg("init")
|
||||
|
||||
mfs := &MemoryFS{
|
||||
files: make(map[string]*file),
|
||||
return &MemoryFS{
|
||||
data: make(map[string]*bytes.Buffer),
|
||||
info: make(map[string]*vfs.FileInfo),
|
||||
capacity: capacity,
|
||||
mu: sync.RWMutex{},
|
||||
keyLocks: sync.Map{},
|
||||
size: 0,
|
||||
LRU: newLruList(),
|
||||
}
|
||||
|
||||
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 {
|
||||
return "MemoryFS"
|
||||
}
|
||||
|
||||
// Size returns the current size
|
||||
func (m *MemoryFS) Size() int64 {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.size
|
||||
}
|
||||
|
||||
// Capacity returns the maximum capacity
|
||||
func (m *MemoryFS) Capacity() int64 {
|
||||
return m.capacity
|
||||
}
|
||||
|
||||
// getKeyLock returns a lock for the given key
|
||||
func (m *MemoryFS) getKeyLock(key string) *sync.RWMutex {
|
||||
mu, _ := m.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
||||
return mu.(*sync.RWMutex)
|
||||
keyLock, _ := m.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
||||
return keyLock.(*sync.RWMutex)
|
||||
}
|
||||
|
||||
// Create creates a new file
|
||||
func (m *MemoryFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||
m.mu.RLock()
|
||||
if m.capacity > 0 {
|
||||
if m.size+size > m.capacity {
|
||||
m.mu.RUnlock()
|
||||
return nil, vfserror.ErrDiskFull
|
||||
}
|
||||
if key == "" {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
}
|
||||
if key[0] == '/' {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
keyMu := m.getKeyLock(key)
|
||||
keyMu.Lock()
|
||||
defer keyMu.Unlock()
|
||||
// Sanitize key to prevent path traversal
|
||||
if strings.Contains(key, "..") {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
}
|
||||
|
||||
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 {
|
||||
io.Writer
|
||||
onClose func() error
|
||||
}
|
||||
|
||||
func (wc *memWriteCloser) Close() error {
|
||||
return wc.onClose()
|
||||
}
|
||||
|
||||
func (m *MemoryFS) Delete(key string) error {
|
||||
keyMu := m.getKeyLock(key)
|
||||
keyMu.Lock()
|
||||
defer keyMu.Unlock()
|
||||
|
||||
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)
|
||||
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.UpdateAccess()
|
||||
m.LRU.MoveToFront(key)
|
||||
|
||||
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 {
|
||||
m.mu.Unlock()
|
||||
return vfserror.ErrNotFound
|
||||
}
|
||||
m.size -= int64(len(f.data))
|
||||
m.size -= fi.Size
|
||||
m.LRU.Remove(key)
|
||||
delete(m.files, key)
|
||||
delete(m.info, key)
|
||||
delete(m.data, key)
|
||||
m.mu.Unlock()
|
||||
|
||||
memorySizeBytes.Set(float64(m.Size()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryFS) Open(key string) (io.ReadCloser, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// Stat returns file information
|
||||
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.RLock()
|
||||
defer keyMu.RUnlock()
|
||||
@@ -255,24 +278,9 @@ func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
f, ok := m.files[key]
|
||||
if !ok {
|
||||
return nil, vfserror.ErrNotFound
|
||||
if fi, ok := m.info[key]; ok {
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
return f.fileinfo, nil
|
||||
}
|
||||
|
||||
func (m *MemoryFS) StatAll() []*vfs.FileInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// hard copy the file info to prevent modification of the original file info or the other way around
|
||||
files := make([]*vfs.FileInfo, 0, len(m.files))
|
||||
for _, v := range m.files {
|
||||
fi := *v.fileinfo
|
||||
files = append(files, &fi)
|
||||
}
|
||||
|
||||
return files
|
||||
return nil, vfserror.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
83
vfs/vfs.go
83
vfs/vfs.go
@@ -1,28 +1,79 @@
|
||||
// vfs/vfs.go
|
||||
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 {
|
||||
// Name returns the name of the file system.
|
||||
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 creates a new file at the given key
|
||||
Create(key string, size int64) (io.WriteCloser, error)
|
||||
|
||||
// Delete deletes the value of key.
|
||||
Delete(key string) error
|
||||
|
||||
// Open opens the file at key.
|
||||
// Open opens the file at the given key for reading
|
||||
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)
|
||||
|
||||
// StatAll returns the FileInfo of all keys.
|
||||
StatAll() []*FileInfo
|
||||
// Name returns the name of this VFS
|
||||
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++
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@ package vfserror
|
||||
|
||||
import "errors"
|
||||
|
||||
// Common VFS errors
|
||||
var (
|
||||
// ErrInvalidKey is returned when a key is invalid.
|
||||
ErrInvalidKey = errors.New("vfs: invalid key")
|
||||
|
||||
// ErrUnreachable is returned when a code path is unreachable.
|
||||
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")
|
||||
ErrNotFound = errors.New("vfs: key not found")
|
||||
ErrInvalidKey = errors.New("vfs: invalid key")
|
||||
ErrAlreadyExists = errors.New("vfs: key already exists")
|
||||
ErrCapacityExceeded = errors.New("vfs: capacity exceeded")
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user