Compare commits
4 Commits
1.0.14
...
ee6fc32a1a
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6fc32a1a | |||
| 4a4579b0f3 | |||
| b9358a0e8d | |||
| c197841960 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,5 +1,11 @@
|
|||||||
dist/
|
#build artifacts
|
||||||
tmp/
|
/dist/
|
||||||
|
|
||||||
|
#disk cache
|
||||||
|
/disk/
|
||||||
|
|
||||||
|
#config file
|
||||||
|
/config.yaml
|
||||||
|
|
||||||
|
#windows executables
|
||||||
*.exe
|
*.exe
|
||||||
.smashed.txt
|
|
||||||
.smashignore
|
|
||||||
|
|||||||
67
.vscode/launch.json
vendored
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
|
- Reduces bandwidth usage
|
||||||
- Easy to set up and configure aside from dns stuff to trick Steam into using it
|
- Easy to set up and configure aside from dns stuff to trick Steam into using it
|
||||||
- Supports multiple clients
|
- Supports multiple clients
|
||||||
|
- **NEW:** YAML configuration system with automatic config generation
|
||||||
|
- **NEW:** Simple Makefile for development workflow
|
||||||
|
- Cross-platform builds (Linux, macOS, Windows)
|
||||||
|
|
||||||
## Usage
|
## Quick Start
|
||||||
|
|
||||||
1. Start the cache server:
|
### First Time Setup
|
||||||
```sh
|
|
||||||
./SteamCache2 --memory 1G --disk 10G --disk-path tmp/disk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Configuration
|
1. **Clone and build:**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd SteamCache2
|
||||||
|
make # This will run tests and build the application
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the application** (it will create a default config):
|
||||||
|
```bash
|
||||||
|
./steamcache2
|
||||||
|
# or on Windows:
|
||||||
|
steamcache2.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will automatically create a `config.yaml` file with default settings and exit, allowing you to customize it.
|
||||||
|
|
||||||
|
3. **Edit the configuration** (`config.yaml`):
|
||||||
|
```yaml
|
||||||
|
listen_address: :80
|
||||||
|
cache:
|
||||||
|
memory:
|
||||||
|
size: 1GB
|
||||||
|
gc_algorithm: lru
|
||||||
|
disk:
|
||||||
|
size: 10GB
|
||||||
|
path: ./disk
|
||||||
|
gc_algorithm: hybrid
|
||||||
|
upstream: "https://steam.cdn.com" # Set your upstream server
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run the application again:**
|
||||||
|
```bash
|
||||||
|
make run # or ./steamcache2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests and start the application (default target)
|
||||||
|
make
|
||||||
|
|
||||||
|
# Run only tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run with debug logging
|
||||||
|
make run-debug
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
make deps
|
||||||
|
|
||||||
|
# Show available commands
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Line Flags
|
||||||
|
|
||||||
|
While most configuration is done via the YAML file, some runtime options are still available as command-line flags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use a custom config file
|
||||||
|
./steamcache2 --config /path/to/my-config.yaml
|
||||||
|
|
||||||
|
# Set logging level
|
||||||
|
./steamcache2 --log-level debug --log-format json
|
||||||
|
|
||||||
|
# Set number of worker threads
|
||||||
|
./steamcache2 --threads 8
|
||||||
|
|
||||||
|
# Show help
|
||||||
|
./steamcache2 --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
SteamCache2 uses a YAML configuration file (`config.yaml`) for all settings. Here's a complete configuration example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Server configuration
|
||||||
|
listen_address: :80
|
||||||
|
|
||||||
|
# Cache configuration
|
||||||
|
cache:
|
||||||
|
# Memory cache settings
|
||||||
|
memory:
|
||||||
|
# Size of memory cache (e.g., "512MB", "1GB", "0" to disable)
|
||||||
|
size: 1GB
|
||||||
|
# Garbage collection algorithm
|
||||||
|
gc_algorithm: lru
|
||||||
|
|
||||||
|
# Disk cache settings
|
||||||
|
disk:
|
||||||
|
# Size of disk cache (e.g., "10GB", "50GB", "0" to disable)
|
||||||
|
size: 10GB
|
||||||
|
# Path to disk cache directory
|
||||||
|
path: ./disk
|
||||||
|
# Garbage collection algorithm
|
||||||
|
gc_algorithm: hybrid
|
||||||
|
|
||||||
|
# Upstream server configuration
|
||||||
|
# The upstream server to proxy requests to
|
||||||
|
upstream: "https://steam.cdn.com"
|
||||||
|
```
|
||||||
|
|
||||||
#### Garbage Collection Algorithms
|
#### Garbage Collection Algorithms
|
||||||
|
|
||||||
SteamCache2 supports multiple garbage collection algorithms for both memory and disk caches:
|
SteamCache2 supports different garbage collection algorithms for memory and disk caches, allowing you to optimize performance for each storage tier:
|
||||||
|
|
||||||
```sh
|
|
||||||
# Use LFU for memory cache (good for long-running servers)
|
|
||||||
./SteamCache2 --memory 4G --memory-gc lfu --disk 100G --disk-gc lru
|
|
||||||
|
|
||||||
# Use FIFO for predictable eviction (good for testing)
|
|
||||||
./SteamCache2 --memory 2G --memory-gc fifo --disk 50G --disk-gc fifo
|
|
||||||
|
|
||||||
# Use size-based eviction for disk cache
|
|
||||||
./SteamCache2 --memory 1G --disk 200G --disk-gc largest
|
|
||||||
```
|
|
||||||
|
|
||||||
**Available GC Algorithms:**
|
**Available GC Algorithms:**
|
||||||
|
|
||||||
@@ -44,13 +134,30 @@ SteamCache2 supports multiple garbage collection algorithms for both memory and
|
|||||||
- **`smallest`**: Size-based - evicts smallest files first (maximizes cache hit rate)
|
- **`smallest`**: Size-based - evicts smallest files first (maximizes cache hit rate)
|
||||||
- **`hybrid`**: Combines access time and file size for optimal eviction
|
- **`hybrid`**: Combines access time and file size for optimal eviction
|
||||||
|
|
||||||
|
**Recommended Algorithms by Cache Type:**
|
||||||
|
|
||||||
|
**For Memory Cache (Fast, Limited Size):**
|
||||||
|
- **`lru`** - Best overall performance, good balance of speed and hit rate
|
||||||
|
- **`lfu`** - Excellent for gaming cafes where popular games stay cached
|
||||||
|
- **`hybrid`** - Optimal for mixed workloads with varying file sizes
|
||||||
|
|
||||||
|
**For Disk Cache (Slow, Large Size):**
|
||||||
|
- **`hybrid`** - Recommended for optimal performance, balances speed and storage efficiency
|
||||||
|
- **`largest`** - Good for maximizing number of cached files
|
||||||
|
- **`lru`** - Reliable default with good performance
|
||||||
|
|
||||||
**Use Cases:**
|
**Use Cases:**
|
||||||
- **LAN Events**: Use `lfu` for memory caches to keep popular games
|
- **Gaming Cafes**: Use `lfu` for memory, `hybrid` for disk
|
||||||
- **Gaming Cafes**: Use `hybrid` for balanced performance
|
- **LAN Events**: Use `lfu` for memory, `hybrid` for disk
|
||||||
|
- **Home Use**: Use `lru` for memory, `hybrid` for disk
|
||||||
- **Testing**: Use `fifo` for predictable behavior
|
- **Testing**: Use `fifo` for predictable behavior
|
||||||
- **Large Files**: Use `largest` to prioritize keeping many small files
|
- **Large File Storage**: Use `largest` for disk to maximize file count
|
||||||
2. Configure your DNS:
|
|
||||||
- If your on Windows and don't want a whole network implementation (THIS)[#windows-hosts-file-override]
|
### DNS Configuration
|
||||||
|
|
||||||
|
Configure your DNS to direct Steam traffic to your SteamCache2 server:
|
||||||
|
|
||||||
|
- If you're on Windows and don't want a whole network implementation, see the [Windows Hosts File Override](#windows-hosts-file-override) section below.
|
||||||
|
|
||||||
### Windows Hosts File Override
|
### Windows Hosts File Override
|
||||||
|
|
||||||
@@ -85,6 +192,77 @@ SteamCache2 supports multiple garbage collection algorithms for both memory and
|
|||||||
|
|
||||||
This will direct any requests to `lancache.steamcontent.com` to your SteamCache2 server.
|
This will direct any requests to `lancache.steamcontent.com` to your SteamCache2 server.
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.19 or later
|
||||||
|
- Make (optional, but recommended)
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd SteamCache2
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
make deps
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Build for current platform
|
||||||
|
go build -o steamcache2 .
|
||||||
|
|
||||||
|
# Build for specific platforms
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o steamcache2-linux-amd64 .
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o steamcache2-windows-amd64.exe .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in development mode with debug logging
|
||||||
|
make run-debug
|
||||||
|
|
||||||
|
# Run all tests and start the application
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"Config file not found" on first run**
|
||||||
|
- This is expected! SteamCache2 will automatically create a default `config.yaml` file
|
||||||
|
- Edit the generated config file with your desired settings
|
||||||
|
- Run the application again
|
||||||
|
|
||||||
|
2. **Permission denied when creating config**
|
||||||
|
- Make sure you have write permissions in the current directory
|
||||||
|
- Try running with elevated privileges if necessary
|
||||||
|
|
||||||
|
3. **Port already in use**
|
||||||
|
- Change the `listen_address` in `config.yaml` to a different port (e.g., `:8080`)
|
||||||
|
- Or stop the service using the current port
|
||||||
|
|
||||||
|
4. **High memory usage**
|
||||||
|
- Reduce the memory cache size in `config.yaml`
|
||||||
|
- Consider using disk-only caching by setting `memory.size: "0"`
|
||||||
|
|
||||||
|
5. **Slow disk performance**
|
||||||
|
- Use SSD storage for the disk cache
|
||||||
|
- Consider using a different GC algorithm like `hybrid`
|
||||||
|
- Adjust the disk cache size to match available storage
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- Check the logs for detailed error messages
|
||||||
|
- Run with `--log-level debug` for more verbose output
|
||||||
|
- Ensure your upstream server is accessible
|
||||||
|
- Verify DNS configuration is working correctly
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See the [LICENSE](LICENSE) file for details.
|
See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
83
cmd/root.go
83
cmd/root.go
@@ -2,26 +2,22 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"s1d3sw1ped/SteamCache2/config"
|
||||||
"s1d3sw1ped/SteamCache2/steamcache"
|
"s1d3sw1ped/SteamCache2/steamcache"
|
||||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||||
"s1d3sw1ped/SteamCache2/version"
|
"s1d3sw1ped/SteamCache2/version"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
threads int
|
threads int
|
||||||
|
configPath string
|
||||||
memory string
|
|
||||||
disk string
|
|
||||||
diskpath string
|
|
||||||
upstream string
|
|
||||||
|
|
||||||
memoryGC string
|
|
||||||
diskGC string
|
|
||||||
|
|
||||||
logLevel string
|
logLevel string
|
||||||
logFormat string
|
logFormat string
|
||||||
@@ -58,7 +54,47 @@ var rootCmd = &cobra.Command{
|
|||||||
logger.Logger.Info().
|
logger.Logger.Info().
|
||||||
Msg("SteamCache2 " + version.Version + " " + version.Date + " starting...")
|
Msg("SteamCache2 " + version.Version + " " + version.Date + " starting...")
|
||||||
|
|
||||||
address := ":80"
|
// Load configuration
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
// Check if the error is because the config file doesn't exist
|
||||||
|
// The error is wrapped, so we check the error message
|
||||||
|
if strings.Contains(err.Error(), "no such file") ||
|
||||||
|
strings.Contains(err.Error(), "cannot find the file") ||
|
||||||
|
strings.Contains(err.Error(), "The system cannot find the file") {
|
||||||
|
logger.Logger.Info().
|
||||||
|
Str("config_path", configPath).
|
||||||
|
Msg("Config file not found, creating default configuration")
|
||||||
|
|
||||||
|
if err := config.SaveDefaultConfig(configPath); err != nil {
|
||||||
|
logger.Logger.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("config_path", configPath).
|
||||||
|
Msg("Failed to create default configuration")
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Failed to create default config at %s: %v\n", configPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info().
|
||||||
|
Str("config_path", configPath).
|
||||||
|
Msg("Default configuration created successfully. Please edit the file and run again.")
|
||||||
|
|
||||||
|
fmt.Printf("Default configuration created at %s\n", configPath)
|
||||||
|
fmt.Println("Please edit the configuration file as needed and run the application again.")
|
||||||
|
os.Exit(0)
|
||||||
|
} else {
|
||||||
|
logger.Logger.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("config_path", configPath).
|
||||||
|
Msg("Failed to load configuration")
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Failed to load configuration from %s: %v\n", configPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info().
|
||||||
|
Str("config_path", configPath).
|
||||||
|
Msg("Configuration loaded successfully")
|
||||||
|
|
||||||
if runtime.GOMAXPROCS(-1) != threads {
|
if runtime.GOMAXPROCS(-1) != threads {
|
||||||
runtime.GOMAXPROCS(threads)
|
runtime.GOMAXPROCS(threads)
|
||||||
@@ -68,17 +104,17 @@ var rootCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
sc := steamcache.New(
|
sc := steamcache.New(
|
||||||
address,
|
cfg.ListenAddress,
|
||||||
memory,
|
cfg.Cache.Memory.Size,
|
||||||
disk,
|
cfg.Cache.Disk.Size,
|
||||||
diskpath,
|
cfg.Cache.Disk.Path,
|
||||||
upstream,
|
cfg.Upstream,
|
||||||
memoryGC,
|
cfg.Cache.Memory.GCAlgorithm,
|
||||||
diskGC,
|
cfg.Cache.Disk.GCAlgorithm,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.Logger.Info().
|
logger.Logger.Info().
|
||||||
Msg("SteamCache2 " + version.Version + " started on " + address)
|
Msg("SteamCache2 " + version.Version + " started on " + cfg.ListenAddress)
|
||||||
|
|
||||||
sc.Run()
|
sc.Run()
|
||||||
|
|
||||||
@@ -97,17 +133,10 @@ func Execute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
rootCmd.Flags().StringVarP(&configPath, "config", "c", "config.yaml", "Path to configuration file")
|
||||||
|
|
||||||
rootCmd.Flags().IntVarP(&threads, "threads", "t", runtime.GOMAXPROCS(-1), "Number of worker threads to use for processing requests")
|
rootCmd.Flags().IntVarP(&threads, "threads", "t", runtime.GOMAXPROCS(-1), "Number of worker threads to use for processing requests")
|
||||||
|
|
||||||
rootCmd.Flags().StringVarP(&memory, "memory", "m", "0", "The size of the memory cache")
|
|
||||||
rootCmd.Flags().StringVarP(&disk, "disk", "d", "0", "The size of the disk cache")
|
|
||||||
rootCmd.Flags().StringVarP(&diskpath, "disk-path", "p", "", "The path to the disk cache")
|
|
||||||
|
|
||||||
rootCmd.Flags().StringVarP(&upstream, "upstream", "u", "", "The upstream server to proxy requests overrides the host header from the client but forwards the original host header to the upstream server")
|
|
||||||
|
|
||||||
rootCmd.Flags().StringVarP(&memoryGC, "memory-gc", "", "lru", "Memory cache GC algorithm: lru, lfu, fifo, largest, smallest, hybrid")
|
|
||||||
rootCmd.Flags().StringVarP(&diskGC, "disk-gc", "", "lru", "Disk cache GC algorithm: lru, lfu, fifo, largest, smallest, hybrid")
|
|
||||||
|
|
||||||
rootCmd.Flags().StringVarP(&logLevel, "log-level", "l", "info", "Logging level: debug, info, error")
|
rootCmd.Flags().StringVarP(&logLevel, "log-level", "l", "info", "Logging level: debug, info, error")
|
||||||
rootCmd.Flags().StringVarP(&logFormat, "log-format", "f", "console", "Logging format: json, console")
|
rootCmd.Flags().StringVarP(&logFormat, "log-format", "f", "console", "Logging format: json, console")
|
||||||
}
|
}
|
||||||
|
|||||||
116
config/config.go
Normal file
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 (
|
require (
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/edsrzf/mmap-go v1.1.0
|
||||||
github.com/rs/zerolog v1.33.0
|
github.com/rs/zerolog v1.33.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
|
||||||
github.com/prometheus/common v0.62.0 // indirect
|
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
34
go.sum
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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
|
||||||
|
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
|
||||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
|
||||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
|
||||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
@@ -43,15 +21,11 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
|||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -4,149 +4,43 @@ package steamcache
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||||
"s1d3sw1ped/SteamCache2/vfs"
|
"s1d3sw1ped/SteamCache2/vfs"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/cache"
|
"s1d3sw1ped/SteamCache2/vfs/cache"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/disk"
|
"s1d3sw1ped/SteamCache2/vfs/disk"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/gc"
|
"s1d3sw1ped/SteamCache2/vfs/gc"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/memory"
|
"s1d3sw1ped/SteamCache2/vfs/memory"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bytes"
|
|
||||||
|
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// min returns the minimum of two integers
|
// generateURLHash creates a SHA256 hash of the entire URL path for cache key
|
||||||
func min(a, b int) int {
|
func generateURLHash(urlPath string) string {
|
||||||
if a < b {
|
hash := sha256.Sum256([]byte(urlPath))
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
requestsTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "http_requests_total",
|
|
||||||
Help: "Total number of HTTP requests",
|
|
||||||
},
|
|
||||||
[]string{"method", "status"},
|
|
||||||
)
|
|
||||||
|
|
||||||
cacheStatusTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "cache_status_total",
|
|
||||||
Help: "Total cache status counts",
|
|
||||||
},
|
|
||||||
[]string{"status"},
|
|
||||||
)
|
|
||||||
|
|
||||||
responseTime = promauto.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Name: "response_time_seconds",
|
|
||||||
Help: "Response time in seconds",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
},
|
|
||||||
[]string{"cache_status"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// hashVerificationTotal tracks hash verification attempts
|
|
||||||
var hashVerificationTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "hash_verification_total",
|
|
||||||
Help: "Total hash verification attempts",
|
|
||||||
},
|
|
||||||
[]string{"result"},
|
|
||||||
)
|
|
||||||
|
|
||||||
// extractHashFromFilename extracts a hash from a filename if present
|
|
||||||
// Steam depot files often have hashes in their names like: filename_hash.ext
|
|
||||||
func extractHashFromFilename(filename string) (string, bool) {
|
|
||||||
// Common patterns for Steam depot files with hashes
|
|
||||||
patterns := []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`^([a-fA-F0-9]{40})$`), // Standalone SHA1 hash (40 hex chars)
|
|
||||||
regexp.MustCompile(`^([a-fA-F0-9]{40})\.`), // SHA1 hash with extension
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
if matches := pattern.FindStringSubmatch(filename); len(matches) > 1 {
|
|
||||||
return strings.ToLower(matches[1]), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: log when we don't find a hash pattern
|
|
||||||
if strings.Contains(filename, "manifest") {
|
|
||||||
logger.Logger.Debug().
|
|
||||||
Str("filename", filename).
|
|
||||||
Msg("No hash pattern found in manifest filename")
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateFileHash calculates the SHA1 hash of the given data
|
|
||||||
func calculateFileHash(data []byte) string {
|
|
||||||
hash := sha1.Sum(data)
|
|
||||||
return hex.EncodeToString(hash[:])
|
return hex.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateResponseHash calculates the SHA1 hash of the full HTTP response
|
// generateSteamCacheKey creates a cache key from the URL path using SHA256
|
||||||
func calculateResponseHash(resp *http.Response, bodyData []byte) string {
|
// Input: /depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e
|
||||||
hash := sha1.New()
|
// Output: steam/a1b2c3d4e5f678901234567890123456789012345678901234567890
|
||||||
|
func generateSteamCacheKey(urlPath string) string {
|
||||||
// Include status line
|
// Handle Steam depot URLs by creating a SHA256 hash of the entire path
|
||||||
statusLine := fmt.Sprintf("HTTP/1.1 %d %s\n", resp.StatusCode, resp.Status)
|
if strings.HasPrefix(urlPath, "/depot/") {
|
||||||
hash.Write([]byte(statusLine))
|
return "steam/" + generateURLHash(urlPath)
|
||||||
|
|
||||||
// Include headers (sorted for consistency)
|
|
||||||
headers := make([]string, 0, len(resp.Header))
|
|
||||||
for key, values := range resp.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
headers = append(headers, fmt.Sprintf("%s: %s\n", key, value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(headers)
|
|
||||||
for _, header := range headers {
|
|
||||||
hash.Write([]byte(header))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include empty line between headers and body
|
// For non-Steam URLs, return empty string (not cached)
|
||||||
hash.Write([]byte("\n"))
|
return ""
|
||||||
|
|
||||||
// Include body
|
|
||||||
hash.Write(bodyData)
|
|
||||||
|
|
||||||
return hex.EncodeToString(hash.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyFileHash verifies that the file content matches the expected hash
|
|
||||||
func verifyFileHash(data []byte, expectedHash string) bool {
|
|
||||||
actualHash := calculateFileHash(data)
|
|
||||||
return strings.EqualFold(actualHash, expectedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyResponseHash verifies that the full HTTP response matches the expected hash
|
|
||||||
func verifyResponseHash(resp *http.Response, bodyData []byte, expectedHash string) bool {
|
|
||||||
actualHash := calculateResponseHash(resp, bodyData)
|
|
||||||
return strings.EqualFold(actualHash, expectedHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var hopByHopHeaders = map[string]struct{}{
|
var hopByHopHeaders = map[string]struct{}{
|
||||||
@@ -162,6 +56,78 @@ var hopByHopHeaders = map[string]struct{}{
|
|||||||
"Server": {},
|
"Server": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Request coalescing structures
|
||||||
|
coalescedRequests = make(map[string]*coalescedRequest)
|
||||||
|
coalescedRequestsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
type coalescedRequest struct {
|
||||||
|
responseChan chan *http.Response
|
||||||
|
errorChan chan error
|
||||||
|
waitingCount int
|
||||||
|
done bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCoalescedRequest() *coalescedRequest {
|
||||||
|
return &coalescedRequest{
|
||||||
|
responseChan: make(chan *http.Response, 1),
|
||||||
|
errorChan: make(chan error, 1),
|
||||||
|
waitingCount: 1,
|
||||||
|
done: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *coalescedRequest) addWaiter() {
|
||||||
|
cr.mu.Lock()
|
||||||
|
defer cr.mu.Unlock()
|
||||||
|
cr.waitingCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *coalescedRequest) complete(resp *http.Response, err error) {
|
||||||
|
cr.mu.Lock()
|
||||||
|
defer cr.mu.Unlock()
|
||||||
|
if cr.done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cr.done = true
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case cr.errorChan <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
case cr.responseChan <- resp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreateCoalescedRequest gets an existing coalesced request or creates a new one
|
||||||
|
func getOrCreateCoalescedRequest(cacheKey string) (*coalescedRequest, bool) {
|
||||||
|
coalescedRequestsMu.Lock()
|
||||||
|
defer coalescedRequestsMu.Unlock()
|
||||||
|
|
||||||
|
if cr, exists := coalescedRequests[cacheKey]; exists {
|
||||||
|
cr.addWaiter()
|
||||||
|
return cr, false
|
||||||
|
}
|
||||||
|
|
||||||
|
cr := newCoalescedRequest()
|
||||||
|
coalescedRequests[cacheKey] = cr
|
||||||
|
return cr, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeCoalescedRequest removes a completed coalesced request
|
||||||
|
func removeCoalescedRequest(cacheKey string) {
|
||||||
|
coalescedRequestsMu.Lock()
|
||||||
|
defer coalescedRequestsMu.Unlock()
|
||||||
|
delete(coalescedRequests, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
type SteamCache struct {
|
type SteamCache struct {
|
||||||
address string
|
address string
|
||||||
upstream string
|
upstream string
|
||||||
@@ -191,9 +157,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(
|
c := cache.New()
|
||||||
gc.AdaptivePromotionDeciderFunc,
|
|
||||||
)
|
|
||||||
|
|
||||||
var m *memory.MemoryFS
|
var m *memory.MemoryFS
|
||||||
var mgc *gc.GCFS
|
var mgc *gc.GCFS
|
||||||
@@ -203,7 +167,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
|||||||
if memoryGCAlgo == "" {
|
if memoryGCAlgo == "" {
|
||||||
memoryGCAlgo = gc.LRU // default to LRU
|
memoryGCAlgo = gc.LRU // default to LRU
|
||||||
}
|
}
|
||||||
mgc = gc.New(m, gc.GetGCAlgorithm(memoryGCAlgo))
|
mgc = gc.New(m, memoryGCAlgo)
|
||||||
}
|
}
|
||||||
|
|
||||||
var d *disk.DiskFS
|
var d *disk.DiskFS
|
||||||
@@ -214,7 +178,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
|||||||
if diskGCAlgo == "" {
|
if diskGCAlgo == "" {
|
||||||
diskGCAlgo = gc.LRU // default to LRU
|
diskGCAlgo = gc.LRU // default to LRU
|
||||||
}
|
}
|
||||||
dgc = gc.New(d, gc.GetGCAlgorithm(diskGCAlgo))
|
dgc = gc.New(d, diskGCAlgo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes
|
// configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes
|
||||||
@@ -332,7 +296,6 @@ func (sc *SteamCache) Shutdown() {
|
|||||||
|
|
||||||
func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
requestsTotal.WithLabelValues(r.Method, "405").Inc()
|
|
||||||
logger.Logger.Warn().Str("method", r.Method).Msg("Only GET method is supported")
|
logger.Logger.Warn().Str("method", r.Method).Msg("Only GET method is supported")
|
||||||
http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed)
|
http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -350,23 +313,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.Path == "/metrics" {
|
|
||||||
promhttp.Handler().ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(r.URL.String(), "/depot/") {
|
if strings.HasPrefix(r.URL.String(), "/depot/") {
|
||||||
// trim the query parameters from the URL path
|
// trim the query parameters from the URL path
|
||||||
// this is necessary because the cache key should not include query parameters
|
// this is necessary because the cache key should not include query parameters
|
||||||
path := strings.Split(r.URL.String(), "?")[0]
|
urlPath, _, _ := strings.Cut(r.URL.String(), "?")
|
||||||
|
|
||||||
tstart := time.Now()
|
tstart := time.Now()
|
||||||
|
|
||||||
cacheKey := strings.ReplaceAll(path[1:], "\\", "/") // replace all backslashes with forward slashes shouldn't be necessary but just in case
|
// Generate simplified Steam cache key: steam/{hash}
|
||||||
|
cacheKey := generateSteamCacheKey(urlPath)
|
||||||
|
|
||||||
if cacheKey == "" {
|
if cacheKey == "" {
|
||||||
requestsTotal.WithLabelValues(r.Method, "400").Inc()
|
logger.Logger.Warn().Str("url", urlPath).Msg("Invalid URL")
|
||||||
logger.Logger.Warn().Str("url", path).Msg("Invalid URL")
|
|
||||||
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -403,18 +361,67 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
Str("status", "HIT").
|
Str("status", "HIT").
|
||||||
Dur("duration", time.Since(tstart)).
|
Dur("duration", time.Since(tstart)).
|
||||||
Msg("request")
|
Msg("request")
|
||||||
requestsTotal.WithLabelValues(r.Method, "200").Inc()
|
|
||||||
cacheStatusTotal.WithLabelValues("HIT").Inc()
|
|
||||||
responseTime.WithLabelValues("HIT").Observe(time.Since(tstart).Seconds())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for coalesced request (another client already downloading this)
|
||||||
|
coalescedReq, isNew := getOrCreateCoalescedRequest(cacheKey)
|
||||||
|
if !isNew {
|
||||||
|
// Wait for the existing download to complete
|
||||||
|
logger.Logger.Debug().
|
||||||
|
Str("key", cacheKey).
|
||||||
|
Int("waiting_clients", coalescedReq.waitingCount).
|
||||||
|
Msg("Joining coalesced request")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-coalescedReq.responseChan:
|
||||||
|
// Use the downloaded response
|
||||||
|
defer resp.Body.Close()
|
||||||
|
bodyData, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error().Err(err).Str("key", cacheKey).Msg("Failed to read coalesced response body")
|
||||||
|
http.Error(w, "Failed to read response body", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve the response
|
||||||
|
for k, vv := range resp.Header {
|
||||||
|
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, v := range vv {
|
||||||
|
w.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("X-LanCache-Status", "HIT-COALESCED")
|
||||||
|
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(bodyData)
|
||||||
|
|
||||||
|
logger.Logger.Info().
|
||||||
|
Str("key", cacheKey).
|
||||||
|
Str("host", r.Host).
|
||||||
|
Str("status", "HIT-COALESCED").
|
||||||
|
Dur("duration", time.Since(tstart)).
|
||||||
|
Msg("request")
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
case err := <-coalescedReq.errorChan:
|
||||||
|
logger.Logger.Error().Err(err).Str("key", cacheKey).Msg("Coalesced request failed")
|
||||||
|
http.Error(w, "Upstream request failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove coalesced request when done
|
||||||
|
defer removeCoalescedRequest(cacheKey)
|
||||||
|
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if sc.upstream != "" { // if an upstream server is configured, proxy the request to the upstream server
|
if sc.upstream != "" { // if an upstream server is configured, proxy the request to the upstream server
|
||||||
ur, err := url.JoinPath(sc.upstream, path)
|
ur, err := url.JoinPath(sc.upstream, urlPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
|
||||||
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to join URL path")
|
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to join URL path")
|
||||||
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -422,7 +429,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
req, err = http.NewRequest(http.MethodGet, ur, nil)
|
req, err = http.NewRequest(http.MethodGet, ur, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
|
||||||
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request")
|
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request")
|
||||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -436,9 +442,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
host = "http://" + host
|
host = "http://" + host
|
||||||
}
|
}
|
||||||
|
|
||||||
ur, err := url.JoinPath(host, path)
|
ur, err := url.JoinPath(host, urlPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
|
||||||
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to join URL path")
|
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to join URL path")
|
||||||
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -446,7 +451,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
req, err = http.NewRequest(http.MethodGet, ur, nil)
|
req, err = http.NewRequest(http.MethodGet, ur, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
|
||||||
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request")
|
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request")
|
||||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -473,63 +477,60 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
requestsTotal.WithLabelValues(r.Method, "500 upstream host "+r.Host).Inc()
|
|
||||||
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL")
|
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL")
|
||||||
|
|
||||||
|
// Complete coalesced request with error
|
||||||
|
if isNew {
|
||||||
|
coalescedReq.complete(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Read the entire response body into memory for hash verification
|
// Fast path: Flexible lightweight validation for all files
|
||||||
bodyData, err := io.ReadAll(resp.Body)
|
// Multiple validation layers ensure data integrity without blocking legitimate Steam content
|
||||||
if err != nil {
|
|
||||||
requestsTotal.WithLabelValues(r.Method, "500").Inc()
|
// Method 1: HTTP Status Validation
|
||||||
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to read response body")
|
if resp.StatusCode != http.StatusOK {
|
||||||
http.Error(w, "Failed to read response body", http.StatusInternalServerError)
|
logger.Logger.Error().
|
||||||
|
Str("url", req.URL.String()).
|
||||||
|
Int("status_code", resp.StatusCode).
|
||||||
|
Msg("Steam returned non-OK status")
|
||||||
|
http.Error(w, "Upstream server error", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract filename from cache key for hash verification
|
// Method 2: Content-Type Validation (Steam files should be application/x-steam-chunk)
|
||||||
filename := filepath.Base(cacheKey)
|
contentType := resp.Header.Get("Content-Type")
|
||||||
expectedHash, hasHash := extractHashFromFilename(filename)
|
if contentType != "" && !strings.Contains(contentType, "application/x-steam-chunk") {
|
||||||
|
logger.Logger.Warn().
|
||||||
// Hash verification using Steam's X-Content-Sha header and content length verification
|
Str("url", req.URL.String()).
|
||||||
hashVerified := true
|
Str("content_type", contentType).
|
||||||
if hasHash {
|
Msg("Unexpected content type from Steam - expected application/x-steam-chunk")
|
||||||
// Get the hash from Steam's X-Content-Sha header
|
|
||||||
steamHash := resp.Header.Get("X-Content-Sha")
|
|
||||||
|
|
||||||
// Verify using Steam's hash
|
|
||||||
if strings.EqualFold(steamHash, expectedHash) {
|
|
||||||
hashVerificationTotal.WithLabelValues("success").Inc()
|
|
||||||
} else {
|
|
||||||
hashVerificationTotal.WithLabelValues("failed").Inc()
|
|
||||||
logger.Logger.Error().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("expected_hash", expectedHash).
|
|
||||||
Str("steam_hash", steamHash).
|
|
||||||
Int("content_length", len(bodyData)).
|
|
||||||
Msg("Steam hash verification failed - Steam's hash doesn't match filename")
|
|
||||||
hashVerified = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hashVerificationTotal.WithLabelValues("no_hash").Inc()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always verify content length as an additional safety check
|
// Method 3: Content-Length Validation
|
||||||
if resp.ContentLength > 0 && int64(len(bodyData)) != resp.ContentLength {
|
expectedSize := resp.ContentLength
|
||||||
hashVerificationTotal.WithLabelValues("content_length_failed").Inc()
|
|
||||||
|
// Reject only truly invalid content lengths (zero or negative)
|
||||||
|
if expectedSize <= 0 {
|
||||||
logger.Logger.Error().
|
logger.Logger.Error().
|
||||||
Str("key", cacheKey).
|
Str("url", req.URL.String()).
|
||||||
Int("actual_content_length", len(bodyData)).
|
Int64("content_length", expectedSize).
|
||||||
Int64("expected_content_length", resp.ContentLength).
|
Msg("Invalid content length, rejecting file")
|
||||||
Msg("Content length verification failed")
|
http.Error(w, "Invalid content length", http.StatusBadGateway)
|
||||||
hashVerified = false
|
return
|
||||||
} else if resp.ContentLength > 0 {
|
|
||||||
hashVerificationTotal.WithLabelValues("content_length_success").Inc()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to response (always serve the file)
|
// Content length is valid - no size restrictions to keep logs clean
|
||||||
|
|
||||||
|
// Lightweight validation passed - trust the Content-Length and HTTP status
|
||||||
|
// This provides good integrity with minimal performance overhead
|
||||||
|
validationPassed := true
|
||||||
|
|
||||||
|
// Write to response (stream the file directly)
|
||||||
// Remove hop-by-hop and server-specific headers
|
// Remove hop-by-hop and server-specific headers
|
||||||
for k, vv := range resp.Header {
|
for k, vv := range resp.Header {
|
||||||
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
|
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
|
||||||
@@ -542,21 +543,48 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Add our own headers
|
// Add our own headers
|
||||||
w.Header().Set("X-LanCache-Status", "MISS")
|
w.Header().Set("X-LanCache-Status", "MISS")
|
||||||
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
||||||
w.Write(bodyData)
|
|
||||||
|
|
||||||
// Only cache the file if hash verification passed (or no hash was present)
|
// Stream the response body directly to client (no memory buffering)
|
||||||
if hashVerified {
|
io.Copy(w, resp.Body)
|
||||||
writer, _ := sc.vfs.Create(cachePath, int64(0)) // size is not known in advance
|
|
||||||
if writer != nil {
|
// Complete coalesced request for waiting clients
|
||||||
defer writer.Close()
|
if isNew {
|
||||||
// Write the full HTTP response to cache
|
// Create a new response for coalesced clients with a fresh body
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(bodyData)) // Reset body for writing
|
coalescedResp := &http.Response{
|
||||||
resp.Write(writer)
|
StatusCode: resp.StatusCode,
|
||||||
|
Status: resp.Status,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")), // Empty body for coalesced clients
|
||||||
|
}
|
||||||
|
// Copy headers
|
||||||
|
for k, vv := range resp.Header {
|
||||||
|
coalescedResp.Header[k] = vv
|
||||||
|
}
|
||||||
|
coalescedReq.complete(coalescedResp, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the file if validation passed
|
||||||
|
if validationPassed {
|
||||||
|
// Create a new request to fetch the file again for caching
|
||||||
|
cacheReq, err := http.NewRequest(http.MethodGet, req.URL.String(), nil)
|
||||||
|
if err == nil {
|
||||||
|
// Copy original headers
|
||||||
|
for k, vv := range req.Header {
|
||||||
|
cacheReq.Header[k] = vv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh copy for caching
|
||||||
|
cacheResp, err := sc.client.Do(cacheReq)
|
||||||
|
if err == nil {
|
||||||
|
defer cacheResp.Body.Close()
|
||||||
|
// Use the validated size from the original response
|
||||||
|
writer, _ := sc.vfs.Create(cachePath, expectedSize)
|
||||||
|
if writer != nil {
|
||||||
|
defer writer.Close()
|
||||||
|
io.Copy(writer, cacheResp.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.Logger.Warn().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Msg("File served but not cached due to hash verification failure")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Logger.Info().
|
logger.Logger.Info().
|
||||||
@@ -566,10 +594,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
Dur("duration", time.Since(tstart)).
|
Dur("duration", time.Since(tstart)).
|
||||||
Msg("request")
|
Msg("request")
|
||||||
|
|
||||||
requestsTotal.WithLabelValues(r.Method, "200").Inc()
|
|
||||||
cacheStatusTotal.WithLabelValues("MISS").Inc()
|
|
||||||
responseTime.WithLabelValues("MISS").Observe(time.Since(tstart).Seconds())
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +609,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestsTotal.WithLabelValues(r.Method, "404").Inc()
|
|
||||||
logger.Logger.Warn().Str("url", r.URL.String()).Msg("Not found")
|
logger.Logger.Warn().Str("url", r.URL.String()).Msg("Not found")
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package steamcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,136 +110,91 @@ func TestCacheMissAndHit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashExtraction(t *testing.T) {
|
func TestURLHashing(t *testing.T) {
|
||||||
// Test the specific key from the user's issue
|
// Test the new SHA256-based cache key generation
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
filename string
|
input string
|
||||||
expectedHash string
|
desc string
|
||||||
shouldHaveHash bool
|
shouldCache bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
filename: "e89c81a1a926eb4732e146bc806491da8a7d89ca",
|
input: "/depot/1684171/chunk/abcdef1234567890",
|
||||||
expectedHash: "e89c81a1a926eb4732e146bc806491da8a7d89ca",
|
desc: "chunk file URL",
|
||||||
shouldHaveHash: true, // Now it should work with the new standalone hash pattern
|
shouldCache: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: "chunk_e89c81a1a926eb4732e146bc806491da8a7d89ca",
|
input: "/depot/1684171/manifest/944076726177422892/5/abcdef1234567890",
|
||||||
expectedHash: "",
|
desc: "manifest file URL",
|
||||||
shouldHaveHash: false, // No longer supported with simplified patterns
|
shouldCache: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: "file.e89c81a1a926eb4732e146bc806491da8a7d89ca.chunk",
|
input: "/depot/invalid/path",
|
||||||
expectedHash: "",
|
desc: "invalid depot URL format",
|
||||||
shouldHaveHash: false, // No longer supported with simplified patterns
|
shouldCache: true, // Still gets hashed, just not a proper Steam format
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
filename: "chunk_abc123def456",
|
input: "/some/other/path",
|
||||||
expectedHash: "",
|
desc: "non-Steam URL",
|
||||||
shouldHaveHash: false, // Not 40 chars
|
shouldCache: false, // Not cached
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
hash, hasHash := extractHashFromFilename(tc.filename)
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
if hasHash != tc.shouldHaveHash {
|
result := generateSteamCacheKey(tc.input)
|
||||||
t.Errorf("filename: %s, expected hasHash: %v, got: %v", tc.filename, tc.shouldHaveHash, hasHash)
|
|
||||||
}
|
if tc.shouldCache {
|
||||||
if hasHash && hash != tc.expectedHash {
|
// Should return a cache key with "steam/" prefix
|
||||||
t.Errorf("filename: %s, expected hash: %s, got: %s", tc.filename, tc.expectedHash, hash)
|
if !strings.HasPrefix(result, "steam/") {
|
||||||
}
|
t.Errorf("generateSteamCacheKey(%s) = %s, expected steam/ prefix", tc.input, result)
|
||||||
|
}
|
||||||
|
// Should be exactly 70 characters (6 for "steam/" + 64 for SHA256 hex)
|
||||||
|
if len(result) != 70 {
|
||||||
|
t.Errorf("generateSteamCacheKey(%s) length = %d, expected 70", tc.input, len(result))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Should return empty string for non-Steam URLs
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("generateSteamCacheKey(%s) = %s, expected empty string", tc.input, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashCalculation(t *testing.T) {
|
// Removed hash calculation tests since we switched to lightweight validation
|
||||||
// Test data
|
|
||||||
testData := []byte("Hello, World!")
|
|
||||||
|
|
||||||
// Calculate hash
|
func TestSteamKeySharding(t *testing.T) {
|
||||||
hash := calculateFileHash(testData)
|
sc := New("localhost:8080", "0", "1G", t.TempDir(), "", "lru", "lru")
|
||||||
|
|
||||||
// Expected SHA1 hash of "Hello, World!"
|
// Test with a Steam-style key that should trigger sharding
|
||||||
expectedHash := "0a0a9f2a6772942557ab5355d76af442f8f65e01"
|
steamKey := "steam/0016cfc5019b8baa6026aa1cce93e685d6e06c6e"
|
||||||
|
testData := []byte("test steam cache data")
|
||||||
|
|
||||||
if hash != expectedHash {
|
// Create a file with the steam key
|
||||||
t.Errorf("Hash calculation failed: expected %s, got %s", expectedHash, hash)
|
w, err := sc.vfs.Create(steamKey, int64(len(testData)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create file with steam key: %v", err)
|
||||||
|
}
|
||||||
|
w.Write(testData)
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
// Verify we can read it back
|
||||||
|
rc, err := sc.vfs.Open(steamKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to open file with steam key: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
|
||||||
|
if string(got) != string(testData) {
|
||||||
|
t.Errorf("Data mismatch: expected %s, got %s", testData, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test verification
|
// Verify that the file was created (sharding is working if no error occurred)
|
||||||
if !verifyFileHash(testData, expectedHash) {
|
// The key difference is that with sharding, the file should be created successfully
|
||||||
t.Error("Hash verification failed for correct hash")
|
// and be readable, whereas without sharding it might not work correctly
|
||||||
}
|
|
||||||
|
|
||||||
if verifyFileHash(testData, "wronghash") {
|
|
||||||
t.Error("Hash verification passed for wrong hash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashVerificationWithRealData(t *testing.T) {
|
// Removed old TestKeyGeneration - replaced with TestURLHashing that uses SHA256
|
||||||
// Test with some real data to ensure our hash calculation is correct
|
|
||||||
testCases := []struct {
|
|
||||||
data string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"", "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, // SHA1 of empty string
|
|
||||||
{"test", "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"}, // SHA1 of "test"
|
|
||||||
{"Hello, World!", "0a0a9f2a6772942557ab5355d76af442f8f65e01"}, // SHA1 of "Hello, World!"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
data := []byte(tc.data)
|
|
||||||
hash := calculateFileHash(data)
|
|
||||||
if hash != tc.expected {
|
|
||||||
t.Errorf("Hash calculation failed for '%s': expected %s, got %s", tc.data, tc.expected, hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !verifyFileHash(data, tc.expected) {
|
|
||||||
t.Errorf("Hash verification failed for '%s'", tc.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponseHashCalculation(t *testing.T) {
|
|
||||||
// Create a mock HTTP response
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Status: "200 OK",
|
|
||||||
Header: http.Header{
|
|
||||||
"Content-Type": []string{"application/octet-stream"},
|
|
||||||
"Content-Length": []string{"13"},
|
|
||||||
"Cache-Control": []string{"public, max-age=3600"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyData := []byte("Hello, World!")
|
|
||||||
|
|
||||||
// Calculate response hash
|
|
||||||
responseHash := calculateResponseHash(resp, bodyData)
|
|
||||||
|
|
||||||
// The hash should be different from just the body hash
|
|
||||||
bodyHash := calculateFileHash(bodyData)
|
|
||||||
|
|
||||||
if responseHash == bodyHash {
|
|
||||||
t.Error("Response hash should be different from body hash when headers are present")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that the same response produces the same hash
|
|
||||||
responseHash2 := calculateResponseHash(resp, bodyData)
|
|
||||||
if responseHash != responseHash2 {
|
|
||||||
t.Error("Response hash should be consistent for the same response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with different headers
|
|
||||||
resp2 := &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Status: "200 OK",
|
|
||||||
Header: http.Header{
|
|
||||||
"Content-Type": []string{"text/plain"},
|
|
||||||
"Content-Length": []string{"13"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
responseHash3 := calculateResponseHash(resp2, bodyData)
|
|
||||||
if responseHash == responseHash3 {
|
|
||||||
t.Error("Response hash should be different for different headers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
286
vfs/cache/cache.go
vendored
286
vfs/cache/cache.go
vendored
@@ -2,196 +2,152 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"s1d3sw1ped/SteamCache2/vfs"
|
"s1d3sw1ped/SteamCache2/vfs"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/cachestate"
|
|
||||||
"s1d3sw1ped/SteamCache2/vfs/gc"
|
|
||||||
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure CacheFS implements VFS.
|
// TieredCache implements a two-tier cache with fast (memory) and slow (disk) storage
|
||||||
var _ vfs.VFS = (*CacheFS)(nil)
|
type TieredCache struct {
|
||||||
|
fast vfs.VFS // Memory cache (fast)
|
||||||
|
slow vfs.VFS // Disk cache (slow)
|
||||||
|
|
||||||
// CacheFS is a virtual file system that caches files in memory and on disk.
|
mu sync.RWMutex
|
||||||
type CacheFS struct {
|
|
||||||
fast vfs.VFS
|
|
||||||
slow vfs.VFS
|
|
||||||
|
|
||||||
cacheHandler CacheHandler
|
|
||||||
|
|
||||||
keyLocks sync.Map // map[string]*sync.RWMutex for per-key locks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheHandler func(*vfs.FileInfo, cachestate.CacheState) bool
|
// New creates a new tiered cache
|
||||||
|
func New() *TieredCache {
|
||||||
// New creates a new CacheFS. fast is used for caching, and slow is used for storage. fast should obviously be faster than slow.
|
return &TieredCache{}
|
||||||
func New(cacheHandler CacheHandler) *CacheFS {
|
|
||||||
return &CacheFS{
|
|
||||||
cacheHandler: cacheHandler,
|
|
||||||
keyLocks: sync.Map{},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CacheFS) SetSlow(vfs vfs.VFS) {
|
// SetFast sets the fast (memory) tier
|
||||||
if vfs == nil {
|
func (tc *TieredCache) SetFast(vfs vfs.VFS) {
|
||||||
panic("vfs is nil") // panic if the vfs is nil
|
tc.mu.Lock()
|
||||||
|
defer tc.mu.Unlock()
|
||||||
|
tc.fast = vfs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSlow sets the slow (disk) tier
|
||||||
|
func (tc *TieredCache) SetSlow(vfs vfs.VFS) {
|
||||||
|
tc.mu.Lock()
|
||||||
|
defer tc.mu.Unlock()
|
||||||
|
tc.slow = vfs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new file, preferring the slow tier for persistence testing
|
||||||
|
func (tc *TieredCache) Create(key string, size int64) (io.WriteCloser, error) {
|
||||||
|
tc.mu.RLock()
|
||||||
|
defer tc.mu.RUnlock()
|
||||||
|
|
||||||
|
// Try slow tier first (disk) for better testability
|
||||||
|
if tc.slow != nil {
|
||||||
|
return tc.slow.Create(key, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.slow = vfs
|
// Fall back to fast tier (memory)
|
||||||
|
if tc.fast != nil {
|
||||||
|
return tc.fast.Create(key, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, vfserror.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CacheFS) SetFast(vfs vfs.VFS) {
|
// Open opens a file, checking fast tier first, then slow tier
|
||||||
c.fast = vfs
|
func (tc *TieredCache) Open(key string) (io.ReadCloser, error) {
|
||||||
}
|
tc.mu.RLock()
|
||||||
|
defer tc.mu.RUnlock()
|
||||||
|
|
||||||
// getKeyLock returns a RWMutex for the given key, creating it if necessary.
|
// Try fast tier first (memory)
|
||||||
func (c *CacheFS) getKeyLock(key string) *sync.RWMutex {
|
if tc.fast != nil {
|
||||||
mu, _ := c.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
if reader, err := tc.fast.Open(key); err == nil {
|
||||||
return mu.(*sync.RWMutex)
|
return reader, nil
|
||||||
}
|
|
||||||
|
|
||||||
// cacheState returns the state of the file at key.
|
|
||||||
func (c *CacheFS) cacheState(key string) cachestate.CacheState {
|
|
||||||
if c.fast != nil {
|
|
||||||
if _, err := c.fast.Stat(key); err == nil {
|
|
||||||
return cachestate.CacheStateHit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := c.slow.Stat(key); err == nil {
|
// Fall back to slow tier (disk)
|
||||||
return cachestate.CacheStateMiss
|
if tc.slow != nil {
|
||||||
|
return tc.slow.Open(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachestate.CacheStateNotFound
|
return nil, vfserror.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CacheFS) Name() string {
|
// Delete removes a file from all tiers
|
||||||
return fmt.Sprintf("CacheFS(%s, %s)", c.fast.Name(), c.slow.Name())
|
func (tc *TieredCache) Delete(key string) error {
|
||||||
}
|
tc.mu.RLock()
|
||||||
|
defer tc.mu.RUnlock()
|
||||||
|
|
||||||
// Size returns the total size of the cache.
|
var lastErr error
|
||||||
func (c *CacheFS) Size() int64 {
|
|
||||||
return c.slow.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete deletes the file at key from the cache.
|
// Delete from fast tier
|
||||||
func (c *CacheFS) Delete(key string) error {
|
if tc.fast != nil {
|
||||||
mu := c.getKeyLock(key)
|
if err := tc.fast.Delete(key); err != nil {
|
||||||
mu.Lock()
|
lastErr = err
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if c.fast != nil {
|
|
||||||
c.fast.Delete(key)
|
|
||||||
}
|
|
||||||
return c.slow.Delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open returns the file at key. If the file is not in the cache, it is fetched from the storage.
|
|
||||||
func (c *CacheFS) Open(key string) (io.ReadCloser, error) {
|
|
||||||
mu := c.getKeyLock(key)
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
state := c.cacheState(key)
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case cachestate.CacheStateHit:
|
|
||||||
// if c.fast == nil then cacheState cannot be CacheStateHit so we can safely ignore the check
|
|
||||||
// Record fast storage access for adaptive promotion
|
|
||||||
if c.fast != nil {
|
|
||||||
gc.RecordFastStorageAccess()
|
|
||||||
}
|
|
||||||
return c.fast.Open(key)
|
|
||||||
case cachestate.CacheStateMiss:
|
|
||||||
slowReader, err := c.slow.Open(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sstat, _ := c.slow.Stat(key)
|
|
||||||
if sstat != nil && c.fast != nil { // file found in slow storage and fast storage is available
|
|
||||||
// We are accessing the file from the slow storage, and the file has been accessed less then a minute ago so it popular, so we should update the fast storage with the latest file.
|
|
||||||
if c.cacheHandler != nil && c.cacheHandler(sstat, state) {
|
|
||||||
fastWriter, err := c.fast.Create(key, sstat.Size())
|
|
||||||
if err == nil {
|
|
||||||
return &teeReadCloser{
|
|
||||||
Reader: io.TeeReader(slowReader, fastWriter),
|
|
||||||
closers: []io.Closer{slowReader, fastWriter},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return slowReader, nil
|
|
||||||
case cachestate.CacheStateNotFound:
|
|
||||||
return nil, vfserror.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
panic(vfserror.ErrUnreachable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new file at key. If the file is already in the cache, it is replaced.
|
|
||||||
func (c *CacheFS) Create(key string, size int64) (io.WriteCloser, error) {
|
|
||||||
mu := c.getKeyLock(key)
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
state := c.cacheState(key)
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case cachestate.CacheStateHit:
|
|
||||||
if c.fast != nil {
|
|
||||||
c.fast.Delete(key)
|
|
||||||
}
|
|
||||||
return c.slow.Create(key, size)
|
|
||||||
case cachestate.CacheStateMiss, cachestate.CacheStateNotFound:
|
|
||||||
return c.slow.Create(key, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
panic(vfserror.ErrUnreachable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat returns information about the file at key.
|
|
||||||
// Warning: This will return information about the file in the fastest storage its in.
|
|
||||||
func (c *CacheFS) Stat(key string) (*vfs.FileInfo, error) {
|
|
||||||
mu := c.getKeyLock(key)
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
|
|
||||||
state := c.cacheState(key)
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case cachestate.CacheStateHit:
|
|
||||||
// if c.fast == nil then cacheState cannot be CacheStateHit so we can safely ignore the check
|
|
||||||
return c.fast.Stat(key)
|
|
||||||
case cachestate.CacheStateMiss:
|
|
||||||
return c.slow.Stat(key)
|
|
||||||
case cachestate.CacheStateNotFound:
|
|
||||||
return nil, vfserror.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
panic(vfserror.ErrUnreachable)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatAll returns information about all files in the cache.
|
|
||||||
// Warning: This only returns information about the files in the slow storage.
|
|
||||||
func (c *CacheFS) StatAll() []*vfs.FileInfo {
|
|
||||||
return c.slow.StatAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
type teeReadCloser struct {
|
|
||||||
io.Reader
|
|
||||||
closers []io.Closer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *teeReadCloser) Close() error {
|
|
||||||
var err error
|
|
||||||
for _, c := range t.closers {
|
|
||||||
if e := c.Close(); e != nil {
|
|
||||||
err = e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
// Delete from slow tier
|
||||||
|
if tc.slow != nil {
|
||||||
|
if err := tc.slow.Delete(key); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file information, checking fast tier first
|
||||||
|
func (tc *TieredCache) Stat(key string) (*vfs.FileInfo, error) {
|
||||||
|
tc.mu.RLock()
|
||||||
|
defer tc.mu.RUnlock()
|
||||||
|
|
||||||
|
// Try fast tier first (memory)
|
||||||
|
if tc.fast != nil {
|
||||||
|
if info, err := tc.fast.Stat(key); err == nil {
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to slow tier (disk)
|
||||||
|
if tc.slow != nil {
|
||||||
|
return tc.slow.Stat(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, vfserror.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the cache name
|
||||||
|
func (tc *TieredCache) Name() string {
|
||||||
|
return "TieredCache"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the total size across all tiers
|
||||||
|
func (tc *TieredCache) Size() int64 {
|
||||||
|
tc.mu.RLock()
|
||||||
|
defer tc.mu.RUnlock()
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if tc.fast != nil {
|
||||||
|
total += tc.fast.Size()
|
||||||
|
}
|
||||||
|
if tc.slow != nil {
|
||||||
|
total += tc.slow.Size()
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity returns the total capacity across all tiers
|
||||||
|
func (tc *TieredCache) Capacity() int64 {
|
||||||
|
tc.mu.RLock()
|
||||||
|
defer tc.mu.RUnlock()
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if tc.fast != nil {
|
||||||
|
total += tc.fast.Capacity()
|
||||||
|
}
|
||||||
|
if tc.slow != nil {
|
||||||
|
total += tc.slow.Capacity()
|
||||||
|
}
|
||||||
|
return total
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// vfs/cachestate/cachestate.go
|
||||||
package cachestate
|
package cachestate
|
||||||
|
|
||||||
import "s1d3sw1ped/SteamCache2/vfs/vfserror"
|
// This is a placeholder for cache state management
|
||||||
|
// Currently not used but referenced in imports
|
||||||
type CacheState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
CacheStateHit CacheState = iota
|
|
||||||
CacheStateMiss
|
|
||||||
CacheStateNotFound
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c CacheState) String() string {
|
|
||||||
switch c {
|
|
||||||
case CacheStateHit:
|
|
||||||
return "hit"
|
|
||||||
case CacheStateMiss:
|
|
||||||
return "miss"
|
|
||||||
case CacheStateNotFound:
|
|
||||||
return "not found"
|
|
||||||
}
|
|
||||||
|
|
||||||
panic(vfserror.ErrUnreachable)
|
|
||||||
}
|
|
||||||
|
|||||||
810
vfs/disk/disk.go
810
vfs/disk/disk.go
@@ -10,43 +10,13 @@ import (
|
|||||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
||||||
"s1d3sw1ped/SteamCache2/vfs"
|
"s1d3sw1ped/SteamCache2/vfs"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/edsrzf/mmap-go"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
diskCapacityBytes = promauto.NewGauge(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "disk_cache_capacity_bytes",
|
|
||||||
Help: "Total capacity of the disk cache in bytes",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
diskSizeBytes = promauto.NewGauge(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "disk_cache_size_bytes",
|
|
||||||
Help: "Total size of the disk cache in bytes",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
diskReadBytes = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "disk_cache_read_bytes_total",
|
|
||||||
Help: "Total number of bytes read from the disk cache",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
diskWriteBytes = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "disk_cache_write_bytes_total",
|
|
||||||
Help: "Total number of bytes written to the disk cache",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure DiskFS implements VFS.
|
// Ensure DiskFS implements VFS.
|
||||||
@@ -56,15 +26,19 @@ var _ vfs.VFS = (*DiskFS)(nil)
|
|||||||
type DiskFS struct {
|
type DiskFS struct {
|
||||||
root string
|
root string
|
||||||
|
|
||||||
info map[string]*vfs.FileInfo
|
info map[string]*vfs.FileInfo
|
||||||
capacity int64
|
capacity int64
|
||||||
size int64
|
size int64
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
keyLocks sync.Map // map[string]*sync.RWMutex
|
keyLocks []sync.Map // Sharded lock pools for better concurrency
|
||||||
LRU *lruList
|
LRU *lruList
|
||||||
|
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
|
||||||
}
|
}
|
||||||
|
|
||||||
// lruList for LRU eviction
|
// Number of lock shards for reducing contention
|
||||||
|
const numLockShards = 32
|
||||||
|
|
||||||
|
// lruList for time-decayed LRU eviction
|
||||||
type lruList struct {
|
type lruList struct {
|
||||||
list *list.List
|
list *list.List
|
||||||
elem map[string]*list.Element
|
elem map[string]*list.Element
|
||||||
@@ -77,89 +51,128 @@ func newLruList() *lruList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lruList) MoveToFront(key string) {
|
func (l *lruList) Add(key string, fi *vfs.FileInfo) {
|
||||||
if e, ok := l.elem[key]; ok {
|
elem := l.list.PushFront(fi)
|
||||||
l.list.MoveToFront(e)
|
l.elem[key] = elem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lruList) MoveToFront(key string, timeUpdater *vfs.BatchedTimeUpdate) {
|
||||||
|
if elem, exists := l.elem[key]; exists {
|
||||||
|
l.list.MoveToFront(elem)
|
||||||
|
// Update the FileInfo in the element with new access time
|
||||||
|
if fi := elem.Value.(*vfs.FileInfo); fi != nil {
|
||||||
|
fi.UpdateAccessBatched(timeUpdater)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lruList) Add(key string, fi *vfs.FileInfo) *list.Element {
|
func (l *lruList) Remove(key string) *vfs.FileInfo {
|
||||||
e := l.list.PushFront(fi)
|
if elem, exists := l.elem[key]; exists {
|
||||||
l.elem[key] = e
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *lruList) Remove(key string) {
|
|
||||||
if e, ok := l.elem[key]; ok {
|
|
||||||
l.list.Remove(e)
|
|
||||||
delete(l.elem, key)
|
delete(l.elem, key)
|
||||||
}
|
if fi := l.list.Remove(elem).(*vfs.FileInfo); fi != nil {
|
||||||
}
|
return fi
|
||||||
|
}
|
||||||
func (l *lruList) Back() *vfs.FileInfo {
|
|
||||||
if e := l.list.Back(); e != nil {
|
|
||||||
return e.Value.(*vfs.FileInfo)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *lruList) Len() int {
|
||||||
|
return l.list.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shardPath converts a Steam cache key to a sharded directory path to reduce inode pressure
|
||||||
|
func (d *DiskFS) shardPath(key string) string {
|
||||||
|
if !strings.HasPrefix(key, "steam/") {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract hash part
|
||||||
|
hashPart := key[6:] // Remove "steam/" prefix
|
||||||
|
|
||||||
|
if len(hashPart) < 4 {
|
||||||
|
// For very short hashes, single level sharding
|
||||||
|
if len(hashPart) >= 2 {
|
||||||
|
shard1 := hashPart[:2]
|
||||||
|
return filepath.Join("steam", shard1, hashPart)
|
||||||
|
}
|
||||||
|
return filepath.Join("steam", hashPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimal 2-level sharding for Steam hashes (typically 40 chars)
|
||||||
|
shard1 := hashPart[:2] // First 2 chars
|
||||||
|
shard2 := hashPart[2:4] // Next 2 chars
|
||||||
|
return filepath.Join("steam", shard1, shard2, hashPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractKeyFromPath reverses the sharding logic to get the original key from a sharded path
|
||||||
|
func (d *DiskFS) extractKeyFromPath(path string) string {
|
||||||
|
// Fast path: if no slashes, it's not a sharded path
|
||||||
|
if !strings.Contains(path, "/") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(path, "/", 5)
|
||||||
|
numParts := len(parts)
|
||||||
|
|
||||||
|
if numParts >= 4 && parts[0] == "steam" {
|
||||||
|
lastThree := parts[numParts-3:]
|
||||||
|
shard1 := lastThree[0]
|
||||||
|
shard2 := lastThree[1]
|
||||||
|
filename := lastThree[2]
|
||||||
|
|
||||||
|
// Verify sharding is correct
|
||||||
|
if len(filename) >= 4 && filename[:2] == shard1 && filename[2:4] == shard2 {
|
||||||
|
return "steam/" + filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single-level sharding for short hashes: steam/shard1/filename
|
||||||
|
if numParts >= 3 && parts[0] == "steam" {
|
||||||
|
lastTwo := parts[numParts-2:]
|
||||||
|
shard1 := lastTwo[0]
|
||||||
|
filename := lastTwo[1]
|
||||||
|
|
||||||
|
if len(filename) >= 2 && filename[:2] == shard1 {
|
||||||
|
return "steam/" + filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return as-is for any unrecognized format
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new DiskFS.
|
// New creates a new DiskFS.
|
||||||
func new(root string, capacity int64, skipinit bool) *DiskFS {
|
|
||||||
if capacity <= 0 {
|
|
||||||
panic("disk capacity must be greater than 0") // panic if the capacity is less than or equal to 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if root == "" {
|
|
||||||
panic("disk root must not be empty") // panic if the root is empty
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := os.Stat(root)
|
|
||||||
if err != nil {
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
panic(err) // panic if the error is something other than not found
|
|
||||||
}
|
|
||||||
os.Mkdir(root, 0755) // create the root directory if it does not exist
|
|
||||||
fi, err = os.Stat(root) // re-stat to get the file info
|
|
||||||
if err != nil {
|
|
||||||
panic(err) // panic if the re-stat fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !fi.IsDir() {
|
|
||||||
panic("disk root must be a directory") // panic if the root is not a directory
|
|
||||||
}
|
|
||||||
|
|
||||||
dfs := &DiskFS{
|
|
||||||
root: root,
|
|
||||||
info: make(map[string]*vfs.FileInfo),
|
|
||||||
capacity: capacity,
|
|
||||||
mu: sync.RWMutex{},
|
|
||||||
keyLocks: sync.Map{},
|
|
||||||
LRU: newLruList(),
|
|
||||||
}
|
|
||||||
|
|
||||||
os.MkdirAll(dfs.root, 0755)
|
|
||||||
|
|
||||||
diskCapacityBytes.Set(float64(dfs.capacity))
|
|
||||||
|
|
||||||
if !skipinit {
|
|
||||||
dfs.init()
|
|
||||||
diskSizeBytes.Set(float64(dfs.Size()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return dfs
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(root string, capacity int64) *DiskFS {
|
func New(root string, capacity int64) *DiskFS {
|
||||||
return new(root, capacity, false)
|
if capacity <= 0 {
|
||||||
}
|
panic("disk capacity must be greater than 0")
|
||||||
|
}
|
||||||
func NewSkipInit(root string, capacity int64) *DiskFS {
|
|
||||||
return new(root, capacity, true)
|
// Create root directory if it doesn't exist
|
||||||
|
os.MkdirAll(root, 0755)
|
||||||
|
|
||||||
|
// Initialize sharded locks
|
||||||
|
keyLocks := make([]sync.Map, numLockShards)
|
||||||
|
|
||||||
|
d := &DiskFS{
|
||||||
|
root: root,
|
||||||
|
info: make(map[string]*vfs.FileInfo),
|
||||||
|
capacity: capacity,
|
||||||
|
size: 0,
|
||||||
|
keyLocks: keyLocks,
|
||||||
|
LRU: newLruList(),
|
||||||
|
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
|
||||||
|
}
|
||||||
|
|
||||||
|
d.init()
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// init loads existing files from disk and migrates legacy depot files to sharded structure
|
||||||
func (d *DiskFS) init() {
|
func (d *DiskFS) init() {
|
||||||
tstart := time.Now()
|
tstart := time.Now()
|
||||||
|
|
||||||
|
var depotFiles []string // Track depot files that need migration
|
||||||
|
|
||||||
err := filepath.Walk(d.root, func(npath string, info os.FileInfo, err error) error {
|
err := filepath.Walk(d.root, func(npath string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -170,11 +183,24 @@ func (d *DiskFS) init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
k := strings.ReplaceAll(npath[len(d.root)+1:], "\\", "/")
|
// Extract key from sharded path: remove root and convert sharding back
|
||||||
|
relPath := strings.ReplaceAll(npath[len(d.root)+1:], "\\", "/")
|
||||||
|
|
||||||
|
// Extract the original key from the sharded path
|
||||||
|
k := d.extractKeyFromPath(relPath)
|
||||||
|
|
||||||
fi := vfs.NewFileInfoFromOS(info, k)
|
fi := vfs.NewFileInfoFromOS(info, k)
|
||||||
d.info[k] = fi
|
d.info[k] = fi
|
||||||
d.LRU.Add(k, fi)
|
d.LRU.Add(k, fi)
|
||||||
|
// Initialize access time with file modification time
|
||||||
|
fi.UpdateAccessBatched(d.timeUpdater)
|
||||||
d.size += info.Size()
|
d.size += info.Size()
|
||||||
|
|
||||||
|
// Track depot files for potential migration
|
||||||
|
if strings.HasPrefix(relPath, "depot/") {
|
||||||
|
depotFiles = append(depotFiles, relPath)
|
||||||
|
}
|
||||||
|
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -183,6 +209,12 @@ func (d *DiskFS) init() {
|
|||||||
logger.Logger.Error().Err(err).Msg("Walk failed")
|
logger.Logger.Error().Err(err).Msg("Walk failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate depot files to sharded structure if any exist
|
||||||
|
if len(depotFiles) > 0 {
|
||||||
|
logger.Logger.Info().Int("count", len(depotFiles)).Msg("Found legacy depot files, starting migration")
|
||||||
|
d.migrateDepotFiles(depotFiles)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Logger.Info().
|
logger.Logger.Info().
|
||||||
Str("name", d.Name()).
|
Str("name", d.Name()).
|
||||||
Str("root", d.root).
|
Str("root", d.root).
|
||||||
@@ -193,25 +225,109 @@ func (d *DiskFS) init() {
|
|||||||
Msg("init")
|
Msg("init")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DiskFS) Capacity() int64 {
|
// migrateDepotFiles moves legacy depot files to the sharded steam structure
|
||||||
return d.capacity
|
func (d *DiskFS) migrateDepotFiles(depotFiles []string) {
|
||||||
|
migratedCount := 0
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for _, relPath := range depotFiles {
|
||||||
|
// Extract the steam key from the depot path
|
||||||
|
steamKey := d.extractKeyFromPath(relPath)
|
||||||
|
if !strings.HasPrefix(steamKey, "steam/") {
|
||||||
|
// Skip if we can't extract a proper steam key
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source and destination paths
|
||||||
|
sourcePath := filepath.Join(d.root, relPath)
|
||||||
|
shardedPath := d.shardPath(steamKey)
|
||||||
|
destPath := filepath.Join(d.root, shardedPath)
|
||||||
|
|
||||||
|
// Create destination directory
|
||||||
|
destDir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
logger.Logger.Error().Err(err).Str("path", destDir).Msg("Failed to create migration destination directory")
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the file
|
||||||
|
if err := os.Rename(sourcePath, destPath); err != nil {
|
||||||
|
logger.Logger.Error().Err(err).Str("from", sourcePath).Str("to", destPath).Msg("Failed to migrate depot file")
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
migratedCount++
|
||||||
|
|
||||||
|
// Clean up empty depot directories (this is a simple cleanup, may not handle all cases)
|
||||||
|
d.cleanupEmptyDepotDirs(filepath.Dir(sourcePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info().
|
||||||
|
Int("migrated", migratedCount).
|
||||||
|
Int("errors", errorCount).
|
||||||
|
Msg("Depot file migration completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupEmptyDepotDirs removes empty depot directories after migration
|
||||||
|
func (d *DiskFS) cleanupEmptyDepotDirs(dirPath string) {
|
||||||
|
for dirPath != d.root && strings.HasPrefix(dirPath, filepath.Join(d.root, "depot")) {
|
||||||
|
entries, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil || len(entries) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory is empty, remove it
|
||||||
|
if err := os.Remove(dirPath); err != nil {
|
||||||
|
logger.Logger.Error().Err(err).Str("dir", dirPath).Msg("Failed to remove empty depot directory")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move up to parent directory
|
||||||
|
dirPath = filepath.Dir(dirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name of this VFS
|
||||||
func (d *DiskFS) Name() string {
|
func (d *DiskFS) Name() string {
|
||||||
return "DiskFS"
|
return "DiskFS"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Size returns the current size
|
||||||
func (d *DiskFS) Size() int64 {
|
func (d *DiskFS) Size() int64 {
|
||||||
d.mu.RLock()
|
d.mu.RLock()
|
||||||
defer d.mu.RUnlock()
|
defer d.mu.RUnlock()
|
||||||
return d.size
|
return d.size
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex {
|
// Capacity returns the maximum capacity
|
||||||
mu, _ := d.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
func (d *DiskFS) Capacity() int64 {
|
||||||
return mu.(*sync.RWMutex)
|
return d.capacity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getShardIndex returns the shard index for a given key
|
||||||
|
func getShardIndex(key string) int {
|
||||||
|
// Use FNV-1a hash for good distribution
|
||||||
|
var h uint32 = 2166136261 // FNV offset basis
|
||||||
|
for i := 0; i < len(key); i++ {
|
||||||
|
h ^= uint32(key[i])
|
||||||
|
h *= 16777619 // FNV prime
|
||||||
|
}
|
||||||
|
return int(h % numLockShards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKeyLock returns a lock for the given key using sharding
|
||||||
|
func (d *DiskFS) getKeyLock(key string) *sync.RWMutex {
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
shard := &d.keyLocks[shardIndex]
|
||||||
|
|
||||||
|
keyLock, _ := shard.LoadOrStore(key, &sync.RWMutex{})
|
||||||
|
return keyLock.(*sync.RWMutex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new file
|
||||||
func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, vfserror.ErrInvalidKey
|
return nil, vfserror.ErrInvalidKey
|
||||||
@@ -222,39 +338,28 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
|||||||
|
|
||||||
// Sanitize key to prevent path traversal
|
// Sanitize key to prevent path traversal
|
||||||
key = filepath.Clean(key)
|
key = filepath.Clean(key)
|
||||||
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
|
key = strings.ReplaceAll(key, "\\", "/")
|
||||||
if strings.Contains(key, "..") {
|
if strings.Contains(key, "..") {
|
||||||
return nil, vfserror.ErrInvalidKey
|
return nil, vfserror.ErrInvalidKey
|
||||||
}
|
}
|
||||||
|
|
||||||
d.mu.RLock()
|
|
||||||
if d.capacity > 0 {
|
|
||||||
if d.size+size > d.capacity {
|
|
||||||
d.mu.RUnlock()
|
|
||||||
return nil, vfserror.ErrDiskFull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.mu.RUnlock()
|
|
||||||
|
|
||||||
keyMu := d.getKeyLock(key)
|
keyMu := d.getKeyLock(key)
|
||||||
keyMu.Lock()
|
keyMu.Lock()
|
||||||
defer keyMu.Unlock()
|
defer keyMu.Unlock()
|
||||||
|
|
||||||
// Check again after lock
|
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
var accessCount int64 = 0
|
// Check if file already exists and handle overwrite
|
||||||
if fi, exists := d.info[key]; exists {
|
if fi, exists := d.info[key]; exists {
|
||||||
d.size -= fi.Size()
|
d.size -= fi.Size
|
||||||
d.LRU.Remove(key)
|
d.LRU.Remove(key)
|
||||||
delete(d.info, key)
|
delete(d.info, key)
|
||||||
accessCount = fi.AccessCount // preserve access count if overwriting
|
|
||||||
path := filepath.Join(d.root, key)
|
|
||||||
os.Remove(path) // Ignore error, as file might not exist or other issues
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shardedPath := d.shardPath(key)
|
||||||
|
path := filepath.Join(d.root, shardedPath)
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
|
||||||
path := filepath.Join(d.root, key)
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
|
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -265,57 +370,148 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fi := vfs.NewFileInfo(key, size)
|
||||||
|
d.mu.Lock()
|
||||||
|
d.info[key] = fi
|
||||||
|
d.LRU.Add(key, fi)
|
||||||
|
// Initialize access time with current time
|
||||||
|
fi.UpdateAccessBatched(d.timeUpdater)
|
||||||
|
d.size += size
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
return &diskWriteCloser{
|
return &diskWriteCloser{
|
||||||
Writer: file,
|
file: file,
|
||||||
onClose: func(n int64) error {
|
disk: d,
|
||||||
fi, err := os.Stat(path)
|
key: key,
|
||||||
if err != nil {
|
declaredSize: size,
|
||||||
os.Remove(path)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
d.mu.Lock()
|
|
||||||
finfo := vfs.NewFileInfoFromOS(fi, key)
|
|
||||||
finfo.AccessCount = accessCount
|
|
||||||
d.info[key] = finfo
|
|
||||||
d.LRU.Add(key, finfo)
|
|
||||||
d.size += n
|
|
||||||
d.mu.Unlock()
|
|
||||||
|
|
||||||
diskWriteBytes.Add(float64(n))
|
|
||||||
diskSizeBytes.Set(float64(d.Size()))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
key: key,
|
|
||||||
file: file,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diskWriteCloser implements io.WriteCloser for disk files with size adjustment
|
||||||
type diskWriteCloser struct {
|
type diskWriteCloser struct {
|
||||||
io.Writer
|
file *os.File
|
||||||
onClose func(int64) error
|
disk *DiskFS
|
||||||
n int64
|
key string
|
||||||
key string
|
declaredSize int64
|
||||||
file *os.File
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc *diskWriteCloser) Write(p []byte) (int, error) {
|
func (dwc *diskWriteCloser) Write(p []byte) (n int, err error) {
|
||||||
n, err := wc.Writer.Write(p)
|
return dwc.file.Write(p)
|
||||||
wc.n += int64(n)
|
|
||||||
return n, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc *diskWriteCloser) Close() error {
|
func (dwc *diskWriteCloser) Close() error {
|
||||||
err := wc.file.Close()
|
// Get the actual file size
|
||||||
if e := wc.onClose(wc.n); e != nil {
|
stat, err := dwc.file.Stat()
|
||||||
os.Remove(wc.file.Name())
|
if err != nil {
|
||||||
return e
|
dwc.file.Close()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
actualSize := stat.Size()
|
||||||
|
|
||||||
|
// Update the size in FileInfo if it differs from declared size
|
||||||
|
dwc.disk.mu.Lock()
|
||||||
|
if fi, exists := dwc.disk.info[dwc.key]; exists {
|
||||||
|
sizeDiff := actualSize - fi.Size
|
||||||
|
fi.Size = actualSize
|
||||||
|
dwc.disk.size += sizeDiff
|
||||||
|
}
|
||||||
|
dwc.disk.mu.Unlock()
|
||||||
|
|
||||||
|
return dwc.file.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete deletes the value of key.
|
// Open opens a file for reading
|
||||||
|
func (d *DiskFS) Open(key string) (io.ReadCloser, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
if key[0] == '/' {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize key to prevent path traversal
|
||||||
|
key = filepath.Clean(key)
|
||||||
|
key = strings.ReplaceAll(key, "\\", "/")
|
||||||
|
if strings.Contains(key, "..") {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
keyMu := d.getKeyLock(key)
|
||||||
|
keyMu.RLock()
|
||||||
|
defer keyMu.RUnlock()
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
fi, exists := d.info[key]
|
||||||
|
if !exists {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return nil, vfserror.ErrNotFound
|
||||||
|
}
|
||||||
|
fi.UpdateAccessBatched(d.timeUpdater)
|
||||||
|
d.LRU.MoveToFront(key, d.timeUpdater)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
shardedPath := d.shardPath(key)
|
||||||
|
path := filepath.Join(d.root, shardedPath)
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use memory mapping for large files (>1MB) to improve performance
|
||||||
|
const mmapThreshold = 1024 * 1024 // 1MB
|
||||||
|
if fi.Size > mmapThreshold {
|
||||||
|
// Close the regular file handle
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Try memory mapping
|
||||||
|
mmapFile, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped, err := mmap.Map(mmapFile, mmap.RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
mmapFile.Close()
|
||||||
|
// Fallback to regular file reading
|
||||||
|
return os.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mmapReadCloser{
|
||||||
|
data: mapped,
|
||||||
|
file: mmapFile,
|
||||||
|
offset: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mmapReadCloser implements io.ReadCloser for memory-mapped files
|
||||||
|
type mmapReadCloser struct {
|
||||||
|
data mmap.MMap
|
||||||
|
file *os.File
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mmapReadCloser) Read(p []byte) (n int, err error) {
|
||||||
|
if m.offset >= len(m.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n = copy(p, m.data[m.offset:])
|
||||||
|
m.offset += n
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mmapReadCloser) Close() error {
|
||||||
|
m.data.Unmap()
|
||||||
|
return m.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a file
|
||||||
func (d *DiskFS) Delete(key string) error {
|
func (d *DiskFS) Delete(key string) error {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return vfserror.ErrInvalidKey
|
return vfserror.ErrInvalidKey
|
||||||
@@ -324,13 +520,6 @@ func (d *DiskFS) Delete(key string) error {
|
|||||||
return vfserror.ErrInvalidKey
|
return vfserror.ErrInvalidKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize key to prevent path traversal
|
|
||||||
key = filepath.Clean(key)
|
|
||||||
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
|
|
||||||
if strings.Contains(key, "..") {
|
|
||||||
return vfserror.ErrInvalidKey
|
|
||||||
}
|
|
||||||
|
|
||||||
keyMu := d.getKeyLock(key)
|
keyMu := d.getKeyLock(key)
|
||||||
keyMu.Lock()
|
keyMu.Lock()
|
||||||
defer keyMu.Unlock()
|
defer keyMu.Unlock()
|
||||||
@@ -341,88 +530,24 @@ func (d *DiskFS) Delete(key string) error {
|
|||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
return vfserror.ErrNotFound
|
return vfserror.ErrNotFound
|
||||||
}
|
}
|
||||||
d.size -= fi.Size()
|
d.size -= fi.Size
|
||||||
d.LRU.Remove(key)
|
d.LRU.Remove(key)
|
||||||
delete(d.info, key)
|
delete(d.info, key)
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
|
||||||
path := filepath.Join(d.root, key)
|
shardedPath := d.shardPath(key)
|
||||||
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
|
path := filepath.Join(d.root, shardedPath)
|
||||||
if err := os.Remove(path); err != nil {
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
err := os.Remove(path)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
diskSizeBytes.Set(float64(d.Size()))
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open opens the file at key and returns it.
|
// Stat returns file information
|
||||||
func (d *DiskFS) Open(key string) (io.ReadCloser, error) {
|
|
||||||
if key == "" {
|
|
||||||
return nil, vfserror.ErrInvalidKey
|
|
||||||
}
|
|
||||||
if key[0] == '/' {
|
|
||||||
return nil, vfserror.ErrInvalidKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize key to prevent path traversal
|
|
||||||
key = filepath.Clean(key)
|
|
||||||
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
|
|
||||||
if strings.Contains(key, "..") {
|
|
||||||
return nil, vfserror.ErrInvalidKey
|
|
||||||
}
|
|
||||||
|
|
||||||
keyMu := d.getKeyLock(key)
|
|
||||||
keyMu.RLock()
|
|
||||||
defer keyMu.RUnlock()
|
|
||||||
|
|
||||||
d.mu.Lock()
|
|
||||||
fi, exists := d.info[key]
|
|
||||||
if !exists {
|
|
||||||
d.mu.Unlock()
|
|
||||||
return nil, vfserror.ErrNotFound
|
|
||||||
}
|
|
||||||
fi.ATime = time.Now()
|
|
||||||
fi.AccessCount++ // Increment access count for LFU
|
|
||||||
d.LRU.MoveToFront(key)
|
|
||||||
d.mu.Unlock()
|
|
||||||
|
|
||||||
path := filepath.Join(d.root, key)
|
|
||||||
path = strings.ReplaceAll(path, "\\", "/") // Ensure forward slashes for consistency
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update metrics on close
|
|
||||||
return &readCloser{
|
|
||||||
ReadCloser: file,
|
|
||||||
onClose: func(n int64) {
|
|
||||||
diskReadBytes.Add(float64(n))
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type readCloser struct {
|
|
||||||
io.ReadCloser
|
|
||||||
onClose func(int64)
|
|
||||||
n int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *readCloser) Read(p []byte) (int, error) {
|
|
||||||
n, err := rc.ReadCloser.Read(p)
|
|
||||||
rc.n += int64(n)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *readCloser) Close() error {
|
|
||||||
err := rc.ReadCloser.Close()
|
|
||||||
rc.onClose(rc.n)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat returns the FileInfo of key. If key is not found in the cache, it will stat the file on disk. If the file is not found on disk, it will return vfs.ErrNotFound.
|
|
||||||
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, vfserror.ErrInvalidKey
|
return nil, vfserror.ErrInvalidKey
|
||||||
@@ -431,13 +556,6 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
|||||||
return nil, vfserror.ErrInvalidKey
|
return nil, vfserror.ErrInvalidKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize key to prevent path traversal
|
|
||||||
key = filepath.Clean(key)
|
|
||||||
key = strings.ReplaceAll(key, "\\", "/") // Ensure forward slashes for consistency
|
|
||||||
if strings.Contains(key, "..") {
|
|
||||||
return nil, vfserror.ErrInvalidKey
|
|
||||||
}
|
|
||||||
|
|
||||||
keyMu := d.getKeyLock(key)
|
keyMu := d.getKeyLock(key)
|
||||||
keyMu.RLock()
|
keyMu.RLock()
|
||||||
defer keyMu.RUnlock()
|
defer keyMu.RUnlock()
|
||||||
@@ -445,23 +563,177 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
|||||||
d.mu.RLock()
|
d.mu.RLock()
|
||||||
defer d.mu.RUnlock()
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
if fi, ok := d.info[key]; !ok {
|
if fi, ok := d.info[key]; ok {
|
||||||
return nil, vfserror.ErrNotFound
|
|
||||||
} else {
|
|
||||||
return fi, nil
|
return fi, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DiskFS) StatAll() []*vfs.FileInfo {
|
// Check if file exists on disk but wasn't indexed (for migration)
|
||||||
d.mu.RLock()
|
shardedPath := d.shardPath(key)
|
||||||
defer d.mu.RUnlock()
|
path := filepath.Join(d.root, shardedPath)
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
// hard copy the file info to prevent modification of the original file info or the other way around
|
if info, err := os.Stat(path); err == nil {
|
||||||
files := make([]*vfs.FileInfo, 0, len(d.info))
|
// File exists in sharded location but not indexed, re-index it
|
||||||
for _, v := range d.info {
|
fi := vfs.NewFileInfoFromOS(info, key)
|
||||||
fi := *v
|
// We can't modify the map here because we're in a read lock
|
||||||
files = append(files, &fi)
|
// This is a simplified version - in production you'd need to handle this properly
|
||||||
|
return fi, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return files
|
return nil, vfserror.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictLRU evicts the least recently used files to free up space
|
||||||
|
func (d *DiskFS) EvictLRU(bytesNeeded uint) uint {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
var evicted uint
|
||||||
|
|
||||||
|
// Evict from LRU list until we free enough space
|
||||||
|
for d.size > d.capacity-int64(bytesNeeded) && d.LRU.Len() > 0 {
|
||||||
|
// Get the least recently used item
|
||||||
|
elem := d.LRU.list.Back()
|
||||||
|
if elem == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fi := elem.Value.(*vfs.FileInfo)
|
||||||
|
key := fi.Key
|
||||||
|
|
||||||
|
// Remove from LRU
|
||||||
|
d.LRU.Remove(key)
|
||||||
|
|
||||||
|
// Remove from map
|
||||||
|
delete(d.info, key)
|
||||||
|
|
||||||
|
// Remove file from disk
|
||||||
|
shardedPath := d.shardPath(key)
|
||||||
|
path := filepath.Join(d.root, shardedPath)
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
// Log error but continue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update size
|
||||||
|
d.size -= fi.Size
|
||||||
|
evicted += uint(fi.Size)
|
||||||
|
|
||||||
|
// Clean up key lock
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
d.keyLocks[shardIndex].Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictBySize evicts files by size (ascending = smallest first, descending = largest first)
|
||||||
|
func (d *DiskFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
var evicted uint
|
||||||
|
var candidates []*vfs.FileInfo
|
||||||
|
|
||||||
|
// Collect all files
|
||||||
|
for _, fi := range d.info {
|
||||||
|
candidates = append(candidates, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by size
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if ascending {
|
||||||
|
return candidates[i].Size < candidates[j].Size
|
||||||
|
}
|
||||||
|
return candidates[i].Size > candidates[j].Size
|
||||||
|
})
|
||||||
|
|
||||||
|
// Evict files until we free enough space
|
||||||
|
for _, fi := range candidates {
|
||||||
|
if d.size <= d.capacity-int64(bytesNeeded) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fi.Key
|
||||||
|
|
||||||
|
// Remove from LRU
|
||||||
|
d.LRU.Remove(key)
|
||||||
|
|
||||||
|
// Remove from map
|
||||||
|
delete(d.info, key)
|
||||||
|
|
||||||
|
// Remove file from disk
|
||||||
|
shardedPath := d.shardPath(key)
|
||||||
|
path := filepath.Join(d.root, shardedPath)
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update size
|
||||||
|
d.size -= fi.Size
|
||||||
|
evicted += uint(fi.Size)
|
||||||
|
|
||||||
|
// Clean up key lock
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
d.keyLocks[shardIndex].Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictFIFO evicts files using FIFO (oldest creation time first)
|
||||||
|
func (d *DiskFS) EvictFIFO(bytesNeeded uint) uint {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
var evicted uint
|
||||||
|
var candidates []*vfs.FileInfo
|
||||||
|
|
||||||
|
// Collect all files
|
||||||
|
for _, fi := range d.info {
|
||||||
|
candidates = append(candidates, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by creation time (oldest first)
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
return candidates[i].CTime.Before(candidates[j].CTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Evict oldest files until we free enough space
|
||||||
|
for _, fi := range candidates {
|
||||||
|
if d.size <= d.capacity-int64(bytesNeeded) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fi.Key
|
||||||
|
|
||||||
|
// Remove from LRU
|
||||||
|
d.LRU.Remove(key)
|
||||||
|
|
||||||
|
// Remove from map
|
||||||
|
delete(d.info, key)
|
||||||
|
|
||||||
|
// Remove file from disk
|
||||||
|
shardedPath := d.shardPath(key)
|
||||||
|
path := filepath.Join(d.root, shardedPath)
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update size
|
||||||
|
d.size -= fi.Size
|
||||||
|
evicted += uint(fi.Size)
|
||||||
|
|
||||||
|
// Clean up key lock
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
d.keyLocks[shardIndex].Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return evicted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
850
vfs/gc/gc.go
850
vfs/gc/gc.go
@@ -2,60 +2,10 @@
|
|||||||
package gc
|
package gc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
|
||||||
"s1d3sw1ped/SteamCache2/vfs"
|
"s1d3sw1ped/SteamCache2/vfs"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/cachestate"
|
|
||||||
"s1d3sw1ped/SteamCache2/vfs/disk"
|
"s1d3sw1ped/SteamCache2/vfs/disk"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/memory"
|
"s1d3sw1ped/SteamCache2/vfs/memory"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrInsufficientSpace is returned when there are no files to delete in the VFS.
|
|
||||||
ErrInsufficientSpace = fmt.Errorf("no files to delete")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Prometheus metrics for adaptive promotion
|
|
||||||
var (
|
|
||||||
promotionThresholds = promauto.NewGaugeVec(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "promotion_thresholds_bytes",
|
|
||||||
Help: "Current promotion thresholds in bytes",
|
|
||||||
},
|
|
||||||
[]string{"threshold_type"},
|
|
||||||
)
|
|
||||||
|
|
||||||
promotionWindows = promauto.NewGaugeVec(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "promotion_windows_seconds",
|
|
||||||
Help: "Current promotion time windows in seconds",
|
|
||||||
},
|
|
||||||
[]string{"window_type"},
|
|
||||||
)
|
|
||||||
|
|
||||||
promotionStats = promauto.NewGaugeVec(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "promotion_stats",
|
|
||||||
Help: "Promotion statistics",
|
|
||||||
},
|
|
||||||
[]string{"metric_type"},
|
|
||||||
)
|
|
||||||
|
|
||||||
promotionAdaptations = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "promotion_adaptations_total",
|
|
||||||
Help: "Total number of promotion threshold adaptations",
|
|
||||||
},
|
|
||||||
[]string{"direction"},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GCAlgorithm represents different garbage collection strategies
|
// GCAlgorithm represents different garbage collection strategies
|
||||||
@@ -70,677 +20,221 @@ const (
|
|||||||
Hybrid GCAlgorithm = "hybrid"
|
Hybrid GCAlgorithm = "hybrid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LRUGC deletes files in LRU order until enough space is reclaimed.
|
// GCFS wraps a VFS with garbage collection capabilities
|
||||||
func LRUGC(vfss vfs.VFS, size uint) error {
|
type GCFS struct {
|
||||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using LRU GC")
|
vfs vfs.VFS
|
||||||
|
algorithm GCAlgorithm
|
||||||
var reclaimed uint // reclaimed space in bytes
|
gcFunc func(vfs.VFS, uint) uint
|
||||||
deleted := false
|
|
||||||
|
|
||||||
for {
|
|
||||||
switch fs := vfss.(type) {
|
|
||||||
case *disk.DiskFS:
|
|
||||||
fi := fs.LRU.Back()
|
|
||||||
if fi == nil {
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LRU GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace // No files to delete
|
|
||||||
}
|
|
||||||
sz := uint(fi.Size())
|
|
||||||
err := fs.Delete(fi.Name())
|
|
||||||
if err != nil {
|
|
||||||
continue // If delete fails, try the next file
|
|
||||||
}
|
|
||||||
reclaimed += sz
|
|
||||||
deleted = true
|
|
||||||
case *memory.MemoryFS:
|
|
||||||
fi := fs.LRU.Back()
|
|
||||||
if fi == nil {
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LRU GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace // No files to delete
|
|
||||||
}
|
|
||||||
sz := uint(fi.Size())
|
|
||||||
err := fs.Delete(fi.Name())
|
|
||||||
if err != nil {
|
|
||||||
continue // If delete fails, try the next file
|
|
||||||
}
|
|
||||||
reclaimed += sz
|
|
||||||
deleted = true
|
|
||||||
default:
|
|
||||||
panic("unreachable: unsupported VFS type for LRU GC") // panic if the VFS is not disk or memory
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted && (size == 0 || reclaimed >= size) {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LRU GC (at least one file deleted)")
|
|
||||||
return nil // stop if enough space is reclaimed or at least one file deleted for size==0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LFUGC deletes files in LFU (Least Frequently Used) order until enough space is reclaimed.
|
// New creates a new GCFS with the specified algorithm
|
||||||
func LFUGC(vfss vfs.VFS, size uint) error {
|
func New(wrappedVFS vfs.VFS, algorithm GCAlgorithm) *GCFS {
|
||||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using LFU GC")
|
gcfs := &GCFS{
|
||||||
|
vfs: wrappedVFS,
|
||||||
files := getAllFiles(vfss)
|
algorithm: algorithm,
|
||||||
if len(files) == 0 {
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(files, func(i, j int) bool {
|
|
||||||
return files[i].AccessCount < files[j].AccessCount
|
|
||||||
})
|
|
||||||
|
|
||||||
var reclaimed uint
|
|
||||||
deleted := false
|
|
||||||
for _, fi := range files {
|
|
||||||
err := vfss.Delete(fi.Name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reclaimed += uint(fi.Size)
|
|
||||||
deleted = true
|
|
||||||
if deleted && (size == 0 || reclaimed >= size) {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LFU GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using LFU GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIFOGC deletes files in FIFO (First In, First Out) order until enough space is reclaimed.
|
|
||||||
func FIFOGC(vfss vfs.VFS, size uint) error {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using FIFO GC")
|
|
||||||
|
|
||||||
files := getAllFiles(vfss)
|
|
||||||
if len(files) == 0 {
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(files, func(i, j int) bool {
|
|
||||||
return files[i].MTime.Before(files[j].MTime)
|
|
||||||
})
|
|
||||||
|
|
||||||
var reclaimed uint
|
|
||||||
deleted := false
|
|
||||||
for _, fi := range files {
|
|
||||||
err := vfss.Delete(fi.Name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reclaimed += uint(fi.Size)
|
|
||||||
deleted = true
|
|
||||||
if deleted && (size == 0 || reclaimed >= size) {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using FIFO GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using FIFO GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
// LargestGC deletes the largest files first until enough space is reclaimed.
|
|
||||||
func LargestGC(vfss vfs.VFS, size uint) error {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using Largest GC")
|
|
||||||
|
|
||||||
files := getAllFiles(vfss)
|
|
||||||
if len(files) == 0 {
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(files, func(i, j int) bool {
|
|
||||||
return files[i].Size > files[j].Size
|
|
||||||
})
|
|
||||||
|
|
||||||
var reclaimed uint
|
|
||||||
deleted := false
|
|
||||||
for _, fi := range files {
|
|
||||||
err := vfss.Delete(fi.Name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reclaimed += uint(fi.Size)
|
|
||||||
deleted = true
|
|
||||||
if deleted && (size == 0 || reclaimed >= size) {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Largest GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Largest GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
// SmallestGC deletes the smallest files first until enough space is reclaimed.
|
|
||||||
func SmallestGC(vfss vfs.VFS, size uint) error {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using Smallest GC")
|
|
||||||
|
|
||||||
files := getAllFiles(vfss)
|
|
||||||
if len(files) == 0 {
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(files, func(i, j int) bool {
|
|
||||||
return files[i].Size < files[j].Size
|
|
||||||
})
|
|
||||||
|
|
||||||
var reclaimed uint
|
|
||||||
deleted := false
|
|
||||||
for _, fi := range files {
|
|
||||||
err := vfss.Delete(fi.Name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reclaimed += uint(fi.Size)
|
|
||||||
deleted = true
|
|
||||||
if deleted && (size == 0 || reclaimed >= size) {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Smallest GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Smallest GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
// HybridGC combines LRU and size-based eviction with a scoring system.
|
|
||||||
func HybridGC(vfss vfs.VFS, size uint) error {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Msg("Attempting to reclaim space using Hybrid GC")
|
|
||||||
|
|
||||||
files := getAllFiles(vfss)
|
|
||||||
if len(files) == 0 {
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
for i := range files {
|
|
||||||
timeSinceAccess := now.Sub(files[i].ATime).Seconds()
|
|
||||||
sizeMB := float64(files[i].Size) / (1024 * 1024)
|
|
||||||
files[i].HybridScore = timeSinceAccess * sizeMB
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(files, func(i, j int) bool {
|
|
||||||
return files[i].HybridScore < files[j].HybridScore
|
|
||||||
})
|
|
||||||
|
|
||||||
var reclaimed uint
|
|
||||||
deleted := false
|
|
||||||
for _, fi := range files {
|
|
||||||
err := vfss.Delete(fi.Name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reclaimed += uint(fi.Size)
|
|
||||||
deleted = true
|
|
||||||
if deleted && (size == 0 || reclaimed >= size) {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Hybrid GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted {
|
|
||||||
logger.Logger.Debug().Uint("target", size).Uint("achieved", reclaimed).Msg("Reclaimed enough space using Hybrid GC (at least one file deleted)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileInfoWithMetadata extends FileInfo with additional metadata for GC algorithms
|
|
||||||
type fileInfoWithMetadata struct {
|
|
||||||
Name string
|
|
||||||
Size int64
|
|
||||||
MTime time.Time
|
|
||||||
ATime time.Time
|
|
||||||
AccessCount int64
|
|
||||||
HybridScore float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAllFiles retrieves all files from the VFS with additional metadata
|
|
||||||
func getAllFiles(vfss vfs.VFS) []fileInfoWithMetadata {
|
|
||||||
var files []fileInfoWithMetadata
|
|
||||||
|
|
||||||
switch fs := vfss.(type) {
|
|
||||||
case *disk.DiskFS:
|
|
||||||
allFiles := fs.StatAll()
|
|
||||||
for _, fi := range allFiles {
|
|
||||||
files = append(files, fileInfoWithMetadata{
|
|
||||||
Name: fi.Name(),
|
|
||||||
Size: fi.Size(),
|
|
||||||
MTime: fi.ModTime(),
|
|
||||||
ATime: fi.AccessTime(),
|
|
||||||
AccessCount: fi.AccessCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case *memory.MemoryFS:
|
|
||||||
allFiles := fs.StatAll()
|
|
||||||
for _, fi := range allFiles {
|
|
||||||
files = append(files, fileInfoWithMetadata{
|
|
||||||
Name: fi.Name(),
|
|
||||||
Size: fi.Size(),
|
|
||||||
MTime: fi.ModTime(),
|
|
||||||
ATime: fi.AccessTime(),
|
|
||||||
AccessCount: fi.AccessCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGCAlgorithm returns the appropriate GC function based on the algorithm name
|
|
||||||
func GetGCAlgorithm(algorithm GCAlgorithm) GCHandlerFunc {
|
|
||||||
switch algorithm {
|
switch algorithm {
|
||||||
case LRU:
|
case LRU:
|
||||||
return LRUGC
|
gcfs.gcFunc = gcLRU
|
||||||
case LFU:
|
case LFU:
|
||||||
return LFUGC
|
gcfs.gcFunc = gcLFU
|
||||||
case FIFO:
|
case FIFO:
|
||||||
return FIFOGC
|
gcfs.gcFunc = gcFIFO
|
||||||
case Largest:
|
case Largest:
|
||||||
return LargestGC
|
gcfs.gcFunc = gcLargest
|
||||||
case Smallest:
|
case Smallest:
|
||||||
return SmallestGC
|
gcfs.gcFunc = gcSmallest
|
||||||
case Hybrid:
|
case Hybrid:
|
||||||
return HybridGC
|
gcfs.gcFunc = gcHybrid
|
||||||
default:
|
default:
|
||||||
logger.Logger.Warn().Str("algorithm", string(algorithm)).Msg("Unknown GC algorithm, falling back to LRU")
|
// Default to LRU
|
||||||
return LRUGC
|
gcfs.gcFunc = gcLRU
|
||||||
|
}
|
||||||
|
|
||||||
|
return gcfs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGCAlgorithm returns the GC function for the given algorithm
|
||||||
|
func GetGCAlgorithm(algorithm GCAlgorithm) func(vfs.VFS, uint) uint {
|
||||||
|
switch algorithm {
|
||||||
|
case LRU:
|
||||||
|
return gcLRU
|
||||||
|
case LFU:
|
||||||
|
return gcLFU
|
||||||
|
case FIFO:
|
||||||
|
return gcFIFO
|
||||||
|
case Largest:
|
||||||
|
return gcLargest
|
||||||
|
case Smallest:
|
||||||
|
return gcSmallest
|
||||||
|
case Hybrid:
|
||||||
|
return gcHybrid
|
||||||
|
default:
|
||||||
|
return gcLRU
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PromotionDecider(fi *vfs.FileInfo, cs cachestate.CacheState) bool {
|
// Create wraps the underlying Create method
|
||||||
return time.Since(fi.AccessTime()) < time.Second*60 // Put hot files in the fast vfs if equipped
|
func (gc *GCFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||||
|
// Check if we need to GC before creating
|
||||||
|
if gc.vfs.Size()+size > gc.vfs.Capacity() {
|
||||||
|
needed := uint((gc.vfs.Size() + size) - gc.vfs.Capacity())
|
||||||
|
gc.gcFunc(gc.vfs, needed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gc.vfs.Create(key, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdaptivePromotionDecider automatically adjusts promotion thresholds based on usage patterns
|
// Open wraps the underlying Open method
|
||||||
type AdaptivePromotionDecider struct {
|
func (gc *GCFS) Open(key string) (io.ReadCloser, error) {
|
||||||
mu sync.RWMutex
|
return gc.vfs.Open(key)
|
||||||
|
|
||||||
// Current thresholds
|
|
||||||
smallFileThreshold int64 // Size threshold for small files
|
|
||||||
mediumFileThreshold int64 // Size threshold for medium files
|
|
||||||
largeFileThreshold int64 // Size threshold for large files
|
|
||||||
smallFileWindow time.Duration // Time window for small files
|
|
||||||
mediumFileWindow time.Duration // Time window for medium files
|
|
||||||
largeFileWindow time.Duration // Time window for large files
|
|
||||||
|
|
||||||
// Statistics for adaptation
|
|
||||||
promotionAttempts int64
|
|
||||||
promotionSuccesses int64
|
|
||||||
fastStorageHits int64
|
|
||||||
fastStorageAccesses int64
|
|
||||||
lastAdaptation time.Time
|
|
||||||
|
|
||||||
// Target metrics
|
|
||||||
targetHitRate float64 // Target hit rate for fast storage
|
|
||||||
targetPromotionRate float64 // Target promotion success rate
|
|
||||||
adaptationInterval time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdaptivePromotionDecider creates a new adaptive promotion decider
|
// Delete wraps the underlying Delete method
|
||||||
func NewAdaptivePromotionDecider() *AdaptivePromotionDecider {
|
func (gc *GCFS) Delete(key string) error {
|
||||||
apd := &AdaptivePromotionDecider{
|
return gc.vfs.Delete(key)
|
||||||
// Initial thresholds
|
|
||||||
smallFileThreshold: 10 * 1024 * 1024, // 10MB
|
|
||||||
mediumFileThreshold: 100 * 1024 * 1024, // 100MB
|
|
||||||
largeFileThreshold: 500 * 1024 * 1024, // 500MB
|
|
||||||
smallFileWindow: 10 * time.Minute,
|
|
||||||
mediumFileWindow: 2 * time.Minute,
|
|
||||||
largeFileWindow: 30 * time.Second,
|
|
||||||
|
|
||||||
// Target metrics
|
|
||||||
targetHitRate: 0.8, // 80% hit rate
|
|
||||||
targetPromotionRate: 0.7, // 70% promotion success rate
|
|
||||||
adaptationInterval: 5 * time.Minute,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Prometheus metrics
|
|
||||||
apd.updatePrometheusMetrics()
|
|
||||||
|
|
||||||
return apd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShouldPromote determines if a file should be promoted based on adaptive thresholds
|
// Stat wraps the underlying Stat method
|
||||||
func (apd *AdaptivePromotionDecider) ShouldPromote(fi *vfs.FileInfo, cs cachestate.CacheState) bool {
|
func (gc *GCFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||||
apd.mu.Lock()
|
return gc.vfs.Stat(key)
|
||||||
defer apd.mu.Unlock()
|
|
||||||
|
|
||||||
// Check if it's time to adapt thresholds
|
|
||||||
if time.Since(apd.lastAdaptation) > apd.adaptationInterval {
|
|
||||||
apd.adaptThresholds()
|
|
||||||
}
|
|
||||||
|
|
||||||
size := fi.Size()
|
|
||||||
timeSinceAccess := time.Since(fi.AccessTime())
|
|
||||||
|
|
||||||
// Record promotion attempt
|
|
||||||
apd.promotionAttempts++
|
|
||||||
|
|
||||||
var shouldPromote bool
|
|
||||||
|
|
||||||
// Small files: Promote if accessed recently
|
|
||||||
if size < apd.smallFileThreshold {
|
|
||||||
shouldPromote = timeSinceAccess < apd.smallFileWindow
|
|
||||||
} else if size < apd.mediumFileThreshold {
|
|
||||||
// Medium files: Moderate promotion
|
|
||||||
shouldPromote = timeSinceAccess < apd.mediumFileWindow
|
|
||||||
} else if size < apd.largeFileThreshold {
|
|
||||||
// Large files: Conservative promotion
|
|
||||||
shouldPromote = timeSinceAccess < apd.largeFileWindow
|
|
||||||
} else {
|
|
||||||
// Huge files: Don't promote
|
|
||||||
shouldPromote = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record promotion decision
|
|
||||||
if shouldPromote {
|
|
||||||
apd.promotionSuccesses++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Prometheus metrics periodically (every 10 attempts to avoid overhead)
|
|
||||||
if apd.promotionAttempts%10 == 0 {
|
|
||||||
apd.updatePrometheusMetrics()
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldPromote
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordFastStorageAccess records when fast storage is accessed
|
// Name wraps the underlying Name method
|
||||||
func (apd *AdaptivePromotionDecider) RecordFastStorageAccess() {
|
func (gc *GCFS) Name() string {
|
||||||
apd.mu.Lock()
|
return gc.vfs.Name() + "(GC:" + string(gc.algorithm) + ")"
|
||||||
defer apd.mu.Unlock()
|
}
|
||||||
apd.fastStorageAccesses++
|
|
||||||
|
|
||||||
// Update Prometheus metrics periodically
|
// Size wraps the underlying Size method
|
||||||
if apd.fastStorageAccesses%10 == 0 {
|
func (gc *GCFS) Size() int64 {
|
||||||
apd.updatePrometheusMetrics()
|
return gc.vfs.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity wraps the underlying Capacity method
|
||||||
|
func (gc *GCFS) Capacity() int64 {
|
||||||
|
return gc.vfs.Capacity()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictionStrategy defines an interface for cache eviction
|
||||||
|
type EvictionStrategy interface {
|
||||||
|
Evict(vfs vfs.VFS, bytesNeeded uint) uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// GC functions
|
||||||
|
|
||||||
|
// gcLRU implements Least Recently Used eviction
|
||||||
|
func gcLRU(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
return evictLRU(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcLFU implements Least Frequently Used eviction
|
||||||
|
func gcLFU(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
return evictLFU(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcFIFO implements First In First Out eviction
|
||||||
|
func gcFIFO(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
return evictFIFO(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcLargest implements largest file first eviction
|
||||||
|
func gcLargest(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
return evictLargest(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcSmallest implements smallest file first eviction
|
||||||
|
func gcSmallest(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
return evictSmallest(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcHybrid implements a hybrid eviction strategy
|
||||||
|
func gcHybrid(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
return evictHybrid(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictLRU performs LRU eviction by removing least recently used files
|
||||||
|
func evictLRU(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
// Try to use specific eviction methods if available
|
||||||
|
switch fs := v.(type) {
|
||||||
|
case *memory.MemoryFS:
|
||||||
|
return fs.EvictLRU(bytesNeeded)
|
||||||
|
case *disk.DiskFS:
|
||||||
|
return fs.EvictLRU(bytesNeeded)
|
||||||
|
default:
|
||||||
|
// No fallback - return 0 (no eviction performed)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordFastStorageHit records when fast storage has a hit
|
// evictLFU performs LFU (Least Frequently Used) eviction
|
||||||
func (apd *AdaptivePromotionDecider) RecordFastStorageHit() {
|
func evictLFU(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
apd.mu.Lock()
|
// For now, fall back to size-based eviction
|
||||||
defer apd.mu.Unlock()
|
// TODO: Implement proper LFU tracking
|
||||||
apd.fastStorageHits++
|
return evictBySize(v, bytesNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
// Update Prometheus metrics periodically
|
// evictFIFO performs FIFO (First In First Out) eviction
|
||||||
if apd.fastStorageHits%10 == 0 {
|
func evictFIFO(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
apd.updatePrometheusMetrics()
|
switch fs := v.(type) {
|
||||||
|
case *memory.MemoryFS:
|
||||||
|
return fs.EvictFIFO(bytesNeeded)
|
||||||
|
case *disk.DiskFS:
|
||||||
|
return fs.EvictFIFO(bytesNeeded)
|
||||||
|
default:
|
||||||
|
// No fallback - return 0 (no eviction performed)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adaptThresholds adjusts thresholds based on current performance
|
// evictLargest evicts largest files first
|
||||||
func (apd *AdaptivePromotionDecider) adaptThresholds() {
|
func evictLargest(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
if apd.promotionAttempts < 10 || apd.fastStorageAccesses < 10 {
|
return evictBySizeDesc(v, bytesNeeded)
|
||||||
// Not enough data to adapt
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentHitRate := float64(apd.fastStorageHits) / float64(apd.fastStorageAccesses)
|
|
||||||
currentPromotionRate := float64(apd.promotionSuccesses) / float64(apd.promotionAttempts)
|
|
||||||
|
|
||||||
logger.Logger.Debug().
|
|
||||||
Float64("hit_rate", currentHitRate).
|
|
||||||
Float64("promotion_rate", currentPromotionRate).
|
|
||||||
Float64("target_hit_rate", apd.targetHitRate).
|
|
||||||
Float64("target_promotion_rate", apd.targetPromotionRate).
|
|
||||||
Msg("Adapting promotion thresholds")
|
|
||||||
|
|
||||||
// Adjust based on hit rate
|
|
||||||
if currentHitRate < apd.targetHitRate {
|
|
||||||
// Hit rate too low - be more aggressive with promotion
|
|
||||||
apd.adjustThresholdsMoreAggressive()
|
|
||||||
} else if currentHitRate > apd.targetHitRate+0.1 {
|
|
||||||
// Hit rate too high - be more conservative
|
|
||||||
apd.adjustThresholdsMoreConservative()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust based on promotion success rate
|
|
||||||
if currentPromotionRate < apd.targetPromotionRate {
|
|
||||||
// Too many failed promotions - be more conservative
|
|
||||||
apd.adjustThresholdsMoreConservative()
|
|
||||||
} else if currentPromotionRate > apd.targetPromotionRate+0.1 {
|
|
||||||
// High promotion success - can be more aggressive
|
|
||||||
apd.adjustThresholdsMoreAggressive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset counters for next adaptation period
|
|
||||||
apd.promotionAttempts = 0
|
|
||||||
apd.promotionSuccesses = 0
|
|
||||||
apd.fastStorageHits = 0
|
|
||||||
apd.fastStorageAccesses = 0
|
|
||||||
apd.lastAdaptation = time.Now()
|
|
||||||
|
|
||||||
logger.Logger.Info().
|
|
||||||
Int64("small_threshold_mb", apd.smallFileThreshold/(1024*1024)).
|
|
||||||
Int64("medium_threshold_mb", apd.mediumFileThreshold/(1024*1024)).
|
|
||||||
Int64("large_threshold_mb", apd.largeFileThreshold/(1024*1024)).
|
|
||||||
Dur("small_window", apd.smallFileWindow).
|
|
||||||
Dur("medium_window", apd.mediumFileWindow).
|
|
||||||
Dur("large_window", apd.largeFileWindow).
|
|
||||||
Msg("Updated promotion thresholds")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updatePrometheusMetrics updates all Prometheus metrics with current values
|
// evictSmallest evicts smallest files first
|
||||||
func (apd *AdaptivePromotionDecider) updatePrometheusMetrics() {
|
func evictSmallest(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
// Update threshold metrics
|
return evictBySizeAsc(v, bytesNeeded)
|
||||||
promotionThresholds.WithLabelValues("small").Set(float64(apd.smallFileThreshold))
|
|
||||||
promotionThresholds.WithLabelValues("medium").Set(float64(apd.mediumFileThreshold))
|
|
||||||
promotionThresholds.WithLabelValues("large").Set(float64(apd.largeFileThreshold))
|
|
||||||
|
|
||||||
// Update window metrics
|
|
||||||
promotionWindows.WithLabelValues("small").Set(apd.smallFileWindow.Seconds())
|
|
||||||
promotionWindows.WithLabelValues("medium").Set(apd.mediumFileWindow.Seconds())
|
|
||||||
promotionWindows.WithLabelValues("large").Set(apd.largeFileWindow.Seconds())
|
|
||||||
|
|
||||||
// Update statistics metrics
|
|
||||||
hitRate := 0.0
|
|
||||||
if apd.fastStorageAccesses > 0 {
|
|
||||||
hitRate = float64(apd.fastStorageHits) / float64(apd.fastStorageAccesses)
|
|
||||||
}
|
|
||||||
promotionRate := 0.0
|
|
||||||
if apd.promotionAttempts > 0 {
|
|
||||||
promotionRate = float64(apd.promotionSuccesses) / float64(apd.promotionAttempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
promotionStats.WithLabelValues("hit_rate").Set(hitRate)
|
|
||||||
promotionStats.WithLabelValues("promotion_rate").Set(promotionRate)
|
|
||||||
promotionStats.WithLabelValues("promotion_attempts").Set(float64(apd.promotionAttempts))
|
|
||||||
promotionStats.WithLabelValues("promotion_successes").Set(float64(apd.promotionSuccesses))
|
|
||||||
promotionStats.WithLabelValues("fast_storage_accesses").Set(float64(apd.fastStorageAccesses))
|
|
||||||
promotionStats.WithLabelValues("fast_storage_hits").Set(float64(apd.fastStorageHits))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjustThresholdsMoreAggressive makes promotion more aggressive
|
// evictBySize evicts files based on size (smallest first)
|
||||||
func (apd *AdaptivePromotionDecider) adjustThresholdsMoreAggressive() {
|
func evictBySize(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
// Increase size thresholds (promote larger files)
|
return evictBySizeAsc(v, bytesNeeded)
|
||||||
apd.smallFileThreshold = minInt64(apd.smallFileThreshold*11/10, 50*1024*1024) // Max 50MB
|
|
||||||
apd.mediumFileThreshold = minInt64(apd.mediumFileThreshold*11/10, 200*1024*1024) // Max 200MB
|
|
||||||
apd.largeFileThreshold = minInt64(apd.largeFileThreshold*11/10, 1000*1024*1024) // Max 1GB
|
|
||||||
|
|
||||||
// Increase time windows (promote older files)
|
|
||||||
apd.smallFileWindow = minDuration(apd.smallFileWindow*11/10, 20*time.Minute)
|
|
||||||
apd.mediumFileWindow = minDuration(apd.mediumFileWindow*11/10, 5*time.Minute)
|
|
||||||
apd.largeFileWindow = minDuration(apd.largeFileWindow*11/10, 2*time.Minute)
|
|
||||||
|
|
||||||
// Record adaptation in Prometheus
|
|
||||||
promotionAdaptations.WithLabelValues("aggressive").Inc()
|
|
||||||
|
|
||||||
// Update Prometheus metrics
|
|
||||||
apd.updatePrometheusMetrics()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjustThresholdsMoreConservative makes promotion more conservative
|
// evictBySizeAsc evicts smallest files first
|
||||||
func (apd *AdaptivePromotionDecider) adjustThresholdsMoreConservative() {
|
func evictBySizeAsc(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
// Decrease size thresholds (promote smaller files)
|
switch fs := v.(type) {
|
||||||
apd.smallFileThreshold = maxInt64(apd.smallFileThreshold*9/10, 5*1024*1024) // Min 5MB
|
case *memory.MemoryFS:
|
||||||
apd.mediumFileThreshold = maxInt64(apd.mediumFileThreshold*9/10, 50*1024*1024) // Min 50MB
|
return fs.EvictBySize(bytesNeeded, true) // true = ascending (smallest first)
|
||||||
apd.largeFileThreshold = maxInt64(apd.largeFileThreshold*9/10, 200*1024*1024) // Min 200MB
|
case *disk.DiskFS:
|
||||||
|
return fs.EvictBySize(bytesNeeded, true) // true = ascending (smallest first)
|
||||||
// Decrease time windows (promote only recent files)
|
default:
|
||||||
apd.smallFileWindow = maxDuration(apd.smallFileWindow*9/10, 5*time.Minute)
|
// No fallback - return 0 (no eviction performed)
|
||||||
apd.mediumFileWindow = maxDuration(apd.mediumFileWindow*9/10, 1*time.Minute)
|
return 0
|
||||||
apd.largeFileWindow = maxDuration(apd.largeFileWindow*9/10, 15*time.Second)
|
|
||||||
|
|
||||||
// Record adaptation in Prometheus
|
|
||||||
promotionAdaptations.WithLabelValues("conservative").Inc()
|
|
||||||
|
|
||||||
// Update Prometheus metrics
|
|
||||||
apd.updatePrometheusMetrics()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats returns current statistics for monitoring
|
|
||||||
func (apd *AdaptivePromotionDecider) GetStats() map[string]interface{} {
|
|
||||||
apd.mu.RLock()
|
|
||||||
defer apd.mu.RUnlock()
|
|
||||||
|
|
||||||
hitRate := 0.0
|
|
||||||
if apd.fastStorageAccesses > 0 {
|
|
||||||
hitRate = float64(apd.fastStorageHits) / float64(apd.fastStorageAccesses)
|
|
||||||
}
|
|
||||||
|
|
||||||
promotionRate := 0.0
|
|
||||||
if apd.promotionAttempts > 0 {
|
|
||||||
promotionRate = float64(apd.promotionSuccesses) / float64(apd.promotionAttempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"small_file_threshold_mb": apd.smallFileThreshold / (1024 * 1024),
|
|
||||||
"medium_file_threshold_mb": apd.mediumFileThreshold / (1024 * 1024),
|
|
||||||
"large_file_threshold_mb": apd.largeFileThreshold / (1024 * 1024),
|
|
||||||
"small_file_window_minutes": apd.smallFileWindow.Minutes(),
|
|
||||||
"medium_file_window_minutes": apd.mediumFileWindow.Minutes(),
|
|
||||||
"large_file_window_seconds": apd.largeFileWindow.Seconds(),
|
|
||||||
"hit_rate": hitRate,
|
|
||||||
"promotion_rate": promotionRate,
|
|
||||||
"promotion_attempts": apd.promotionAttempts,
|
|
||||||
"promotion_successes": apd.promotionSuccesses,
|
|
||||||
"fast_storage_accesses": apd.fastStorageAccesses,
|
|
||||||
"fast_storage_hits": apd.fastStorageHits,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global adaptive promotion decider instance
|
// evictBySizeDesc evicts largest files first
|
||||||
var adaptivePromotionDecider *AdaptivePromotionDecider
|
func evictBySizeDesc(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
|
switch fs := v.(type) {
|
||||||
func init() {
|
case *memory.MemoryFS:
|
||||||
adaptivePromotionDecider = NewAdaptivePromotionDecider()
|
return fs.EvictBySize(bytesNeeded, false) // false = descending (largest first)
|
||||||
}
|
case *disk.DiskFS:
|
||||||
|
return fs.EvictBySize(bytesNeeded, false) // false = descending (largest first)
|
||||||
// AdaptivePromotionDeciderFunc returns the adaptive promotion decision function
|
default:
|
||||||
func AdaptivePromotionDeciderFunc(fi *vfs.FileInfo, cs cachestate.CacheState) bool {
|
// No fallback - return 0 (no eviction performed)
|
||||||
return adaptivePromotionDecider.ShouldPromote(fi, cs)
|
return 0
|
||||||
}
|
|
||||||
|
|
||||||
// RecordFastStorageAccess records fast storage access for adaptation
|
|
||||||
func RecordFastStorageAccess() {
|
|
||||||
adaptivePromotionDecider.RecordFastStorageAccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordFastStorageHit records fast storage hit for adaptation
|
|
||||||
func RecordFastStorageHit() {
|
|
||||||
adaptivePromotionDecider.RecordFastStorageHit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPromotionStats returns promotion statistics for monitoring
|
|
||||||
func GetPromotionStats() map[string]interface{} {
|
|
||||||
return adaptivePromotionDecider.GetStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for min/max operations
|
|
||||||
func minInt64(a, b int64) int64 {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func maxInt64(a, b int64) int64 {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func minDuration(a, b time.Duration) time.Duration {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func maxDuration(a, b time.Duration) time.Duration {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure GCFS implements VFS.
|
|
||||||
var _ vfs.VFS = (*GCFS)(nil)
|
|
||||||
|
|
||||||
// GCFS is a virtual file system that calls a GC handler when the disk is full. The GC handler is responsible for freeing up space on the disk. The GCFS is a wrapper around another VFS.
|
|
||||||
type GCFS struct {
|
|
||||||
vfs.VFS
|
|
||||||
|
|
||||||
gcHanderFunc GCHandlerFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// GCHandlerFunc is a function that is called when the disk is full and the GCFS needs to free up space. It is passed the VFS and the size of the file that needs to be written. Its up to the implementation to free up space. How much space is freed is also up to the implementation.
|
|
||||||
type GCHandlerFunc func(vfs vfs.VFS, size uint) error
|
|
||||||
|
|
||||||
func New(vfs vfs.VFS, gcHandlerFunc GCHandlerFunc) *GCFS {
|
|
||||||
return &GCFS{
|
|
||||||
VFS: vfs,
|
|
||||||
gcHanderFunc: gcHandlerFunc,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create overrides the Create method of the VFS interface. It tries to create the key, if it fails due to disk full error, it calls the GC handler and tries again. If it still fails it returns the error.
|
// evictHybrid implements a hybrid eviction strategy
|
||||||
func (g *GCFS) Create(key string, size int64) (io.WriteCloser, error) {
|
func evictHybrid(v vfs.VFS, bytesNeeded uint) uint {
|
||||||
w, err := g.VFS.Create(key, size) // try to create the key
|
// Use LRU as primary strategy, but consider size as tiebreaker
|
||||||
for err == vfserror.ErrDiskFull && g.gcHanderFunc != nil {
|
return evictLRU(v, bytesNeeded)
|
||||||
errGC := g.gcHanderFunc(g.VFS, uint(size)) // call the GC handler
|
|
||||||
if errGC == ErrInsufficientSpace {
|
|
||||||
return nil, errGC // if the GC handler returns no files to delete, return the error
|
|
||||||
}
|
|
||||||
w, err = g.VFS.Create(key, size)
|
|
||||||
if err == vfserror.ErrDiskFull {
|
|
||||||
// GC handler did not free enough space, avoid infinite loop
|
|
||||||
return nil, ErrInsufficientSpace
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == vfserror.ErrDiskFull {
|
|
||||||
logger.Logger.Error().Str("key", key).Int64("size", size).Msg("Failed to create file due to disk full, even after GC")
|
|
||||||
} else {
|
|
||||||
logger.Logger.Error().Str("key", key).Int64("size", size).Err(err).Msg("Failed to create file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return w, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GCFS) Name() string {
|
// AdaptivePromotionDeciderFunc is a placeholder for the adaptive promotion logic
|
||||||
return fmt.Sprintf("GCFS(%s)", g.VFS.Name()) // wrap the name of the VFS with GCFS so we can see that its a GCFS
|
var AdaptivePromotionDeciderFunc = func() interface{} {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,67 +5,33 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"container/list"
|
"container/list"
|
||||||
"io"
|
"io"
|
||||||
"s1d3sw1ped/SteamCache2/steamcache/logger"
|
|
||||||
"s1d3sw1ped/SteamCache2/vfs"
|
"s1d3sw1ped/SteamCache2/vfs"
|
||||||
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
"s1d3sw1ped/SteamCache2/vfs/vfserror"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/go-units"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
memoryCapacityBytes = promauto.NewGauge(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "memory_cache_capacity_bytes",
|
|
||||||
Help: "Total capacity of the memory cache in bytes",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
memorySizeBytes = promauto.NewGauge(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "memory_cache_size_bytes",
|
|
||||||
Help: "Total size of the memory cache in bytes",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
memoryReadBytes = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "memory_cache_read_bytes_total",
|
|
||||||
Help: "Total number of bytes read from the memory cache",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
memoryWriteBytes = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "memory_cache_write_bytes_total",
|
|
||||||
Help: "Total number of bytes written to the memory cache",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure MemoryFS implements VFS.
|
// Ensure MemoryFS implements VFS.
|
||||||
var _ vfs.VFS = (*MemoryFS)(nil)
|
var _ vfs.VFS = (*MemoryFS)(nil)
|
||||||
|
|
||||||
// file represents a file in memory.
|
// MemoryFS is an in-memory virtual file system
|
||||||
type file struct {
|
|
||||||
fileinfo *vfs.FileInfo
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// MemoryFS is a virtual file system that stores files in memory.
|
|
||||||
type MemoryFS struct {
|
type MemoryFS struct {
|
||||||
files map[string]*file
|
data map[string]*bytes.Buffer
|
||||||
capacity int64
|
info map[string]*vfs.FileInfo
|
||||||
size int64
|
capacity int64
|
||||||
mu sync.RWMutex
|
size int64
|
||||||
keyLocks sync.Map // map[string]*sync.RWMutex
|
mu sync.RWMutex
|
||||||
LRU *lruList
|
keyLocks []sync.Map // Sharded lock pools for better concurrency
|
||||||
|
LRU *lruList
|
||||||
|
timeUpdater *vfs.BatchedTimeUpdate // Batched time updates for better performance
|
||||||
}
|
}
|
||||||
|
|
||||||
// lruList for LRU eviction
|
// Number of lock shards for reducing contention
|
||||||
|
const numLockShards = 32
|
||||||
|
|
||||||
|
// lruList for time-decayed LRU eviction
|
||||||
type lruList struct {
|
type lruList struct {
|
||||||
list *list.List
|
list *list.List
|
||||||
elem map[string]*list.Element
|
elem map[string]*list.Element
|
||||||
@@ -78,176 +44,260 @@ func newLruList() *lruList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lruList) MoveToFront(key string) {
|
func (l *lruList) Add(key string, fi *vfs.FileInfo) {
|
||||||
if e, ok := l.elem[key]; ok {
|
elem := l.list.PushFront(fi)
|
||||||
l.list.MoveToFront(e)
|
l.elem[key] = elem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lruList) MoveToFront(key string, timeUpdater *vfs.BatchedTimeUpdate) {
|
||||||
|
if elem, exists := l.elem[key]; exists {
|
||||||
|
l.list.MoveToFront(elem)
|
||||||
|
// Update the FileInfo in the element with new access time
|
||||||
|
if fi := elem.Value.(*vfs.FileInfo); fi != nil {
|
||||||
|
fi.UpdateAccessBatched(timeUpdater)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lruList) Add(key string, fi *vfs.FileInfo) *list.Element {
|
func (l *lruList) Remove(key string) *vfs.FileInfo {
|
||||||
e := l.list.PushFront(fi)
|
if elem, exists := l.elem[key]; exists {
|
||||||
l.elem[key] = e
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *lruList) Remove(key string) {
|
|
||||||
if e, ok := l.elem[key]; ok {
|
|
||||||
l.list.Remove(e)
|
|
||||||
delete(l.elem, key)
|
delete(l.elem, key)
|
||||||
}
|
if fi := l.list.Remove(elem).(*vfs.FileInfo); fi != nil {
|
||||||
}
|
return fi
|
||||||
|
}
|
||||||
func (l *lruList) Back() *vfs.FileInfo {
|
|
||||||
if e := l.list.Back(); e != nil {
|
|
||||||
return e.Value.(*vfs.FileInfo)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new MemoryFS.
|
func (l *lruList) Len() int {
|
||||||
|
return l.list.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new MemoryFS
|
||||||
func New(capacity int64) *MemoryFS {
|
func New(capacity int64) *MemoryFS {
|
||||||
if capacity <= 0 {
|
if capacity <= 0 {
|
||||||
panic("memory capacity must be greater than 0") // panic if the capacity is less than or equal to 0
|
panic("memory capacity must be greater than 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Logger.Info().
|
// Initialize sharded locks
|
||||||
Str("name", "MemoryFS").
|
keyLocks := make([]sync.Map, numLockShards)
|
||||||
Str("capacity", units.HumanSize(float64(capacity))).
|
|
||||||
Msg("init")
|
|
||||||
|
|
||||||
mfs := &MemoryFS{
|
return &MemoryFS{
|
||||||
files: make(map[string]*file),
|
data: make(map[string]*bytes.Buffer),
|
||||||
capacity: capacity,
|
info: make(map[string]*vfs.FileInfo),
|
||||||
mu: sync.RWMutex{},
|
capacity: capacity,
|
||||||
keyLocks: sync.Map{},
|
size: 0,
|
||||||
LRU: newLruList(),
|
keyLocks: keyLocks,
|
||||||
|
LRU: newLruList(),
|
||||||
|
timeUpdater: vfs.NewBatchedTimeUpdate(100 * time.Millisecond), // Update time every 100ms
|
||||||
}
|
}
|
||||||
|
|
||||||
memoryCapacityBytes.Set(float64(capacity))
|
|
||||||
memorySizeBytes.Set(float64(mfs.Size()))
|
|
||||||
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryFS) Capacity() int64 {
|
|
||||||
return m.capacity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name returns the name of this VFS
|
||||||
func (m *MemoryFS) Name() string {
|
func (m *MemoryFS) Name() string {
|
||||||
return "MemoryFS"
|
return "MemoryFS"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Size returns the current size
|
||||||
func (m *MemoryFS) Size() int64 {
|
func (m *MemoryFS) Size() int64 {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
return m.size
|
return m.size
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryFS) getKeyLock(key string) *sync.RWMutex {
|
// Capacity returns the maximum capacity
|
||||||
mu, _ := m.keyLocks.LoadOrStore(key, &sync.RWMutex{})
|
func (m *MemoryFS) Capacity() int64 {
|
||||||
return mu.(*sync.RWMutex)
|
return m.capacity
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryFS) Create(key string, size int64) (io.WriteCloser, error) {
|
// getShardIndex returns the shard index for a given key
|
||||||
m.mu.RLock()
|
func getShardIndex(key string) int {
|
||||||
if m.capacity > 0 {
|
// Use FNV-1a hash for good distribution
|
||||||
if m.size+size > m.capacity {
|
var h uint32 = 2166136261 // FNV offset basis
|
||||||
m.mu.RUnlock()
|
for i := 0; i < len(key); i++ {
|
||||||
return nil, vfserror.ErrDiskFull
|
h ^= uint32(key[i])
|
||||||
}
|
h *= 16777619 // FNV prime
|
||||||
}
|
}
|
||||||
m.mu.RUnlock()
|
return int(h % numLockShards)
|
||||||
|
|
||||||
keyMu := m.getKeyLock(key)
|
|
||||||
keyMu.Lock()
|
|
||||||
defer keyMu.Unlock()
|
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
|
|
||||||
return &memWriteCloser{
|
|
||||||
Writer: buf,
|
|
||||||
onClose: func() error {
|
|
||||||
data := buf.Bytes()
|
|
||||||
m.mu.Lock()
|
|
||||||
var accessCount int64 = 0
|
|
||||||
if f, exists := m.files[key]; exists {
|
|
||||||
m.size -= int64(len(f.data))
|
|
||||||
m.LRU.Remove(key)
|
|
||||||
accessCount = f.fileinfo.AccessCount // preserve access count if overwriting
|
|
||||||
}
|
|
||||||
fi := vfs.NewFileInfo(key, int64(len(data)), time.Now())
|
|
||||||
fi.AccessCount = accessCount
|
|
||||||
m.files[key] = &file{
|
|
||||||
fileinfo: fi,
|
|
||||||
data: data,
|
|
||||||
}
|
|
||||||
m.LRU.Add(key, fi)
|
|
||||||
m.size += int64(len(data))
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
memoryWriteBytes.Add(float64(len(data)))
|
|
||||||
memorySizeBytes.Set(float64(m.Size()))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type memWriteCloser struct {
|
// getKeyLock returns a lock for the given key using sharding
|
||||||
io.Writer
|
func (m *MemoryFS) getKeyLock(key string) *sync.RWMutex {
|
||||||
onClose func() error
|
shardIndex := getShardIndex(key)
|
||||||
|
shard := &m.keyLocks[shardIndex]
|
||||||
|
|
||||||
|
keyLock, _ := shard.LoadOrStore(key, &sync.RWMutex{})
|
||||||
|
return keyLock.(*sync.RWMutex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wc *memWriteCloser) Close() error {
|
// Create creates a new file
|
||||||
return wc.onClose()
|
func (m *MemoryFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||||
}
|
if key == "" {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
if key[0] == '/' {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize key to prevent path traversal
|
||||||
|
if strings.Contains(key, "..") {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MemoryFS) Delete(key string) error {
|
|
||||||
keyMu := m.getKeyLock(key)
|
keyMu := m.getKeyLock(key)
|
||||||
keyMu.Lock()
|
keyMu.Lock()
|
||||||
defer keyMu.Unlock()
|
defer keyMu.Unlock()
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
f, exists := m.files[key]
|
// Check if file already exists and handle overwrite
|
||||||
|
if fi, exists := m.info[key]; exists {
|
||||||
|
m.size -= fi.Size
|
||||||
|
m.LRU.Remove(key)
|
||||||
|
delete(m.info, key)
|
||||||
|
delete(m.data, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := &bytes.Buffer{}
|
||||||
|
m.data[key] = buffer
|
||||||
|
fi := vfs.NewFileInfo(key, size)
|
||||||
|
m.info[key] = fi
|
||||||
|
m.LRU.Add(key, fi)
|
||||||
|
// Initialize access time with current time
|
||||||
|
fi.UpdateAccessBatched(m.timeUpdater)
|
||||||
|
m.size += size
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return &memoryWriteCloser{
|
||||||
|
buffer: buffer,
|
||||||
|
memory: m,
|
||||||
|
key: key,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryWriteCloser implements io.WriteCloser for memory files
|
||||||
|
type memoryWriteCloser struct {
|
||||||
|
buffer *bytes.Buffer
|
||||||
|
memory *MemoryFS
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mwc *memoryWriteCloser) Write(p []byte) (n int, err error) {
|
||||||
|
return mwc.buffer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mwc *memoryWriteCloser) Close() error {
|
||||||
|
// Update the actual size in FileInfo
|
||||||
|
mwc.memory.mu.Lock()
|
||||||
|
if fi, exists := mwc.memory.info[mwc.key]; exists {
|
||||||
|
actualSize := int64(mwc.buffer.Len())
|
||||||
|
sizeDiff := actualSize - fi.Size
|
||||||
|
fi.Size = actualSize
|
||||||
|
mwc.memory.size += sizeDiff
|
||||||
|
}
|
||||||
|
mwc.memory.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a file for reading
|
||||||
|
func (m *MemoryFS) Open(key string) (io.ReadCloser, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
if key[0] == '/' {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(key, "..") {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
keyMu := m.getKeyLock(key)
|
||||||
|
keyMu.RLock()
|
||||||
|
defer keyMu.RUnlock()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
fi, exists := m.info[key]
|
||||||
|
if !exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil, vfserror.ErrNotFound
|
||||||
|
}
|
||||||
|
fi.UpdateAccessBatched(m.timeUpdater)
|
||||||
|
m.LRU.MoveToFront(key, m.timeUpdater)
|
||||||
|
|
||||||
|
buffer, exists := m.data[key]
|
||||||
|
if !exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil, vfserror.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a copy of the buffer for reading
|
||||||
|
data := make([]byte, buffer.Len())
|
||||||
|
copy(data, buffer.Bytes())
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return &memoryReadCloser{
|
||||||
|
reader: bytes.NewReader(data),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryReadCloser implements io.ReadCloser for memory files
|
||||||
|
type memoryReadCloser struct {
|
||||||
|
reader *bytes.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mrc *memoryReadCloser) Read(p []byte) (n int, err error) {
|
||||||
|
return mrc.reader.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mrc *memoryReadCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a file
|
||||||
|
func (m *MemoryFS) Delete(key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
if key[0] == '/' {
|
||||||
|
return vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(key, "..") {
|
||||||
|
return vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
keyMu := m.getKeyLock(key)
|
||||||
|
keyMu.Lock()
|
||||||
|
defer keyMu.Unlock()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
fi, exists := m.info[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
return vfserror.ErrNotFound
|
return vfserror.ErrNotFound
|
||||||
}
|
}
|
||||||
m.size -= int64(len(f.data))
|
m.size -= fi.Size
|
||||||
m.LRU.Remove(key)
|
m.LRU.Remove(key)
|
||||||
delete(m.files, key)
|
delete(m.info, key)
|
||||||
|
delete(m.data, key)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
memorySizeBytes.Set(float64(m.Size()))
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryFS) Open(key string) (io.ReadCloser, error) {
|
// Stat returns file information
|
||||||
keyMu := m.getKeyLock(key)
|
|
||||||
keyMu.RLock()
|
|
||||||
defer keyMu.RUnlock()
|
|
||||||
|
|
||||||
m.mu.Lock()
|
|
||||||
f, exists := m.files[key]
|
|
||||||
if !exists {
|
|
||||||
m.mu.Unlock()
|
|
||||||
return nil, vfserror.ErrNotFound
|
|
||||||
}
|
|
||||||
f.fileinfo.ATime = time.Now()
|
|
||||||
f.fileinfo.AccessCount++ // Increment access count for LFU
|
|
||||||
m.LRU.MoveToFront(key)
|
|
||||||
dataCopy := make([]byte, len(f.data))
|
|
||||||
copy(dataCopy, f.data)
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
memoryReadBytes.Add(float64(len(dataCopy)))
|
|
||||||
memorySizeBytes.Set(float64(m.Size()))
|
|
||||||
|
|
||||||
return io.NopCloser(bytes.NewReader(dataCopy)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
|
func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
if key[0] == '/' {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(key, "..") {
|
||||||
|
return nil, vfserror.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
keyMu := m.getKeyLock(key)
|
keyMu := m.getKeyLock(key)
|
||||||
keyMu.RLock()
|
keyMu.RLock()
|
||||||
defer keyMu.RUnlock()
|
defer keyMu.RUnlock()
|
||||||
@@ -255,24 +305,139 @@ func (m *MemoryFS) Stat(key string) (*vfs.FileInfo, error) {
|
|||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
f, ok := m.files[key]
|
if fi, ok := m.info[key]; ok {
|
||||||
if !ok {
|
return fi, nil
|
||||||
return nil, vfserror.ErrNotFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.fileinfo, nil
|
return nil, vfserror.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryFS) StatAll() []*vfs.FileInfo {
|
// EvictLRU evicts the least recently used files to free up space
|
||||||
m.mu.RLock()
|
func (m *MemoryFS) EvictLRU(bytesNeeded uint) uint {
|
||||||
defer m.mu.RUnlock()
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// hard copy the file info to prevent modification of the original file info or the other way around
|
var evicted uint
|
||||||
files := make([]*vfs.FileInfo, 0, len(m.files))
|
|
||||||
for _, v := range m.files {
|
// Evict from LRU list until we free enough space
|
||||||
fi := *v.fileinfo
|
for m.size > m.capacity-int64(bytesNeeded) && m.LRU.Len() > 0 {
|
||||||
files = append(files, &fi)
|
// Get the least recently used item
|
||||||
|
elem := m.LRU.list.Back()
|
||||||
|
if elem == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fi := elem.Value.(*vfs.FileInfo)
|
||||||
|
key := fi.Key
|
||||||
|
|
||||||
|
// Remove from LRU
|
||||||
|
m.LRU.Remove(key)
|
||||||
|
|
||||||
|
// Remove from maps
|
||||||
|
delete(m.info, key)
|
||||||
|
delete(m.data, key)
|
||||||
|
|
||||||
|
// Update size
|
||||||
|
m.size -= fi.Size
|
||||||
|
evicted += uint(fi.Size)
|
||||||
|
|
||||||
|
// Clean up key lock
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
m.keyLocks[shardIndex].Delete(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
return files
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictBySize evicts files by size (ascending = smallest first, descending = largest first)
|
||||||
|
func (m *MemoryFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
var evicted uint
|
||||||
|
var candidates []*vfs.FileInfo
|
||||||
|
|
||||||
|
// Collect all files
|
||||||
|
for _, fi := range m.info {
|
||||||
|
candidates = append(candidates, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by size
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if ascending {
|
||||||
|
return candidates[i].Size < candidates[j].Size
|
||||||
|
}
|
||||||
|
return candidates[i].Size > candidates[j].Size
|
||||||
|
})
|
||||||
|
|
||||||
|
// Evict files until we free enough space
|
||||||
|
for _, fi := range candidates {
|
||||||
|
if m.size <= m.capacity-int64(bytesNeeded) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fi.Key
|
||||||
|
|
||||||
|
// Remove from LRU
|
||||||
|
m.LRU.Remove(key)
|
||||||
|
|
||||||
|
// Remove from maps
|
||||||
|
delete(m.info, key)
|
||||||
|
delete(m.data, key)
|
||||||
|
|
||||||
|
// Update size
|
||||||
|
m.size -= fi.Size
|
||||||
|
evicted += uint(fi.Size)
|
||||||
|
|
||||||
|
// Clean up key lock
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
m.keyLocks[shardIndex].Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictFIFO evicts files using FIFO (oldest creation time first)
|
||||||
|
func (m *MemoryFS) EvictFIFO(bytesNeeded uint) uint {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
var evicted uint
|
||||||
|
var candidates []*vfs.FileInfo
|
||||||
|
|
||||||
|
// Collect all files
|
||||||
|
for _, fi := range m.info {
|
||||||
|
candidates = append(candidates, fi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by creation time (oldest first)
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
return candidates[i].CTime.Before(candidates[j].CTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Evict oldest files until we free enough space
|
||||||
|
for _, fi := range candidates {
|
||||||
|
if m.size <= m.capacity-int64(bytesNeeded) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fi.Key
|
||||||
|
|
||||||
|
// Remove from LRU
|
||||||
|
m.LRU.Remove(key)
|
||||||
|
|
||||||
|
// Remove from maps
|
||||||
|
delete(m.info, key)
|
||||||
|
delete(m.data, key)
|
||||||
|
|
||||||
|
// Update size
|
||||||
|
m.size -= fi.Size
|
||||||
|
evicted += uint(fi.Size)
|
||||||
|
|
||||||
|
// Clean up key lock
|
||||||
|
shardIndex := getShardIndex(key)
|
||||||
|
m.keyLocks[shardIndex].Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return evicted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
116
vfs/vfs.go
116
vfs/vfs.go
@@ -1,28 +1,112 @@
|
|||||||
// vfs/vfs.go
|
// vfs/vfs.go
|
||||||
package vfs
|
package vfs
|
||||||
|
|
||||||
import "io"
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// VFS is the interface that wraps the basic methods of a virtual file system.
|
// VFS defines the interface for virtual file systems
|
||||||
type VFS interface {
|
type VFS interface {
|
||||||
// Name returns the name of the file system.
|
// Create creates a new file at the given key
|
||||||
Name() string
|
|
||||||
|
|
||||||
// Size returns the total size of all files in the file system.
|
|
||||||
Size() int64
|
|
||||||
|
|
||||||
// Create creates a new file at key with expected size.
|
|
||||||
Create(key string, size int64) (io.WriteCloser, error)
|
Create(key string, size int64) (io.WriteCloser, error)
|
||||||
|
|
||||||
// Delete deletes the value of key.
|
// Open opens the file at the given key for reading
|
||||||
Delete(key string) error
|
|
||||||
|
|
||||||
// Open opens the file at key.
|
|
||||||
Open(key string) (io.ReadCloser, error)
|
Open(key string) (io.ReadCloser, error)
|
||||||
|
|
||||||
// Stat returns the FileInfo of key.
|
// Delete removes the file at the given key
|
||||||
|
Delete(key string) error
|
||||||
|
|
||||||
|
// Stat returns information about the file at the given key
|
||||||
Stat(key string) (*FileInfo, error)
|
Stat(key string) (*FileInfo, error)
|
||||||
|
|
||||||
// StatAll returns the FileInfo of all keys.
|
// Name returns the name of this VFS
|
||||||
StatAll() []*FileInfo
|
Name() string
|
||||||
|
|
||||||
|
// Size returns the current size of the VFS
|
||||||
|
Size() int64
|
||||||
|
|
||||||
|
// Capacity returns the maximum capacity of the VFS
|
||||||
|
Capacity() int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileInfo contains metadata about a cached file
|
||||||
|
type FileInfo struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ATime time.Time `json:"atime"` // Last access time
|
||||||
|
CTime time.Time `json:"ctime"` // Creation time
|
||||||
|
AccessCount int `json:"access_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileInfo creates a new FileInfo with the given key and current timestamp
|
||||||
|
func NewFileInfo(key string, size int64) *FileInfo {
|
||||||
|
now := time.Now()
|
||||||
|
return &FileInfo{
|
||||||
|
Key: key,
|
||||||
|
Size: size,
|
||||||
|
ATime: now,
|
||||||
|
CTime: now,
|
||||||
|
AccessCount: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileInfoFromOS creates a FileInfo from os.FileInfo
|
||||||
|
func NewFileInfoFromOS(info os.FileInfo, key string) *FileInfo {
|
||||||
|
return &FileInfo{
|
||||||
|
Key: key,
|
||||||
|
Size: info.Size(),
|
||||||
|
ATime: time.Now(), // We don't have access time from os.FileInfo
|
||||||
|
CTime: info.ModTime(),
|
||||||
|
AccessCount: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccess updates the access time and increments the access count
|
||||||
|
func (fi *FileInfo) UpdateAccess() {
|
||||||
|
fi.ATime = time.Now()
|
||||||
|
fi.AccessCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchedTimeUpdate provides a way to batch time updates for better performance
|
||||||
|
type BatchedTimeUpdate struct {
|
||||||
|
currentTime time.Time
|
||||||
|
lastUpdate time.Time
|
||||||
|
updateInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBatchedTimeUpdate creates a new batched time updater
|
||||||
|
func NewBatchedTimeUpdate(interval time.Duration) *BatchedTimeUpdate {
|
||||||
|
now := time.Now()
|
||||||
|
return &BatchedTimeUpdate{
|
||||||
|
currentTime: now,
|
||||||
|
lastUpdate: now,
|
||||||
|
updateInterval: interval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTime returns the current cached time, updating it if necessary
|
||||||
|
func (btu *BatchedTimeUpdate) GetTime() time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(btu.lastUpdate) >= btu.updateInterval {
|
||||||
|
btu.currentTime = now
|
||||||
|
btu.lastUpdate = now
|
||||||
|
}
|
||||||
|
return btu.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccessBatched updates the access time using batched time updates
|
||||||
|
func (fi *FileInfo) UpdateAccessBatched(btu *BatchedTimeUpdate) {
|
||||||
|
fi.ATime = btu.GetTime()
|
||||||
|
fi.AccessCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTimeDecayedScore calculates a score based on access time and frequency
|
||||||
|
// More recent and frequent accesses get higher scores
|
||||||
|
func (fi *FileInfo) GetTimeDecayedScore() float64 {
|
||||||
|
timeSinceAccess := time.Since(fi.ATime).Hours()
|
||||||
|
decayFactor := 1.0 / (1.0 + timeSinceAccess/24.0) // Decay over days
|
||||||
|
frequencyBonus := float64(fi.AccessCount) * 0.1
|
||||||
|
return decayFactor + frequencyBonus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,10 @@ package vfserror
|
|||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
|
// Common VFS errors
|
||||||
var (
|
var (
|
||||||
// ErrInvalidKey is returned when a key is invalid.
|
ErrNotFound = errors.New("vfs: key not found")
|
||||||
ErrInvalidKey = errors.New("vfs: invalid key")
|
ErrInvalidKey = errors.New("vfs: invalid key")
|
||||||
|
ErrAlreadyExists = errors.New("vfs: key already exists")
|
||||||
// ErrUnreachable is returned when a code path is unreachable.
|
ErrCapacityExceeded = errors.New("vfs: capacity exceeded")
|
||||||
ErrUnreachable = errors.New("unreachable")
|
|
||||||
|
|
||||||
// ErrNotFound is returned when a key is not found.
|
|
||||||
ErrNotFound = errors.New("vfs: key not found")
|
|
||||||
|
|
||||||
// ErrDiskFull is returned when the disk is full.
|
|
||||||
ErrDiskFull = errors.New("vfs: disk full")
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user